13 KiB
Interaction and states
Every interactive element has eight states. Most AI-generated UI styles two (default, hover) and forgets the rest. That's where interfaces break.
The eight states
| State | When | Treatment |
|---|---|---|
| Default | At rest | Base styling |
| Hover | Pointer over (only with @media (hover: hover)) |
Small shift: colour, 1px translate, subtle border |
| Focus | Keyboard or programmatic focus | Visible ring, :focus-visible |
| Active / Pressed | During press | Pressed-in: darker, translate(0 1px) |
| Disabled | Not interactive | Reduced opacity (0.5) + cursor: not-allowed + aria-disabled |
| Loading | Processing | Inline spinner or progress, label stays readable |
| Error | Failed state | Red border, error icon, message, aria-invalid |
| Success | Completed | Green check, confirmation, auto-dismiss |
If any of these is missing on a production element, the element isn't finished.
Focus rings
Visible, always, on every interactive element. The default focus ring most browsers give you is fine; a custom one is better.
:focus { outline: none; }
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
border-radius: inherit;
}
Requirements:
- 2–3px, ≥ 3:1 contrast against both element and page.
- 2px offset from the element.
:focus-visible, not:focus, so it's keyboard-only.- Never
outline: nonewithout a replacement.outline: nonewith no other focus style is the most common accessibility bug and an immediate audit failure.
Hit targets
Minimum 44×44 CSS px for any touch-reachable element. Use padding or an ::before overlay to expand the hit target without changing visual size:
.icon-btn {
position: relative;
}
.icon-btn::before {
content: "";
position: absolute;
inset: -12px;
}
Forms
- Labels above inputs. Visible. Never placeholder-as-label.
- Placeholders show format, not instruction.
Placeholder: 01 Jan 2026, notPlaceholder: Enter your birth date. - Helper text below input. Error text replaces helper text.
- Validate on blur, not on every keystroke. Revalidate on change once the field has been blurred once (the "touched" pattern).
- Error message: (1) what broke, (2) why, (3) what to do. One sentence if possible.
- Associate errors with
aria-describedby. Setaria-invalid="true"on the field. - Required fields marked with
aria-required, never with colour alone. - Disable the submit button only when the form is in a known-invalid or in-flight state. Never on idle.
Input field states — the exhaustive checklist
This is where the most "almost right" UIs lose. An input field with two states (default + hover) and a different border-width on focus reads as a default settings page — the geometry shifts, the eye notices, the page feels untuned. Every text input, textarea, select, and combobox must satisfy every rule below.
The no-layout-shift rule
Border thickness is constant across every state. Default · hover · focus · error · disabled — the border-width value never changes. Layout shift on focus is a tell. State changes go to background-color, outline, or box-shadow, never to border-width.
.input {
border: 1px solid var(--color-rule-2); /* 1px, always — every state */
outline: 2px solid transparent; /* reserved slot for focus ring; no shift on activate */
outline-offset: 1px;
}
The outline starts transparent at 2 px so when the focus ring appears, the box geometry is already correct. No layout shift. No paint thrash.
State-by-state recipe
| State | Treatment | Why |
|---|---|---|
| Default | border: 1px solid var(--color-rule-2) · background: var(--color-paper) · placeholder in var(--color-muted) |
Visible field, readable empty signal |
| Hover | background: var(--color-paper-2) (4–6 % darker than paper) · border unchanged |
Subtle background shift, no border flash. Border colour changing alone is missable. |
| Focus | outline: 2px solid var(--color-focus) · outline-offset: 1px · border may deepen to var(--color-ink-2) but width stays 1 px |
Outline is the focus signal; never animated; ≥ 3:1 contrast against page AND field. |
| Active / typing | Same as focus. Don't add a separate "typing" state. | Focus already says "active here". A second signal is noise. |
| Filled | Same as default — the value carries the state. Optionally a subtle ink-2 border to visually distinguish from empty. | Don't fight the user's content with a styled chrome change. |
| Disabled | opacity: 0.55 · cursor: not-allowed · placeholder var(--color-rule-2) · aria-disabled="true" · tabindex="-1" |
Three independent signals (opacity + cursor + colour) so no single channel carries the whole load. |
| Error | border-color: var(--color-error, oklch(58% 0.20 25)) · helper-text replaced by error message · aria-invalid="true" · small ⚠ glyph at right edge |
Border colour flip is OK because helper-text and aria signal it too. Never colour alone. |
| Success | Subtle accent-coloured border (3 % chroma above default) · small ✓ glyph · auto-clear if user re-edits | Quiet; success doesn't deserve celebration unless it was hard. |
| Loading (validating, async) | Inline spinner at the right edge replacing the standard glyph slot · field stays editable but submit disabled | Don't lock the user out of the field. They may want to fix what they typed. |
Heights and rhythm
- Input height = button height. A page with 44 px buttons and 38 px inputs feels untuned. Pick one base height (44 px is the touch-target floor) and apply it to every text input AND every adjacent button.
- Vertical padding =
(height − line-height-px) / 2. No magic numbers. - Right-edge slot reserved. Every input reserves a ~24 px right-edge slot for an optional clear button, error glyph, or loading spinner. If unused, the slot sits empty — never reflow on icon appearance.
Labels, helper, error
- Label above the input, 4–8 px gap. Never inline (placeholder-as-label is a tell).
- Helper text below, ~4 px gap. Same
font-sizeas the label, lower visual weight. - Error replaces helper — same position, same size, error colour. Never both at once (causes vertical jump on validation).
- Helper has stable height. Reserve a 1-line height even when empty, so adding an error doesn't push the page down. CSS:
min-height: 1lhon the helper container.
Don't, list
- Don't transition
border-width,padding, orheighton any state. Always layout-shift. - Don't transition the focus ring's
opacityortransform. Focus must be instant. - Don't put hover effects inside
@media (hover: hover)— wait, do put them inside@media (hover: hover)so touch users don't get stuck states. - Don't disable the field as a way to indicate "wait, loading" — use a loading state with the field still editable.
- Don't change
cursoron:focus. The pointer is already a beam; don't fight it. - Don't use
outline: noneon focus without an explicit replacement.
Specific control overrides
- Textarea. Same rules as input, plus
resize: vertical(nevernone, neverbothon a small textarea),min-height: 6remfor multi-line UX. - Select. Custom-styled
<select>only if you can replicate native a11y (keyboard, screen-reader). Otherwise leave it native and style the wrapper. - Checkbox / radio. Use
accent-color: var(--color-accent)for cheap correct styling on modern browsers; only build a custom one when the design requires it. If custom: still a 1 px outline-offset focus ring. - Toggle / switch. It IS a checkbox. Same a11y rules. The visual design doesn't change the contract.
- Range / slider. The thumb gets focus state, not the track. Thumb hit-target ≥ 44 px even if visual size is smaller (use a transparent expansion).
- File input. Always wrap in a styled label. Native
<input type="file">is unstyleable; the label is the surface. - Combobox / search. Listbox sits below,
aria-expandedmirrors visibility, arrow-keys cycle, Enter selects, Escape closes — and the listbox doesn't push page content (useposition: absolute+ a parentposition: relative).
Modals and overlays
- Use the native
<dialog>element. It handles focus trap, escape to close, and::backdropstyling for free. - Centre it explicitly. A
<dialog>opened withshowModal()centres on its own — but the moment you add custom positioning (amargin-top, atop, a straytransform) it can snap to the viewport's top-left corner. Pin it:position: fixed; inset: 0; margin: auto; height: fit-content; max-height: min(80vh, 40rem);. A non-<dialog>overlay centres via its container instead:position: fixed; inset: 0; display: grid; place-items: center;. Never ship a modal or command palette stuck in the corner. - Set
inerton the page content behind a modal so tab order doesn't leak. - Close on: escape key, backdrop click, explicit close button.
- First focus goes to the first interactive element, not the close button.
Dropdowns, tooltips, popovers
- Use the Popover API (
popoverattribute). It handles light-dismiss, stacking, and escape for free, and works in every modern browser. - Position with CSS Anchor Positioning where it's available; fall back to
position: fixed+getBoundingClientRect(). - Never put a dropdown inside an
overflow: hiddencontainer without escape. It will clip. - Flip when near the viewport edge.
Undo over confirm
- For reversible actions, skip the confirm dialog. Do the thing. Show a toast with an Undo button for 5–10 seconds.
- For destructive, irreversible actions (delete account, drop table), keep the confirm — and make the user type the thing being destroyed, not just click "OK".
Loading and empty states
- Skeleton screens over spinners for content that has a predictable shape (lists, cards, tables).
- Inline spinners for in-button state. Replace the label, don't add beside it.
- Empty states always have: an illustration or icon (a small one), a one-line explanation of why it's empty, an action to fix it.
- Never show a generic "No results" with no context.
Bans
- Placeholder-as-label.
- Hover-only functionality (touch users can't hover).
- Focus rings removed without replacement.
- Confirmation dialogs for low-stakes actions.
- Touch targets < 44px.
- Custom cursors on interactive elements.
- Disabled elements with no explanation of why they're disabled.
- Colour-only error states.
- Spinners where a skeleton would show layout.
Contrast discipline
Hallmark output must pass slop-test gates 40–41 before shipping. Compute contrast for every (color, background-color) pair on the page. The common failures Hallmark output trips on:
- Text on a flipped surface.
.section--ink { background: var(--color-ink); }flips the surface dark; nested text still inheritscolor: var(--color-ink)→ ink-on-ink. Fix: any rule that sets a darkbackgroundmust also setcolor: var(--color-paper)in the same rule. - Button text on accent fill.
background: var(--color-accent); color: white;— but white is 4.5:1 against this accent only if--color-accentis dark enough. Usevar(--color-accent-ink)instead, which the theme guarantees passes ≥ APCA Lc 60. - Muted text on tinted paper.
color: var(--color-muted); background: var(--color-paper-3);— both mid-lightness, often falls below 4.5:1. Use--color-neutral(darker) or lift the background to--color-paper. - Focus ring on accent-coloured button.
outline: 2px solid var(--color-focus);on a button whose fill is--color-accent— if--color-focus = --color-accent, the ring vanishes. Use the contrast pair:--color-focusset to a colour with ≥ 3:1 against both the element and the page.
Computation
For each (text-colour, background-colour) pair the page actually renders:
- Run APCA Lc (preferred — perceptual) or WCAG 2.1 ratio.
- Pre-check: if both are in OKLCH with
|L_a − L_b| < 50 %, flag for full check. - Body text passes at APCA Lc ≥ 60 ≈ WCAG 4.5:1.
- Large text / focus rings / icons pass at APCA Lc ≥ 45 ≈ WCAG 3:1.
Token contract
Every theme MUST define --color-accent-ink — the text colour to use whenever --color-accent fills a surface that carries text. The accent-ink colour is verified ≥ APCA Lc 60 against the accent at the time the theme is built. Hallmark code that uses background: var(--color-accent) must also set color: var(--color-accent-ink). Falling back to hardcoded color: white is a tell — the theme's accent could be a light colour, and white-on-light is the bug.
When the surface flips
The rule: any rule that overrides background-color must also state the appropriate color. Don't rely on inheritance for surface-flipping classes. Example:
/* WRONG — text inherits color: var(--color-ink); section is now dark; ink-on-ink */
.section--manifesto { background: var(--color-ink); }
/* RIGHT */
.section--manifesto {
background: var(--color-ink);
color: var(--color-paper);
}
Same applies to per-theme overrides like [data-theme="manifesto"] .vs__col:first-child { background: var(--color-ink); } — set color: var(--color-paper) at the same time, OR declare the rule on a parent and let descendants inherit explicitly.