../

UX/UI design principles

Heuristics, laws, layout, type, states, forms, feedback, accessibility (WCAG 2.2 AA) and design tokens, as a checklist for building and reviewing interfaces. Palettes and contrast math live in Color theory; the CSS side is in CSS.

Usability heuristics

Jakob Nielsen's 10 heuristics (NN/g). Use them for heuristic evaluation: 3–5 reviewers each walk the UI alone, then merge findings and rate severity.

#HeuristicMeaningExample
1Visibility of system statusalways show what is happening, promptlyupload progress, "Saved" indicator, active nav item
2Match with the real worldthe user's words and mental models, not the system's"Bin" not "Purge queue"; calendar that looks like a calendar
3User control and freedoma clear exit from any stateundo, cancel, back, close on Esc
4Consistency and standardssame thing, same name, same look; follow platform conventionslogo links home, blue underlined links
5Error preventiondesign the error out before messaging itdisable impossible dates, confirm destructive actions, constraints
6Recognition rather than recalloptions visible, not memorizedrecent searches, autocomplete, visible labels
7Flexibility and efficiencyaccelerators for experts that novices can ignorekeyboard shortcuts, ⌘K palette, bulk actions
8Aesthetic and minimalist designevery extra element competes with the useful onesone primary action per view
9Recognize, diagnose, recover from errorsplain-language cause and a fix"Card expired. Use another card or update the date."
10Help and documentationsearchable, task-focused, in contextinline hints, tooltips, docs linked from the screen

Severity scale: 0 not a problem, 1 cosmetic, 2 minor, 3 major (fix before release), 4 catastrophe.

Laws of UX

LawSaysApply it
Fitts's lawtime to hit a target grows with distance and shrinks with sizebig, close targets for frequent actions; screen edges and corners are "infinite" targets
Hick's lawdecision time grows with the number and complexity of choicesfewer options, sensible defaults, progressive disclosure
Jakob's lawusers spend most time on other sites and expect yours to work the samereuse conventions (cart top-right, search icon)
Miller's lawworking memory holds about 7 ± 2 items (newer research: ~4 chunks)chunk content (phone numbers, card numbers); not a cap on menu items
Tesler's lawevery system has complexity that can't be removed, only movedthe product absorbs it, not the user (smart defaults, inference)
Doherty thresholdproductivity climbs when the system responds in under ~400 msfast feedback, optimistic UI, perceived-performance tricks
Peak-end ruleexperiences are judged by their peak and their endpolish the key moment and the final step (confirmation, success)
Aesthetic-usability effectattractive designs are perceived as easier to usepolish matters, but it also hides problems in testing
Von Restorff effectthe item that differs is rememberedone visually distinct primary CTA; don't make everything stand out
Serial position effectfirst and last items are recalled bestkey nav items at the start and end
Postel's lawbe liberal in what you accept, conservative in what you sendaccept "+44 20…", "020…", spaces and dashes; output one format
Goal-gradient effecteffort rises as the goal gets closerprogress bars, checklists, pre-filled first step
Zeigarnik effectunfinished tasks stick in memory"profile 60% complete", visible incomplete steps

Gestalt & visual hierarchy

Gestalt principles

PrinciplePerceptionUI use
Proximitynear things belong togetherless space inside a group than between groups
Similaritysame color, shape or size means same kindall links styled alike; all destructive actions red
Common regiona shared boundary makes a groupcards, panels, fieldsets
Uniform connectednessconnected elements relatelines in steppers, timelines
Closurethe mind completes partial shapesicons, cropped carousels hinting more content
Continuityeyes follow lines and curvesaligned edges, horizontal scrollers
Figure-groundforeground separates from backgroundmodals with a scrim, elevated cards
Common fatethings moving together are a groupitems animating together after a filter
Symmetry and order (Prägnanz)the simplest reading winsplain shapes, clear grids
proximity similarity closure common region continuity 2 groups by gap rows by fill gaps filled in box beats spacing 2 lines, not a V
Five Gestalt principles at a glance

Hierarchy levers

