Interface Playbook

Field guide · 43 details · Starter pack · Pre-ship checklist

Interface Playbook

An interface rarely feels great because of one big idea. It feels great because hundreds of small decisions were made correctly. This is a field guide to those decisions. Each one comes with a rule you can apply today, a live demo, and code in the starter pack.

Distilled from Jakub Krehel, Josh Puckett's Interface Craft, Vercel's Web Interface Guidelines, Rauno Freiberg, Emil Kowalski and Gustavo Fior's Craft. Everything here is rewritten and rebuilt; the originals are worth reading in full.

r 16
r 16 · gap 8
MLno outline
Transfer sentToday, 09:41
Delivered

Your transfer to Maya Lindqvist is on its waywrap

$2,480.00pnum

The same card, eight details apart

Switch details on one at a time, or jump straight to finished. Turn on redlines to see the measurements behind each change.

Chapter 1

Seeing

Polish starts before any pixel moves. Most people glance at a screen; good designers count, name and measure what is actually on it. These habits make everything else in this guide easier to spot.

Critique in a fixed order

Run every review through the same passes, judge each pass on its own terms, and finish with a short ranked list.

Visual design, interface design and interaction solve different problems. A beautiful screen can be hard to use and a usable one can be ugly, so judge them separately before you combine the verdict. Begin by naming what the screen is for and the state its user arrives in: a routine check-in and a stressful money decision deserve different care.

Adapted from Josh Puckett's critique method · Interface Craft

The critique pass
  1. ContextWhat is it, who uses it, and in what mood?
  2. First lookOne honest paragraph of gut reaction.
  3. VisualColor intent, type scale, shadows, weight, spacing, icons.
  4. InterfaceEntry point, disclosure, density, feedback, repetition.
  5. ConsistencyShared patterns, platform conventions, one visual language.
  6. FeelingName the emotion. Where would extra care surprise them?
  7. ShortlistThe three to five highest-impact changes, ranked.
Write each finding as observation, impact, opportunity. “Four background colors compete, so nothing reads as primary; drop to two” is actionable. “Feels busy” is not.

Fix structure before surface

Rank findings by how deep they go. A wrong mental model outweighs a muddy shadow every time.

Structural problems change what the product is. Behavioral problems change how it feels to use. Visual problems change how it looks. Polishing the visual layer of a broken structure only makes the wrong thing prettier.

Adapted from Interface Craft's severity guide

Severity, deepest first
StructuralInformation architecture, missing capability, the wrong mental model
BehavioralFlow, response, feedback, knowing what happens next
VisualColor, type, spacing, depth, alignment

Four habits of noticing

Be specific, compare against the best, decide what quality means for this product, and look where others stop.

Vague feedback can't be acted on. Counting elements, naming colors and measuring gaps turns taste into something a team can discuss and fix.

Adapted from Interface Craft

Count and name

“Six columns per row” and “three greys that are nearly identical” can be fixed. “Cluttered” can't.

Know the bar

People judge your product against the best app in its category, not against your last release. Ask what that app would do here.

Pick your facets

Quality is a set of attributes. Choose the three to five your product values, such as calm, fast or trustworthy, and deliver them everywhere.

Look past the happy path

Empty states, errors, loading, transitions and edge cases are where care shows, because most teams stop before they reach them.

The laws underneath

Most details in this guide trace back to a few well-documented effects in how people perceive and act. Reach for them when a detail starts to feel like a matter of taste.

Summarised from Laws of UX by Jon Yablonski

Doherty threshold

Responses under about 400ms keep people in flow.

→ optimistic updates, instant hover
Fitts's law

Large, nearby targets are faster to hit.

→ hit areas, actions near the thumb
Hick's law

More choices make decisions slower.

→ progressive disclosure
Aesthetic–usability effect

People perceive polished interfaces as easier to use.

→ the case for this whole guide
Peak–end rule

Experiences are remembered by their best moment and their ending.

→ success states, finishing touches
Goal-gradient effect

Effort rises as the finish line comes into view.

→ progress framed as milestones

Chapter 2

Type

Text is most of any interface. A few properties decide whether it reads as set or merely poured in: which sizes you allow, how lines break, how digits behave, which characters you use, and how hierarchy is built.

Set type on one ladder

Pick every size from one short ladder, name each step by its role, and tighten line height as size grows.

Half-pixel sizes pile up until nobody can say which one a new label should use. Around ten named steps cover everything from captions to display titles, and a role name like subtitle outlives the number behind it. Big type needs tight leading, about 1.05 at 60px, because the space between lines grows with the size; body text needs about 1.5 to stay easy to read. Labels sit at a medium weight so they hold up at 12 and 14px.

Scale and line heights via Dan Billson's dnbls/ui · tracking tuned for Inter Display

CSS
:root {
  --fs-display-lg: 60px; --lh-display-lg: 1.05;
  --fs-title-xl: 36px;   --lh-title-xl: 1.15;
  --fs-title-lg: 30px;   --lh-title-lg: 1.2;
  --fs-title: 24px;      --lh-title: 1.25;
  --fs-subtitle: 18px;   --lh-subtitle: 1.35;
  --fs-body: 16px;       --lh-body: 1.5;
  --fs-small: 14px;
  --fs-label: 14px;      --lh-label: 1.25;
  --fs-caption: 12px;    --lh-caption: 1.35;
}
.card-title { font-size: var(--fs-subtitle); line-height: var(--lh-subtitle); }
This page's type ladder
Interfacedisplay-lg60 / 1.05 · −6%
$2,480.00title-xl36 / 1.15 · −5%
Concentric cornerstitle-lg30 / 1.2 · −5%
Eight details aparttitle24 / 1.25 · −4%
Card ending 4417subtitle18 / 1.35 · −3%
Small decisions, made correctly.lead18 / 1.5 · −3%
Transfers settle in seconds.body16 / 1.5 · −2%
Due in 3 dayssmall14 / 1.5 · −2%
Save changeslabel14 / 1.25 · 500
Today, 09:41caption12 / 1.35 · 500
Every piece of text on this page uses one of these steps. The hero specimen is the single deliberate exception.

