Keyboard Accessibility
Keyboard Works Everywhere
- All flows are keyboard-operable
- Follow WAI-ARIA Authoring Patterns
- Every interactive element is reachable via Tab/Shift+Tab
Clear Focus Indicators
- Every focusable element shows a visible focus ring
- Prefer
:focus-visibleover:focusto avoid distracting pointer users - Set
:focus-withinfor grouped controls - Use
box-shadowfor focus rings, notoutline(outline doesn't respect border-radius)
Example:
.button:focus-visible {
box-shadow: 0 0 0 3px var(--focus-ring);
}
Focus Management
- Use focus traps in modals/dialogs
- Return focus to trigger element when closing
- Move focus to first interactive element when opening
- Follow WAI-ARIA patterns for specific components
Keyboard Navigation in Lists
- Focusable elements in sequential lists: navigable with ↑↓ arrow keys
- Deletable list items: support ⌘ Backspace or Delete key
Hit Targets
Minimum Sizes
- Desktop: 24×24px minimum hit target
- Mobile: 44×44px minimum hit target
- If visual target is smaller, expand the hit target with padding/pseudo-elements
Match Visual & Hit Targets
- Exception: if visual target < 24px, expand hit target to ≥ 24px
- Use
paddingor::before/::afterpseudo-elements to expand clickable area
Example:
.small-icon-button {
/* Visual: 16x16px icon */
padding: 4px; /* Hit target: 24x24px */
}
No Dead Zones
- Interactive elements in vertical/horizontal lists: no dead areas between items
- Increase padding instead of margin to eliminate gaps
- If part of a control looks interactive, it should be interactive
Hit Area Expansion
There's a gap between interactive elements — a few dead pixels where nothing reacts to your pointer. Usually invisible. You don't notice it until something animates: hover states flicker off, transitions reset, things feel broken. The fix is to extend the hit area — the clickable box — beyond the visible bounds of the element.
Hit areas have a formal floor: WCAG 2.5.8 requires interactive targets to be at least 24×24 CSS pixels (Level AA). The Level AAA criterion raises that to 44×44 — the threshold most often cited in practice. Apple recommends 44×44; Google 48×48.
Three techniques, in order of preference:
Padding
The simplest fix. Adding padding to a small button stretches its clickable box without changing the visible element. Best for sparse rows of small targets — scroll indicators, dot navigation, icon clusters — where adjacent hit areas should tile edge-to-edge so the cursor never falls into dead space between them.
<button class="py-[5px]">
<span class="block h-[3px] w-4 bg-current" />
</button>
When dots sit 10px apart, 5px of vertical padding on each button bridges the gap. The active state hands off cleanly between neighbors instead of snapping back mid-transition.
::before pseudo-element
Padding doesn't work when the visible box has its own background, border, or hover highlight — extending the box would extend the highlight too. Use an invisible ::before pseudo-element to extend the hit area without disturbing the design. It's visually empty but still receives hover and click events.
<a class="relative before:absolute before:inset-x-[-8px] before:inset-y-0 before:content-['']">
Menu item
</a>
The parent needs position: relative (just relative in Tailwind) so the absolutely-positioned pseudo-element is contained. Use this for nav menus, tab strips, pill groups — anywhere a hover background must stay sized to the visible label.
Negative margin + padding
When you need to bridge to a sibling element that floats nearby — like a copy icon next to a heading — shift the box with negative margin and restore the visible text position with matching padding. The padding becomes the bridge.
<h2 class="-ml-6 pl-6">
Section heading
<CopyIcon class="opacity-0 group-hover:opacity-100" />
</h2>
-ml-6 shifts the heading's box left by 24px; pl-6 keeps the visible text in its original spot. The 24px of left padding becomes the hover bridge to the icon, so moving from icon to heading no longer crosses dead space that would dismiss the copy button.
When to reach for which
- Padding — small standalone targets, no background/border conflicts
::before— element has a visible background, border, or hover highlight that must not grow- Negative margin + padding — bridging to a separate sibling element that floats nearby
Source: Hit Area — sharqiewicz.com
Touch Interactions
Hover States on Touch Devices
- Only show hover states on devices with hover capability
- Use
@media (hover: hover)media query
@media (hover: hover) {
.button:hover {
background: var(--bg-light);
}
}
Prevent Double-Tap Zoom
- Set
touch-action: manipulationon interactive controls - Disables double-tap to zoom on that element
button, a, [role="button"] {
touch-action: manipulation;
}
Custom Tap Highlight
- Set
-webkit-tap-highlight-colorto match your design - Don't just disable it - provide a replacement
button {
-webkit-tap-highlight-color: rgba(var(--primary-rgb), 0.2);
}
Mobile Input Zoom Prevention
<input>font size must be ≥ 16px on mobile- Prevents iOS Safari auto-zoom/pan on focus
- Alternative: set viewport
maximum-scale=1(not recommended - blocks user zoom)
<input style="font-size: 16px;" />
Proximity Feedback
Hover is binary: an element is either lit or dead, and everything a few pixels away stays inert. Proximity feedback is the continuous version. As the cursor approaches, nearby elements respond by degree, subtly scaling up and darkening based on distance, before the pointer ever lands on them. The interface stops feeling like a grid of on/off switches and starts feeling alive and physically responsive, the way a macOS dock magnifies the icons around the one you are reaching for.
When to use it
- Dense clusters of peer elements: icon docks, toolbars, tab strips, swatch grids, app launchers
- Surfaces where the cursor travels across many small targets and you want to telegraph reachability before the click
- Decorative or brand moments (hero rows, install-tool icons) where the extra life is worth the motion
Skip it for: dense data tables, text-heavy lists, form fields, and anything where movement competes with reading. Proximity is a flourish, not a default for every interactive element.
How it works
Listen for pointermove, measure the distance from the cursor to each element's center, and map that distance to a 0..1 falloff. Closer means a larger factor. Drive scale and brightness from that factor so the response is continuous, not stepped.
const RADIUS = 120; // px of influence
const MAX_SCALE = 0.5; // up to 1.5x at the center
const MAX_DARKEN = 0.25; // up to 25% darker at the center
const ease = (t) => t * t * (3 - 2 * t); // smoothstep falloff
container.addEventListener("pointermove", (e) => {
for (const el of container.children) {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dist = Math.hypot(e.clientX - cx, e.clientY - cy);
const t = ease(Math.max(0, 1 - dist / RADIUS));
el.style.transform = `scale(${1 + t * MAX_SCALE})`;
el.style.filter = t > 0 ? `brightness(${1 - t * MAX_DARKEN})` : "";
}
});
container.addEventListener("pointerleave", () => {
for (const el of container.children) {
el.style.transform = "";
el.style.filter = "";
}
});
The original one-liner measures distance on the X axis only (Math.abs(e.clientX - center)), which is enough for a single horizontal row. Use 2D distance (Math.hypot) for grids or any layout that wraps.
Rules
- Keep it subtle. A maximum scale around 1.3x to 1.5x and a darken under ~25% reads as responsive. Past that it looks like a fairground. The point is to feel alive, not to shout.
- Continuous, not binary. The whole value is the gradient between neighbors. If only the single closest element reacts, you have reinvented hover with extra code.
- Pointer only. Touch has no hover and no cursor-approach, so the effect never fires there. Do not bolt it onto tap. Gate behind
@media (hover: hover) and (pointer: fine)if you also apply effects via CSS. - Respect reduced motion. Attach the effect only when
matchMedia("(prefers-reduced-motion: no-preference)").matchesis true. Scaling and brightness shifts are exactly the kind of motion that setting opts out of, and testing the positive query means a browser that cannot evaluate it never starts the effect at all. - Never the only affordance. Proximity scaling is decoration layered on top of real hover, focus, and active states. It must not carry meaning a keyboard or screen-reader user would miss. Focus rings and
:hoverstyles still do the actual work. - Batch the writes.
pointermovefires fast. Coalesce updates into a singlerequestAnimationFrame, and read all geometry (getBoundingClientRect) before writing any styles, so you do not thrash layout by interleaving reads and writes. - Clean up. Clear the inline
transformandfilteronpointerleaveand on unmount, or elements get stranded mid-scale. - Choose the origin.
transform-origin: bottom centermakes a row grow upward like a dock;centergrows in place. Pick the one that suits the surface.
Links vs Buttons
Links are Links
- Use
<a>or<Link>for navigation - Enables standard browser behaviors:
- Cmd/Ctrl+Click to open in new tab
- Middle-click to open in new tab
- Right-click to copy link/open in new window
- Never use
<button>or<div>for navigational links
Button Click Timing
- Dropdown menus: trigger on
mousedown, notclick - Opens immediately on press for better perceived performance
URL as State
Deep-Link Everything
- Persist state in URL when possible:
- Filters
- Tabs
- Pagination
- Expanded panels
- Search queries
- Modal/dialog state
- Enables share, refresh, Back/Forward navigation
- Use libraries like
nuqsfor Next.js
Example with nuqs:
import { useQueryState } from 'nuqs';
const [filter, setFilter] = useQueryState('filter');
// URL: ?filter=active
Scroll Position Persistence
- Back/Forward navigation restores prior scroll position
- Next.js handles this automatically
- For custom scroll containers, implement manually
Drag Interactions
Clean Drag UX
- Disable text selection during drag (
user-select: none) - Apply
inertattribute to prevent interaction with other elements - Prevents selection/hover happening simultaneously with drag
<div
draggable
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}
style={{ userSelect: dragging ? 'none' : 'auto' }}
inert={dragging ? '' : undefined}
>
Draggable item
</div>
Hydration
Hydration-Safe Inputs
- Inputs must not lose focus after hydration
- Inputs must not lose value after hydration
- Test SSR → client hydration carefully
- Use controlled inputs carefully (can cause issues)
Optimistic Updates
Update UI Immediately
- Update UI when success is likely
- Reconcile on server response
- On failure: show error + roll back or provide Undo
- Makes interface feel instant
Example:
const addItem = async (item) => {
// Optimistic update
setItems([...items, item]);
try {
await api.addItem(item);
} catch (error) {
// Roll back on error
setItems(items);
showError('Failed to add item');
}
};
Loading States
Loading Buttons
- Show loading indicator + keep original label text
- Disable button during loading
- Add
aria-busy="true"for screen readers
<button disabled={loading} aria-busy={loading}>
{loading && <Spinner />}
Submit
</button>
Minimum Loading Duration
- If showing spinner/skeleton, use:
- Short show-delay (~150–300ms) to avoid flicker on fast responses
- Minimum visible time (~300–500ms) once shown
- React
<Suspense>does this automatically
Ellipsis for Loading States
- Loading/processing states end with ellipsis
- "Loading…", "Saving…", "Generating…"
- Menu options that open follow-up also use ellipsis: "Rename…"
Loading Button Width Stability
A loading button should not change its outer width when the label switches to a loading state. The user just committed an action — if the button shrinks or grows, the secondary action moves, the footer rhythm changes, and the exact control that should feel most stable now looks like it's reflowing under pressure.
This matters most in tight layouts: form footers, modal actions, table row actions, dense settings panels.
The rule: If the action is the same button before and during loading, keep the same outer width. Change the content inside the shell — don't let the shell resize itself.
<button class="relative inline-grid min-h-11 place-items-center rounded-full
bg-black px-4 py-2 text-sm font-medium text-white">
<!-- Invisible span defines the width -->
<span class="invisible inline-flex items-center gap-2">Save changes</span>
<!-- Absolute layer handles the visible state -->
<span class="absolute inset-0 inline-flex items-center justify-center gap-2">
<span class="h-3 w-3 animate-spin rounded-full border-2
border-white/35 border-t-white"></span>
Saving...
</span>
</button>
The invisible span defines the width. The absolute layer handles the visible state. Works with CSS grid, a fixed min-width, or two overlaid spans — the specific technique matters less than the rule.
This is especially valuable when the loading state is short (300–800ms). Users might barely read the label change, but they will notice the button jumping and neighboring controls shifting.
Choosing a Loading Pattern
Most loading decisions are wrong before the animation even starts. The better question is: what exactly is unavailable right now, and what useful context can the user keep seeing while they wait?
Is the final structure already known and useful to preview?
├── Yes → Skeleton
└── No
Is the user waiting on a process rather than a layout?
├── Yes → Spinner (near the action that caused the wait)
└── No
Can the current UI stay visible without misleading?
└── Yes → Nothing (preserve current UI, replace when ready)
Skeleton: Use when the page would otherwise jump around when content arrives. Keep the skeleton close to the final layout. Skeletons also shorten the perceived wait: replacing blank space with the page's shape reads as "almost there," and the shimmer's movement creates a sense of progress even while nothing has actually loaded. LinkedIn and Facebook popularized the pattern for exactly this reason — their feeds paint grey placeholder cards instantly, so the wait feels like rendering rather than fetching. That persuasive power is also the caveat: don't use a skeleton to make a fast interface feel "more dynamic" — skeletons are layout placeholders, not decoration.
Spinner: Makes sense when the user needs acknowledgement that work is in flight, but a full placeholder layout wouldn't add clarity. Process-like work (exporting CSV, connecting account, uploading file) or when the spinner can live close to the action that caused the wait. Weak at preserving layout and explaining structure.
Nothing: Not every update deserves visible loading chrome. If a sort is local, no spinner. If a tab switch is instant from cache, no skeleton. If a background refresh happens while the current table is still useful, don't wipe the table to prove something is happening. Keep current results visible, dim slightly or show a lightweight status label, update when the next version is ready.
Honest Loading States
Loading UI is a trust contract. When the interface says "wait," the user assumes something real is happening. The reason so many loading states feel fake is that they are disconnected from actual waiting.
Initial fetch: Skeletons earn their keep only when data is genuinely unavailable. If your server already rendered the content, or the data is in client cache, a fade-in skeleton is fake — you're hiding truth to simulate progress.
Mutations: Keep cause visible. Disable the submitted control, show pending state on the button, preserve form values, keep surrounding content visible. Saving... inside the clicked button is more trustworthy than a distant spinner in the corner because it explains exactly what is happening.
Background refresh: Never blank already-visible data just because fresher data is being requested. Keep current content, show subtle progress indicator in header or near affected region. Temporary staleness is usually less harmful than abrupt disappearance.
Filters/sorts: If sorting is local and instant, no loading state at all — showing a spinner because "something happened" is pure theater. If filtering triggers a server request, keep current results visible, dim or soften the list slightly, replace results once the new set arrives.
The rule: Show the minimum loading UI that honestly explains the real wait while preserving as much useful context as possible. Anything more starts to look like performance theater.
Accessibility Announcements
Announce Async Updates
- Use
aria-liveregions for dynamic content aria-live="polite"for toasts and inline validationaria-live="assertive"for critical errors
<div aria-live="polite" aria-atomic="true">
{message}
</div>
Icon-Only Buttons
- Must have
aria-labelfor screen readers
<button aria-label="Close dialog">
<CloseIcon />
</button>
Tooltips
Tooltip Timing
- First tooltip in a group: ~500ms delay
- Subsequent tooltips (while hovering nearby): no delay
- Implement using
data-instantattribute
[data-tooltip] {
/* First tooltip */
transition: opacity 200ms;
transition-delay: 500ms;
}
[data-instant] [data-tooltip] {
/* Subsequent tooltips */
transition-delay: 0ms;
}
Tooltip Restrictions
- Tooltips triggered by hover should not contain interactive content
- Interactive content belongs in popovers/dialogs, not tooltips
Semantic HTML & ARIA
Semantics Before ARIA
- Prefer native elements (
<button>,<a>,<label>,<table>) - Only use ARIA when native elements can't achieve the pattern
- Native elements have built-in keyboard support and semantics
Headings & Skip Link
- Use hierarchical
<h1>through<h6> - Include a "Skip to content" link as first focusable element
- Hides visually but available to screen readers
<a href="#main" class="skip-link">Skip to content</a>
<main id="main">...</main>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 0;
top: 0;
z-index: 9999;
}
Prevent Accidental Input
Don't Block Paste
- Never disable paste in
<input>or<textarea> - Users should be able to paste passwords, codes, etc.
Respect Browser Zoom
- Never disable browser zoom
- Never set
user-scalable=noin viewport meta tag
Locale & Content
Non-Breaking Spaces
- Use
(non-breaking space) to keep units/terms together:10 MB(not10 MB)⌘ + K(not⌘ + K)Vercel SDK(notVercel SDK)
- Use
⁠(word joiner) for no space but prevent break
Locale-Aware Formats
- Format dates, times, numbers, delimiters, currencies for user's locale
- Use browser
IntlAPIs or libraries likedate-fns
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(1234.56); // "$1,234.56"
Prefer Language Settings Over Location
- Detect language via
Accept-Languageheader +navigator.languages - Never rely on IP/GPS for language (user might be traveling)
Confirm Destructive Actions
- Require confirmation for destructive actions
- Or provide Undo with a safe time window
- Make safe option (Cancel) more prominent than destructive action
Overscroll Behavior
- Set
overscroll-behavior: containintentionally in modals/drawers - Prevents background page from scrolling when modal is open
- Use
overscroll-behavior: noneon navbars and fixed containers to prevent users from scrolling past them - Use per-axis variants for more granular control:
overscroll-behavior-x: none— prevent horizontal overscroll onlyoverscroll-behavior-y: none— prevent vertical overscroll only
.modal {
overscroll-behavior: contain;
}
.navbar, .sidebar {
overscroll-behavior: none;
}
Direct Manipulation & Gestural Intent
People communicate with gesture, not just words — we point at things and say "that one." Interfaces that let users act directly on the object beat interfaces that make them describe what they want.
Prefer Direct Manipulation Over Description
- Let users act on the thing itself instead of describing it
- A user circling a region, dragging a slider, or selecting a layer is faster and less ambiguous than typing "the navbar in the top-right"
- Build affordances that accept gesture as input (lasso, drag-to-target, click-to-anchor) wherever the alternative is asking the user to describe what they're already looking at
- The selected object becomes the subject of the next command — no need to name it
- Give visible feedback on pointer-down, not after
click, so the interface feels connected to the hand - During a drag, keep the object attached to the pointer and preserve the grab offset. Snapping the object center to the pointer makes it feel like the user lost the object and picked up a value.
Examples:
- Click an element on a canvas → the next prompt is implicitly scoped to it
- Drag a file onto a target zone instead of opening a picker and typing a path
- Lasso multiple items to batch-act on "these"
Shape the Response to the Intent
- The format of a reply should match the context of the request, not a fixed template
- The same query in different contexts deserves different UIs
- Avoid one-size-fits-all panels for actions that have a natural inline representation
Examples:
- Point at a color and ask for "more like this" → swatch palette, not a paragraph
- Point at a button and ask for "more options" → docked picker, not a modal
- Select a chart region and ask "what's this?" → inline annotation, not a sidebar
Anchor Responses to the Point of Intent
- When a user's action targets a specific element, the system's response should appear at or near that element
- Don't make users map answers back to questions across the screen
- Inline popovers, docked menus, and floating workspaces that spawn next to the selection keep attention where the work is happening
- Reserve full-page or sidebar responses for queries that aren't tied to a single element
Pattern: selection → response spawns at the selection's bounding box, not in a fixed location.
Keep Gesture State Interruptible
- Never block input while a gesture completion animation runs. If the user grabs a moving sheet, card, or slider, the object should respond from its current on-screen position.
- Track a short pointer history during movement: position, time, and axis. Use it to compute velocity on release instead of guessing from the final point alone.
- Choose the next resting state from both distance and velocity. A fast flick can mean "continue" even when distance is short; a slow drag can mean "cancel" even when distance is long.
- Use soft boundaries for overscroll, sheet expansion, and drag limits. Movement should get harder past the edge, not stop dead.
- Define a small set of semantic snap points before the gesture begins:
center,open, or content-driven detents. For controlled, reversible states, disable free inertia, choose the destination from position and velocity, and spring from the visible release position to that snap point. Keep momentum only when throwing is part of the intent. - Capture the pointer when dragging begins, ignore extra touches until release, and clean up listeners on cancel. Losing pointer ownership is the fastest way to make direct manipulation feel broken.
For the full gesture implementation rules, load get-ux-principle({ topic: "gestures" }).
When Not to Animate
Motion is a cost paid on every repetition. Judge an animation by how it feels on the hundredth run, not the first. A transition that delights during review becomes a wait once the interaction is habitual.
Do not fade in a menu the user is about to click. Popovers, dropdowns, and command menus launched deliberately should appear instantly. The user already knows what is coming and is moving toward it; the fade is pure latency. Keep the fade out, which confirms the action registered without standing between the user and the target.
Do not animate keyboard driven interfaces. Keyboard input is mechanical rather than physical, so there is no momentum for motion to continue. Command palettes, list navigation, and active indicators driven by arrow keys feel faster with no transition at all. Pointer input is fluid and reads better with motion; keyboard input does not.
Do not animate values the user is reading. Counting up a number in a tooltip while someone is trying to compare data points makes the interface harder to use. The same applies to a video seeker or any control where precision is the point. If the brain can process the new state faster than the animation can play, the animation is in the way.
Do not exaggerate a response the input did not earn. Celebration on a cancel button, a bouncy spring on a plain press, or an elaborate surface morph on an error menu all misread the moment. Match the size of the response to the size of the action and to the user's likely state of mind. Someone debugging a failure has no appetite for flourish.
Reserve expressive motion for moments of genuine magnitude, for gestures that carry real momentum, and for audiences receptive to it. Everything high frequency should be quiet.
Animation Intent
Motion in a product should answer a question. Before adding any, name which job it is doing.
- Tangible — makes an interface feel physical. Nothing in the real world teleports, so a panel that slides and a card that lifts on hover read as objects rather than repaints.
- Orienting — explains where something came from or went. A menu growing out of the button that opened it tells the user what belongs to what.
- Directing — moves the eye to the one thing that changed. Reserve it, because everything cannot be the exception.
- Character — the effects with no job beyond delight. These are the ones worth being distinctive about, and the ones to use least.
The common failure is a product where each animation was borrowed from somewhere different: one bounces, one fades, one slides, none of them agree. That reads as a pile of effects, not a product.
- Pick one motion concept and apply it everywhere. For example, surfaces that brighten and lift toward the pointer, and nothing else.
- A repeated, boring concept beats a set of individually clever ones. Consistency is what registers as craft.
- If an animation cannot be assigned to one of the four jobs, cut it.
Action-Driven Transitions
Most transitions are modelled on state: open or closed, on or off. But there are often several ways to reach the same state, and they do not mean the same thing.
- A confirmation dialog closes on both confirm and cancel. Confirm is progress, cancel is retreat, and the same closing animation for both throws that information away.
- Animate the action, not the state. Confirm can settle forward into the page it committed to. Cancel can drop back toward the control that opened it.
- The difference should be small: a direction, not a different effect. The user should feel the outcome rather than notice the animation.
- This applies anywhere an element has more than one exit: a dismissed versus accepted notification, a saved versus discarded draft, a deleted versus archived row.
Staggered Reveals
When a container and its contents animate together, there is a moment where half-visible content sits on top of a half-visible surface. It is brief, and it reads as cheap without the user knowing why.
- Orchestrate the sequence: the surface arrives first, then its contents, then any decorative flourish.
- Keep the offsets small, roughly 40ms to 80ms. This is a handoff, not a performance, and long staggers make the interface feel slow.
- Order the stagger by reading order, so the eye is led rather than scattered.
- Reverse the order on exit. Contents leave first, the surface last, so nothing is left floating over a dissolving background.
- Cap the number of staggered pieces. Past roughly 5, the last item is waiting on a queue the user never asked for.
Motion That Adapts to Repeat Use
The standard advice is that a user should never wait for an animation, which is usually taken to mean cutting the flourish. There is a better option: keep it for the first encounter and shorten it after.
- Play the full version the first time, then a shorter version on repeat visits, keyed off local storage or session state.
- The rule that matters is that motion may never gate an interaction. Any flourish must be skippable by the next click, tap or key press.
- Never pad a real delay to fit an animation. If the work finishes early, move on and let the animation cut short.
- The heaviest flourishes belong at moments the user reaches rarely: first run, a completed purchase, an empty state that has just been filled for the first time.
Creating the Ultimate Slider
A conventional slider becomes more confident when the whole surface participates in the gesture. The label and value can live inside the track, reducing the distance between the control and its meaning. Proportion, typography, and handle behavior then turn a functional primitive into a coherent component.
Important refinements include:
- make the complete control a generous drag target;
- use tabular or monospaced figures so the value does not jitter;
- let the handle respond to contact without obscuring the value;
- tune track, type, and handle as one composition;
- keep keyboard, focus, and assistive-technology support equal to pointer behavior;
- allow interruption and reversal without snapping or lag.
State Machines
Explicit states prevent an interface from accumulating contradictory booleans. Describe what states exist, which events are accepted in each state, and what visible or asynchronous effects follow.
An asynchronous action might be idle, submitting, succeeded, or failed. The transition model becomes the source of truth for button labels, disabled behavior, progress, confirmation, and recovery.
type SaveState =
| { status: "idle" }
| { status: "submitting" }
| { status: "succeeded"; savedAt: number }
| { status: "failed"; message: string };
type SaveEvent =
| { type: "SUBMIT" }
| { type: "RESOLVE"; savedAt: number }
| { type: "REJECT"; message: string }
| { type: "RESET" };
export function transition(state: SaveState, event: SaveEvent): SaveState {
switch (event.type) {
case "SUBMIT":
return state.status === "submitting" ? state : { status: "submitting" };
case "RESOLVE":
return state.status === "submitting"
? { status: "succeeded", savedAt: event.savedAt }
: state;
case "REJECT":
return state.status === "submitting"
? { status: "failed", message: event.message }
: state;
case "RESET":
return { status: "idle" };
}
}
Create a playground that can display and trigger every state without waiting for production conditions. For a larger flow, expose its relevant flags and events in a control panel. This makes combinations inspectable and often reveals impossible or missing transitions before they reach users.
Checklist
Keyboard
- All interactive elements are keyboard accessible
- Focus indicators are visible (
:focus-visible) - Focus management (traps, return focus)
- Arrow key navigation in lists
- Delete key works for deletable items
Touch
- Hover states only on
@media (hover: hover) - Hit targets ≥ 24px desktop, ≥ 44px mobile
-
touch-action: manipulationon controls - Custom tap highlight color set
- Input font size ≥ 16px on mobile
Proximity Feedback
- Used only for dense peer clusters (docks, toolbars, swatch grids), not data tables or text lists
- Response is continuous by distance, not a single binary closest-element toggle
- Subtle: max scale ~1.3x to 1.5x, darken under ~25%
- Gated to pointer devices (
@media (hover: hover) and (pointer: fine)); never fires on touch - Attached only on a positive match of
prefers-reduced-motion: no-preference - Layered on top of real hover/focus/active states, never the only affordance
- Writes batched in
requestAnimationFrame; geometry read before styles written - Inline
transform/filtercleared onpointerleaveand unmount
Links & Navigation
- Use
<a>for navigation, not<button> - URL persists state (filters, tabs, pagination)
- Scroll position persists on Back/Forward
- Deep-link everything
Loading & Updates
- Optimistic updates implemented
- Loading states show spinner + label
- Minimum loading duration prevents flicker
- Ellipsis used for loading states
- Loading buttons maintain stable width
- Correct loading pattern chosen (skeleton/spinner/nothing)
- Loading UI preserves context (no unnecessary blanking)
Accessibility
- Semantic HTML before ARIA
- Icon-only buttons have
aria-label - Async updates announced with
aria-live - Skip to content link included
- Heading hierarchy correct
Input
- Paste is never blocked
- Browser zoom is never disabled
- Inputs are hydration-safe
- Destructive actions require confirmation
Locale
- Non-breaking spaces used for units
- Dates/numbers formatted for locale
- Language from settings, not location
Direct Manipulation
- Users can act on objects directly (click, drag, lasso) instead of describing them
- Selection scopes the next command — no need to re-name the target
- Response format matches intent (swatch for color, picker for options, annotation for region)
- Responses anchor to the selection's location, not a fixed panel
- Pointer-down feedback is immediate
- Dragged objects preserve grab offset and track the pointer one-to-one
- Gesture completion uses velocity, not distance alone
- Moving objects remain interruptible and retarget from their current on-screen value
- Boundaries resist and spring back instead of clamping abruptly
- Constrained drags settle on explicit snap points; free inertia is reserved for intentional throws
Motion Restraint
- Menus the user launches deliberately appear with no fade in
- Keyboard driven navigation is not animated
- Values the user needs to read are not counted up or animated
- The size of the response matches the size of the action
- Expressive motion is reserved for moments of genuine magnitude