Rank each screen's content (primary, secondary, tertiary), then use the fewest levers that make the ranking obvious. The squint test: blur the screen and check that the primary thing still wins.

LeverStrongerWeakerNote
Sizelargersmalleruse a type scale, not ad-hoc sizes
Weight600–700400two or three weights are enough
Colorsaturated, high contrastgray, lower contrastde-emphasize secondary text with color, not smaller size
Spacingmore whitespace aroundtightspace isolates and elevates
Contrastdark on light / light on darkmutedthe only lever that also affects legibility
Positiontop-left (LTR), above the foldbottom, trailingF and Z scan patterns
Depthshadow, elevationflatreserve for overlays and draggable items

Buttons follow the same rule: one solid primary, outline or tonal secondary, text-only tertiary. Destructive actions are only red when they are the primary action of that view.

hierarchy.html
<style>
  .card { max-width: 320px; padding: 16px;
    border: 1px solid var(--chip); border-radius: 8px; }
  .eyebrow { font-size: 12px; color: var(--muted);
    text-transform: uppercase; letter-spacing: 0.06em; }
  h3 { margin: 4px 0; font-size: 20px; font-weight: 700; }
  .body { margin: 0 0 12px; color: var(--muted); }
  .cta { font: inherit; font-weight: 600; border: 0;
    padding: 6px 12px; border-radius: 6px; color: white;
    background: oklch(0.55 0.2 260); }
</style>
<div class="card">
  <div class="eyebrow">Invoice 1042</div>
  <h3>€1,240 due Friday</h3>
  <p class="body">Paid by the card on file.</p>
  <button class="cta">Review invoice</button>
</div>
Result

Size and weight make the amount primary, muted color demotes the eyebrow and body, and the only saturated element is the one action.

Layout, spacing & responsive

4/8 pt grid

Every size and space is a multiple of 4 px (fine steps) or 8 px (most layout). It keeps rhythm consistent and maps well to 1x/1.5x/2x/3x screens.

TokenpxremTypical use
space-140.25icon to label gap
space-280.5inside compact controls, related items
space-3120.75input padding, list item gap
space-4161default gap, card padding (mobile)
space-6241.5card padding, form field groups
space-8322between sections in a card
space-12483between page sections (mobile)
space-16644between page sections (desktop)
space-24966hero and landing blocks
4 8 12 16 24 32 48 64 1 2 3 4 6 8 12 16 px space-n drawn 1.5× nesting: inner space ≤ outer space 24 card padding 8 label → field 24 field → field tight inside a group, loose between groups
The 4/8 pt scale, and how it nests inside a card

Start with too much whitespace and remove it. Internal spacing (padding) should be smaller than external spacing (margin to the next group), otherwise proximity lies.

spacing.css
:root {
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-12: 3rem;
  --space-16: 4rem;
}
 