Balance headings, tidy paragraphs

Put text-wrap: balance on headings and text-wrap: pretty on body copy.

Balance evens out line lengths, so a two-line title never ends on one lonely word. Browsers only balance short blocks, which is why it belongs on headings. Pretty is cheaper and made for paragraphs: it mainly stops a single word from being stranded on the last line. Browsers without support simply wrap as they always have.

via Jakub Krehel

CSS
h1, h2, h3, h4 { text-wrap: balance; }
p, li, figcaption { text-wrap: pretty; }
Line breaking
Width232px
wrap

Move money between your accounts instantly

Transfers settle in seconds, and you can undo one for a full minute after it is sent.

balance

Move money between your accounts instantly

Transfers settle in seconds, and you can undo one for a full minute after it is sent.

balance + pretty

Move money between your accounts instantly

Transfers settle in seconds, and you can undo one for a full minute after it is sent.

Drag the width. The top box keeps producing ragged titles and orphaned last words; the bottom one stays even.

Tabular figures for moving numbers

Any number that updates or lines up in a column gets font-variant-numeric: tabular-nums.

Most fonts ship proportional digits, where a 1 is narrower than an 8. When a balance ticks up or a timer runs, the whole string shuffles sideways. Tabular figures give every digit the same width, so counters, prices and table columns hold still. Some typefaces redraw a few digits in tabular mode, so check how yours looks.

via Jakub Krehel · Vercel Web Interface Guidelines

Digit width
proportional
$41,118.11
tabular
$41,118.11
Watch the dashed right edge while it counts. The proportional number jitters; the tabular one stays pinned.

Use the real characters

Typewriter substitutes read as unfinished. Reach for the proper glyph.

Curly quotes, a true ellipsis, en dashes for ranges and non-breaking spaces between a number and its unit are small, but readers notice when they are missing. Menu items that open a dialog end in an ellipsis to signal that more input follows.

via Vercel Web Interface Guidelines

Glyph swaps
UseInstead ofWrite
Quotes"Pending"“Pending”
Apostrophedon'tdon’t
Opens a dialogRename...Rename…
Ranges9-5, Mon-Fri9–5, Mon–Fri
Dimensions1920x10801920 × 1080
Units10 MB (may break)10 MB  

Hierarchy from weight and color

Rank text with weight and color first and size last, and keep the number of text styles small.

When every line is bold and a different size, nothing leads. Holding one size and letting color do the ranking gives a calmer row that scans faster. Secondary text still needs enough contrast to read comfortably.

A long-standing principle, popularised by Refactoring UI

List row
Size and bold everywhere
Office rentScheduled payment$1,850.00 · 1 Oct
Weight and color
Office rentScheduled payment$1,850.00 · 1 Oct

Crisp text, calm inputs

Switch to grayscale antialiasing at the root, and never size a form field below 16px on mobile.

On macOS the default subpixel rendering can make light text look heavier than intended; antialiased draws it thinner and cleaner. Set it once on the root so every element inherits it. Separately, iOS Safari zooms the whole page when a field under 16px gains focus, which knocks the layout sideways.

via Jakub Krehel · Vercel Web Interface Guidelines

CSS
/* Once, at the root */
body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Stops iOS zooming into focused fields */
input, select, textarea {
  font-size: max(16px, 1em);
}

/* Tailwind: add `antialiased` to <body> */

Chapter 3

Surfaces

Corners, edges and depth tell the eye what is an object, what sits inside it and where light comes from. Get the geometry right and a layout stops feeling assembled.

Concentric corners

When one rounded box sits inside another, the outer radius equals the inner radius plus the gap between them.

Use the same radius on both and the two curves stop sharing a center, so the gap swells at every corner. It is one of the most common reasons a card feels slightly off without anyone saying why. If a design system starts from the outer radius, subtract instead, and never let the inner radius drop below zero. Include any border width in the gap.

via Jakub Krehel · Gustavo Fior, Craft · Vercel guidelines

CSS
.card {
  --r: 20px;          /* outer radius */
  --gap: 8px;         /* padding between the edges */
  border-radius: var(--r);
  padding: var(--gap);
}
.card > .inner {
  border-radius: max(0px, calc(var(--r) - var(--gap)));
}
Radius lab
Inner12
Gap8
outer 20 = inner 12 + gap 8
Gap on edges8px
Gap at corner8.0px
The pink dot is the outer curve's center, the blue dot the inner one's. When they meet, the gap stays even all the way round.

Edges from shadow, not border

Draw an element's edge with a hairline of translucent shadow instead of a solid border.

A solid grey border only looks right on the background it was picked for. A 1px ring of black at low opacity, stacked with a soft drop, darkens whatever sits behind it, so it holds up on tints, gradients and photos. In dark mode the ring becomes a faint white line and does the job a border would. Darken the same stack slightly for hover and transition box-shadow alone.

via Jakub Krehel

CSS
.surface {
  box-shadow:
    0 0 0 1px rgb(15 17 22 / 0.08),       /* edge */
    0 1px 2px rgb(15 17 22 / 0.06),       /* contact */
    0 6px 16px -4px rgb(15 17 22 / 0.10); /* ambient */
  transition: box-shadow 160ms ease-out;
}
@media (prefers-color-scheme: dark) {
  .surface { box-shadow: 0 0 0 1px rgb(255 255 255 / 0.08); }
}
Three backgrounds
The border version looks pasted on over the lilac and the photo. The shadow version sits naturally on all three.

Light comes in layers

Build depth from at least two shadows: a tight, darker one where the object touches the surface and a wide, soft one for ambient light.

