Every state you did not draw still ships
The list
A component exists in more conditions than the one in the mockup. For anything interactive:
default resting
hover pointer over it (does not exist on touch)
focus-visible keyboard focus (does not exist for mouse users)
active being pressed
disabled not available
loading working
selected currently chosen, in a set
error in an invalid condition
read-only shown but not editable
visited for linksEvery one of these ships whether or not somebody designed it. Undesigned, they ship as the browser's defaults, which are inconsistent across platforms, or as nothing at all, which means the interface does not respond to being used.
Hover and focus are different audiences
A point that gets conflated constantly. Hover does not exist on touch devices. Any information revealed only on hover is invisible to most of your users, which is why tooltips as the only explanation of an icon is a failure and why hover-only menus are a recurring accessibility complaint.
Focus does not appear for mouse users, and should not — a focus ring after every click was the original reason people removed focus styles. :focus-visible gives the ring to keyboard users and not to mouse clicks, which resolves the conflict entirely and is the correct selector to use.
So the two states serve different people, need different treatments, and neither substitutes for the other.
Disabled is the one that goes wrong
Disabled controls are conventionally shown at low contrast to signal unavailability, and WCAG explicitly exempts disabled controls from the contrast requirements. That exemption is real, it is also widely criticised, and it produces a genuine problem: a disabled button that nobody can read is a control whose label cannot be checked, by anybody, including people with perfect vision in bright light.
The deeper problem is that disabled controls communicate nothing about why. A Submit button that is greyed out tells the user they cannot proceed and not what to fix. They then hunt.
Three better patterns, in order of how often they apply:
- Keep the control enabled and explain on activation. The user presses Submit, and the form shows what is missing and moves focus there. This is the strongest pattern for forms and it is now common.
- Say why next to the control. If it must be disabled, put the reason in text beside it, not in a tooltip that touch users cannot reach.
- Hide it entirely, where the action is not merely unavailable but irrelevant in this context.
If you do keep a disabled state, make it legible anyway. The exemption permits low contrast; nothing requires it.
Loading, and the width problem
A button that replaces its label with a spinner changes width, which shifts everything beside it. The user's finger is already moving.
Fix it by reserving the width: keep the label in place at reduced opacity and overlay the spinner, or set an explicit minimum width so the shortest state and the longest state are the same size. This is the layout-shift point from the previous block, at component scale.
Also decide what the button does while loading. The correct answer is almost always that it stops accepting presses, because the common failure is a user tapping three times and submitting three orders.
Error is a component state, not a screen
An invalid field needs: a changed border, an icon, a message, and — from the colour block — none of those carrying the meaning alone. The message goes next to the field, not at the top of the form, because a message at the top makes the user hunt for which field it refers to.
The field should also keep what the user typed. Clearing an invalid field is the single most disliked behaviour in form design, and it is still common.
Specify them together
The practical method is a small table of decisions per component, drawn once:
Button, primary
default surface: --colour-action label: --colour-on-action
hover surface: --colour-action-hover
active surface: --colour-action-active, translate-y 1px
focus ring: 2px --colour-focus, offset 2px
disabled surface: --colour-action-muted, cursor default
loading label at 0% opacity, spinner centred, width lockedSix rows. It takes ten minutes, it removes a week of small inconsistencies later, and every value in it is a token from the earlier lesson rather than a number.
The one thing to keep
A component ships in every state whether or not it was designed, hover and focus serve different audiences and never substitute for each other, and a disabled control that cannot be read or explained should usually be an enabled control that explains what is missing on activation.
Before you move on
A signup form disables Submit until every field is valid. Users report getting stuck without knowing why. The team proposes adding a tooltip on the disabled button explaining what is missing. What is the flaw?
Pick the one you would defend. Nobody sees your answer.