Interaction Design
Every interaction is the same three beats: the user works out what can be done, does it, and finds out what happened. Visual failures are embarrassing; interaction failures cost people their work.
The dominant defect in generated UI is that only the rest state exists. A component looks finished because it renders, and the states nobody screenshots — focus, loading, error, disabled — are guessed or omitted.
1. Every control has a state matrix
Enumerate the states before styling: rest, hover, focus-visible, active, disabled, loading, selected, error, read-only. They combine — a selected row can be hovered and focused at once — so fix precedence once rather than discovering conflicts later. Details are in the state-matrix reference. Three are almost always wrong in generated code.
Focus must use :focus-visible, not :focus. A plain :focus rule fires on mouse
clicks too, so authors delete the ring to stop buttons "flashing", and keyboard users lose
the only cue telling them where they are. :focus-visible applies the browser's own
heuristic: keyboard focus gets a ring, pointer focus does not. Never write outline: none
without a replacement in the same rule.
Hover does not exist on touch. Gate it behind @media (hover: hover) so a tap does
not leave the control stuck in hover, and never put functionality behind it alone.
Disabled must communicate why. A greyed control with no explanation is a dead end.
2. Affordance without colour
A control must read as operable from its shape, not its hue — text that is merely blue in a paragraph of black is not a link to the one man in twelve with a colour vision deficiency. Carry affordance in a non-colour channel: an underline on inline links, a filled or outlined surface on buttons, a border and inset shading on inputs, a caret on menus. Render it in greyscale and ask what still looks clickable.
The mirror failure is false affordance — cards with hover lift that are not clickable. A user who clicks something inert learns to distrust everything.
3. Feedback latency has hard thresholds
These come from perception, not fashion.
- Under ~100ms the response feels caused by the user's own action. Show nothing; a spinner here makes the interaction feel slower.
- Up to ~1s attention holds. An indeterminate indicator suffices.
- Beyond ~1s the train of thought breaks. Show determinate progress — "3 of 12 files" — because a spinner gives no basis for deciding whether to keep waiting.
- Beyond ~10s attention is gone. Move the work to the background and notify on completion.
Acknowledgement is separate from completion: the press state must render within one frame of pointer-down no matter how long the request takes.
4. Loading: nothing, spinner, skeleton, progress
Nothing is correct under about 300ms. Add hysteresis — delay the indicator ~300ms, then hold it ~500ms minimum. Without both, fast responses flash and read as a glitch.
A spinner suits short waits of unknown shape and in-place actions, such as a button's label swapping for a spinner of the same width. Never let a button resize.
A skeleton suits a first load whose layout is known. Its whole justification is reserving space, so a skeleton whose geometry differs from the real content is worse than no skeleton — it guarantees layout shift at the moment the user starts reading. Match line counts, heights, and column widths.
5. Optimistic UI, and when it lies
Rendering the result before the server confirms it is right when the operation is very likely to succeed, cheap to reverse, and locally computable — a like, a rename, a reorder. It is dishonest when failure is plausible, when the true result differs from the guess (server-assigned ids, computed totals, moderation), or when the user would act on the false state. "Payment complete" before the charge settles is not optimism but a lie.
Optimistic UI is incomplete without a rollback path: revert, report the failure, and do not silently discard what the user typed.
6. Destructive actions: prefer undo to confirmation
A confirmation dialog taxes every user to catch the rare mistaken one, and because it appears constantly it is clicked through reflexively. It trains dismissal, then fails at the moment it was built for.
Undo inverts the cost. The action happens immediately and a transient affordance offers reversal for 5-10 seconds. The common case costs nothing; the mistake is recoverable.
Confirm only when reversal is genuinely impossible: hard deletion with no soft-delete, irreversible external side effects, destroying other people's data. Then make it effortful — require typing the resource name — and state the blast radius concretely ("deletes 1,204 records"), never "Are you sure?".
7. Disabled buttons are an anti-pattern
Disabling a submit button until a form is valid is the most common way to make a form
unusable. The disabled attribute removes the element from the tab order, so keyboard
users cannot reach it; it gives no reason; and it leaves the user hunting for the offending
field with no feedback loop.
The correct pattern: keep the button operable, let the click fail, explain the failure. On submit, validate, focus the first invalid field, announce a summary. The click is the user asking "what's wrong?" — answer it.
Legitimate uses of disabled are narrow: a control inapplicable in the current mode, or
one mid-submission. Where a control is inert but should stay discoverable, prefer
aria-disabled="true" with an explanation.
8. Errors that can be recovered from
A usable error contains three things: what happened, why, and what to do next. "Something went wrong" contains none of them.
Write in the user's terms. Never blame them ("Invalid input"), never surface raw exception text as the primary message, never lose their data. Put the message next to what failed, keep a correlation id available for support, and give a real next step. Offer retry only when retrying might work.
9. Forms are the hardest surface
Validation timing. Validate on blur, then re-validate on change once a field has
already errored. Per-keystroke validation tells someone their email is invalid at the
second character, which is both wrong and hostile. CSS :user-invalid gives this natively
— unlike :invalid it matches only after interaction. Errors must clear once fixed.
Announcement. Bind each message with aria-describedby and set aria-invalid. On
failed submit, render a summary listing every error as links to their fields, and move
focus to it — it is the only way a screen-reader user learns a long form failed.
Let the browser help. Correct autocomplete tokens (email, street-address,
cc-number, one-time-code, new-password) enable autofill and password managers, and
are a WCAG requirement. Match the keyboard to the data with type and inputmode —
inputmode="numeric" for codes rather than type="number" — and set enterkeyhint.
Double submission. Disable the button and guard the handler with an in-flight flag, and send an idempotency key so a retried request cannot create two orders.
Never destroy input. Preserve values across failed submits, and never clear a password field on error.
10. Pointer, touch, and keyboard parity
Hit targets need at least 24x24 CSS pixels, and 44x44 on touch. Expand the target without expanding the visual, using padding or a pseudo-element overlay: an icon button should look small and hit large.
Anything achievable by dragging must also be achievable by single clicks or keyboard — move-up/move-down controls, a "move to" menu, cut-and-paste semantics. Drag-only reordering excludes keyboard and screen-reader users and anyone with a motor impairment.
Rules
MUST NOT — Do not make any action reachable only by hovering; provide a keyboard-focus equivalent and a persistent or tap-revealed alternative for touch.
Why: Touch devices have no hover state and keyboards cannot produce one, so hover-gated controls are simply absent for those input methods rather than merely harder to find.
Incorrect:
.row-actions { opacity: 0 } .row:hover .row-actions { opacity: 1 }
Correct:
.row-actions { opacity: 0 } @media (hover: hover) { .row:hover .row-actions, .row:focus-within .row-actions { opacity: 1 } } @media (hover: none) { .row-actions { opacity: 1 } }
MUST NOT — Do not ship "Something went wrong" or equivalent as the complete text of a user-facing error.
Why: Such a message conveys only that the interface is aware of a problem. It distinguishes no cause, suggests no remedy, and cannot be reported usefully to support, so it costs the user attention while giving them nothing to act on.
Exceptions:
- Genuinely unclassifiable failures, which still need a correlation id and a stated next step such as retrying or contacting support.
MUST — Give every interactive element a visible focus indicator using :focus-visible, and never write outline: none without a replacement indicator in the same rule.
Why: The focus indicator is the only signal telling a keyboard user which element will receive their next keystroke. :focus-visible exists because :focus also fires on pointer clicks, and that unwanted ring is what motivates authors to remove the indicator entirely.
Source: WCAG 2.2 Success Criterion 2.4.7 (Focus Visible)
Incorrect:
.btn:focus { outline: none; }
Correct:
.btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
MUST — Ensure the focus indicator reaches at least 3:1 contrast against both the control it surrounds and the background behind that control.
Why: A focus ring is a non-text visual indicator, so it is only perceivable if it contrasts with what it sits against. A single fixed ring colour typically clears one of the two adjacent surfaces and fails the other, which is why two-tone rings are used.
Source: WCAG 2.2 Success Criterion 1.4.11 (Non-text Contrast)
MUST — Signal that an element is interactive through at least one non-colour channel such as an underline, border, surface, or shape.
Why: Roughly 8% of men have a colour vision deficiency, so a control distinguished only by hue is indistinguishable from static content for a substantial minority. Shape and enclosure survive greyscale; hue does not.
Source: WCAG 2.2 Success Criterion 1.4.1 (Use of Color)
MUST — Acknowledge every user action visually within about 100ms, independently of how long the underlying work takes.
Why: Responses under roughly 100ms are perceived as caused by the user’s own action; beyond that the causal link weakens and the user starts to suspect the input was not registered, which produces repeat clicks.
Source: Miller, "Response time in man-computer conversational transactions", AFIPS 1968
MUST — Ensure a skeleton placeholder matches the dimensions and structure of the content that will replace it.
Why: The only benefit a skeleton provides over a spinner is reserving the final layout. A mismatched skeleton removes that benefit and adds a layout shift precisely when the user has begun reading, which is the most disruptive possible moment.
MUST — Pair every optimistic update with a rollback path that reverts the visual state, reports the failure, and preserves any data the user entered.
Why: An optimistic update is a prediction. Without an explicit rollback the interface retains a state the server never accepted, so the user believes work is saved that does not exist — a silent data-loss bug rather than a visible error.
MUST — Require an effortful confirmation, such as typing the resource name, for genuinely irreversible actions, and state the concrete consequences rather than asking "Are you sure?".
Why: A single-click confirmation is defeated by the same reflex that caused the original mis-click, since both are satisfied by clicking in roughly the same place. Typing a name cannot be performed reflexively, so it forces the user to re-read what is being destroyed.
MUST — Make the reason for any disabled control discoverable, using aria-disabled with an explanation rather than the disabled attribute when the user is likely to ask why.
Why: Reduced opacity encodes only that the control is inert, not why. Because the native disabled attribute also strips focusability and pointer events, the control cannot even be hovered or tabbed to in order to surface a tooltip.
MUST — State what failed, why it failed, and what the user should do next in every error message.
Why: An error message exists to restore the user’s ability to proceed. A message lacking the recovery step ends the interaction at the failure, which converts a recoverable problem into an abandoned task.
Source: WCAG 2.2 Success Criterion 3.3.3 (Error Suggestion)
Incorrect:
Something went wrong.
Correct:
We could not save your changes because the connection dropped. Your edits are still here — press Save to try again.
MUST — Validate a form field on blur rather than on every keystroke, and only then re-validate on change while it remains in error.
Why: A partially typed value is not an invalid value, so keystroke validation reports failures that are merely incomplete. Once the user is repairing a known error, per-keystroke feedback becomes correct because it confirms the fix the moment it lands.
Exceptions:
- Password-strength meters and debounced availability checks, where continuous feedback is the feature.
Incorrect:
input:invalid { border-color: red }
Correct:
input:user-invalid { border-color: var(--danger) }
MUST — Associate each field error with its field via aria-describedby and aria-invalid, and on failed submit render a focusable error summary linking to every invalid field.
Why: A screen-reader or magnifier user perceives only a small region at a time, so individual inline errors placed further down a long form are never discovered. The summary is the only mechanism that reports the failure at the point of submission.
Source: WCAG 2.2 Success Criterion 3.3.1 (Error Identification)
MUST — Set standard autocomplete tokens on every field collecting information about the user, and do not use autocomplete="off" on address, payment, or password fields.
Why: Autofill removes the largest single source of typing errors and lets password managers generate credentials. The tokens are also the only machine-readable statement of a field’s purpose, which assistive technology uses to relabel fields for users with cognitive disabilities.
Source: WCAG 2.2 Success Criterion 1.3.5 (Identify Input Purpose)
Incorrect:
<input name="e" type="text" autocomplete="off">
Correct:
<input name="email" type="email" autocomplete="email" enterkeyhint="next">
MUST — Preserve every value the user entered across a failed submission, including after a full page reload, and never clear a field because it was invalid.
Why: Re-entering data is the highest-cost recovery action a form can demand, and it is imposed at the moment the user is already frustrated by the failure. Clearing input converts a correctable error into an abandonment.
Source: WCAG 2.2 Success Criterion 3.3.7 (Redundant Entry)
MUST — Guard submissions with an in-flight flag in the handler as well as a disabled control, and send an idempotency key on any request that creates or charges.
Why: A disabled attribute is applied after the handler runs and can be bypassed by Enter key repeat or a re-tap during the network round trip. Only a server-side idempotency key prevents a retried request from producing a duplicate record.
MUST — Give every interactive target at least 24x24 CSS pixels of hit area, and at least 44x44 on touch, expanding the target with padding or a pseudo-element rather than enlarging the visual.
Why: A fingertip contact patch is around 8-10mm, so targets below roughly 44px on touch are hit by estimation rather than by aim. Expanding the hit area independently of the visual keeps small icon buttons visually small while making them reliably tappable.
Source: WCAG 2.2 Success Criterion 2.5.8 (Target Size (Minimum))
MUST — Provide a single-pointer and keyboard alternative for every action achievable by dragging, such as move controls, a "move to" menu, or cut-and-paste semantics.
Why: Dragging requires sustained precise pointer control while a button is held, which is not available to keyboard users, screen-reader users, or people with tremor or limited dexterity. The alternative must exist because the interaction cannot be adapted.
Source: WCAG 2.2 Success Criterion 2.5.7 (Dragging Movements)
SHOULD NOT — Do not give non-interactive elements interactive signifiers such as hover elevation, pointer cursors, or link styling.
Why: Affordance cues are learned as a contract. A click that produces nothing teaches the user that the cue is unreliable, which degrades their willingness to interact with every genuinely interactive element on the page.
SHOULD NOT — Do not apply optimistic UI to operations whose failure is plausible, whose true result differs from the predicted one, or which the user would act upon irreversibly.
Why: Optimistic rendering asserts an outcome the system has not verified. Where the assertion can be wrong and the user acts on it — payments, submissions, confirmations — the interface has published a falsehood that the later correction cannot undo.
Incorrect:
setStatus("Payment complete"); await charge(card)
Correct:
setStatus("Processing…"); const r = await charge(card); setStatus(r.ok ? "Payment complete" : r.message)
SHOULD NOT — Do not disable a form’s primary submit button to indicate that the form is incomplete or invalid; allow the submission, then explain the failure.
Why: The disabled attribute removes the element from the tab order and suppresses events, so a keyboard user cannot reach it and no user receives any explanation. The click is the user asking why they cannot proceed, and disabling it refuses to answer.
Exceptions:
- Suppressing re-submission while a submission is in flight, where the reason is already visible in the button itself.
Incorrect:
<button type="submit" disabled={!isValid}>Create account</button>
Correct:
<button type="submit" aria-busy={submitting}>Create account</button> // validate on submit, focus first invalid field
SHOULD — Show determinate progress rather than an indeterminate spinner for any operation expected to exceed one second, and move work expected to exceed ten seconds into the background.
Why: Around one second the user’s train of thought breaks and they begin deciding whether to wait. An indeterminate spinner supplies no information on which to base that decision, whereas a percentage or step count does.
Exceptions:
- Operations whose total work is genuinely unknowable, where an elapsed-time or step label is the best available substitute.
SHOULD — Delay any loading indicator by roughly 300ms and keep it visible for at least roughly 500ms once shown.
Why: A response that arrives in 150ms renders and unrenders the indicator faster than the eye can resolve it, producing a flash that reads as a rendering fault. Hysteresis converts fast responses into no indicator at all, which is what "instant" should look like.
SHOULD — Offer undo for a 5-10 second window instead of a confirmation dialog for any destructive action that can be reversed.
Why: A confirmation taxes every correct invocation to catch a rare incorrect one, and its frequency trains reflexive dismissal, so it stops being read before it is ever needed. Undo moves the cost onto the rare mistake instead of the common success.
Exceptions:
- Actions with irreversible external side effects, such as sending an email that has already left, or permanently destroying data with no soft-delete window.
SHOULD — Set type and inputmode so the virtual keyboard matches the expected data, and use inputmode="numeric" rather than type="number" for digit strings such as codes and postcodes.
Why: type="number" applies numeric semantics that are wrong for identifiers: it strips leading zeros, mutates values on scroll wheel, and renders spinner controls. inputmode changes only the keyboard, which is the part that actually needs changing.
SHOULD — Announce asynchronous state changes to assistive technology with aria-busy during the wait and a polite live region on completion.
Why: A spinner and a rendered result are purely visual events. Without a live region a screen-reader user receives no notification that the operation finished, so they cannot tell a slow response from a completed one.
Exceptions:
- Changes that move focus to the new content, which are announced by the focus change itself.
Before reporting completion
Run these checks against your own output. Answer each question explicitly rather than assuming the answer, because the point of the exercise is to notice what you did not notice while building.
Confirm every interactive element has a complete, composable state matrix. (blocking)
- For each interactive element, which of rest, hover, focus-visible, active, disabled, loading, selected, error, and read-only apply, and is each one styled?
- Is the focus indicator produced by :focus-visible, at least 2px, offset from the border, and legible against every background it can land on?
- Is hover styling gated behind @media (hover: hover), and does every hover-revealed control have a keyboard and touch equivalent?
- Does the selected state remain the dominant signal when the element is also hovered, and is it distinguishable without colour?
Confirm feedback timing matches the duration of the work. (blocking)
- Does every action render a press or busy state within one frame of the pointer going down, regardless of request duration?
- Are loading indicators delayed by roughly 300ms and held for roughly 500ms once shown?
- For operations that may exceed one second, is progress determinate rather than an indeterminate spinner?
- Does every skeleton match the dimensions and line count of the content that replaces it, and does the swap produce zero layout shift?
Confirm form validation, submission, and recovery behave correctly. (blocking)
- Does validation fire on blur rather than on every keystroke, and does it re-validate on change only once a field has already errored?
- Is the submit button operable at all times except while a submission is in flight?
- Does every error carry aria-invalid and aria-describedby, and does a failed submit render a focusable summary linking to each invalid field?
- Do all entered values survive a failed submission and a page reload?
- Is the submit handler guarded by an in-flight flag, and does any creating request carry an idempotency key?
- Does every field have the correct autocomplete token, type, and inputmode?
Confirm destructive actions are recoverable or deliberately effortful. (blocking)
- For each destructive action, is it reversible? If so, does it use undo with a 5-10 second window instead of a confirmation dialog?
- If it is genuinely irreversible, does the confirmation require an effortful step such as typing the resource name?
- Does the confirmation state the concrete consequences, including counts of what will be destroyed, rather than asking "Are you sure?"
Confirm error messages restore the user’s ability to proceed. (blocking)
- Does every user-facing error state what happened, why, and what to do next?
- Is there any message whose complete text is "Something went wrong" or equivalent?
- Is any raw exception text, stack trace, or status code shown as the primary message?
- For unclassifiable failures, is a correlation id available and a next step stated?
Confirm every interaction is available to every input method.
- Does every drag interaction have a click-only and keyboard-only alternative?
- Is every interactive target at least 24x24 CSS pixels, and at least 44x44 at touch widths?
- Can the entire flow be completed with the keyboard alone, with focus visible at every stop?
- Does any functionality depend on hover, long-press, or a multi-point gesture without an alternative?
Further reference
These are not loaded by default. Read one only when its question is the question you currently have.
references/state-matrix.md— What are all the states an interactive element needs, how do they combine, and what should each one actually look like?references/form-validation.md— When should each field be validated, how should errors be presented and announced, and how do I stop a form losing the user’s work?