A single large blurred shadow reads as fog. Real objects cast a crisp contact shadow plus a diffuse glow, and both fall downward because light comes from above. Tint shadows toward the surface color on colored backgrounds rather than using pure black.

via Vercel Web Interface Guidelines · Interface Craft critique

Anatomy of a shadow
Edge1px ring, 8%
Contacty 1, blur 2
Ambienty 10, blur 24, spread −6
Combinedone box-shadow list

Give images an edge

Draw a 1px outline inside every image and avatar at about 10% opacity: black in light mode, white in dark.

Images don't know what is behind them. A pale sky on a white card, or a mostly white avatar, has no edge at all and seems to leak into the page. The line is too faint to read as a frame, so what you notice is that every image suddenly has a shape. Much below 5% it disappears; much above 20% it starts to look like a border. Use outline with a negative offset so it takes no space.

via Jakub Krehel · Gustavo Fior, Craft

CSS
img, .avatar {
  outline: 1px solid rgb(0 0 0 / 0.1);
  outline-offset: -1px;
}
.dark img, .dark .avatar {
  outline-color: rgb(255 255 255 / 0.1);
}
Pale images
AOJKRM
This stage is pure white on purpose, the hardest case for pale images.

Grain against banding

Lay a faint layer of noise over large gradients to hide banding and add a little texture.

Smooth gradients across big areas often break into visible steps. Noise scatters light and dark pixels over the surface, which breaks the steps up. Generate it with an SVG feTurbulence filter, blend it with overlay at around 5–10% and isolate the container so it only mixes with its own surface. On large or moving areas, tile a small pre-rendered tile rather than a live filter, which is expensive to repaint.

via Gustavo Fior, Craft

Gradient surface
Quarterly report
Toggle it off and look at the transition from violet to pink.

Paint the canvas

Set the page color on html itself, match the browser chrome with theme-color, and declare color-scheme.

Pull past the top of a dark app in Safari and a white strip can flash behind it, because the document canvas was never painted. Painting the root fixes overscroll, theme-color tints the browser bars to match, and color-scheme: dark makes scrollbars and native controls follow the theme. All three must switch with the active theme.

via Gustavo Fior, Craft · Vercel guidelines

HTML + CSS
<meta name="theme-color" content="#F4F5F7"
      media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0D0F13"
      media="(prefers-color-scheme: dark)">

