$640.00 to Studio Arkade
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.
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
- ContextWhat is it, who uses it, and in what mood?
- First lookOne honest paragraph of gut reaction.
- VisualColor intent, type scale, shadows, weight, spacing, icons.
- InterfaceEntry point, disclosure, density, feedback, repetition.
- ConsistencyShared patterns, platform conventions, one visual language.
- FeelingName the emotion. Where would extra care surprise them?
- ShortlistThe three to five highest-impact changes, ranked.
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
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
“Six columns per row” and “three greys that are nearly identical” can be fixed. “Cluttered” can't.
People judge your product against the best app in its category, not against your last release. Ask what that app would do here.
Quality is a set of attributes. Choose the three to five your product values, such as calm, fast or trustworthy, and deliver them everywhere.
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
Responses under about 400ms keep people in flow.
→ optimistic updates, instant hoverLarge, nearby targets are faster to hit.
→ hit areas, actions near the thumbMore choices make decisions slower.
→ progressive disclosurePeople perceive polished interfaces as easier to use.
→ the case for this whole guideExperiences are remembered by their best moment and their ending.
→ success states, finishing touchesEffort rises as the finish line comes into view.
→ progress framed as milestonesChapter 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); }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; }Move money between your accounts instantly
Transfers settle in seconds, and you can undo one for a full minute after it is sent.
Move money between your accounts instantly
Transfers settle in seconds, and you can undo one for a full minute after it is sent.
Move money between your accounts instantly
Transfers settle in seconds, and you can undo one for a full minute after it is sent.
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
$41,118.11
$41,118.11
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
| Use | Instead of | Write |
|---|---|---|
| Quotes | "Pending" | “Pending” |
| Apostrophe | don't | don’t |
| Opens a dialog | Rename... | Rename… |
| Ranges | 9-5, Mon-Fri | 9–5, Mon–Fri |
| Dimensions | 1920x1080 | 1920 × 1080 |
| Units | 10 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
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)));
}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); }
}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
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);
}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
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; }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 */
}what you draw
what the finger gets
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
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
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);
}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
| 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
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); }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
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"> */Payroll that runs itself
Schedule once, approve with a tap, and every salary lands on the same morning.
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
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
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
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 } };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
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
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
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
:focus-visible ring that nothing covers.aria-label.type, inputmode and autocomplete so autofill and mobile keyboards help.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
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>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); }| Token | Role | Light | Dark |
|---|---|---|---|
--bg | Page canvas | #EDEFF2 | #0D0F13 |
--surface | Cards and plates | #FFFFFF | #15181E |
--surface-2 | Insets, raised rows | #F5F6F8 | #1B1F26 |
--ink | Primary text | #14161B | #E9EBF0 |
--muted | Secondary text | #646B7A | #8E95A3 |
--red | Accent, redlines | #D6336C | #FF5C8F |
--blue | Focus, selection | #2E66F5 | #6B94FF |
--edge | Hairline ring | ink at 7% | white at 8% |
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
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"))
);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
currentColorfollow 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; }
}
<!-- ======================================================================
Interface Playbook · theme toggle
Light, Dark and System, with no flash on load and no ripple on switch.
====================================================================== -->
<!-- 1. In <head>, before your stylesheet: pick the theme before first paint -->
<script>
try {
var saved = localStorage.getItem("theme");
if (saved === "light" || saved === "dark") {
document.documentElement.dataset.theme = saved;
}
} catch (e) {}
</script>
<meta name="theme-color" content="#F4F5F7">
<!-- 2. The control: three options, System selected by default -->
<div class="theme-toggle" role="radiogroup" aria-label="Color theme">
<button type="button" role="radio" data-theme-set="light" aria-checked="false">Light</button>
<button type="button" role="radio" data-theme-set="dark" aria-checked="false">Dark</button>
<button type="button" role="radio" data-theme-set="system" aria-checked="true">System</button>
</div>
<style>
/* 3. Role tokens: light by default, dark from the OS unless overridden, dark when chosen */
:root {
color-scheme: light;
--bg: #F4F5F7;
--surface: #FFFFFF;
--text: #15171C;
--text-muted: #646B7A;
--edge: rgb(15 17 22 / 0.08);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bg: #0E1014;
--surface: #171A20;
--text: #E8EAEE;
--text-muted: #8E95A3;
--edge: rgb(255 255 255 / 0.08);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #0E1014;
--surface: #171A20;
--text: #E8EAEE;
--text-muted: #8E95A3;
--edge: rgb(255 255 255 / 0.08);
}
html { background: var(--bg); color: var(--text); }
/* 4. No ripple: transitions are off for the frame the theme changes */
.theme-switching,
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
.theme-toggle button[aria-checked="true"] { font-weight: 600; }
</style>
<script>
// 5. Apply, remember, follow the OS while on System, and sync other tabs
(function () {
var KEY = "theme";
var root = document.documentElement;
var buttons = Array.prototype.slice.call(document.querySelectorAll("[data-theme-set]"));
var meta = document.querySelector('meta[name="theme-color"]');
var osDark = window.matchMedia("(prefers-color-scheme: dark)");
function syncMeta() {
if (meta) meta.content = getComputedStyle(root).getPropertyValue("--bg").trim();
}
function apply(choice, save) {
root.classList.add("theme-switching");
if (choice === "system") delete root.dataset.theme;
else root.dataset.theme = choice;
getComputedStyle(root).color; // commit the new colors while transitions are off
requestAnimationFrame(function () {
requestAnimationFrame(function () { root.classList.remove("theme-switching"); });
});
buttons.forEach(function (b) {
var on = b.dataset.themeSet === choice;
b.setAttribute("aria-checked", String(on));
b.tabIndex = on ? 0 : -1;
});
syncMeta();
if (save) {
try {
if (choice === "system") localStorage.removeItem(KEY);
else localStorage.setItem(KEY, choice);
} catch (e) {}
}
}
var saved = "system";
try { saved = localStorage.getItem(KEY) || "system"; } catch (e) {}
apply(saved, false);
buttons.forEach(function (b, i) {
b.addEventListener("click", function () { apply(b.dataset.themeSet, true); });
b.addEventListener("keydown", function (e) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
var next = buttons[(i + (e.key === "ArrowRight" ? 1 : buttons.length - 1)) % buttons.length];
apply(next.dataset.themeSet, true);
next.focus();
e.preventDefault();
});
});
// The OS switched (for example at sunset) while the page follows System
osDark.addEventListener("change", syncMeta);
// Another tab changed the theme
window.addEventListener("storage", function (e) {
if (e.key === KEY) apply(e.newValue || "system", false);
});
})();
</script>
| Detail | Classes |
|---|---|
| Balanced headings, tidy paragraphs | text-balance · text-pretty |
| Numbers that move or align | tabular-nums |
| Crisp text | antialiased on <body> |
| Concentric corners (24 = 16 + 8) | outer rounded-3xl p-2, inner rounded-2xl |
| Image and avatar outline | outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10 |
| Press feedback | transition-transform duration-150 ease-out active:scale-[0.97] |
| Bigger hit area | relative before:absolute before:-inset-3 |
| No tap delay | touch-manipulation |
| Keyboard focus | focus-visible:outline-2 focus-visible:outline-offset-2 |
| Reduced motion | motion-safe:animate-… · motion-reduce:transition-none |
| Custom easing tokens (v4) | @theme { --ease-snappy: cubic-bezier(0.22, 1, 0.36, 1); } then ease-snappy |
In Tailwind v4, hover: already applies only on devices that can hover, so touch screens don't get stuck hover states.
Jakub Krehel's own agent skill that applies the details from his article to a codebase. Works with Claude Code, Codex, Cursor, Gemini CLI and others. After installing, run /make-interfaces-feel-better in your project.
npx skills add jakubkrehel/make-interfaces-feel-betterJosh Puckett's library on motion, micro-interactions and critique, with interactive playgrounds. It is where the storyboard pattern, DialKit tuning panels and the critique method on this page come from. Paid, one-time access.
interfacecraft.dev →Emil Kowalski's course and articles on interface motion: easing, springs, gestures and when to leave animation out.
animations.dev →Gustavo Fior's collection of short, demo-backed concepts: nested radius, grain, image outlines, hover restraint and more.
craft.gustavofior.com →# Critique: [screen or flow]
## Context
- What it is:
- Who uses it, and in what state of mind:
- Qualities we care about most (pick 3–5):
- Best-in-class reference for this category:
## First look
One honest paragraph. What stands out, what feels off.
## Visual design
- **[Issue]**: what you see, counted or named. Why it matters. What it could be.
Check: color intent · type hierarchy · shadow and stroke quality ·
visual weight vs importance · spacing and alignment · icon consistency
## Interface design
- We're missing a chance to [set expectations / reduce load / reward progress]…
Check: clear entry point · progressive disclosure · density ·
expectation setting · feedback · repetition
## Consistency and conventions
Check: same actions behave the same · platform conventions ·
shared components · one visual language
## User context
- How this makes the person feel:
- Where extra care would surprise them (empty, error, loading, edge cases):
## Top opportunities
Ranked deepest first: structural, then behavioral, then visual.
1.
2.
3.
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.
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.
Text wrapping, concentric radius, icon transitions, tabular numbers, interruptible and staggered motion, shadows and image outlines.
Read the article → Josh PuckettInterface CraftA library on designing with uncommon care: storyboarded animation, live tuning with DialKit, and a systematic critique method.
Visit the library → VercelWeb Interface GuidelinesA thorough checklist across interactions, animation, layout, content, forms, performance and visual design.
Read the guidelines → Rauno FreibergInvisible details of interaction designMetaphor, momentum, interruptibility, spatial consistency, and why frequent actions shouldn't animate.
Read the essay → Emil KowalskiPractical animation tipsEasing choices, durations, origins, scale and the case for leaving animation out. His course lives at animations.dev.
Read the tips → Gustavo FiorCraftShort design-engineering concepts with live demos: nested radius, grain, image outlines, canvas color, hover restraint.
Browse the concepts → Jon YablonskiLaws of UXThe 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 UIPractical visual design for developers: hierarchy, spacing systems, color and depth.
Visit the site → AppleHuman Interface GuidelinesThe reference for platform conventions, touch targets, motion and accessibility on Apple devices.
Read the guidelines → Josh W. ComeauThe quest for the perfect dark modeWhy themes flash on load, and how to resolve the theme before the first paint.
Read the article → web.devprefers-color-schemeSupporting the system setting properly: media queries, color-scheme, images and a three-way switch.
Read the article → Google Material DesignDesigning a dark themeDark greys instead of black, lighter surfaces for elevation, softened brand colors and text emphasis levels.
Open the codelab →