.stack > * + * { margin-block-start: var(--space-4); }
.cluster {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

Layout rules

RuleDetail
Content-first widthtext columns cap at ~65ch; don't stretch forms to 1200 px
12-column gridcommon for desktop; 4 columns on mobile, 8 on tablet
Align to few edgesfewer alignment lines read as calmer
Group, then separatewhitespace first, then background, borders last
Mobile-first CSSbase styles for small screens, min-width queries add layout
Container queriescomponents respond to their container, not the viewport
Reflowusable at 320 CSS px wide with no horizontal scroll (WCAG 1.4.10)
Breakpoint (common)WidthLayout
sm640 pxsingle column, bottom nav
md768 pxtwo columns, collapsible sidebar
lg1024 pxpersistent sidebar
xl1280 pxmax content width, extra whitespace

Pick breakpoints where your content breaks, not per device. Tailwind's defaults match the table: see Tailwind.

Typography

Type scale

Multiply a base size (usually 16 px) by a ratio per step.

RatioName16 px after 4 stepsSuits
1.067minor second20.7 pxdense apps, data tables
1.125major second25.6 pxproduct UI, mobile
1.2minor third33.2 pxgeneral UI, docs
1.25major third39.1 pxcontent sites, dashboards with headings
1.333perfect fourth50.5 pxblogs, marketing
1.414augmented fourth64 pxeditorial
1.5perfect fifth81 pxlanding pages, display
1.618golden ratio109.7 pxposters, hero text only
type-scale.html
<style>
  :root { --r: 1.25; --s0: 16px;
    --s1: calc(var(--s0) * var(--r));
    --s2: calc(var(--s1) * var(--r));
    --s3: calc(var(--s2) * var(--r));
    --s4: calc(var(--s3) * var(--r)); }
  p { margin: 0 0 4px; line-height: 1.2; }
</style>
<p style="font-size: var(--s4)">Step 4 · 39 px</p>
<p style="font-size: var(--s3)">Step 3 · 31 px</p>
<p style="font-size: var(--s2)">Step 2 · 25 px</p>
<p style="font-size: var(--s1)">Step 1 · 20 px</p>
<p style="font-size: var(--s0)">Step 0 · 16 px body</p>
Result

Use a tighter ratio on mobile and a larger one on desktop, or interpolate with clamp() (see Recipes). In practice, many systems hand-tune a fixed list (12, 14, 16, 18, 20, 24, 30, 36, 48).

Readability

PropertyGuideline
Body size16 px minimum on the web; 17 pt iOS default; don't go below 12 px for any text
Line length45–75 characters, ~66 ideal; max-inline-size: 65ch
Line heightbody 1.4–1.6; headings 1.1–1.25; longer lines need more
Paragraph spacingabout one line (0.75–1em) between paragraphs; WCAG 1.4.12 layouts must survive user overrides of line height 1.5, paragraph spacing 2em, letter 0.12em, word 0.16em
Letter spacingslightly positive for all-caps and small text, slightly negative for large display
Alignmentleft (start) aligned; avoid justified text on the web (rivers)
Unitsrem for font sizes so browser zoom and user settings scale them
Wrappingtext-wrap: balance for headings, text-wrap: pretty for paragraphs (no Firefox yet)

Pairing

ApproachExampleRule
One familyInter for everythinguse weights and sizes for hierarchy; safest
Sans + serifserif headings, sans bodycontrast in structure, similar x-height
Sans + monoUI sans, mono for code and numbersmono or font-variant-numeric: tabular-nums for data
System stacksystem-uizero load time, native feel

Limit to two families and three or four weights. Load variable fonts to get every weight in one file.

Components & states

State matrix

Every interactive component needs a design for each state that applies.

StateTriggerVisualCode / a11y
Defaultrestingbase stylesemantic element (button, a, input)
Hoverpointer oversubtle bg or color shift@media (hover: hover) so touch doesn't stick
Focus visiblekeyboard focus2 px+ outline, 3:1 against neighbors:focus-visible; never outline: none without a replacement
Active / pressedpointer or key downdarker, slight inset or scale:active; toggles use aria-pressed
Selected / checkedchosenfill, check mark, not color alonearia-selected, aria-checked, aria-current="page"
Disablednot availablereduced contrast, no pointerdisabled; prefer aria-disabled="true" plus a reason when users must discover why
Loadingwork in progressspinner or skeleton, keep size stablearia-busy="true"; block double submit
Emptyno data yetexplanation + next actionsee below
Errorfailed or invalidred + icon + messagearia-invalid, message linked with aria-describedby
Successcompletedbrief confirmationrole="status" live region
Read-onlyvisible, not editableno input chrome, still selectablereadonly, still focusable

The five pointer and keyboard states side by side; the real :hover, :focus-visible and :active rules also work on the first button.

button-states.html
<style>
  .btn { font: inherit; padding: 6px 12px; border: 0;
    border-radius: 6px; color: white;
    background: oklch(0.55 0.2 260); }
  @media (hover: hover) {
    .btn:hover { background: oklch(0.49 0.2 260); } }
  .hover { background: oklch(0.49 0.2 260); }
  .btn:focus-visible, .focus {
    outline: 2px solid var(--fg); outline-offset: 2px; }
  .btn:active, .active {
    background: oklch(0.43 0.2 260); scale: 0.97; }
  .btn:disabled {
    background: oklch(0.55 0.05 260 / 45%);
    cursor: not-allowed; }
  .row { display: flex; gap: 10px; flex-wrap: wrap; }
</style>
<div class="row">
  <button class="btn">Default</button>
  <button class="btn hover">Hover</button>
  <button class="btn focus">Focus</button>
  <button class="btn active">Active</button>
  <button class="btn" disabled>Disabled</button>
</div>
Result

Disabled controls are exempt from contrast rules but still need to be recognizable. Don't disable a submit button to signal invalid input: let the user press it and show what's missing.

Empty, error & onboarding states

SituationShowAvoid
First use (nothing created)what goes here, why it helps, one primary action, optional sample dataa blank table with column headers
User-cleared ("inbox zero")positive confirmation, maybe what's nextthe same copy as first use
No resultsthe query, why nothing matched, fixes (clear filters, spelling, broader search)"No data"
Load failurewhat failed, a retry, keep what did loadfull-page error for one widget
Offlinecached content plus a banner, queue writessilent failures
No permissionwho can grant access, a request buttona 404 that hides the reason
404 pagesearch, key links, a way homeblame ("you typed it wrong")

Onboarding: prefer contextual hints and a short checklist (goal-gradient, Zeigarnik) over a multi-screen tour. Delay sign-up until the user has seen value, and let them skip and resume later.

Forms

RuleWhy
Visible label above each fieldplaceholders vanish on input and fail contrast
One columnfaster to scan, fewer skipped fields
Ask only what you needeach field costs completion rate
Mark optional fields "(optional)"usually fewer than required ones; or mark required with text, not just *
Right input type and autocompletecorrect mobile keyboard, autofill, password managers
Size fields to the expected inputa postcode field hints at its length
Group with fieldset and legendradio sets, addresses
Primary button at the end, aligned with fieldsfollow the reading path
Allow paste everywhereWCAG 3.3.8: don't block password managers or pasted codes
Don't ask twiceWCAG 3.3.7: reuse earlier answers (shipping = billing checkbox)

Validation timing

WhenUse for
On submitalways; show an error summary at the top, link each error, move focus to it
On blur (first time)format checks after the user leaves a field
On input, after an errorclear the error as soon as the value becomes valid
On input, debouncedusername availability, password strength meters
Neverwhile the user is still typing a field for the first time ("premature errors")

Error messages

DoDon't
"Enter an email address like name@example.com""Invalid input"
"Password must be at least 12 characters""Password error"
Place under the field, keep the typed valueclear the field
Icon + text + colorred border only
Say how to fix itblame ("You entered a wrong date")
<label for="email">Email</label>
<p id="email-hint">We'll send the receipt here.</p>
<input id="email" type="email" autocomplete="email"
  required aria-describedby="email-hint email-error">
<p id="email-error" aria-live="polite"></p>

More on the DOM side in Forms.

Feedback & latency

Response timeFeelsDo
up to 0.1 sinstant, direct manipulationno indicator
0.1–1 sa delay, flow of thought intactno spinner needed; subtle state change
up to ~0.4 sDoherty threshold for staying engagedaim here for interactions
1–10 sattention holds, but the user waitsspinner or skeleton
over 10 sattention driftsdeterminate progress, estimate, cancel, notify when done

Web targets (Core Web Vitals, 75th percentile): INP ≤ 200 ms, LCP ≤ 2.5 s, CLS ≤ 0.1.

Loading patterns

PatternWhenNote
Nothingunder ~300 msa flashing spinner feels slower than none
Inline spinner1–~5 s, small area (button)keep the button width fixed
Skeletonloading a known layout (lists, cards, pages)match the final layout to avoid shifts; no skeleton for tiny waits
Progress barlong, measurable work (upload, export)determinate if at all possible
Optimistic UIlikely-to-succeed, reversible writes (like, reorder, rename)update now, roll back with a message on failure
Background + notifyminutes (video render, report)let the user leave; toast or email when done

Other feedback rules: acknowledge every input within 100 ms (pressed state), confirm success unobtrusively (toast, inline tick), and prefer undo over confirmation dialogs for reversible actions. Data-fetching patterns in TanStack Query.

PatternUse whenWatch out
Top nav barfew top-level sections, marketing sitesoverflows on small screens
Sidebarapps with many sections, deep hierarchiescollapse to icons or a drawer on mobile
Bottom tab barmobile apps, 3–5 top destinationsnot for actions; labels under icons
Tabs2–~6 peer views of one objectnot for sequential steps
Breadcrumbshierarchies 3+ levels deepsupplement, not primary nav
Searchlarge or unpredictable contentshow recent and suggested queries
Command palette (⌘K)power users, many actionsa shortcut, not the only path
Hamburger menusecondary items on mobilehides nav and lowers discoverability
Steppers / wizardslong linear tasksshow progress, allow back without data loss
Pagination / load more / infinite scrolllong listsinfinite scroll breaks the footer, back button and "find again"; prefer load more

Rules: show "you are here" (aria-current="page"), keep the URL in sync with view state (filters, tabs, pagination) so Back and sharing work, label with the user's words (validate with card sorting and tree testing), and prefer broad-and-shallow over narrow-and-deep hierarchies.

UX writing

RuleDoDon't
Buttons are verbs that name the outcome"Save changes", "Send invoice""OK", "Submit", "Yes"
Confirmations restate the action"Delete 3 files?" then [Delete] [Cancel]"Are you sure?" then [Yes] [No]
Front-load the important words"Password reset link sent""We have now sent you a link which…"
Sentence case"Create new project""Create New Project"
One term per conceptalways "delete""delete", "remove", "trash" for the same thing
Plain words, short sentences"Can't connect. Check your Wi-Fi.""Network error 0x80070"
Errors: what happened + how to fix"That code expired. Send a new one.""Error. Try again."
Numerals and specifics"3 items", "2 min left""three items", "shortly"
Descriptive link text"Read the pricing guide""Click here"
Positive framing"Keep me signed in""Don't sign me out"
Match tone to the momentlight on success, calm and direct on errorsjokes in error messages

Write the empty state, error and success copy with the design, not after. Keep UI strings out of code so they can be translated; leave ~30% extra room for longer languages like German.

Accessibility & touch

WCAG 2.2 AA essentials

SCLevelRequirementQuick check
1.1.1 Non-text contentAtext alternative for images and icons; alt="" for decorationscreen reader or alt-text audit
1.3.1 Info and relationshipsAstructure in markup: headings, lists, labels, tablesturn off CSS, still makes sense
1.4.1 Use of colorAcolor is never the only signalgrayscale screenshot
1.4.3 Contrast (minimum)AAtext 4.5:1; large text (24 px, or 18.66 px bold) 3:1contrast checker
1.4.4 Resize textAAusable at 200% text sizebrowser zoom 200%
1.4.10 ReflowAAno 2D scrolling at 320 CSS px width400% zoom on 1280 px
1.4.11 Non-text contrastAAUI component boundaries, icons, focus indicators 3:1check borders and icons
1.4.12 Text spacingAAno loss when users increase line, letter, word spacingtext-spacing bookmarklet
1.4.13 Content on hover or focusAAtooltips dismissible (Esc), hoverable, persistenthover then move into the tooltip
2.1.1 KeyboardAeverything works with a keyboardunplug the mouse
2.1.2 No keyboard trapAfocus can always leave (except modal by design, with Esc)tab through everything
2.2.2 Pause, stop, hideAauto-moving content over 5 s can be pausedcarousels, tickers
2.3.1 Three flashesAnothing flashes more than 3 times per secondvideo, animations
2.4.3 Focus orderAtab order follows the visual ordertab through
2.4.7 Focus visibleAAa visible keyboard focus indicatortab through
2.4.11 Focus not obscured (min)AAfocused item not fully hidden by sticky headers or bannerstab under sticky UI; scroll-padding
2.5.7 Dragging movementsAAa single-pointer alternative to dragreorder with buttons too
2.5.8 Target size (minimum)AAtargets at least 24 × 24 CSS px, or spaced so a 24 px circle fitsinline links in text are exempt
3.3.1 / 3.3.2 Errors, labelsAerrors described in text; inputs have labelssubmit an empty form
3.3.8 Accessible authentication (min)AAno cognitive tests to log in; allow paste and password managerstry pasting the password
4.1.2 Name, role, valueAcustom widgets expose role and state (ARIA)accessibility tree in devtools
4.1.3 Status messagesAAstatus updates announced without moving focusrole="status", aria-live
2.3.3 Animation from interactionsAAAmotion triggered by interaction can be turned offprefers-reduced-motion

Also: one h1, logical heading levels, a skip link, lang on html, visible labels that match accessible names (2.5.3), and semantic HTML before ARIA. Automated tools (axe, Lighthouse) catch only part of the issues; test with a keyboard and a screen reader (VoiceOver, NVDA).

Text needs 4.5:1 (1.4.3); borders, icons and focus rings need 3:1 (1.4.11), the same bar as large text, so read the "large" grade for them:

Placeholder text in #9e9e9e
#9e9e9e on #ffffff · 2.67:1 · text fail · large fail
Body text in #757575
#757575 on #ffffff · 4.60:1 · text AA · large AAA
▬▬▬ input border #c4c4c4: fails 3:1
#c4c4c4 on #ffffff · 1.74:1 · text fail · large fail
▬▬▬ input border #949494: passes 3:1
#949494 on #ffffff · 3.03:1 · text fail · large AA

Touch targets

SourceMinimumNote
WCAG 2.5.8 (AA)24 × 24 CSS pxor enough spacing around smaller targets
WCAG 2.5.5 (AAA)44 × 44 CSS pxthe comfortable target
Apple HIG44 × 44 ptiOS, iPadOS
Material 348 × 48 dp~9 mm; at least 8 dp between targets
12 12 24 44 48 20 px icon same icon, 44 px target minimums (CSS px) hit area = icon below 24: fails AA ::after, inset: -12px 24 WCAG AA 44 AAA, Apple · 48 Material
Visual size vs hit area (drawn 3×)

Put primary mobile actions in the thumb zone (bottom half), avoid hover-only affordances, and use @media (pointer: coarse) to enlarge controls on touch devices.

Design systems, tokens & dark mode

Token tiers

TierExamplePoints toChanges when
Primitive (reference)color.blue.600, space.4raw valuesthe brand palette changes
Semantic (system)color.bg.accent, color.fg.mutedprimitivesthe theme changes (light, dark, high contrast)
Componentbutton.primary.bgsemanticsone component needs an override

Components use semantic tokens only. Themes swap the semantic layer; primitives stay the same.

tokens package
tokens/primitives/color.tokens.json  # blue.50 to blue.950, gray, redspace.tokens.json  # 4 px scaletype.tokens.json   # families, sizes, weightssemantic/light.tokens.json  # aliases: bg, fg, border, accentdark.tokens.jsoncomponents/button.tokens.jsonbuild.ts               # Style Dictionary / Terrazzodist/tokens.css         # generated custom propertiestokens.ts          # generated typed constantspackage.json

Tokens use the W3C Design Tokens Community Group format (first stable version 2025.10): every token has a $value and optional $type, and {path.to.token} makes an alias.

semantic/light.tokens.json
{
  "color": {
    "bg": {
      "accent": {
        "$type": "color",
        "$value": "{color.blue.600}"
      }
    }
  }
}
tokens.ts
export const space = {
  0: "0",
  1: "0.25rem",
  2: "0.5rem",
  3: "0.75rem",
  4: "1rem",
  6: "1.5rem",
  8: "2rem",
  12: "3rem",
  16: "4rem",
} as const;
 
export type Space = keyof typeof space;   // 0 | 1 | 2 | ...
 
export const pad = (n: Space) => `var(--space-${n})`;
pad(4);
// @ts-expect-error 5 is not on the scale
pad(5);

Dark mode considerations

ConcernDo
Backgroundnear-black (#121212-ish) or dark gray, not only #000; pure black smears on OLED scroll
Textoff-white (~87% white) for body, lower for secondary; avoid pure white on pure black (halation)
Elevationhigher surfaces are lighter; shadows barely show on dark
Colorlighter, less saturated tints of brand colors; saturated colors vibrate on dark
Contrastre-check every pair; a passing light-mode pair often fails when mapped
Imagesdim bright images slightly, swap logos and illustrations
Form controlscolor-scheme: light dark so scrollbars and inputs follow
Choicesystem by default, plus a light/dark/system toggle that persists

Dark mode is a second semantic theme, not filter: invert(). Palette-building details in Color theory.

Research & testing

MethodAnswersWhenSample
User interviewsneeds, context, languagediscovery5–10 per segment
Contextual inquiryhow work really happensdiscovery4–8 sessions
Surveyhow many, how oftenvalidate at scale100+
Card sorting (open / closed)how users group and name contentbuilding IA15–30
Tree testingcan users find X in this hierarchyvalidating IA50+
Moderated usability testwhy users struggleany prototype stage~5 per round, then iterate
Unmoderated usability testtask success, time on taskmid to late15–40
First-click / five-second testis the entry point or message clearlayouts, landing pages20–50
Heuristic evaluationknown usability problems, cheaplyany stage3–5 experts
Cognitive walkthroughcan a first-time user learn the flownew flows2–4 reviewers
A/B testwhich variant performs betterlive productpower-calculated, often thousands
Analytics and session replaywhere users drop offlive productall traffic
Diary studybehavior over days or weekslong journeys10–15

Metrics: task success rate, time on task, error rate, SUS (System Usability Scale; ~68 is average), and single ease question (SEQ). Five users per round finds most problems in one flow; run several small rounds rather than one big one.

Design review checklist

  • One clear primary action per screen; hierarchy survives the squint test
  • Spacing and sizes come from the scale; related items are closer than unrelated ones
  • Type uses the scale, body text is at least 16 px, lines at most ~75 characters
  • Every interactive element has hover, focus-visible, active, disabled and loading states
  • Empty, error, loading, offline and no-permission states are designed
  • Text contrast is at least 4.5:1 (3:1 large); UI and focus indicators at least 3:1
  • Color is never the only signal (icons, text, patterns too)
  • Full keyboard path works, focus is visible and never hidden under sticky UI
  • Targets are at least 24 px (44–48 px on touch), with space between them
  • Forms have visible labels, helpful errors, autocomplete, and allow paste
  • Copy uses verbs on buttons, one term per concept, sentence case
  • Works at 320 px wide and 200% zoom; content, not devices, sets breakpoints
  • Dark mode checked separately, prefers-reduced-motion respected
  • Uses existing components and tokens; new ones are documented

Recipes

Focus ring that works everywhere

Visible for keyboard users only, and outline survives Windows forced-colors mode (box-shadow doesn't).

:where(a, button, input, select, textarea,
  summary, [tabindex]):focus-visible {
  outline: 2px solid var(--color-focus, Highlight);
  outline-offset: 2px;
}
 
/* keep focused items clear of a sticky header */
html { scroll-padding-block-start: 5rem; }

Press Tab inside the result: the ring appears for keyboard focus, not for clicks. The third button has it permanently so you can see it.

focus-ring.html
<style>
  :root { --color-focus: var(--graph-0); }
  :where(a, button, input):focus-visible,
  .show-ring {
    outline: 2px solid var(--color-focus, Highlight);
    outline-offset: 2px;
  }
  button, input { font: inherit; padding: 4px 10px; }
</style>
<button>Tab to me</button>
<input placeholder="then me" size="8">
<button class="show-ring">Ring shown</button>
Result

Motion only for those who want it

Opt in to animation instead of stripping it out afterward. See Animation.

.card { transition: none; }
 
@media (prefers-reduced-motion: no-preference) {
  .card {
    transition: transform 200ms ease-out,
      box-shadow 200ms ease-out;
  }
  .card:hover { transform: translateY(-2px); }
}
motion.html
<style>
  .card { padding: 16px; border-radius: 8px;
    background: var(--chip); transition: none; }
  @media (prefers-reduced-motion: no-preference) {
    .card { transition: transform 200ms ease-out,
      box-shadow 200ms ease-out; }
    .card:hover { transform: translateY(-2px);
      box-shadow: 0 6px 16px rgb(0 0 0 / 25%); }
  }
</style>
<div class="card">Hover me (still if motion is reduced)</div>
Result

Bigger hit area, same visual size

When a small icon button must meet 44 px on touch without looking bigger.

.icon-btn {
  position: relative;
  inline-size: 1.25rem;
  block-size: 1.25rem;
}
 
/* 20 px icon + 12 px each side = 44 px target */
.icon-btn::after {
  content: "";
  position: absolute;
  inset: -12px;
}

The dashed outline marks the invisible target; hover anywhere inside it.

hit-area.html
<style>
  .icon-btn { position: relative; margin: 20px;
    inline-size: 1.25rem; block-size: 1.25rem;
    padding: 0; border: 0; border-radius: 4px;
    background: var(--graph-0); }
  /* 20 px icon + 12 px each side = 44 px target */
  .icon-btn::after { content: ""; position: absolute;
    inset: -12px; outline: 1px dashed var(--muted); }
  .icon-btn:hover::after { background: color-mix(
    in srgb, var(--graph-0) 20%, transparent); }
</style>
<button class="icon-btn" aria-label="Close"></button>
Result

Fluid type scale

Headings that grow with the viewport but still respond to zoom, because the rem term keeps scaling.

:root {
  --step-0: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --step-1: clamp(1.2rem, 1.1rem + 0.5vw, 1.5rem);
  --step-2: clamp(1.44rem, 1.25rem + 0.9vw, 2rem);
  --step-3: clamp(1.73rem, 1.4rem + 1.6vw, 2.67rem);
}
 
h1 { font-size: var(--step-3); line-height: 1.1; }
p { font-size: var(--step-0); max-inline-size: 65ch; }

Spinner that doesn't flash

Show loading only if work takes longer than delayMs, then keep it up for minMs to avoid flicker.

const wait = (ms: number) =>
  new Promise<void>((r) => setTimeout(r, ms));
 
export async function withSpinner<T>(
  work: Promise<T>,
  show: (visible: boolean) => void,
  delayMs = 300,
  minMs = 500,
): Promise<T> {
  const started = { at: -1 };
  const timer = setTimeout(() => {
    started.at = performance.now();
    show(true);
  }, delayMs);
  try {
    return await work;
  } finally {
    clearTimeout(timer);
    if (started.at >= 0) {
      const shown = performance.now() - started.at;
      await wait(Math.max(0, minMs - shown));
      show(false);
    }
  }
}

Validate on blur, then live

The validation timing from Forms: no errors while typing the first time.

type Validator = (value: string) => string | null;
 
export function wireField(
  input: HTMLInputElement,
  validate: Validator,
): void {
  const error = document.getElementById(`${input.id}-error`);
  const state = { touched: false };
 
  const run = () => {
    const msg = validate(input.value);
    input.setAttribute("aria-invalid", String(msg !== null));
    if (error) error.textContent = msg ?? "";
  };
 
  input.addEventListener("blur", () => {
    state.touched = true;
    run();
  });
  input.addEventListener("input", () => {
    if (state.touched) run();
  });
}

Optimistic update with rollback

For likes, toggles and renames that almost always succeed. In React, see useOptimistic in TypeScript + React.

type Store<S> = { get: () => S; set: (next: S) => void };
 
export async function optimistic<S>(
  store: Store<S>,
  update: (prev: S) => S,
  commit: () => Promise<void>,
): Promise<void> {
  const before = store.get();
  store.set(update(before));          // paint the result now
  try {
    await commit();
  } catch (err) {
    store.set(before);                // roll back
    throw new Error("Could not save", { cause: err });
  }
}
 
// optimistic(post, (p) => ({ ...p, likes: p.likes + 1 }),
//   () => api.like("p1"));

References