html { background: #F4F5F7; color-scheme: light; }

@media (prefers-color-scheme: dark) {
  html { background: #0D0F13; color-scheme: dark; }
}

Chapter 4

Alignment

Math gets you most of the way. The rest is judged by eye: shapes that look off-center when they are centered, targets that look fine but are hard to hit, and spacing that only feels right when it follows a system.

Align for the eye, not the box

When a shape looks off-center while being mathematically centered, nudge it until it looks right.

A play triangle centered by its bounding box looks pushed left, because most of its visual weight sits on the flat side. A button with a trailing icon looks lopsided with equal padding, because the icon has built-in whitespace. Shift the triangle toward its point, and trim the padding on the icon side by a few pixels. Where you can, fix it inside the SVG so every use inherits the correction.

via Jakub Krehel · Gustavo Fior, Craft · Vercel guidelines

CSS
/* Less padding on the icon side */
.button:has(svg:last-child) { padding-inline: 16px 12px; }
.button:has(svg:first-child) { padding-inline: 12px 16px; }

/* Play glyphs: nudge toward the point */
.play-icon { translate: 8% 0; }
Optical centering
Play
16 · 16
Switch between the two with guides on. The box stays put; only what your eye reads as the center moves.

Hit areas bigger than they look

Make every target at least 24px for a mouse and 44px for touch, even when it is drawn smaller.

Small icon buttons keep interfaces tidy but punish imprecise hands. You can keep the 20px look and extend the clickable area with an invisible pseudo-element. Where a checkbox or radio sits next to its label, make the label part of one generous target.

via Vercel Web Interface Guidelines · Fitts's law

CSS
.icon-button { position: relative; width: 20px; height: 20px; }
.icon-button::before {
  content: "";
  position: absolute;
  inset: -12px;          /* 20 + 12 + 12 = 44px */
}
Target size
20 × 20
what you draw
44 × 44
what the finger gets
Both buttons look identical. Hover around them to feel the difference.

Space from a scale

Pick every gap from one short scale, and keep related things closer together than unrelated ones.

Spacing is how people read grouping. Padding inside a group should be smaller than the space around it, or the group dissolves. A 4px base gives enough steps without inviting arguments over 13 versus 14. Everything should line up with something: a grid column, a baseline, an edge or an optical center.

via Vercel guidelines · Refactoring UI · Gestalt proximity

A 4px scale
4
8
12
16
24
32
48
64
Small steps for tight groups, big jumps between sections. Skip the values in between.

Chapter 5

Motion

Good motion explains where things came from and confirms that input landed. Bad motion makes people wait. Most of the craft is deciding when not to animate, and keeping what remains short, interruptible and physical.

First ask whether it should move

The more often an interaction happens, the less animation it can afford.

A flourish that delights on first use grates by the fiftieth. Keyboard shortcuts, command menus and hover states should respond instantly; a modal opened once a session can take a moment; a milestone reached once a year can celebrate. Motion needs a job: showing where something came from, confirming a state change, or explaining a relationship. Looking nice is not enough on something people see all day.

via Rauno Freiberg · Emil Kowalski · Gustavo Fior, Craft

Motion budget by frequency
Hundreds of times a dayShortcuts, command menu, typing
none
Tens of times a dayHover, list navigation, tabs
instant to ~100ms
A few times a dayDropdowns, popovers, toggles
150–250ms
Once a sessionModals, sheets, page transitions
200–400ms
Rare momentsFirst run, milestones, success
room to delight

Ease out, never ease in

Use ease-out for things entering or responding, ease-in-out for things moving across the screen, and keep ease-in off your UI.

Ease-in starts slowly, which delays the exact moment someone is watching for, so the interface feels sluggish. Ease-out moves most of the distance up front and settles gently, so the same 300ms feels faster. The built-in CSS curves are weak; stronger custom curves read as more intentional. Keep two or three and reuse them everywhere.

via Emil Kowalski · Vercel guidelines

CSS
:root {
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
}
Same 600ms, three curves
ease-in0.55, 0, 1, 0.45
ease-out0.22, 1, 0.36, 1
ease-in-out0.65, 0, 0.35, 1
All three dots arrive together, yet the green one feels quickest because it reacts at once.

Keep it short

Most interface motion belongs under 300ms. Large surfaces can take a little longer; small ones should take less.

A 180ms dropdown feels responsive where a 400ms one feels like waiting. Duration should scale with the size of the thing moving and the distance it travels. After the first tooltip in a toolbar, skip the delay and the animation for its neighbours.

Ranges via Emil Kowalski

Duration ranges · 0 to 500ms
Button press
100–160ms
Tooltip, small popover
125–200ms
Dropdown, select
150–250ms
Modal, drawer, sheet
200–500ms

Nothing appears from nothing

Start entrances from about scale(0.96) and zero opacity, and grow popovers from the element that opened them.

Real objects don't materialise from a point, so scale(0) looks cartoonish and draws the eye to the animation instead of the content. A small scale change plus a fade feels like the element arrived. Set transform-origin to the trigger's side so a menu unfolds from its button; centered modals are the exception.

via Emil Kowalski · Gustavo Fior, Craft

Popover entrance
scale(0), center origin
Edit transfer
Duplicate
Download PDF
scale(0.96), top origin
Edit transfer
Duplicate
Download PDF

Press means something

Scale pressable things to about 0.97 while they are held, over 100–160ms with ease-out.

A slight squeeze confirms the press landed before anything else happens, the way a physical button gives under a finger. Keep it subtle, between 0.95 and 0.98; more reads as a toy. Because scale shrinks the label and icon too, the whole control responds as one object.

via Emil Kowalski

CSS
.pressable { transition: transform 120ms var(--ease-out); }
.pressable:active { transform: scale(0.97); }
Hold each button
No feedback
scale 0.97

Let people change their mind

Drive interactive motion with CSS transitions, which can be interrupted, and keep keyframes for sequences that play once.

People open a menu and then decide against it mid-animation. A transition simply heads toward the newest state from wherever it is. A keyframe animation runs its fixed timeline from the start, so interrupting it causes a jump. For gestures, the element should follow the finger immediately and keep its momentum when released.

via Jakub Krehel · Rauno Freiberg · Vercel guidelines

Click toggle twice, quickly
keyframes: restarts
transition: retargets
Durations are slowed to 700ms so the interruption is easy to catch.

Enter in pieces

Split an entrance into parts and stagger them by 50–100ms instead of moving one big block.

A whole section sliding in at once feels heavy. Letting the heading, text and actions arrive in sequence guides the eye down the page. A short rise of about 8px combined with opacity and a light blur feels soft rather than mechanical. For a hero, you can even split the headline into words. Stagger is decoration, so never block input while it plays.

via Jakub Krehel · Emil Kowalski

CSS
@keyframes rise {
  from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
}
.enter {
  animation: rise 560ms var(--ease-out) both;
  animation-delay: calc(var(--i, 0) * 70ms);
}
/* <h2 class="enter" style="--i:0"> … <p class="enter" style="--i:1"> */
Entrance

Payroll that runs itself

Schedule once, approve with a tap, and every salary lands on the same morning.

Start freeBook a demo
Pick a style to replay it.

Exits ask for less

Make exits quicker and smaller than entrances.

Something leaving no longer needs attention. Instead of sliding a panel the full distance off screen, move it part of the way and let opacity and a slight blur finish the job. The exit feels softer and the person's focus stays on what comes next.

via Jakub Krehel

Closing a menu
Full slide out
Short slide, fade, blur

Swap icons, don't blink them

When an icon changes in place, cross-fade it with a little scale and blur.

A copy icon that snaps to a check looks like a glitch. Fading the old icon down in scale while the new one grows in, with a couple of pixels of blur, reads as one object changing state. Around 150–200ms is enough; springs work well if you use a motion library.

via Jakub Krehel

Click each button
Instant swap
Scale, blur, fade

Hover is instant; tooltips wait once

Show hover states with no transition. Delay the first tooltip, then show its neighbours immediately.

Sweeping a cursor across a menu crosses ten items a second; fading each highlight in leaves the interface trailing behind the pointer. Tooltips are the reverse case: without a short delay they pop up while you are passing through. Once someone has shown they want labels, making them wait again for each one is irritating. Gate hover effects behind @media (hover: hover) so touch screens don't get sticky states.

via Gustavo Fior, Craft · Emil Kowalski · Vercel guidelines

Sweep the cursor across
First tooltip waits 500ms, the rest are instant

Storyboard your sequences

Write multi-step animations as a script: one comment that reads like a shot list, then every timing and value as a named constant.

Timing buried inside components can't be read or tuned. Putting a storyboard at the top of the file, a single TIMING object measured from the trigger, and a config object per element means anyone can scan the sequence and adjust one number. Drive the whole thing from a single stage counter rather than scattered booleans, and prefer springs for natural motion.

Adapted from Josh Puckett's storyboard pattern · Interface Craft

Storyboard
/* ────────────────────────────────────────────
 * STORYBOARD · payment toast
 *
 *    0ms  trigger: payment confirmed
 *   80ms  card rises 8px → 0, fades in
 *  200ms  icon pops, scale 0.6 → 1
 *  300ms  title and detail fade up
 *  420ms  actions arrive, staggered 60ms
 * ──────────────────────────────────────────── */
const TIMING = {
  card:    80,   // card enters
  icon:    200,  // success icon
  text:    300,  // title and detail
  actions: 420,  // buttons, then +60ms each
};
const CARD = { rise: 8, spring: { type: "spring", duration: 0.35, bounce: 0 } };
const ICON = { from: 0.6, spring: { type: "spring", duration: 0.3, bounce: 0.25 } };
Storyboard, played
Payment sent

$640.00 to Studio Arkade

ViewUndo
0ms
trigger
80ms
card
200ms
icon
300ms
text
420ms
actions
The timeline is the code. Change one number in TIMING and the whole sequence shifts.

Animate cheap properties, and respect the setting

Animate transform, opacity and filter, list the properties explicitly, and provide a reduced-motion version.

Transform and opacity run on the compositor and stay smooth; width, height and top trigger layout on every frame. transition: all animates things you never meant to, like colors during a theme switch. For people who ask for reduced motion, keep fades and state changes but remove travel, scaling and parallax. Anything that auto-plays for more than a few seconds needs a pause control.

via Vercel Web Interface Guidelines · Emil Kowalski

CSS
/* Say exactly what moves */
.panel {
  transition:
    transform 220ms var(--ease-out),
    opacity 220ms var(--ease-out);
}

/* Keep the fade, drop the travel */
@media (prefers-reduced-motion: reduce) {
  .panel { transition: opacity 160ms linear; }
  .enter { animation-name: fade; }
}
@keyframes fade { from { opacity: 0; } }

Chapter 6

States & behavior

Most screens are designed in their ideal state and shipped into every other one. Finished interfaces plan for no data, slow data, bad data and too much data, and they answer input before the server does.

Design every state

For each screen, design empty, loading, sparse, error and full states before you call it done.

An empty state is an onboarding moment: say what belongs here and offer the first action. Skeletons should mirror the final layout exactly, so nothing jumps when data arrives. Errors say what happened and how to recover, without apology or jargon. Every screen should leave a clear next step.

via Vercel Web Interface Guidelines · Interface Craft critique

One list, five states
No payees yetAdd someone to send money in two taps.Add payee
EmptyExplain and invite
LoadingSkeleton matches layout
That's everyone. Add more from your contacts.
SparseOne item still looks intended
Couldn't load payeesYou seem to be offline. We'll retry when you reconnect.Try again
ErrorCause and recovery
FullDense but scannable

Loading without flicker

Wait 150–300ms before showing a spinner, keep it up for a minimum time once shown, and keep the button's label and width.

Most requests finish quickly. A spinner that flashes for 80ms reads as a glitch and makes a fast action feel slow. Delay it so fast responses never show one, and once it appears, hold it long enough to be read. Keep the button the same width and keep the label beside the spinner so the layout doesn't jump.

via Vercel Web Interface Guidelines

Save
Spinner immediately
200ms delay, 500ms minimum
Fast server answers in 120ms. Click both.

Answer before the server does

When success is likely, update the interface immediately and reconcile when the response arrives.

Likes, toggles, reordering and renames almost always succeed. Showing the result instantly keeps people inside the roughly 400ms window where an interface feels like it is keeping up. If the request fails, roll back and say so plainly. Save the wait-and-confirm pattern for money movement and anything irreversible.

via Vercel Web Interface Guidelines · Doherty threshold

Save to favourites
Waits 700ms
Optimistic

Undo beats “Are you sure?”

For destructive actions, prefer doing it and offering undo; confirm only when undo is impossible.

Confirmation dialogs get clicked through on reflex, so they protect less than they seem to. Performing the action with a visible undo window is faster for the common case and safer for the mistaken one. When an action truly can't be reversed, like sending money or deleting an account, confirm with a button that names the outcome.

  • Label buttons with the result: “Delete 3 files”, not “OK”.
  • Keep the undo window long enough to react, around 5–10 seconds.
  • Warn before navigation would discard unsaved work.

via Vercel Web Interface Guidelines

Visible focus. Every focusable element shows a :focus-visible ring that nothing covers.
Full keyboard path. Every flow works with Tab, Enter, Space, Escape and arrows.
Named icons. Icon-only buttons carry an aria-label.
State in the URL. Filters, tabs and open panels survive a refresh and can be shared.
Real labels. Every field has a label, and clicking it focuses the field.
Enter submits. In a textarea, ⌘/Ctrl + Enter submits and Enter adds a line.
Submit stays enabled. Let submission surface errors instead of disabling the button in advance.
Errors beside fields. On submit, focus the first field with a problem.
Right keyboard. Set type, inputmode and autocomplete so autofill and mobile keyboards help.
More than color. Pair status colors with text or an icon, and announce async changes politely.

Chapter 7

Light & dark

A theme switch touches every pixel at once, so small mistakes show up everywhere: a white flash on load, a palette that is the light one flipped, a toggle that forgets. These rules make both themes feel designed. The toggle in this page's header follows every one of them.

Three states, system first

Offer Light, Dark and System, start on System, and remember what people pick.

Most people set a preference once, in their operating system, and expect every app to follow it. A two-way switch has no way back to following the OS once it has been touched, which is why System needs to be an option of its own. While on System, keep listening, so the page changes when the OS switches at sunset.

  • Put the control where people look for it: the header, the account menu or settings.
  • Show which option is active, rather than an icon that flips back and forth.
  • Give each option an accessible name and reach all three from the keyboard.
  • Sync the choice to other open tabs.

via Paco Coursey's next-themes · web.dev

Resolving the theme
WalletSynced
Available$4,210.60
Groceries−$62.40
Salary+$3,800.00
Pretend OS setting
Choice System · OS Light · Showing Light

Decide before the first paint

Resolve the theme with a tiny blocking script in the <head>, before any content renders.

If the theme is applied after your framework loads, dark-mode visitors get a white flash on every visit. A few lines in the head read the saved choice and stamp it on <html>; with no saved choice, the CSS media query takes over. With server rendering, keep the choice in a cookie so the HTML arrives already themed.

via Josh W. Comeau · next-themes

HTML
<head>
  <script>
    try {
      const saved = localStorage.getItem("theme");
      if (saved === "light" || saved === "dark") {
        document.documentElement.dataset.theme = saved;
      }
    } catch (e) {}
  </script>
  <link rel="stylesheet" href="/styles.css">
</head>
A dark-mode visitor reloads
Theme set after load
Theme set in the head
The left page paints light first, then corrects itself once its script runs. The right one is dark from the first frame.

Name colors by role

Give components role names like surface, text-muted and edge, and redefine only those tokens per theme.

When a card uses #FFFFFF directly, dark mode turns into a hunt through every file. When it uses --surface, dark mode is one block of overrides, and the same names can carry a high-contrast or brand theme later. With color-scheme set, light-dark() lets you write both values side by side; it has worked in every major browser since 2024.

via MDN · Vercel Web Interface Guidelines

CSS
:root {
  color-scheme: light dark;
  --surface: light-dark(#FFFFFF, #171A20);
  --text: light-dark(#15171C, #E8EAEE);
  --text-muted: light-dark(#646B7A, #8E95A3);
  --edge: light-dark(rgb(15 17 22 / 0.08), rgb(255 255 255 / 0.08));
}
/* An explicit choice pins the scheme */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }

.card { background: var(--surface); color: var(--text); }
This page's own tokens
TokenRoleLightDark
--bgPage canvas#EDEFF2#0D0F13
--surfaceCards and plates#FFFFFF#15181E
--surface-2Insets, raised rows#F5F6F8#1B1F26
--inkPrimary text#14161B#E9EBF0
--mutedSecondary text#646B7A#8E95A3
--redAccent, redlines#D6336C#FF5C8F
--blueFocus, selection#2E66F5#6B94FF
--edgeHairline ringink at 7%white at 8%
Every component on the page reads these names. Switching themes changes eight values, not hundreds of rules.

Dark is designed, not inverted

Build dark from dark greys and off-white text, soften the accents, and show elevation with lighter surfaces.

Pure white text on pure black glares, and the letters seem to bleed into the background. Shadows barely register on dark, so a raised surface should get lighter instead, with a faint white ring for its edge. Saturated brand colors vibrate against dark grounds, so use lighter, softer versions. Material's dark theme starts from #121212 and ranks text at about 87%, 60% and 38% opacity.

via Google Material Design · Apple Human Interface Guidelines

The same card in dark
Inverted
Card ending 4417Visa · Expires 08/29
Monthly limit$2,000
Freeze card
Designed
Card ending 4417Visa · Expires 08/29
Monthly limit$2,000
Freeze card
Left: black ground, pure white text, a shadow nobody can see and a saturated red. Right: grey ground, off-white text, a lighter inset row, a hairline ring and a softened accent.

Switch without a ripple

Turn every transition off for the frame the theme changes, then turn them back on.

Components pick up transitions over time: 150ms on a button, 400ms on a card. When the theme flips, each one recolors at its own pace, and the page ripples through half-light, half-dark states. Adding a class that disables transitions, committing the new styles and removing the class on the next frame makes the switch clean. For a deliberate crossfade, the View Transitions API animates the whole page as one image, and it should respect reduced motion.

via next-themes

CSS + JS
.theme-switching,
.theme-switching * { transition: none !important; }

root.classList.add("theme-switching");
root.dataset.theme = next;
getComputedStyle(root).color;   // commit styles with transitions off
requestAnimationFrame(() =>
  requestAnimationFrame(() => root.classList.remove("theme-switching"))
);
Flip both panels
Transitions left on
Invoice #1042Due in 3 days
Invoice #1043Paid
Send reminder
Paused during the switch
Invoice #1042Due in 3 days
Invoice #1043Paid
Send reminder
Transition times on the left are exaggerated (150–1200ms) so the ripple is easy to see. Real codebases do the same thing, just faster.

Tell the browser

Set color-scheme and theme-color for the active theme, and paint the html background.

color-scheme makes scrollbars, form controls and the default canvas match the theme. theme-color tints the browser bar on phones; when someone overrides the system choice, update it from script too, because the media-based meta tags only follow the OS. Painting html stops the wrong color showing during overscroll, as in the Surfaces chapter.

via Vercel Web Interface Guidelines · Gustavo Fior, Craft

CSS + JS
:root { color-scheme: light; background: var(--bg); }
:root[data-theme="dark"] { color-scheme: dark; }

/* When the page follows the OS */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { color-scheme: dark; }
}

// After applying an explicit choice
const meta = document.querySelector('meta[name="theme-color"]');
meta.content = getComputedStyle(root).getPropertyValue("--bg").trim();

Assets that work in both

Draw icons with currentColor, give logos and illustrations a dark version, and never invert images automatically.

  • Icons drawn with currentColor follow the text color in both themes for free.
  • Logos, screenshots and diagrams often need a separate dark file. A <picture> source with a color-scheme media query only follows the OS, so when your site has its own toggle, swap versions with the theme attribute instead.
  • Photos stay as they are. Add the 10% white inset outline so pale edges don't vanish into dark cards.
  • Check chart, status and category colors separately in each theme; one palette rarely works on both grounds.

via web.dev · Jakub Krehel

HTML + CSS
<!-- Follows the OS only -->
<picture>
  <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="logo.svg" alt="Northwind">
</picture>

<!-- Follows your toggle too -->
<img class="only-light" src="logo.svg" alt="Northwind">
<img class="only-dark" src="logo-dark.svg" alt="">

.only-dark { display: none; }
:root[data-theme="dark"] .only-light { display: none; }
:root[data-theme="dark"] .only-dark { display: inline; }

Check both, and forced colors

Review every screen in both themes, and make sure focus rings and edges survive high-contrast mode.

Secondary text, borders, disabled states and focus rings are what usually fail in whichever theme was designed second, so check their contrast in both. In forced-colors mode, such as Windows high contrast, the browser replaces your palette and removes box-shadows. A focus ring or edge drawn only with box-shadow disappears, so pair it with a transparent outline that the system can recolor.

via MDN · Vercel Web Interface Guidelines

CSS
/* The outline is invisible normally and drawn in forced colors */
:focus-visible {
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--focus);
  outline: 2px solid transparent;
  outline-offset: 2px;
}

/* Shadow edges vanish in forced colors; give surfaces a real border */
@media (forced-colors: active) {
  .surface { border: 1px solid CanvasText; }
}

Take it with you

Starter pack

Everything above as code you can paste today: tokens and utilities for plain CSS, a flash-free theme toggle, the Tailwind equivalents, a skill that applies these details for you, and a critique template for design reviews.

/* ==========================================================================
   Interface Playbook · UI starter pack
   Tokens and utilities for interfaces that feel finished.
   Paste into your global stylesheet, then swap values for your brand.
   ========================================================================== */

:root {
  /* Spacing: one 4px scale, nothing off-grid */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;

  /* Radius: choose the outer radius, derive nested ones (outer = inner + gap) */
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 16px;
  --radius-xl: 24px;
  --radius-full: 999px;

  /* Easing: three curves cover almost everything */
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);      /* enter, respond to input */
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);   /* move something across the screen */
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);   /* sheets and drawers */

  /* Duration: most UI motion stays under 300ms */
  --dur-press: 120ms;
  --dur-fast: 160ms;
  --dur-base: 220ms;
  --dur-slow: 320ms;

  /* Depth: a hairline ring plus light that falls from above */
  --edge: 0 0 0 1px rgb(15 17 22 / 0.08);
  --shadow-1: var(--edge), 0 1px 2px rgb(15 17 22 / 0.06);
  --shadow-2: var(--edge), 0 1px 2px rgb(15 17 22 / 0.05), 0 6px 16px -4px rgb(15 17 22 / 0.10);
  --shadow-3: var(--edge), 0 2px 4px rgb(15 17 22 / 0.05), 0 18px 40px -12px rgb(15 17 22 / 0.22);
  --img-outline: rgb(0 0 0 / 0.10);

  /* Focus: a ring that clears the element, in your accent */
  --focus-ring: 0 0 0 2px var(--page-bg, #fff), 0 0 0 4px #2e66f5;
}

