Web Design
A practitioner-sourced reference for building web interfaces well. Synthesized from Refactoring UI, Tailwind CSS, shadcn/ui, Laws of UX, animations.dev, detail.design, Every Layout, Web Interface Guidelines, jakub.kr, userinterface.wiki (Raphael Salaja), and other authoritative sources.
Use this skill whenever you are building, reviewing, or improving a web interface.
Implementation Priority
When building or reviewing a UI, work through these tiers in order. Each tier depends on the ones above it — fixing a shadow detail is wasted effort if the layout is broken.
Tier 1: Structure (get this right first)
- Semantic HTML. Correct elements (
<button>,<nav>,<main>, headings in order). Everything else builds on this. - Layout. Grid/flex structure, spacing scale, content width constraints (
max-w-prose, 12-col grid). Does the page hold together at every viewport? - Responsive behavior. Mobile-first, intrinsic sizing (
auto-fillgrids,clamp(),flex-wrap). No content hidden on small screens without reason. - Typography fundamentals. Type scale, line height (1.5 body, 1.1-1.25 headings), line length (
max-width: 65ch),remunits for font sizes.
Tier 2: Visual System (the design backbone)
- Color and contrast. Palette applied, semantic tokens set, WCAG AA contrast met (4.5:1 text, 3:1 UI). Dark mode if needed.
- Visual hierarchy. Four text levels working (foreground, muted, muted/70, muted/50). Primary action obvious. Squint test passes.
- Component states. Every interactive element has hover, focus-visible, active, disabled, loading, error, and empty states accounted for.
- Spacing and proportion. Outer padding >= inner padding. No ambiguous gaps. Button padding ratio (2x horizontal : 1x vertical). Input heights match button heights.
Tier 3: Interaction and Motion (make it feel alive)
- Keyboard and accessibility. Focus styles, tab order, skip links,
ariaattributes, touch targets (44px+). Test with keyboard only. - Transitions. Hover/active feedback (150ms ease-out), state transitions (200-300ms), correct easing per direction (ease-out for enter, ease-in for exit).
- Animation. Stagger entrances, subtle exits, interruptible transitions,
prefers-reduced-motionrespected. Spring physics where appropriate.
Tier 4: Polish (the last 10% that makes it feel crafted)
- Shadows and depth. Layered shadows, shadows-instead-of-borders where appropriate, consistent light source. Dark mode: surface lightness instead of shadows.
- Optical adjustments. Icon-side button padding, concentric border radii, play button offset, tabular-nums on data, image outlines.
- Micro-interactions. Contextual icon animation, blur on stagger entrances, copy-to-clipboard feedback, optimistic updates.
- Defensive CSS.
min-width: 0on flex children,overflow-wrap: break-word,scrollbar-gutter: stable, text truncation, safe-area padding. - Final checks. Squint test, grayscale test, swap test, "would a human ship this?" test. No AI slop (gratuitous gradients, identical metric cards, centered everything).
Rule of thumb: If you're debating a shadow opacity while the layout breaks at 768px, stop and go back to Tier 1.
Table of Contents
- Layout and Spacing
- Typography
- Color
- Shadows and Depth
- Visual Hierarchy
- Animation and Motion
- Components
- Responsive Design
- Accessibility
- Performance
- UX Psychology
- Design Tokens
- Polish and Craft
- Microcopy and UX Writing
- AI Slop Prevention
- Advanced Craft
- Defensive CSS
- Modern CSS Reset
- Predictive Prefetching
- Audio Feedback and Sound Design
1. Layout and Spacing
Spacing Scale
Use a consistent mathematical scale rooted in a base unit. The standard base is 4px (0.25rem). Every spacing value should be a multiple of this base.
| Token | Value | Use |
|---|---|---|
| 0.5 | 2px | Hairline gaps, icon padding |
| 1 | 4px | Tight inline spacing |
| 1.5 | 6px | Small component internal padding |
| 2 | 8px | Default gap between related items |
| 3 | 12px | Compact card padding |
| 4 | 16px | Standard card/section padding |
| 5 | 20px | Comfortable padding |
| 6 | 24px | Section padding |
| 8 | 32px | Section gaps |
| 10 | 40px | Large section gaps |
| 12 | 48px | Page section spacing |
| 16 | 64px | Major page divisions |
| 20 | 80px | Hero spacing |
| 24 | 96px | Large hero spacing |
Spacing Rules
- Outer padding >= inner padding. A card's outer margin must equal or exceed its internal padding. Interior elements relate more closely to each other than to external elements.
- Proximity signals relationship. Elements closer together are perceived as related (Gestalt Law of Proximity). Use spacing deliberately to group or separate.
- Eliminate ambiguous spacing. If the gap between two elements could belong to either, it's ambiguous. Make relationships clear through asymmetric spacing.
- Eliminate dead zones. Use padding on child elements instead of margins on containers. Every pixel between interactive items should be clickable.
- Use consistent increments. Don't pick arbitrary values. Constrain to the scale. Three similar spacings (14px, 16px, 18px) look like mistakes -- pick one.
Layout Primitives
These CSS patterns create responsive layouts without media queries:
Stack -- Vertical flow with consistent gaps:
.stack > * + * { margin-block-start: var(--space); }
Cluster -- Horizontal wrapping with gaps:
.cluster { display: flex; flex-wrap: wrap; gap: var(--space); }
Sidebar -- Two columns where one is fixed:
.sidebar { display: flex; flex-wrap: wrap; gap: var(--space); }
.sidebar > :first-child { flex-basis: 20rem; flex-grow: 1; }
.sidebar > :last-child { flex-basis: 0; flex-grow: 999; min-inline-size: 60%; }
Grid (auto-fill) -- Responsive columns without breakpoints:
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: var(--space); }
Center -- Constrained width with centering:
.center { max-inline-size: var(--measure); margin-inline: auto; padding-inline: var(--space); }
Grid and Flex Guidance
- Use CSS Grid for two-dimensional layouts (rows and columns together).
- Use Flexbox for one-dimensional layouts (single row or column).
- Prefer
frunits over percentages in Grid --frdistributes space after gaps, preventing overflow. - Use
gapinstead of margins between items. - A 12-column grid provides maximum flexibility (divisible by 1, 2, 3, 4, 6).
- Set
min-width: 0on flex children to prevent content overflow. - Use
align-self: starton sticky sidebar elements inside grid layouts.
In Practice: Tailwind Layout Patterns
Page shell with sidebar:
<div className="flex min-h-screen">
<aside className="hidden lg:flex w-64 flex-col border-r bg-muted/40 p-4">
<nav className="flex flex-col gap-1">{/* nav items */}</nav>
</aside>
<main className="flex-1 p-6">
<div className="mx-auto max-w-4xl space-y-8">{children}</div>
</main>
</div>
Card grid that adapts without breakpoints:
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-4">
{items.map(item => <Card key={item.id} {...item} />)}
</div>
Content section with consistent vertical rhythm:
<section className="space-y-6 py-12">
<h2 className="text-2xl font-semibold tracking-tight">Features</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map(f => <FeatureCard key={f.id} {...f} />)}
</div>
</section>
Card with proper spacing hierarchy (outer > inner):
<div className="rounded-lg border bg-card p-6"> {/* outer: p-6 */}
<div className="space-y-4"> {/* inner gap: 4 */}
<h3 className="font-semibold leading-tight">Title</h3>
<p className="text-sm text-muted-foreground">Description text here.</p>
</div>
</div>
2. Typography
Type Scale
Use a mathematical ratio to generate harmonious font sizes. Choose the ratio based on context:
| Ratio | Name | Best For |
|---|---|---|
| 1.067 | Minor Second | Dense UI, dashboards |
| 1.125 | Major Second | Compact interfaces |
| 1.200 | Minor Third | General purpose (recommended default) |
| 1.250 | Major Third | Content-heavy sites |
| 1.333 | Perfect Fourth | Marketing, editorial |
| 1.414 | Augmented Fourth | Bold presentation |
| 1.500 | Perfect Fifth | Dramatic hierarchy |
| 1.618 | Golden Ratio | High-impact landing pages |
Font Size Rules
- Body text minimum: 16px (1rem). This is the browser default. Never go smaller for primary reading content.
- Use
remfor font sizes. This respects both browser zoom and the user's default font size preference. Pixels prevent users from scaling text. - Use
remfor media queries. When users increase their default font size, rem-based breakpoints trigger mobile layouts on wider screens, giving them more room. - Use
pxfor padding, borders, and decorative elements. These shouldn't scale with text size. - Use
remfor vertical margins. Spacing between paragraphs should grow with text.
Fluid Typography
Use clamp() for responsive sizing that scales smoothly between breakpoints:
/* Heading: 32px on small screens, 48px on large, fluid between */
font-size: clamp(2rem, 1.5rem + 2vw, 3rem);
Define a small-screen scale (e.g., 1.2x at 320px) and a large-screen scale (e.g., 1.333x at 1500px). The browser interpolates between them.
Line Height
- Body text: 1.5. This meets WCAG criteria and improves readability for all users.
- Headings: 1.1 - 1.25. Larger text needs tighter leading.
- The bigger the text, the less line-height it needs. Scale inversely.
- Headings and buttons: 1.1 is a good default.
Line Length
- Optimal: 60-75 characters (about 8-10 words per line).
- Maximum: 80 characters. Beyond this, readers lose their place.
- Use
max-width: 65chon content containers to enforce this.
Letter Spacing
- Large text: reduce letter-spacing. Bigger text needs less space between characters.
- Small text: increase letter-spacing slightly.
- All-caps text: add +0.05 to +0.1em letter-spacing. Uppercase letters crowd each other without extra space.
- Use
font-variant-numeric: tabular-numsin tables and timers so digits maintain consistent width.
Text Wrapping
/* Balanced line breaks for headings */
h1, h2, h3, h4, h5, h6 { text-wrap: balance; }
/* Avoid orphans in paragraphs */
p { text-wrap: pretty; }
/* Prevent overflow from long words/URLs */
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
Font Rendering
body { -webkit-font-smoothing: antialiased; }
html { text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; }
Font Weight
- Never use weights below 400 -- they become illegible on most screens.
- Use 500-600 for medium headings.
- Use 700 for strong emphasis.
- Prevent layout shift from weight changes: Use a hidden
::afterpseudo-element with bold text to reserve the bold width, preventing jitter when toggling active states.
OpenType Features
Modern fonts ship with OpenType features that dramatically improve typographic quality. Enable them intentionally:
| Feature | CSS | Use |
|---|---|---|
| Tabular numbers | font-variant-numeric: tabular-nums |
Tables, dashboards, pricing, timers -- equal-width digits align in columns |
| Oldstyle numbers | font-variant-numeric: oldstyle-nums |
Body text/prose -- digits with ascenders/descenders blend with lowercase |
| Slashed zero | font-variant-numeric: slashed-zero |
Code-adjacent UIs, IDs, error codes -- disambiguate 0 from O |
| Proper fractions | font-variant-numeric: diagonal-fractions |
Recipes, specs -- converts 1/2 to typographic fraction |
| Contextual alternates | font-feature-settings: "calt" 1 |
Usually on by default -- keep enabled for smart glyph adjustments |
| Disambiguation set | font-feature-settings: "ss02" |
Code UIs -- distinguish I/l/1 and 0/O (font-dependent) |
/* Data display: aligned, disambiguated */
.data { font-variant-numeric: tabular-nums slashed-zero; }
/* Prose: numbers that blend with text */
.prose { font-variant-numeric: oldstyle-nums; }
/* Code-adjacent UI */
.code-ui { font-variant-numeric: tabular-nums slashed-zero; font-feature-settings: "ss02"; }
font-optical-sizing: auto-- leave at default. The font adjusts glyph shapes per size (thicker strokes at small sizes, finer details at large).font-synthesis: none-- disable to prevent the browser from generating faux bold/italic when the font lacks those weights. Forces you to load proper font files.- Variable fonts accept any weight 100-900, not just standard stops. Use precise values like 450 or 550 for fine-grained hierarchy without loading extra files.
text-decoration-skip-ink: autois the default and correctly skips descenders. Addtext-underline-offset: 3pxto push underlines below descenders for cleaner links.
Typeface Pairing
- Limit to two typefaces. One for headings, one for body. A second typeface should reinforce the design concept.
- A monospace third face for code is acceptable.
- If using a single typeface, differentiate hierarchy through weight, size, and color instead.
In Practice: Next.js + Tailwind Typography
Font setup in app/layout.tsx:
import { Inter, JetBrains_Mono } from "next/font/google"
const sans = Inter({ subsets: ["latin"], variable: "--font-sans" })
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" })
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${sans.variable} ${mono.variable}`}>
<body className="font-sans antialiased">{children}</body>
</html>
)
}
Tailwind type scale in use (shadcn conventions):
{/* Page heading */}
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">Dashboard</h1>
{/* Section heading */}
<h2 className="text-2xl font-semibold tracking-tight">Recent Activity</h2>
{/* Card title */}
<h3 className="font-semibold leading-tight">Monthly Revenue</h3>
{/* Body text with constrained width */}
<p className="max-w-prose text-muted-foreground">
Your revenue increased 12% compared to last month.
</p>
{/* Small label */}
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Status
</span>
{/* Tabular numbers in a data display */}
<td className="tabular-nums text-right font-medium">$12,450.00</td>
3. Color
Color System Structure
A working palette needs three categories:
- Neutrals (Greys): 8-10 shades. Used for text, backgrounds, panels, form controls -- almost everything.
- Primary Colors: 1-2 colors for primary actions, navigation, and branding. 5-10 shades each.
- Accent/Semantic Colors: Red (destructive/error), yellow/amber (warning), green/emerald (success), blue (info). 5-10 shades each.
The 9-Shade System
For each color, define 9 shades numbered 50-900:
| Shade | Use |
|---|---|
| 50 | Subtle tinted backgrounds |
| 100 | Hover backgrounds, light fills |
| 200 | Active backgrounds, badges |
| 300 | Borders, rings |
| 400 | Placeholder text, disabled states |
| 500 | Primary buttons, icons, links |
| 600 | Hover on primary buttons |
| 700 | Active/pressed states |
| 800 | Heading text on light backgrounds |
| 900 | Body text on light backgrounds |
| 950 | Darkest shade for high contrast |
Color Format
- Use OKLCH as the primary color format. It's perceptually uniform (colors at the same lightness actually look equally light), supported in all modern browsers, and used by Tailwind v4 and shadcn/ui. Format:
oklch(lightness chroma hue). - HSL remains a solid alternative when OKLCH tooling is unavailable. It's intuitive: adjusting lightness creates shades, adjusting saturation creates tints.
- Store color channels as CSS variables for maximum flexibility:
:root {
/* OKLCH approach (preferred) */
--primary: oklch(62% 0.21 260);
/* HSL approach (alternative) */
--primary-h: 221;
--primary-s: 72%;
--primary-l: 62%;
--primary-hsl: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
}
Color Rules
- Use near-black and near-white, not pure. Pure black (#000) on pure white (#fff) creates uncomfortable contrast. Use
#0a0a0a/#111and#fafafa/#f5f5f5instead. - Saturate your neutrals. Add a hint of your primary hue to grey tones (keep saturation under 5% in HSB). This creates palette cohesion.
- Maintain consistent color temperature. Use either warm or cool tinted neutrals, not both.
- Colors must differ in brightness, not just hue. Two colors at the same lightness compete visually. Vary brightness to create distinction.
- Rotate hue for vibrant variants. When creating lighter shades, rotate hue toward a brighter anchor (yellow, cyan). For darker shades, rotate toward a deeper anchor (blue, violet). This prevents washed-out pastels.
- Don't rely on color alone to convey meaning. Always pair color with text, icons, or patterns for accessibility.
- Use
color-mix()for dynamic variations:
background: color-mix(in oklch, var(--primary), transparent 95%);
Dark Mode
- Swap light and dark values (900 becomes foreground, 50 becomes background).
- Don't just invert -- dark backgrounds need reduced contrast. Container brightness should differ from background by max 12%.
- Avoid pure white text on dark backgrounds. Use near-white (e.g.,
#e5e5e5). - Shadows are ineffective in dark mode -- use subtle borders or lighter elevated surfaces instead.
- Respect
prefers-color-schemeas the default. Don't add a theme toggle unless specifically needed.
In Practice: shadcn/ui Theming
app/globals.css -- semantic color tokens (shadcn/ui pattern):
@layer base {
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.965 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.965 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.965 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(0.269 0 0);
}
}
Dark mode toggle with next-themes (only add when users need manual control; system preference is the default):
// app/layout.tsx
import { ThemeProvider } from "next-themes"
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}
// components/theme-toggle.tsx
"use client"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { Moon, Sun } from "lucide-react"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<Button variant="ghost" size="icon"
=> setTheme(theme === "dark" ? "light" : "dark")}>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-[transform,opacity] dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-[transform,opacity] dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
4. Shadows and Depth
Shadow Principles
- Single consistent light source. All shadows should share the same angle ratio (typically above and slightly left). Every shadow on the page must use the same offset ratio.
- Elevation mapping. As elements rise: offset increases, blur grows, opacity decreases. This mimics physical light behavior.
- Layer multiple shadows for realism. A single
box-shadowlooks flat. Stack 2-5 layers with geometrically increasing values. (Shadow color uses HSL below for readability; any color format works.)
/* Low elevation */
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.3);
/* Medium elevation */
box-shadow:
0 1px 2px hsl(var(--shadow-color) / 0.2),
0 2px 4px hsl(var(--shadow-color) / 0.2),
0 4px 8px hsl(var(--shadow-color) / 0.2);
/* High elevation */
box-shadow:
0 1px 2px hsl(var(--shadow-color) / 0.12),
0 2px 4px hsl(var(--shadow-color) / 0.12),
0 4px 8px hsl(var(--shadow-color) / 0.12),
0 8px 16px hsl(var(--shadow-color) / 0.12),
0 16px 32px hsl(var(--shadow-color) / 0.12);
- Match shadow color to the environment. Don't use pure black/grey shadows -- tint them with the background's hue for vibrant, natural depth.
- Shadow blur = 2x shadow distance. A 4px vertical offset pairs with 8px blur.
- Elements closer to the user appear lighter. Mimic real-world light: surfaces near the light source are brighter.
- Avoid shadows in dark interfaces. They lack visual logic. Use subtle borders or surface lightness variation instead.
- Don't mix depth techniques. Pick one approach (shadows, borders, or surface lightness) and use it consistently.
- Never animate layered shadows directly. It's too expensive. Instead, animate
opacitybetween two shadow states using pseudo-elements.
Border Radius
- Nest corner radii correctly. Inner radius = outer radius - gap between elements. If outer is 12px and padding is 8px, inner should be 4px.
- Container borders need dual contrast. They must be distinguishable from both the container's background and the page background.
Shadows Instead of Borders
Subtle layered box-shadow adds depth better than solid borders and adapts to any background through transparency:
/* Multi-shadow composition — replaces border with soft depth */
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 2px -1px rgba(0, 0, 0, 0.06),
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
/* Hover: slightly increase opacity for lift effect */
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.08),
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
- The first layer (
0px 0px 0px 1px) acts as the border replacement — a zero-blur spread gives a crisp 1px edge. - Works on image backgrounds and color backgrounds where solid
border-colorwould clash. - Transition
box-shadowon hover for interactive lift. Usetransition: box-shadow 150ms ease(or Tailwind'stransition-shadow).
Image Outlines
Add a 1px outline to images for consistent visual framing and depth:
img {
outline: 1px solid rgba(0, 0, 0, 0.1);
outline-offset: -1px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
img { outline-color: rgba(255, 255, 255, 0.1); }
}
This is simpler than the inset ring technique and works on any <img> directly. Use outline-offset: -1px so the outline sits inside the image bounds.
In Practice: Tailwind Shadow and Depth
Elevation classes from Tailwind mapped to use cases:
{/* Flat surface — default cards */}
<div className="rounded-lg border bg-card shadow-sm">
{/* Raised — hover state, dropdown trigger */}
<div className="rounded-lg border bg-card shadow-md hover:shadow-lg transition-shadow">
{/* Floating — dropdowns, popovers, command palette */}
<div className="rounded-xl border bg-popover shadow-xl">
{/* Dialog/modal backdrop + elevated panel */}
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm">
<div className="rounded-xl border bg-card shadow-2xl">
Shadow instead of border (card with hover lift):
<div className="rounded-lg bg-card p-4
shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_-1px_rgba(0,0,0,0.06),0px_2px_4px_0px_rgba(0,0,0,0.04)]
hover:shadow-[0px_0px_0px_1px_rgba(0,0,0,0.08),0px_1px_2px_-1px_rgba(0,0,0,0.08),0px_2px_4px_0px_rgba(0,0,0,0.06)]
transition-shadow">
<h3 className="font-semibold">No border needed</h3>
<p className="text-sm text-muted-foreground">Shadow provides the edge.</p>
</div>
Image with inset outline (simple approach):
<img
src={src} alt={alt}
className="rounded-lg outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
/>
Nested border radius (card with inner image):
{/* Outer: rounded-xl (12px), padding: p-2 (8px), so inner = 12-8 = 4px = rounded-sm */}
<div className="rounded-xl border bg-card p-2">
<img className="rounded-sm w-full" src={src} alt={alt} />
<div className="p-3">
<h3 className="font-semibold">Title</h3>
</div>
</div>
5. Visual Hierarchy
Core Principles
- Size, weight, and color communicate importance. Primary content is larger, bolder, and higher contrast. Secondary content is smaller, lighter, and lower contrast.
- De-emphasize secondary content instead of over-emphasizing primary content. It's more effective to make supporting elements quieter than to make everything louder.
- Semantic hierarchy != visual hierarchy. An
<h2>doesn't always need to look like a big heading. Style for the role the content plays in the layout, not its HTML tag. - Reduce label reliance. When context makes meaning clear, labels are redundant. "john@example.com" doesn't need an "Email:" label -- the format itself communicates.
- Use weight and contrast as separate levers. Bold dark text and regular grey text occupy different hierarchy levels. Combining size + weight + color gives you three independent hierarchy controls.
- High contrast for important elements, low contrast for structural elements. Draw attention to actions and content, not chrome and borders.
Visual Weight Ordering
- Arrange series of elements in descending visual weight. The heaviest element sits at the outer edge.
- Use fewer borders. Replace borders with spacing, background color differences, or shadows for cleaner separation.
- Accent borders (3-5px) on one edge of a card or callout add visual interest and hierarchy without heavy separation.
Alignment
- Align everything with something. Alignment shows that elements are related and that placement is intentional.
- Prefer optical alignment over mathematical. Triangular shapes (play buttons, arrows) have visual centers that differ from their geometric centers. Trust your eyes.
- Measure spacing between high-contrast points. Eyes find element edges based on contrast, not bounding boxes.
Four-Level Text Hierarchy
Every interface needs exactly four text levels. More creates noise, fewer creates flatness:
{/* Level 1: Primary — the main content the user came to see */}
<p className="text-foreground">Invoice #1234 — $2,400.00</p>
{/* Level 2: Secondary — supporting info that adds context */}
<p className="text-muted-foreground">Due March 15, 2026</p>
{/* Level 3: Tertiary — metadata, timestamps, IDs */}
<span className="text-xs text-muted-foreground/70">Created 2 days ago</span>
{/* Level 4: Muted — disabled, placeholder, non-essential */}
<span className="text-xs text-muted-foreground/50">Optional</span>
Surface Elevation (Dark Mode Depth)
In dark mode, shadows are invisible. Instead, use lightness shifts to create depth. Each elevated layer gets a few percentage points brighter:
{/* Level 0: Page background */}
<div className="bg-background"> {/* darkest */}
{/* Level 1: Card surface */}
<div className="bg-card"> {/* slightly lighter */}
{/* Level 2: Dropdown / popover on top of card */}
<div className="bg-popover"> {/* lighter still */}
</div>
</div>
</div>
The lightness difference between levels should be subtle (2-5% in OKLCH lightness). Larger jumps fragment the interface into disconnected "worlds."
Quality Checks
- Squint test. Blur your eyes and look at the page. Hierarchy should still be visible -- headings distinct from body, primary actions distinct from secondary.
- Swap test. If you replaced the typeface with a system font, would the design still hold? If not, the hierarchy depends on the font, not the structure.
- Grayscale test. Convert to grayscale. If elements that were distinct now merge, you're relying on hue alone (an accessibility failure).
6. Animation and Motion
When to Animate
- Feedback: Confirm that the system recognized an action (button press, form submit, toggle).
- State transitions: Signal changes between interface modes or views.
- Spatial navigation: Help users understand their position in a hierarchy (zoom = depth, slide = lateral movement).
- Signifiers: Show how to interact with an element (drag direction, swipeable area).
When NOT to Animate
- High-frequency interactions. Actions performed many times daily should feel instant -- no animation.
- Keyboard-initiated actions. These should be immediate and feel connected to the input.
- When performance suffers. A janky animation is worse than no animation. 60fps or nothing.
- Repetitive daily-use elements. Animations seen repeatedly throughout the day become annoying.
- Gratuitous motion. If it doesn't clarify interaction or explain functionality, remove it.
Easing Functions
| Easing | Use For |
|---|---|
ease-out |
Elements entering the screen, appearing, expanding. Default choice. |
ease-in-out |
Elements moving while already visible on screen. |
ease |
Hover effects, color transitions. |
linear |
Continuous/looping animations only (spinners, progress fills). |
ease-in |
Elements leaving the screen. Never for elements that remain visible. |
Custom curves are strongly preferred over built-in CSS easings. Example production curve: cubic-bezier(0.32, 0.72, 0, 1) (iOS sheet behavior, used at 500ms).
Duration Guidelines
| Context | Duration |
|---|---|
| Hover effects | 100-150ms |
| Button press feedback | ~160ms |
| Tooltip enter/exit | ~125ms |
| Dropdown/select open | ~180ms |
| General UI transitions | 150-300ms |
| Toast notifications | ~400ms |
| Drawer/sheet open | ~500ms |
| Hold-to-confirm (destructive) | 2000ms (safety) |
| Hold release feedback | 200ms |
- Asymmetric timing for safety: Slow, predictable timing for destructive actions paired with fast release feedback.
- Entry != exit. Differentiate enter vs. exit animations. Entry should feel decisive; exit should feel natural.
- Exit animations should be subtler than entrances. Use less movement on exit — a fixed small value (e.g.,
y: -12px) instead of the full distance (e.g.,y: calc(-100% - 4px)). The exiting element needs just enough motion for directional indication, not a full departure animation. Add a lightblur(4px)on exit to soften the disappearance.
Animation Techniques
Scale on press: scale(0.97) on :active for instant tactile feedback.
Never scale from 0. Start from scale(0.9) or higher. Even a deflated balloon has a visible shape.
Origin-aware popover: Scale from the trigger point using transform-origin, not center.
Tooltip delay pattern: First tooltip shows with delay. Subsequent tooltips while hovering appear instantly.
Blur as rescue: When easing adjustments still feel off, add filter: blur(2px) during the transition.
Use clip-path for reveals: Hardware-accelerated with no layout shifts. clip-path: inset(0 0 100% 0) to inset(0 0 0 0).
Make animations interruptible. CSS transitions interpolate toward the latest state and can be interrupted mid-action — use them for interactions (hovers, toggles, drags). CSS keyframe animations run a fixed timeline and cannot retarget — use them for staged sequences that play once (page load entrances, celebration effects). Users change intent mid-interaction; interruptible animations feel responsive, non-interruptible ones feel broken.
Animate icons contextually. When icons appear or disappear conditionally (e.g., a checkmark replacing a copy icon), animate opacity, scale, and blur together for a smooth transition instead of an instant swap. Use spring physics or short transitions (150-200ms). This applies to any element that swaps in/out based on state.
Staging and Choreography (from Disney's 12 Principles)
- One focal point at a time. Only one element should animate prominently. If two things move simultaneously, the eye doesn't know where to look.
- Context menus animate on exit only, not entrance. They should appear instantly and animate away.
- Squash and stretch range: 0.95-1.05 scale. Anything beyond looks cartoonish in UI.
- Stagger delays: max 50ms per item. Longer stagger feels sluggish. For a 5-item list: 0ms, 50ms, 100ms, 150ms, 200ms.
- Split and stagger for impact. Instead of animating an entire block at once, break it into smaller chunks (sections, lines, even words) and animate each individually. Combine
opacity,blur(4-5px), andtranslateY(6-8px)for the initial state. Use CSS custom properties for clean stagger control:animation-delay: calc(var(--stagger-delay, 80ms) * var(--stagger-index, 0)). Three tiers of granularity: container-level (simple), section-level (balanced), word-level (dramatic). - Dim backgrounds on overlays. Darkened backdrops direct focus to the foreground element.
- Use spring physics for bounce-and-settle effects. Don't fake it with easing curves. Framer Motion
type: "spring"withstiffness: 300, damping: 20is a good starting point.
Properties Safe to Animate
transform(translate, rotate, scale) -- GPU-acceleratedopacity-- GPU-acceleratedclip-path-- GPU-accelerated
Properties to Avoid Animating
width,height,padding,margin-- triggers layout recalculationtop,left,right,bottom-- triggers layout recalculationbackground-color-- triggers repaint (expensive)
Performance Ranking
- CSS Animations/Transitions (best -- runs off main thread)
- Web Animations API (good -- survives main-thread congestion)
- JavaScript
requestAnimationFrame(worst -- lags when browser is busy)
Accessibility
Always respect prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
In Practice: Tailwind + Framer Motion
Button with press feedback (CSS only, Tailwind). Duration matches the ~160ms guideline (Tailwind's closest: duration-150):
<button className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground
transition-[background-color,transform] duration-150 ease-out
hover:bg-primary/90
active:scale-[0.97]">
Save Changes
</button>
Animated dialog with Framer Motion + shadcn:
"use client"
import { motion, AnimatePresence } from "motion/react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
export function AnimatedDialog({ open, onOpenChange, children }) {
return (
<Dialog open={open}
<AnimatePresence>
{open && (
<DialogContent forceMount asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }}
>
{children}
</motion.div>
</DialogContent>
)}
</AnimatePresence>
</Dialog>
)
}
Staggered list entrance:
import { motion } from "motion/react"
export function StaggeredList({ items }) {
return (
<ul className="space-y-2">
{items.map((item, i) => (
<motion.li key={item.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05, duration: 0.25, ease: "easeOut" }}
className="rounded-lg border bg-card p-4"
>
{item.label}
</motion.li>
))}
</ul>
)
}
Staggered entrance with blur (CSS-only, higher impact):
@keyframes stagger-in {
from {
opacity: 0;
filter: blur(5px);
transform: translateY(8px);
}
}
.stagger-item {
animation: stagger-in 800ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation-delay: calc(var(--stagger-index, 0) * 80ms);
}
{/* Assign --stagger-index per element */}
<div className="stagger-item" style={{ "--stagger-index": 0 } as React.CSSProperties}>Section 1</div>
<div className="stagger-item" style={{ "--stagger-index": 1 } as React.CSSProperties}>Section 2</div>
<div className="stagger-item" style={{ "--stagger-index": 2 } as React.CSSProperties}>Section 3</div>
Subtle exit animation (Framer Motion — less motion than entrance):
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -12, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
Skeleton loading with Tailwind animate-pulse:
function CardSkeleton() {
return (
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-4/5 animate-pulse rounded bg-muted" />
</div>
)
}
Spring Physics vs Easing Decision Framework
Choose the right timing model based on what drives the motion:
| Motion Type | Use | Why |
|---|---|---|
| User-driven (drag, flick, swipe) | Spring | Survives interruption, preserves velocity |
| System-driven (state change, feedback) | Easing | Clear start/end, predictable timing |
| Time representation (progress, loading) | Linear | 1:1 relationship between time and progress |
| High-frequency (typing, fast toggles) | None | Animation adds noise, feels slower |
| Keyboard-initiated actions | None | Must feel instant and connected to input |
Spring parameter guidance: Start with stiffness: 300, damping: 20 (slight bounce) or stiffness: 500, damping: 30 (snappy, minimal overshoot). Avoid damping < 10 with high stiffness -- creates excessive oscillation. Always pass velocity from drag events to preserve input energy:
onDragEnd={(e, info) => {
animate(target, { x: 0 }, {
type: "spring",
velocity: info.velocity.x,
…(truncated)