/* --------------------------------------------------------------------------
   Type ladder: one short set of sizes, named by role.
   Line height tightens as size grows; tracking tightens with it.
   Scale and line heights after dnbls/ui; tracking tuned for Inter Display.
   -------------------------------------------------------------------------- */
:root {
  --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;

  --fs-display-lg: 60px; --lh-display-lg: 1.05; --tr-display-lg: -0.06em;
  --fs-display: 48px;    --lh-display: 1.1;     --tr-display: -0.06em;
  --fs-title-xl: 36px;   --lh-title-xl: 1.15;   --tr-title-xl: -0.05em;
  --fs-title-lg: 30px;   --lh-title-lg: 1.2;    --tr-title-lg: -0.05em;
  --fs-title: 24px;      --lh-title: 1.25;      --tr-title: -0.04em;
  --fs-subtitle: 18px;   --lh-subtitle: 1.35;   --tr-subtitle: -0.03em;

  --fs-lead: 18px;       --tr-lead: -0.03em;
  --fs-body: 16px;       --lh-body: 1.5;        --tr-body: -0.02em;
  --fs-small: 14px;

  --fs-label: 14px;      --lh-label: 1.25;
  --fs-caption: 12px;    --lh-caption: 1.35;
}

/* Titles use Inter's Display cut */
.t-display-lg, .t-display, .t-title-xl, .t-title-lg, .t-title, .t-subtitle {
  font-family: var(--font-sans);
  font-weight: 600;
  font-variation-settings: "opsz" 32;
}
.t-display-lg { font-size: var(--fs-display-lg); line-height: var(--lh-display-lg); letter-spacing: var(--tr-display-lg); }
.t-display    { font-size: var(--fs-display);    line-height: var(--lh-display);    letter-spacing: var(--tr-display); }
.t-title-xl   { font-size: var(--fs-title-xl);   line-height: var(--lh-title-xl);   letter-spacing: var(--tr-title-xl); }
.t-title-lg   { font-size: var(--fs-title-lg);   line-height: var(--lh-title-lg);   letter-spacing: var(--tr-title-lg); }
.t-title      { font-size: var(--fs-title);      line-height: var(--lh-title);      letter-spacing: var(--tr-title); }
.t-subtitle   { font-size: var(--fs-subtitle);   line-height: var(--lh-subtitle);   letter-spacing: var(--tr-subtitle); }

.t-lead    { font-size: var(--fs-lead);    line-height: var(--lh-body);    letter-spacing: var(--tr-lead); }
.t-body    { font-size: var(--fs-body);    line-height: var(--lh-body);    letter-spacing: var(--tr-body); }
.t-small   { font-size: var(--fs-small);   line-height: var(--lh-body);    letter-spacing: var(--tr-body); }
.t-label   { font-size: var(--fs-label);   line-height: var(--lh-label);   letter-spacing: var(--tr-body); font-weight: 500; }
.t-caption { font-size: var(--fs-caption); line-height: var(--lh-caption); letter-spacing: var(--tr-body); font-weight: 500; }

@media (prefers-color-scheme: dark) {
  :root {
    --edge: 0 0 0 1px rgb(255 255 255 / 0.08);
    --shadow-1: var(--edge);
    --shadow-2: var(--edge), 0 8px 20px -6px rgb(0 0 0 / 0.55);
    --shadow-3: var(--edge), 0 20px 44px -12px rgb(0 0 0 / 0.70);
    --img-outline: rgb(255 255 255 / 0.10);
  }
}

/* --------------------------------------------------------------------------
   Base
   -------------------------------------------------------------------------- */

/* Paint the canvas so overscroll never flashes the wrong colour */
html {
  background: var(--page-bg, Canvas);
  -webkit-text-size-adjust: 100%;
}

/* Thinner, crisper text on macOS */
body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Even lines for headings, no orphans in paragraphs */
h1, h2, h3, h4, blockquote, figcaption { text-wrap: balance; }
p, li, dd { text-wrap: pretty; }

/* 16px or larger stops iOS zooming into a field on focus */
input, select, textarea { font-size: max(16px, 1em); }

/* No double-tap zoom delay, no grey tap flash */
a, button, [role="button"], label {
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}

/* Keyboard users always see where they are */
:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
}

/* --------------------------------------------------------------------------
   Utilities
   -------------------------------------------------------------------------- */

/* Numbers that change or line up in columns */
.num { font-variant-numeric: tabular-nums; }

/* Edges from translucent shadow: adapts to any background */
.surface        { box-shadow: var(--shadow-1); }
.surface-raised { box-shadow: var(--shadow-2); }
.surface-float  { box-shadow: var(--shadow-3); }
.surface-raised:hover { transition: box-shadow var(--dur-fast) var(--ease-out); }

/* A faint inset line gives every image a shape */
.img-outline {
  outline: 1px solid var(--img-outline);
  outline-offset: -1px;
}

/* Concentric nesting: set --r and --gap on the parent */
.nest {
  --gap: var(--space-2);
  --r: var(--radius-xl);
  padding: var(--gap);
  border-radius: var(--r);
}
.nest > * { border-radius: max(0px, calc(var(--r) - var(--gap))); }

/* Bigger hit area without a bigger look (24px desktop, 44px touch) */
.hit { position: relative; }
.hit::before {
  content: "";
  position: absolute;
  inset: -10px;
}

/* Tactile press on anything clickable */
.pressable { transition: transform var(--dur-press) var(--ease-out); }
.pressable:active { transform: scale(0.97); }

/* Hover is instant, and only for a real pointer */
@media (hover: hover) and (pointer: fine) {
  .hover-fill:hover { background: color-mix(in oklab, currentColor 7%, transparent); }
}

/* Enter animation, staggered: set style="--i: 0", "--i: 1", ... on children */
@keyframes rise {
  from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
}
.enter {
  animation: rise 560ms var(--ease-out) both;
  animation-delay: calc(var(--i, 0) * 70ms);
}

/* Exits ask for less attention than entrances */
@keyframes sink {
  to { opacity: 0; transform: translateY(4px); filter: blur(2px); }
}
.exit { animation: sink var(--dur-fast) var(--ease-out) both; }

/* Popovers grow from their trigger, never from zero */
.popover {
  transform-origin: var(--origin, top center);
  transition:
    opacity var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}
.popover[data-state="closed"] {
  opacity: 0;
  transform: scale(0.96);
  pointer-events: none;
}

/* Icon swap: cross-fade with a little scale and blur */
.swap { display: inline-grid; }
.swap > * {
  grid-area: 1 / 1;
  transition:
    opacity 180ms var(--ease-out),
    transform 180ms var(--ease-out),
    filter 180ms var(--ease-out);
}
.swap > [data-hidden] {
  opacity: 0;
  transform: scale(0.5);
  filter: blur(3px);
}

/* Grain: breaks up gradient banding. Keep it on small or static surfaces */
.grain { position: relative; isolation: isolate; }
.grain::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0.08;
  mix-blend-mode: overlay;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") 0 0 / 180px 180px;
}

/* --------------------------------------------------------------------------
   Reduced motion: keep the fades, drop the travel
   -------------------------------------------------------------------------- */
@keyframes fade { from { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  .enter { animation-name: fade; }
  .exit { animation: none; }
  .pressable:active { transform: none; }
  .popover[data-state="closed"] { transform: none; }
  html { scroll-behavior: auto; }
}

Before you ship

Checklist

Run a screen through this list before it goes out. Ticks are kept in this browser only, so you can work through a review in more than one sitting.

0 of 0

Sources

Library

The people and references this guide is built on. Each goes much further than a summary can, and most include their own interactive demos.

Jakub KrehelDetails that make interfaces feel better

Text wrapping, concentric radius, icon transitions, tabular numbers, interruptible and staggered motion, shadows and image outlines.

Read the article →
Josh PuckettInterface Craft

A library on designing with uncommon care: storyboarded animation, live tuning with DialKit, and a systematic critique method.

Visit the library →
VercelWeb Interface Guidelines

A thorough checklist across interactions, animation, layout, content, forms, performance and visual design.

Read the guidelines →
Rauno FreibergInvisible details of interaction design

Metaphor, momentum, interruptibility, spatial consistency, and why frequent actions shouldn't animate.

Read the essay →
Emil KowalskiPractical animation tips

Easing choices, durations, origins, scale and the case for leaving animation out. His course lives at animations.dev.

Read the tips →
Gustavo FiorCraft

Short design-engineering concepts with live demos: nested radius, grain, image outlines, canvas color, hover restraint.

Browse the concepts →
Jon YablonskiLaws of UX

The psychology behind interface decisions, from Fitts's and Hick's laws to the Doherty threshold and the peak–end rule.

Browse the laws →
Adam Wathan & Steve SchogerRefactoring UI

Practical visual design for developers: hierarchy, spacing systems, color and depth.

Visit the site →
AppleHuman Interface Guidelines

The reference for platform conventions, touch targets, motion and accessibility on Apple devices.

Read the guidelines →
Josh W. ComeauThe quest for the perfect dark mode

Why themes flash on load, and how to resolve the theme before the first paint.

Read the article →
web.devprefers-color-scheme

Supporting the system setting properly: media queries, color-scheme, images and a three-way switch.

Read the article →
Google Material DesignDesigning a dark theme

Dark greys instead of black, lighter surfaces for elevation, softened brand colors and text emphasis levels.

Open the codelab →