
Effective Ui Design
- 74 installs
- 1 repo stars
- Updated June 9, 2026
- sebastian-software/effective-ui-design-skill
Helps with design & ui/ux tasks.
About
effective-ui-design is a Claude Code skill for design & ui/ux. It helps solo builders move faster with AI-assisted development.
- effective-ui-design
- Design & UI/UX
- AI-coding skill
Effective Ui Design by the numbers
- 74 all-time installs (skills.sh)
- +7 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,182 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sebastian-software/effective-ui-design-skill --skill effective-ui-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 74 |
|---|---|
| repo stars | ★ 1 |
| Last updated | June 9, 2026 |
| Repository | sebastian-software/effective-ui-design-skill ↗ |
What it does
Helps with design & ui/ux tasks.
Files
Effective UI Design
Enforces professional UI design guidelines for accessible, well-structured interfaces. Apply these rules to every UI design decision without exception.
Quick Reference
| Topic | Reference File |
|---|---|
| Fundamentals | references/01-fundamentals.md |
| Less is More | references/02-less-is-more.md |
| Colour | references/03-colour.md |
| Layout & Spacing | references/04-layout-spacing.md |
| Typography | references/05-typography.md |
| Web Fonts | references/05b-webfonts.md |
| Copywriting | references/06-copywriting.md |
| Buttons | references/07-buttons.md |
| Forms | references/08-forms.md |
| SEO for Frontend | references/09-seo.md |
Core Principles (ALWAYS Apply)
1. Minimise Usability Risks
- Meet WCAG 2.1 level AA accessibility requirements
- Consider users with poor eyesight, low computer literacy, reduced dexterity
- Avoid thin/light grey text, icons without labels, colored headings that look like links
2. Have a Logical Reason for Every Design Detail
- Every element must have a purpose that improves usability
- Design using objective logic, not subjective opinion
- Be able to articulate the rationale behind each decision
3. Minimise Interaction Cost
- Keep related actions close (Fitts's Law)
- Reduce distractions
- Minimise choices (Hick's Law)
- Use minimum 48pt × 48pt target areas
4. Minimise Cognitive Load
- Remove unnecessary styles/information
- Break information into smaller groups
- Use familiar design patterns (Jakob's Law)
- Maintain consistency
- Create clear visual hierarchy
5. Create a Design System
- Define predefined colour palette
- Set typography scale
- Use 8pt spacing increments: 8pt, 16pt, 24pt, 32pt, 48pt, 80pt
- Create reusable components
Critical Rules (NEVER Violate)
Colour
- Text contrast: minimum 4.5:1 ratio (small text ≤18px)
- Large text/UI elements: minimum 3:1 ratio
- Never rely on colour alone to convey meaning
- Use brand colour ONLY for interactive elements
- Avoid pure black (#000000) on white - use dark grey instead
- Use OKLCH for perceptually uniform palettes; derive variations with relative color syntax
- Use
light-dark()for theme switching without media query duplication
Typography
- Use single sans-serif typeface for most interfaces
- UI text (labels, buttons, nav): 14px base
- Body text: 16px base, scale to 18-20px for long-form reading via
clamp() - Line height: minimum 1.5 (150%) for body text
- Line length: 40-80 characters per line
- Left-align text (for English)
- Limit font weights: typically 2, max 3 (e.g. Regular 400, Medium 500, Bold 700)
Layout
- Space elements based on relationship (closer = more related)
- Use 12-column grid for main layout
- Align elements to create neat edges
- Be generous with white space
- Use container queries for component-level responsiveness; media queries for page-level layout
- Use subgrid to align nested content across sibling elements (e.g. card grids)
- Use responsive images (
srcset,loading="lazy",aspect-ratioto prevent layout shift) - Gate hover effects behind
@media (hover: hover) and (pointer: fine)— never hide content behind hover - Use
env(safe-area-inset-*)withviewport-fit=coverfor fixed/sticky elements on notched devices
Icons
- Use SVG icons exclusively — never emoji or icon fonts
- One icon set, used consistently (e.g. Lucide, Heroicons, Phosphor)
- Use
currentColorto inherit text colour; match stroke width to font weight - Always pair icons with visible text labels; icon-only buttons need
aria-label
Buttons
- Define 3 button weights: Primary, Secondary, Tertiary
- Use single primary button per screen
- Left-align buttons (most important first)
- Button text: verb + noun (e.g., "Save post")
- Minimum size: 48pt × 48pt
- Avoid disabled buttons - validate on submit instead
Forms
- Single column layout
- Stack labels above inputs
- Mark both required (*) and optional fields
- Match field width to expected input
- Use conventional form field styles
- Display hints above fields (not below)
- Use
:user-valid/:user-invalidfor validation that respects interaction timing
SEO
- Unique
<title>per page (50-60 chars, primary keyword near the beginning) - Unique
<meta name="description">per page (150-160 chars) - Self-referencing
<link rel="canonical">on every page - One
<h1>per page, logical heading hierarchy (no level skipping) - Open Graph tags (
og:title,og:description,og:image) for social sharing - JSON-LD structured data for rich results (Article, Product, BreadcrumbList, FAQPage)
- Descriptive, hyphenated image file names and natural alt text
- Meet Core Web Vitals: LCP < 2.5s, INP < 200ms, CLS < 0.1
Favicons
- Five icons:
favicon.ico(32×32),icon.svg(with dark mode),apple-touch-icon.png(180×180),icon-192.png,icon-512.png - SVG favicons support
@media (prefers-color-scheme: dark)for automatic dark mode - For PWAs: add
manifest.webmanifestwithicon-192.png,icon-mask.png(512×512, maskable), andicon-512.png - Maskable icons need extra padding — safe zone is a 409×409 circle
- Do not generate multiple PNG sizes, multiple Apple touch icon sizes, or
browserconfig.xml
Design Checklist
Before finalizing any UI design:
1. Accessibility
- [ ] All text has 4.5:1+ contrast ratio
- [ ] UI elements have 3:1+ contrast ratio
- [ ] Colour is not the only indicator
- [ ] Target areas are 48pt+ minimum
- [ ] Text links are underlined
- [ ] Semantic HTML used (
<nav>,<main>,<search>, landmarks) - [ ] Icons have visible text labels (or
aria-labelfor icon-only buttons) - [ ] Images have meaningful
alttext (emptyalt=""for decorative) - [ ] Skip link present as first focusable element
- [ ] Viewport meta tag does not disable zoom (
user-scalable=no/maximum-scale=1) - [ ] Composite widgets (tabs, toolbars, menus) use single Tab stop with arrow-key navigation
2. Visual Hierarchy
- [ ] Clear order of importance
- [ ] Primary action is most prominent
- [ ] Related elements are grouped
- [ ] Consistent spacing applied
3. Typography
- [ ] 1-2 typefaces (+ optional display/heading typeface)
- [ ] UI text 14px+, body text 16px+ (18-20px for long-form reading)
- [ ] Line height 1.5+ for body text
- [ ] Left-aligned text
- [ ] 40-80 characters per line
4. Copywriting
- [ ] Concise text (no unnecessary words)
- [ ] Sentence case used
- [ ] Plain language (no jargon)
- [ ] Front-loaded important info
- [ ] Descriptive button text
5. Forms
- [ ] Single column layout
- [ ] Labels above inputs
- [ ] Required/optional fields marked
- [ ] Field widths match expected input
- [ ] High contrast borders (3:1+)
6. SEO
- [ ] Unique
<title>with primary keyword (50-60 chars) - [ ] Unique
<meta name="description">(150-160 chars) - [ ] Self-referencing canonical tag
- [ ] One
<h1>per page, no heading level skips - [ ] Open Graph tags present (
og:title,og:description,og:image) - [ ] JSON-LD structured data where applicable
- [ ] Descriptive image file names and alt text
- [ ] LCP image not lazy-loaded, has
fetchpriority="high" - [ ] Favicons:
favicon.ico,icon.svg(with dark mode),apple-touch-icon.png,icon-192.png,icon-512.png - [ ] PWA:
manifest.webmanifestwithicon-192.png,icon-mask.png(maskable),icon-512.png
Implementation
When creating UI code:
1. Read the relevant reference files for detailed guidelines 2. Apply ALL rules without exception 3. Verify against the checklist above 4. Use the predefined spacing scale (8pt increments) 5. Use the colour palette structure from references/03-colour.md 6. Use the Popover API for tooltips, dropdowns, and menus (no JS library needed) 7. Use @starting-style for CSS-only entry animations on dialogs and popovers 8. Use the View Transition API for page transitions and shared element animations; always respect prefers-reduced-motion
Colour Palette Template (OKLCH)
Brand: oklch(60% 0.15 hue) - Interactive elements
Text strong: oklch(25% 0.02 hue) - Headings, primary text (4.5:1+)
Text weak: oklch(45% 0.02 hue) - Secondary text (4.5:1+)
Stroke strong: oklch(58% 0.02 hue) - Form borders, icons (3:1+)
Stroke weak: oklch(92% 0.005 hue) - Decorative borders
Fill: oklch(97% 0.003 hue) - Secondary backgrounds
Background: oklch(100% 0 0) - White or near-whiteTypography Scale (1.200 Minor Third)
Heading 1: 40px / 48px line-height / bold
Heading 2: 32px / 40px line-height / bold
Heading 3: 24px / 32px line-height / bold
Heading 4: 20px / 28px line-height / bold
Body: 16px / 24px line-height / regular
Small: 14px / 20px line-height / regularSpacing Scale (8pt Grid)
XS: 8pt - Closely related elements
S: 16pt - Related elements
M: 24pt - Component padding
L: 32pt - Grid gutters, section gaps
XL: 48pt - Large section gaps
XXL: 80pt - Page section paddingMIT License
Copyright (c) 2026 Sebastian Software GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Effective UI Design
You've seen it happen: your AI agent generates a form with thin gray text, buttons that all look the same, and spacing that feels random. You fix it, move on, and next time it happens again.
This Agent Skill stops that cycle. Install it once, and every UI task follows professional design guidelines automatically. No reminding, no repeating yourself.
Works with Claude Code, Cursor, GitHub Copilot, Windsurf, Gemini CLI, and any other agent that supports the Agent Skills format.
How this differs from the frontend-design skill
Anthropic's frontend-design skill handles creative direction: picking a bold aesthetic, choosing distinctive fonts, avoiding generic "AI look" output. It's good at that.
It doesn't touch the technical layer. No contrast ratios, no spacing system, no form patterns, no accessibility rules, no SEO. A visually striking page that fails WCAG or ships without meta tags is still a problem.
This skill covers the engineering side. Concrete values, not creative direction:
| This skill | frontend-design |
|---|---|
| WCAG 2.1 AA contrast (4.5:1 text, 3:1 UI) | No contrast requirements |
OKLCH palettes, light-dark(), relative color syntax | "Cohesive palette" (no method specified) |
| 8pt spacing grid, container queries, subgrid | "Generous negative space" (no system) |
Fluid typography with clamp(), type scale, vertical rhythm | "Choose distinctive fonts" (no sizing rules) |
Form validation, :user-valid/:user-invalid, field sizing | Not covered |
| Button hierarchy, target sizes, destructive action patterns | Not covered |
| Dark mode, reduced motion, screen reader support | Not covered |
| SEO meta tags, JSON-LD, Core Web Vitals | Not covered |
They work together. Use frontend-design for the mood. Use this one to make sure the result actually works for every user.
The problem
AI agents have no design opinion. Without guidance they default to:
- Text that fails WCAG contrast requirements
- Buttons with no visual hierarchy (everything looks equally important)
- Forms with no validation feedback or sensible field sizing
- Arbitrary spacing with no system behind it
- No consideration for dark mode, reduced motion, or screen readers
You can fix this per-prompt, but that gets old after the third time.
What this changes
The skill loads 10 reference files covering the decisions you'd otherwise make by hand:
| Topic | What it handles |
|---|---|
| Fundamentals | Interaction cost, cognitive load, affordance, animation, reduced motion, semantic HTML |
| Less is more | Progressive disclosure, mobile-first, when to remove rather than add |
| Colour | OKLCH palettes, contrast ratios (WCAG 2.1 AA), dark mode, light-dark(), relative color syntax |
| Layout & spacing | 8pt grid, container queries, subgrid, responsive images, safe areas, intrinsic layouts |
| Typography | Type scale, fluid sizing with clamp(), OpenType features, vertical rhythm |
| Web fonts | font-display, preloading, variable fonts, fallback matching |
| Copywriting | Concise text, sentence case, error messages, empty states |
| Buttons | Primary/secondary/tertiary weights, destructive actions, undo over confirmation |
| Forms | Single column layout, validation with :user-valid/:user-invalid, dropdowns vs alternatives |
| SEO | Meta tags, structured data (JSON-LD), Core Web Vitals, internal linking |
Built on 20+ years of shipping web interfaces. Updated for modern CSS (Baseline 2023-2025) and current accessibility standards.
What it doesn't do
This isn't a component library or a CSS framework. It doesn't pick your aesthetic direction or choose fonts for you. It's the technical layer underneath: the spacing, contrast, accessibility, and patterns that need to be right regardless of how the interface looks.
Install
npx skills add sebastian-software/effective-ui-design-skillOr manually via git:
git clone https://github.com/sebastian-software/effective-ui-design-skill.git ~/.claude/skills/effective-ui-designThe skill activates automatically whenever the agent works on frontend tasks. Nothing to configure.
License
MIT — see LICENSE for details.
Copyright (c) 2026 Sebastian Software GmbH, Mainz, Germany.
Fundamentals
Overarching UI design principles that form the foundation of all guidelines.
Minimise Usability Risks
Base design decisions on risk - the risk that someone could have difficulty using an interface.
Common usability risks:
- Thin, light grey text - some may find it difficult to read
- Icons without labels - some might not understand what icons mean (especially those with cognitive/vision impairments)
- Coloured headings - could be mistaken for links
- Navigation with only arrows/dots (e.g. carousels) - use descriptive labels instead that tell users what content awaits them
Guidelines:
- Consider people with poor eyesight, low computer literacy, reduced dexterity and cognitive ability
- Meet WCAG 2.1 level AA requirements as minimum
- Keep an eye out for potential usability risks
- If anything is slightly vague, confusing or unclear - simplify it
Have a Logical Reason for Every Design Detail
Every element must have a purpose that improves usability.
- Design using objective logic, rather than subjective opinion
- Be able to articulate the rationale behind each decision
- "That looks nice" or "I don't like it" are NOT logical or constructive feedback
- Focus on HOW it works and WHY it works that way
Minimise Interaction Cost
Interaction cost = sum of physical and mental effort required to achieve a task.
Actions that add to interaction cost:
- Looking, scrolling, searching, reading
- Clicking, waiting, typing
- Thinking, remembering
How to Minimise Interaction Cost
1. Keep related actions close (Fitts's Law)
- The closer and larger a target, the faster it is to click
- Keep actions close to the element they relate to
- Ensure sufficient target area: minimum 48pt x 48pt
2. Reduce distractions
- Avoid animated banners, pop-ups, unnecessary visuals
- Remove attention-grabbing elements that pull focus from tasks
3. Minimise choice (Hick's Law)
- Time to make a decision increases with number and complexity of choices
- Reduce choices to speed up decisions
- Highlight recommended or popular items
Minimise Cognitive Load
Cognitive load = amount of brain power required to use an interface.
Quick ways to reduce cognitive load:
- Remove unnecessary styles, information, and decisions
- Break up information into smaller groups
- Use conventional design patterns (Jakob's Law)
- Maintain consistency - similar elements look and work similarly
- Create clear visual hierarchy
Create a Design System
A system of predefined options and guidelines for efficient design decisions.
1. Set Predefined Style Options
Colour Options (Colour Palette):
Brand - Interactive elements (buttons, links)
Text strong - Headings, primary text
Text weak - Secondary text
Stroke strong - Form borders, icons
Stroke weak - Decorative borders
Fill - Secondary backgrounds
Background - White or near-whiteTypography Options: See 05-typography.md for the type scale (1.200 Minor Third, base 16px).
Spacing Options: See 04-layout-spacing.md for the 8pt grid (XS–XXL).
Shadow Options: See 03-colour.md for the two shadow levels (Raised, Overlay).
Border Radius Options:
- Small: 8pt
- Medium: 16pt
- Large: 32pt
Icon Options:
- Use SVG icons exclusively — never emoji, bitmap icons, or icon fonts
- Pick one icon set and use it consistently (e.g. Lucide, Heroicons, Phosphor)
- Default size:
1.5rem(24px) for UI icons,1rem(16px) for inline icons - Use
currentColorso icons inherit the parent's text colour - Match stroke width to the surrounding font weight (2px stroke ≈ regular weight, 2.5px ≈ bold)
<!-- Decorative icon (label provides meaning) -->
<button>
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<path d="..."/>
</svg>
Save post
</button>
<!-- Standalone icon (icon IS the label) -->
<button aria-label="Close dialog">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<path d="..."/>
</svg>
</button>Icon accessibility rules:
- Icons next to text: add
aria-hidden="true"to the SVG (the text is the label) - Icon-only buttons: add
aria-labelto the button (not to the SVG) - Never use icons without a visible text label unless space is extremely limited — always prefer icon + text over icon alone
2. Create Reusable Modules
- Start with smallest components (buttons, avatars, form inputs)
- Combine small components to create larger ones
- Arrange components in specific layouts for reusable page templates
- Create a component library / UI kit
3. Define Usage Guidelines
- How to use components and visual styles
- How to write interface text (copywriting)
- Examples from the book:
- Indicate interactive elements using brand colour
- Use sentence case
- Left align buttons and text
- Avoid disabled buttons
- Front-load text
- Be concise, use plain language
Ensure an Interface is Accessible
Design interfaces that can be used by everyone, regardless of disability.
Consider:
- Blindness, low vision, colour blindness
- Motor impairment
- Mental disabilities
Key principles:
- Provide comparable experience for all
- Meet WCAG 2.1 level AA as minimum
- Include people with disabilities in usability testing
Use Semantic HTML
Use meaningful HTML elements instead of generic <div> everywhere:
<!-- Don't: ambiguous structure -->
<div>
<div>Logo + Nav</div>
<div>
<div>Main content</div>
<div>Sidebar</div>
</div>
<div>Footer</div>
</div>
<!-- Do: clear structure -->
<header>Logo + Nav</header>
<nav>Navigation</nav>
<main>
<section>Main content</section>
<aside>Sidebar</aside>
</main>
<footer>Footer</footer>Why it matters:
- Screen readers use semantic elements to navigate (jump to
<nav>, skip to<main>) - Improves SEO and machine readability
- Makes code self-documenting
Key elements: <header>, <nav>, <main>, <section>, <article>, <aside>, <footer>, <figure>, <figcaption>, <search>
The <search> element (Baseline 2023) wraps any search or filtering interface — not just site search, but also filter controls on a results page:
<search>
<form action="/search">
<label for="q">Search</label>
<input id="q" type="search" name="q">
<button type="submit">Search</button>
</form>
</search>It provides a semantic landmark that screen readers expose as a "search" role, letting users jump directly to it.
Provide Skip Links
Skip links let keyboard users bypass repeated navigation and jump directly to the main content. This is a WCAG 2.4.1 Level A requirement (Bypass Blocks).
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header><!-- navigation --></header>
<main id="main-content" tabindex="-1">
<!-- page content -->
</main>
</body>The tabindex="-1" on the target element ensures browsers move focus there when the skip link is activated. Without it, some browsers scroll to the element but leave focus on the link.
.skip-link {
position: absolute;
transform: translateY(-100%);
transition: transform 0.2s ease-out;
z-index: 9999;
background: var(--bg, #fff);
padding: 8px 16px;
}
.skip-link:focus-visible {
transform: translateY(0);
}Guidelines:
- Make the skip link the first focusable element on the page
- Hide it visually but keep it accessible (no
display: none) - Reveal it on focus so sighted keyboard users can see it
- Point to the
<main>element (or equivalent primary content container)
Assistive Technology
Screen Readers:
- Software that describes interface using speech or braille
- Users use keyboard to step through elements
- Mobile users swipe or drag finger across screen
Screen Magnifiers:
- Enlarges part of interface for people with low vision
- Users have limited view - can only see small part at a time
- Important: Keep this in mind when designing
Good Accessibility Benefits Everyone
- Anyone could get temporary disability (eye/arm injury)
- Situational disabilities (bright sunny day affecting screen visibility)
- Good accessibility = great usability
Never Disable Zoom
Setting user-scalable=no or maximum-scale=1 on the viewport meta tag prevents users from zooming in. This violates WCAG 2.1 Success Criterion 1.4.4 (Resize Text, Level AA) and makes the interface unusable for people with low vision.
<!-- Never do this -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<!-- Correct -->
<meta name="viewport" content="width=device-width, initial-scale=1">If zoom is disabled to prevent double-tap-to-zoom delay on mobile: Use touch-action: manipulation on interactive elements instead. This disables double-tap zoom on those elements while keeping pinch-to-zoom available everywhere:
button, a, input, select, textarea {
touch-action: manipulation;
}Design for Affordance
Affordance is an object's ability to convey its function through its appearance. A raised button suggests pressing; a handle suggests pulling; text that flows off the edge suggests scrolling.
In digital design:
- Buttons should look tappable (depth, fill colour, hover states)
- Scrollable areas should show partial content at the edges to hint at more
- Draggable items should have grab handles or visual weight
- Input fields should look like containers that accept text
The risk of flat design: Removing all depth cues can make interfaces ambiguous. Ensure interactive elements are still distinguishable from static content through colour, weight, or shape.
Use Common Design Patterns (Jakob's Law)
Stick with conventional design patterns that people are already familiar with.
- Building on existing mental models means less learning time
- Reduces usability issues, cognitive load, and interaction cost
- Example: Accordion components for expandable content
Play it safe:
- Use conventional form field styles
- Save time on usability testing
- Focus creativity on unique selling points
Use the 80/20 Rule (Pareto Principle)
Roughly 80% of effects come from 20% of causes.
In product design:
- ~80% of users use 20% of features
- ~80% of complaints come from 20% of issues
- ~80% of attention is spent on 20% of a page
Application:
- Prioritise the small number of things with largest impact
- Optimise for tasks most people will be doing
- Don't over-invest in edge cases
Keep Costs in Mind
Every minute spent costs money.
Ways to improve efficiency:
- Use existing design systems, templates, icon sets
- Outsource time-intensive tasks
- Stick with familiar UI patterns
- Learn technical constraints of implementation
- Talk to developers early and often
- Simple approach is usually cheaper and easier
Be Consistent
Similar elements look and work in a similar way.
Within Your Product
- Create design system with guidelines for components, templates, visual styles, language
- Predictable functionality improves usability and reduces errors
With Other Products
- Maintain consistency with well-established products
- Follow platform guidelines (iOS, Android) unless they test poorly
- Follow well-established, accessible UI patterns
Conventional patterns:
- Text links are underlined
- Checkboxes are small squares with tick icon
- Input fields are rectangles with label on top
Clearly Indicate Interaction States
Interactive elements must change appearance when interacted with.
8 Interaction States Checklist:
| State | When | Design Treatment |
|---|---|---|
| Default | At rest | Base styling |
| Hover | Pointer over (not touch) | Subtle lift, colour shift |
| Focus | Keyboard/programmatic focus | Visible focus ring |
| Active/Press | Being clicked/tapped | Pressed in, darker |
| Disabled | Not interactive | Reduced opacity, no pointer |
| Loading | Processing | Spinner, skeleton |
| Error | Invalid state | Red border, icon, message |
| Success | Completed | Green check, confirmation |
Common miss: Designing hover without focus. Keyboard users never see hover states - they need visible focus indicators.
Focus Rings with :focus-visible
Never remove focus indicators without replacement - it's an accessibility violation.
/* Show focus ring only for keyboard navigation (~96% support) */
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}Browsers that support :focus-visible automatically suppress focus rings on mouse/touch clicks. No need for a separate :focus { outline: none } rule.
Focus ring requirements:
- High contrast (3:1 minimum against adjacent colours)
- 2-3px thick
- Offset from element (not inside it)
- Consistent across all interactive elements
Keyboard Navigation in Composite Widgets
Composite widgets — tab bars, toolbars, menu bars, listboxes, tree views — should act as a single Tab stop. Users press Tab to reach the widget, then use arrow keys to move between items within it. This is the roving tabindex pattern defined in the WAI-ARIA Authoring Practices Guide (APG).
The pattern: 1. The focused item gets tabindex="0", all other items get tabindex="-1" 2. Arrow keys move focus and update tabindex values 3. Tab moves focus out of the widget entirely
<div role="tablist">
<button role="tab" tabindex="0" aria-selected="true">Tab 1</button>
<button role="tab" tabindex="-1">Tab 2</button>
<button role="tab" tabindex="-1">Tab 3</button>
</div>tablist.addEventListener('keydown', (e) => {
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
const current = tabs.indexOf(document.activeElement);
let next;
if (e.key === 'ArrowRight') next = (current + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (current - 1 + tabs.length) % tabs.length;
else return;
tabs[current].tabIndex = -1;
tabs[next].tabIndex = 0;
tabs[next].focus();
});When to use roving tabindex:
- Tab bars, toolbars, menu bars (horizontal: Left/Right arrows)
- Listboxes, tree views (vertical: Up/Down arrows)
- Grids and data tables (all four arrow keys)
When NOT needed:
- A simple list of links or buttons — each is an independent Tab stop
- Form fields in sequence — standard Tab order is correct
Animation and Motion
Animation adds context that static interfaces can't provide. It offloads spatial reasoning to the brain's visual cortex, reducing cognitive load and increasing perceived speed. But animation is a powerful tool that must be used purposefully — decorative animation wears on users after the fiftieth viewing.
The test for good animation: If users notice your animation, it may be doing too much. Good microinteraction animations are seamless — users don't consciously register them, they just feel the interface is responsive and clear.
Animation Patterns
Use these categories to identify and communicate the purpose of an animation:
| Pattern | Purpose | Example |
|---|---|---|
| Transition | Move users between places/tasks in the information space | Page-to-page slide, tab switching |
| Supplement | Bring info on/off page without changing location or task | Tooltip appearing, dropdown opening |
| Feedback | Connect user action to interface reaction | Button press ripple, form validation |
| Demonstration | Show how something works instead of telling | Onboarding walkthrough, feature tour |
| Decoration | Purely aesthetic, no new information | Background particle effects |
Prioritisation: Transitions and feedback provide the most cognitive benefit. Decorations provide the least and risk annoying users. When resources are limited, prioritise animations that answer "What just happened?" for the user.
Questions to justify an animation:
- Does it show the user where information came from or went to?
- Does it indicate progress?
- Does it move the user through an information space?
- Does it explain something faster than words could?
The Cone of Vision
The eye is most sensitive to colour and detail at the centre (foveal region). Peripheral vision is blurry but highly sensitive to motion.
Practical implications:
- Centre of vision: Colour fades and small movements are sufficient to draw attention
- Peripheral vision: Use motion to attract attention — colour changes alone may go unnoticed
- Two independently moving objects on opposite sides of the screen cannot both be tracked
- Overusing motion causes "banner blindness" — the brain learns to ignore excessive animacy
Easing (Timing Functions)
Easing describes the rate of change over time. Different easings suit different situations:
| Easing | CSS | Best For |
|---|---|---|
| Ease-out (deceleration) | ease-out | User-initiated actions (clicks, taps). Feels snappy and responsive |
| Ease-in (acceleration) | ease-in | System-initiated animations (alerts, pop-ups). Starts slowly, less startling |
| Ease-in-out | ease-in-out | Moving elements toward each other |
| Linear | linear | Fades and colour changes only. Motion with linear easing looks mechanical |
For unique brand feel, use custom cubic-bezier() curves. Keep a chart of your project's easings to maintain consistency.
Duration (Timing Scale)
Use a predefined set of durations for consistency, similar to a typographic scale:
Immediate: 100ms — Fades, colour changes under cursor/finger
Fast: 300ms — Button presses, toggles, responsive interactions
Slower: 400ms — Elements moving on page, dropdowns, tooltips
Deliberate: 700ms — Large movements across screen, demonstrationsThese values follow a Fibonacci-like relationship (100 + 300 = 400, 300 + 400 = 700) creating natural harmony.
Guidelines:
- Colour/opacity changes under the cursor feel slow above 100ms
- Moving elements across the page needs 300ms+ to track
- Centre-of-vision animations need shorter durations (70-200ms)
- Peripheral animations benefit from longer durations (300-700ms)
- When in doubt, halve your duration — developers consistently overestimate how long animations should run
Spring Easing with linear()
The linear() easing function (Baseline 2023) accepts a list of output values that the browser interpolates between, enabling easing curves that cubic-bezier() cannot express — including spring physics.
Spring-based motion feels more natural than cubic-bezier because it models physical deceleration. Apple (iOS 17+), Framer Motion, and the animations.dev community have converged on two parameters: duration and bounce (0 = critically damped, no overshoot; positive = elastic overshoot).
Default to critically damped springs (bounce = 0) — they feel responsive without being playful. Reserve bouncy springs for confirmations, celebrations, or deliberately playful interfaces.
:root {
/* Critically damped spring — natural deceleration, no overshoot */
--ease-spring: linear(
0, 0.009, 0.035, 0.078, 0.136, 0.206, 0.286, 0.373,
0.464, 0.557, 0.65, 0.738, 0.819, 0.891, 0.951,
0.998, 1.029, 1.047, 1.051, 1.044, 1.029, 1.01,
0.99, 0.974, 0.965, 0.961, 0.963, 0.969, 0.978,
0.988, 0.997, 1.003, 1.005, 1.003, 1
);
/* Gentle bounce — for playful/confirming interactions */
--ease-spring-bouncy: linear(
0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141, 0.191,
0.25, 0.316, 0.391, 0.474, 0.566, 0.666, 0.775,
0.893, 1.02, 1.086, 1.125, 1.139, 1.131, 1.106,
1.067, 1.019, 0.968, 0.921, 0.882, 0.855, 0.843,
0.849, 0.871, 0.905, 0.946, 0.989, 1.027, 1.054,
1.067, 1.063, 1.044, 1.015, 0.983, 0.956, 0.94,
0.939, 0.953, 0.977, 1.005, 1.026, 1.035, 1.029,
1.012, 0.993, 0.98, 0.977, 0.984, 0.997, 1.009,
1.014, 1.009, 0.999, 0.992, 0.99, 0.994, 1.001, 1
);
}Generate these values from spring parameters using tools like linear-easing-generator.
When CSS springs are enough:
- Hover effects, button presses, entry/exit transitions, layout shifts
When a JS animation library is needed (Framer Motion, React Spring, Motion One):
- Gesture-driven animation (drag, swipe, pinch) — the animation must respond to ongoing input
- Interruptible animations — a new trigger mid-animation must redirect smoothly, not restart
- Complex orchestrated sequences with stagger, layout animations, or shared element transitions
Only Animate Transform and Opacity
For smooth 60fps animations, only animate transform and opacity. Other properties (width, height, margin, padding) trigger expensive layout recalculations.
/* Good - GPU accelerated */
.card:hover {
transform: translateY(-2px);
opacity: 0.9;
}
/* Avoid - triggers layout */
.card:hover {
margin-top: -2px;
height: 102%;
}A consistent frame rate matters more than a high one — a steady 30fps looks smoother than 60fps with dips.
Animate Height with grid-template-rows
The one exception to "only animate transform and opacity": grid-template-rows can animate between 0fr and 1fr, enabling smooth accordion-style height transitions without JavaScript height calculations (~93% browser support):
.collapsible {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease-out;
}
.collapsible.is-open {
grid-template-rows: 1fr;
}
.collapsible__inner {
overflow: hidden;
min-height: 0;
}<div class="collapsible">
<div class="collapsible__inner">
Content that collapses smoothly
</div>
</div>Accessibility caveat: When collapsed (0fr), the content is visually hidden but still in the accessibility tree. Add visibility: hidden to the inner element when collapsed so screen readers skip it, and transition visibility alongside:
.collapsible__inner {
overflow: hidden;
min-height: 0;
visibility: hidden;
transition: visibility 300ms;
}
.collapsible.is-open .collapsible__inner {
visibility: visible;
}Future alternative: interpolate-size: allow-keywords (Chrome 129+) will allow direct height: 0 to height: auto transitions — but browser support is not yet sufficient (~70%).
Every Entrance Needs an Exit
When something animates onto the screen, it must also animate as it leaves. Alerts that beautifully slide in but instantly vanish on dismissal make the interface feel unfinished. Invest in a system that waits for exit animations to complete before removing elements.
Exit animations should be faster than entrances. Users have already processed the element — the exit just needs to confirm it's gone. Use 75-85% of the entrance duration for the exit:
| Entrance | Exit | |
|---|---|---|
| Duration | 300ms | 225-250ms |
| Easing | ease-out (decelerate in) | ease-in (accelerate out) |
| Perception | "Here I am" — arrives confidently | "Done" — leaves quickly |
The asymmetry is subtle but noticeable. Symmetric enter/exit feels sluggish because the exit draws too much attention to something the user already dismissed.
Use @starting-style for Entry Animations
@starting-style (Baseline 2024) defines the initial state for CSS transitions when an element first appears — enabling entry animations without JavaScript. Previously, animating an element "from hidden to visible" required JS to add a class after insertion.
dialog[open] {
opacity: 1;
transform: translateY(0);
transition: opacity 300ms ease-out, transform 300ms ease-out;
@starting-style {
opacity: 0;
transform: translateY(-8px);
}
}This is particularly useful for elements that toggle visibility: dialogs, popovers, tooltips, and notification toasts. Combine with allow-discrete to also transition display: none:
.tooltip {
transition: opacity 200ms ease-out, display 200ms allow-discrete;
opacity: 1;
@starting-style { opacity: 0; }
}
.tooltip[hidden] {
opacity: 0;
display: none;
}Use the Popover API for Tooltips, Dropdowns, and Menus
The Popover API (Baseline 2025) provides native behaviour for floating UI elements — no JavaScript libraries needed for the basics:
<button popovertarget="actions-menu">Actions</button>
<div id="actions-menu" popover>
<ul role="menu">
<li><button>Edit</button></li>
<li><button>Duplicate</button></li>
<li><button>Delete</button></li>
</ul>
</div>What the browser handles automatically:
- Renders in the top layer (no z-index wars)
- Dismisses on outside click or Escape key
- Manages focus — moves focus to the popover, returns it on close
- Prevents background scrolling
When to use popover:
- Dropdown menus and action menus
- Non-modal information panels
- Notification toasts (combine with
popover="manual"for persistent display)
When to use `<dialog>` instead:
- Modal dialogs that require user action before continuing
- Confirmation prompts, form overlays
Combine with @starting-style for animated entry/exit — the Popover API and @starting-style were designed to work together.
Use the inert Attribute for Non-Modal Content
The inert attribute (Baseline 2023) makes an element and all its descendants non-interactive and invisible to assistive technology. It is the correct way to trap focus inside a custom overlay, off-canvas drawer, or wizard step.
<nav class="drawer" id="drawer">
<!-- Drawer content -->
</nav>
<main id="main-content" inert>
<!-- Main content is unreachable while drawer is open -->
</main>When `inert` is set:
- Click/tap events are ignored
- The element is removed from the tab order
- Screen readers skip the entire subtree
find-in-pagedoes not match text insideinertcontent
You do NOT need `inert` when using:
<dialog>.showModal()— the browser automatically makes everything outside the dialog inert- Popover API — the browser manages focus and dismissal
Use `inert` when:
- Building a custom drawer/sidebar that overlays the page
- Implementing a step-by-step wizard where only the current step should be interactive
- Creating a custom modal without
<dialog>(though<dialog>is preferred)
Style inert regions to reinforce their inactive state:
[inert] {
opacity: 0.5;
pointer-events: none;
}Avoid Flashes of Unloaded States (FOULS)
When loading content dynamically, ensure users never see empty/unloaded pages:
1. Start in a loading state (skeleton screens, shimmer placeholders) 2. Transition loaded content in smoothly 3. Never show the unloaded state — build with an "always be loading" mentality
Signal Oncoming Animations
Anticipatory animation helps users mentally prepare. For example, hovering over a collapsible sidebar can cause it to slightly shift in the direction it will expand if clicked.
Respect Reduced Motion Preferences
Vestibular disorders affect ~35% of adults over 40. Seizure-triggering animations can be dangerous — never flash elements more than twice per second.
/* Safety net: disable all motion by default */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none;
transition: none;
}
}No !important — this allows more specific selectors (dialogs, view transitions, popovers) to override with safe opacity fades. WCAG 2.3.3 explicitly excludes opacity changes from "motion animation", so fades do not trigger vestibular disorders.
Replace motion-based animations with fades where a visual signal is still needed:
View Transitions API
The View Transition API provides native browser support for animated transitions between UI states — both within a single page (SPA) and across page navigations (MPA). It answers the question "what just happened?" by showing spatial relationships between states, reducing cognitive load.
Browser support: Same-document transitions are Baseline 2025 (Chrome 111+, Safari 18+, Firefox 144+). Cross-document (MPA) transitions have no Firefox support — use as progressive enhancement only.
Same-Document Transitions (SPA)
Wrap DOM updates in document.startViewTransition():
function navigate(item) {
if (!document.startViewTransition) {
updateDOM(item);
return;
}
document.startViewTransition(() => updateDOM(item));
}The browser snapshots the old state, runs the callback, then cross-fades between old snapshot and live new DOM over 250ms by default. The old state is a static bitmap; the new state is live (videos continue, content is interactive).
Cross-Document Transitions (MPA)
Opt in with CSS on both pages — no JavaScript needed:
@view-transition {
navigation: auto;
}Both pages must be on the same origin and both must opt in. Navigations taking longer than 4 seconds are automatically skipped.
Shared Element Transitions
Give matching elements the same view-transition-name on both pages (or both states). The browser automatically morphs position, size, and content between them:
/* List page */
.product-thumbnail { view-transition-name: product-image; }
/* Detail page */
.product-hero { view-transition-name: product-image; }Each name must be unique across all rendered elements at the same time. Use view-transition-name: match-element (Baseline 2025) for lists where items reorder but don't change identity. Use view-transition-class to apply shared animation styles to groups of named elements.
Custom Animations
Override the default cross-fade by targeting the pseudo-elements:
::view-transition-old(root) {
animation: 300ms ease-in slide-out-left;
}
::view-transition-new(root) {
animation: 300ms ease-out slide-in-right;
}Use :active-view-transition-type() for directional animations (forward vs backward navigation).
When to Use View Transitions vs @starting-style
| View Transitions | @starting-style | |
|---|---|---|
| Purpose | Animate between two complete UI states | Animate an element's first appearance |
| Scope | Full page or named elements | Individual elements |
| Trigger | startViewTransition() or navigation | Element insertion, display: none → visible |
| Best for | Page transitions, list reordering, shared elements | Dialogs, popovers, tooltips, toasts |
Performance and Accessibility
- Keep transitions under 500ms — interaction is blocked during the animation
- Use
view-transition-namesparingly — each named element creates a bitmap snapshot - Do not lazy-load images that participate in transitions
Always respect `prefers-reduced-motion`. For MPA transitions, only enable when motion is acceptable:
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
}For SPA transitions, substitute a quick cross-fade instead of disabling entirely — fades do not trigger vestibular disorders:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) {
animation: none;
}
::view-transition-old(*) {
animation: 150ms ease-out fade-out;
}
::view-transition-new(*) {
animation: 150ms ease-in fade-in;
}
}Chapter Summary
1. Minimise usability risk by keeping interfaces simple and familiar 2. Every interface detail needs a logical reason behind it 3. Minimise interaction cost and cognitive load as much as possible 4. Create a design system of predefined styles, modular components, and usage guidelines 5. Good accessibility means great usability — provide skip links, never disable zoom, use semantic HTML 6. Use roving tabindex in composite widgets (tabs, toolbars, menus) — one Tab stop, arrow keys within 7. Use inert for off-screen content and custom overlays — native <dialog>.showModal() handles this automatically 8. Only animate transform and opacity — except grid-template-rows for height animations (accordions) 9. Use linear() for spring-like easing in pure CSS; reserve JS animation libraries for gesture-driven or interruptible motion 10. Exit animations should be ~75-85% of entrance duration with ease-in (vs ease-out for entrance) 11. Use @starting-style for CSS-only entry animations; Popover API for native tooltips/dropdowns/menus 12. Use the View Transition API for page transitions and shared element animations — always respect prefers-reduced-motion and keep under 500ms
Less is More
Practical techniques to simplify interfaces by removing unnecessary details.
Remove Unnecessary Information
Every element competes with existing elements and can make things harder to understand.
Guidelines:
- Remove repeated elements to instantly simplify without losing information
- Avoid unneeded words and introductory phrases
- Reveal less important information gradually (progressive disclosure)
- Ensure every interface element has a logical reason behind it
Example: Course name repeated in each chapter title - make course name a subheading instead.
Remove Unnecessary Styles
Unnecessary styles = those that don't convey information (purely decorative).
Avoid:
- Unnecessary lines, colours, backgrounds, animations
- Decorative colours without purpose (could confuse users who assume colours have meaning)
- Blue/underlined headings that aren't links
- Decorative icons that could be confused for buttons
- Very prominent icons that compete for attention
Replace Borders with Subtler Alternatives
Borders add visual noise. When separating elements, try less prominent alternatives first.
Background Colours Instead of Borders
Give input fields and section footers a subtle background colour instead of a border:
/* Instead of border: 1px solid #ccc */
background-color: hsl(210, 9%, 96%);Creates visual distinction without the hard line.
Spacing Instead of Divider Lines
Replace borders between list rows with increased padding:
/* Instead of border-bottom: 1px solid #eee */
padding: 12px 10px;The whitespace between items provides enough separation.
Box Shadows Instead of Borders
Give elevated elements (modals, dropdowns, cards) a subtle box-shadow instead of a border:
/* Instead of border: 1px solid #ddd */
box-shadow: 0 2px 6px 0 hsla(0, 0%, 0%, 0.2);Feels more natural - mimics real-world depth rather than drawn-on outlines.
When to Keep Borders
- Form fields still need visible borders for accessibility (3:1 contrast)
- When background colours aren't distinct enough
- When you need to indicate interactive boundaries
Style Trends Fade:
- Trendy effects age poorly
- Minimal styles highlighting quality content are better for longevity
- Some styles aren't practical (Glassmorphic, Neumorphic) - cause accessibility/hierarchy issues
IMPORTANT: Don't let aesthetics hinder usability or exclude people.
Not All Links Need to Be Underlined
For accessibility, text links should be coloured and underlined. EXCEPT when elements already look interactive:
Elements that can skip conventional link treatment:
- Navigation menus
- Cards (image + text in raised container)
- Tabs
When to remove colour from links:
- When blue underlined text makes links too prominent
- When it confuses visual hierarchy
- Keep underline, remove colour to correct hierarchy
Use Progressive Disclosure
Reveal information gradually as needed.
Benefits:
- Reduces cognitive load
- Speeds up decision making
- Shows only what's needed for current task
Implementation:
- Use descriptive labels for text links
- Show additional information via expandable sections
- Use opt-in checkboxes to reveal additional form fields
Example: Instead of optional mobile number field, use checkbox "Receive updates via text message" - when checked, reveal required mobile number field.
Don't Confuse Minimalism with Simplicity
Minimal ≠ Simple
Minimal interfaces can be vague or confusing - lack crucial details for usability.
Common issues in minimal interfaces:
- Unlabelled pages, filters, navigation
- Very subtle selected states
- Hidden share/save actions
- Insufficient icon contrast
Fix by ensuring:
- Important actions are clearly visible
- Elements are properly labelled
- Sufficient contrast on all elements
Make Sure Important Content is Visible
People don't use what they can't see.
Guidelines:
- Hiding behind menus is convenient but risky
- If there's space, ensure important content is visible when needed
- Make hidden content discoverable (expose edges of off-screen cards)
Design for the Smallest Screen First
Mobile-first approach benefits:
- Restricted space forces prioritisation
- Removes unnecessary elements
- Results in simpler interfaces on larger screens too
Why it works:
- Large screens encourage filling space with unnecessary information
- More information = increased cognitive load
- More information = slower decision making
- You don't need to fill the screen
Reduce Choice to Speed Up Decision Making
Hick's Law: Time to decide increases with number and complexity of choices.
4 Ways to Reduce Choice
1. Remove choices
- Ensure every option earns its place
- If not necessary, it's a potential distraction
- Example: Remove unnecessary form fields
2. Group or categorise choices
- Simpler to decide between categories than large lists
- Example: Break articles into categories (Interiors, Architecture, Gardens)
3. Break up choices into multiple steps
- Large complex tasks seem less overwhelming
- Decreases cognitive load
- Focus on one thing at a time
- Example: Multi-step forms, multi-level navigation menus
4. Recommend choices
- Suggest popular or common choices
- If many prefer certain choices, others likely will too
- Example: Video streaming recommending popular videos
- Example: Search boxes suggesting common terms
Chapter Summary
1. Remove unnecessary information and styles to reduce cognitive load 2. Replace borders with subtler alternatives (background colours, spacing, box-shadows) 3. Reveal information gradually (progressive disclosure) to avoid overwhelming users 4. Minimal ≠ Simple - don't remove critical information for aesthetics 5. Ensure important content is visible or discoverable 6. To help people decide faster: remove choices, group them, break into steps, or offer recommendations
Colour
Learn how to use colour sparingly and purposefully to add meaning to an interface.
Ensure Sufficient Contrast
Contrast = difference in perceived brightness between two colours (ratio 1:1 to 21:1).
WCAG 2.1 Level AA Requirements
3:1 Minimum - Large text and UI elements:
- Text above 18px bold OR above 24px regular
- UI elements (form fields, buttons, checkboxes)
- Decorative elements don't need to meet this ratio
4.5:1 Minimum - Small text:
- Text 18px or less
Common Contrast Issues
- Close icon contrast < 3:1
- Secondary text contrast < 4.5:1
- Search field border < 3:1
- Placeholder text < 4.5:1
- Button background against text < 4.5:1
- Link text contrast < 4.5:1
APCA (Accessible Perceptual Contrast Algorithm)
Improved method in WCAG 3 draft - solves WCAG 2 limitations.
APCA Contrast Values:
- 90 - Preferred for body text (14px regular+)
- 75 - Minimum for body text (18px regular+)
- 60 - Minimum for other text (24px regular OR 16px bold+)
- 45 - Minimum for large text (36px regular OR 24px bold+) and UI elements
- 30 - Absolute minimum (placeholder text, disabled button text)
- 15 - Minimum for non-text elements
Key differences from WCAG 2:
- No ratios - uses contrast numbers (higher = more contrast)
- Value depends on text size AND weight
- Swapping text/background colours affects contrast
- Works better for dark interfaces
Recommendation: Use APCA for personal projects. For commercial projects requiring compliance, stick with WCAG 2 until WCAG 3 releases, but try to pass both.
Don't Rely on Colour Alone to Convey Meaning
For accessibility to colour blind users:
- Use additional visual cues to differentiate elements
- Never use colour as the ONLY indicator
Examples:
- Form errors: Add icon + thicker border + background (not just red colour)
- Text links: Add underline (not just blue colour)
Use System Colours to Indicate Status
3 System Colours (Traffic light colours):
- Red (Error) - negative message, error, urgent attention needed
- Amber (Warning) - caution, risky action
- Green (Success) - positive message, action completed
Accessibility requirements:
- Don't rely on system colours alone - use icons as additional cues
- System colours for text: minimum 4.5:1 contrast
- System colours for UI elements/icons: minimum 3:1 contrast
Use Colour to Define Visual Hierarchy
Present information in order of importance.
Saturation
- Higher saturation = more prominent
- Use saturated colours for important elements (links, buttons)
Hue
- Certain hues are more prominent (red stands out most)
- Use prominent hues for important elements
Contrast
- Higher contrast = more prominent
- Make headings darker than body text
Use Black and White for Timeless Aesthetic
Benefits:
- Fewer distractions
- Highlights content
- Timeless look
Design tip: Design in black and white FIRST, regardless of brand colours. Focus on spacing, size, layout, contrast before adding colour.
Proportion depends on brand:
- Mostly white = simple, classic, minimal feel
- Mostly black = dramatic, powerful, luxurious feel
Avoid Pure Black (#000000)
- High contrast against white causes eye strain
- Use dark grey instead
- Black: 0% brightness, White: 100% brightness - large difference strains eyes
Add a Tinge of Colour to Black and White (Tinted Neutrals)
Pure grey doesn't exist in nature - real shadows always have a colour cast. Adding a minimal tint of your brand colour to neutrals creates subconscious cohesion.
/* Pure grays - feel "dead" */
--gray-100: oklch(95% 0 0);
--gray-900: oklch(15% 0 0);
/* Tinted grays - feel alive */
--gray-100: oklch(95% 0.01 250); /* Tiny hint of blue */
--gray-900: oklch(15% 0.01 250);The chroma is tiny (0.01) but perceptible. Benefits:
- Get benefits of black/white interface
- Creates visual harmony with brand colour
- Adjust mood with pinch of colour
Use 1 Brand Colour
Single unique colour alongside black and white.
Benefits:
- Conveys brand mood/personality
- Can indicate interactive elements
Colour Psychology Note:
- Not universal - affected by culture, personal experience, colour blindness, surrounding elements
- Use as loose guideline only
- Test with users
Tips for choosing brand colour:
- Choose distinctive colour
- Remember system colours (red, amber, green) have strong meanings - using red for other elements causes confusion
Apply Brand Colour to Interactive Elements
Simple, effective approach:
- Use brand colour for text links and buttons
- Teaches users what's interactive
- Don't add to ALL interactive elements (some already have visual cues)
- NEVER use brand colour on non-interactive elements
CRITICAL: Brand colour must have 4.5:1 contrast against background.
What About Low Contrast Colours?
If brand colour is too light (e.g., yellow):
- Try darkening slightly (without losing brand recognition)
- Add text shadow to white button text
- Use text colour for button text/links
- Add border to buttons (3:1 contrast)
If Brand Colour Has Meaning (Red/Green/Amber)
Avoid using for interactive elements to prevent conflicting meanings.
Multiple Brand Colours
- Use highest contrast colour for interactive elements
- Use others sparingly for decorative elements
- NEVER use more than one colour for interactive elements
Consider OKLCH for Modern Colour Systems
OKLCH is a modern alternative to HSB/HSL that is "perceptually uniform" - equal numerical steps result in equal visual changes across all colours.
The HSL Problem:
- 50% lightness in yellow looks much brighter than 50% in blue
- Darkening/lightening colours produces unexpected results
- Hard to create consistent palettes
OKLCH Benefits:
- Predictable lightness across all hues
- Better for generating colour variations
- Easier to meet contrast requirements
- Wide gamut support (P3 displays)
- Browser support: Chrome 111+, Safari 15.4+, Firefox 113+
/* OKLCH: lightness (0-100%), chroma (0-0.4+), hue (0-360) */
--brand: oklch(60% 0.15 250); /* Blue */
--brand-light: oklch(85% 0.08 250); /* Lighter - reduce chroma */
--brand-dark: oklch(35% 0.12 250); /* Darker */Tip: When moving toward white or black, reduce chroma. High chroma at extreme lightness looks garish.
Derive Colour Variations with Relative Color Syntax
Relative color syntax (Baseline 2024) lets you derive new colours from existing ones — no manual calculations or preprocessors needed. Create hover states, tints, and shades programmatically:
:root {
--brand: oklch(60% 0.15 250);
/* Darken for hover — reduce lightness by 15% */
--brand-hover: oklch(from var(--brand) calc(l - 0.1) c h);
/* Lighten for tint — increase lightness, reduce chroma */
--brand-tint: oklch(from var(--brand) 92% calc(c * 0.4) h);
/* Desaturate for disabled — reduce chroma */
--brand-disabled: oklch(from var(--brand) l calc(c * 0.3) h);
/* Shift hue for complementary — rotate 180° */
--accent: oklch(from var(--brand) l c calc(h + 180));
}Why this matters for design systems:
- Define a single brand colour → derive the entire palette
- Hover, active, and disabled states stay mathematically consistent
- Changing the base colour cascades through all variations
- Works with any colour space (
oklch,hsl,rgb, etc.) — prefer OKLCH for perceptual uniformity
Consider the 60-30-10 Rule
A guideline (not strict rule) for colour distribution from interior design:
- 60% - Dominant colour (backgrounds, white space)
- 30% - Secondary colours (text, borders, cards)
- 10% - Accent colour (CTAs, links, highlights)
The accent colour works because it's rare. Overuse kills its impact. This is a helpful starting point, but context matters more than rigid proportions.
Create a Colour Palette
Small set of predefined colours with rules governing usage.
7 Colour Variations (Solid Palette — OKLCH)
Brand: oklch(60% 0.15 hue) - Interactive elements
Must have 4.5:1 against fill
Text strong: oklch(25% 0.02 hue) - Headings, primary text
Very dark grey with brand tinge
Must have 4.5:1 against fill
Text weak: oklch(45% 0.02 hue) - Secondary text
Dark grey with brand tinge
Must have 4.5:1 against fill
Stroke strong: oklch(58% 0.02 hue) - Form borders, icons
Medium grey with brand tinge
Must have 3:1 against fill
Stroke weak: oklch(92% 0.005 hue) - Decorative borders
Light grey with brand tinge
No contrast requirement (decorative only)
Fill: oklch(97% 0.003 hue) - Secondary backgrounds
Very light grey with brand tinge
Background: oklch(100% 0 0) - WhiteVerify contrast with tools like oklch.fyi or Chrome DevTools — maximum chroma varies by hue, so adjust values per project.
Interaction States
Change opacity:
- Default: 100%
- Hover: 80%
- Disabled: 20%
- Focus: outline
Change fill colour:
- Hover: Fill colour variation
- Press: Stroke weak colour variation
Change elevation:
- Hover: Add/increase shadow
Toggle underline:
- Text links: Remove underline on hover
- Navigation: Add underline on hover
Use animation:
- Move button up slightly on hover
- Keep subtle and quick
Dark Colour Palette (OKLCH)
Brand: oklch(78% 0.12 hue)
Text strong: oklch(98% 0 0) - White
Text weak: oklch(80% 0.01 hue)
Stroke strong: oklch(58% 0.02 hue)
Stroke weak: oklch(25% 0.03 hue)
Fill: oklch(18% 0.02 hue)
Background: oklch(13% 0.03 hue)Tips:
- Increase contrast above WCAG minimum (dark interfaces harder to see)
- Check with APCA for accuracy
- Start with white for Text strong
- Gradually increase saturation, decrease brightness
- Avoid pure black background
Use light-dark() for Theme Switching
The light-dark() function (Baseline 2024) returns one of two colours based on the active colour scheme — eliminating the need for @media (prefers-color-scheme) queries on individual properties:
:root {
color-scheme: light dark;
}
body {
color: light-dark(oklch(20% 0.01 250), oklch(95% 0.01 250));
background: light-dark(#fff, oklch(15% 0.02 250));
}
.card {
background: light-dark(white, oklch(22% 0.015 250));
border-color: light-dark(oklch(90% 0.01 250), oklch(30% 0.02 250));
}When to use `light-dark()`:
- Individual property values that differ between themes
- Simpler and more readable than media query blocks for per-property changes
When to keep `@media (prefers-color-scheme)`:
- Structural changes (different layouts, different components per theme)
- Swapping images or assets between themes
Combine with semantic tokens for the best of both worlds — define tokens with light-dark(), reference tokens in components:
:root {
color-scheme: light dark;
--text-strong: light-dark(oklch(20% 0.01 250), oklch(95% 0.01 250));
--text-weak: light-dark(oklch(40% 0.01 250), oklch(75% 0.01 250));
--bg: light-dark(#fff, oklch(15% 0.02 250));
--fill: light-dark(oklch(97% 0.005 250), oklch(22% 0.015 250));
}Add Depth Using Colour and Shadows
Elements with higher elevation appear closer and more prominent.
Define 2 Shadow Options
- Raised - small, sharp shadow for interactive elements (cards)
- Overlay - larger, softer shadow for floating elements (dropdowns, dialogs)
Shadow tips:
- Light comes from top (mimics real world)
- Use "Text strong" colour instead of black
- Small/sharp = slightly raised
- Large/soft = elevated higher
Colour Indicates Depth
- Light colours look more elevated than dark
- Place lighter colours on top of darker colours
Dark Interface Elevation
Shadows hard to see - rely on colour instead.
3 Background Colours:
- Base - darkest, main background
- Raised - slightly brighter
- Overlay - slightly brighter than raised
Transparent Colours
Lower opacity makes colours see-through (alpha value 0-1).
Problem with Solid Colours
Solid colours remain same regardless of background. Elements can have inconsistent prominence on different backgrounds.
Common failure: fixed grays on coloured backgrounds. A gray like #6b6b6b that passes 4.5:1 contrast on white can drop below 3:1 on a brand-coloured banner or tinted card — the contrast ratio depends on the actual rendered background, not an assumed white. This is the most common real-world contrast failure in component libraries.
Always check contrast against the rendered background, not white. Chrome DevTools' contrast picker evaluates the actual composite colour beneath the text.
Transparent Colour Solution
Allows background to mix with foreground - maintains consistent prominence.
Transparent Colour Palette (Dark Mode)
5 variations of white:
Text strong: 100% opacity (4.5:1 against overlay)
Text weak: 78% opacity (4.5:1 against overlay)
Stroke strong: 60% opacity (3:1 against overlay)
Stroke weak: 12% opacity (decorative)
Fill: 6% opacity5 variations of black (Light Mode):
Text strong: 90% opacity (4.5:1 against fill)
Text weak: 60% opacity (4.5:1 against fill)
Stroke strong: 45% opacity (3:1 against fill)
Stroke weak: 10% opacity (decorative)
Fill: 4% opacityBrand colour variations (4 each for light/dark):
- 100% - Brand
- 80% - Text
- 20% - Stroke strong
- 5% - Fill
System colour variations (4 each for red/amber/green):
- 100% - Text (4.5:1)
- 80% - Stroke strong (3:1)
- 20% - Stroke weak
- 5% - Fill
Transparent Layers for Interaction States
- Hover: Fill colour variation overlay
- Press: Stroke weak colour variation overlay
Colour Token Architecture
Use a 3-tier system for scalable, maintainable colour systems:
┌─────────────────────────────────────────────────────┐
│ 1. Raw Value oklch(48% 0.2 162) │
│ ↓ │
│ 2. Primitive Colour green.1000 │
│ ↓ │
│ 3. Semantic Colour text.success │
└─────────────────────────────────────────────────────┘1. Raw Values
The actual colour definition in OKLCH (or hex as fallback).
- Only referenced by primitive tokens, never used directly in components
- Easy to adjust globally
2. Primitive Colours
Named by appearance. Format: [colour.mode.number]
- Number 0-1000 indicates contrast level (1000 = highest)
- Examples:
grey.light.1000,green.dark.800,blue.500
3. Semantic Colours (Tokens)
Named by usage - what the colour does. Format: [element.tone.emphasis.state]
Elements: Text, Stroke, Icon, Fill, Background Tones: Neutral, Brand, Error, Warning, Success Emphasis: Strong, Weak States: Hover, Press, Focus, Disabled
Examples:
text.success→green.1000→oklch(48% 0.2 162)stroke.strong→grey.600→oklch(45% 0.01 250)fill.error.weak→red.100→oklch(95% 0.05 25)
Why this matters:
- Change
green.1000once → all success states update - Swap themes by remapping semantic → primitive
- Components only reference semantic tokens = consistent usage
Adjust Photo Colour Temperature
Match photo colour temperature to palette for harmonious look:
- Cool palette (blue) = cooler photos
- Warm palette (orange) = warmer photos
Not for product photos where realistic colours matter.
Chapter Summary
1. Ensure text/UI elements have sufficient contrast; don't rely on colour alone 2. Design in black and white first, then add colour purposefully (brand colour for interactive elements) 3. Create small predefined colour palette with usage rules 4. Use OKLCH for perceptually uniform palettes; derive variations with relative color syntax 5. Use light-dark() for clean theme switching without media query duplication 6. Consider transparent colours for consistent prominence across backgrounds 7. Name colours systematically using a 3-tier token architecture
Layout and Spacing
Create a consistent spacing system and learn about alignment and layout.
Group Related Elements
Breaking up information into smaller groups helps structure and organise an interface.
4 Grouping Methods
1. Place related elements in the same container
- Strongest visual cue for grouping
- Create containers using borders, shadows, background colours
- Use for main structure (headers, sidebars, content areas)
- Cards and dialog boxes group smaller related content
- Avoid containers for EVERY group - causes clutter
2. Space related elements close together (Proximity)
- Related elements closer together
- Unrelated elements further apart
- Can help declutter vs containers
3. Make related elements look similar (Similarity)
- Similar visual characteristics: size, shape, colour
- Highlight certain elements by making them slightly different
- If elements look similar, they should FUNCTION similarly
- Don't make non-interactive elements look like buttons
4. Align related elements in a continuous line (Continuity)
- Eyes naturally follow elements aligned in lines
- Lists group related elements
- Disrupt continuity to indicate end of group or highlight element
Combining Methods
Use multiple methods together for clearer groupings. If elements are grouped multiple ways, you can often remove containers to simplify.
Don't Default to Cards
Cards group heterogeneous content (image + text + action) into a self-contained, browsable unit. They are not a general-purpose container.
Use cards when:
- Displaying browsable collections of mixed content (product listings, article teasers)
- Each item is an entry point to a detail view
- Items contain varied content types that need visual containment
Don't use cards when:
- A simple list would work — homogeneous text items are faster to scan in a plain list
- Wrapping a single content section — use a heading and whitespace instead
- Grouping form fields — use
<fieldset>and spacing, not a card container - Displaying tabular data — use a
<table>(supports sorting, filtering, comparison) - Content is meant to be read sequentially — use body text with headings
Never nest cards inside cards. Each card level adds border/shadow/padding chrome that compounds visual noise without adding information. If content inside a card needs sub-grouping, use a heading, divider, or subtle background tint.
Alternatives to card containers (from least to most visual weight):
| Separation method | Visual weight |
|---|---|
| Spacing (whitespace between groups) | Lightest |
| Background colour (subtle fill difference) | Light |
| Box shadow (depth cue) | Medium |
| Border / card container | Heaviest |
Before adding a card border, ask: does this container help the user understand the grouping, or is it just visual noise?
Create a Clear Visual Hierarchy
Present information in order of importance.
Methods to Control Prominence
Size - Make important elements larger
Colour - Use brighter, richer, warmer, higher contrast colours for important elements
Contrast - Style important elements differently to stand out
Spacing - Surround important elements with more space
Position - Place important elements toward top or first in row
Depth - Elevate important elements (appear closer)
Practical Steps to Improve Visual Hierarchy
1. Group related information into separate sections 2. Order information in each section by importance 3. Order sections by importance (most important = top) 4. Change element styles based on importance:
- Important: large, bold, "Text strong" colour
- Secondary: smaller, regular weight, "Text weak" colour
- Use icons for common patterns (star ratings)
- Important CTAs: primary button with brand colour
- Less important details: remove unnecessary labels
Test Visual Hierarchy - The Squint Test
Quick test: Squint at your design (or blur/step back).
Should be able to:
- Tell what most important elements are
- Recognise what interface is for
Use Depth to Create Visual Hierarchy
Elevation:
- Higher elevation = closer, more prominent
- Lower elevation = further away, less prominent
- Elevate important elements higher
- Elevation can make elements feel interactive
Understand the Box Model
Interfaces = rectangles within rectangles.
Each rectangle has:
- Margin - space between box and neighbouring boxes
- Border - stroke around edge
- Padding - space between border and contents
Key observation: Spacing starts small in innermost rectangles and increases moving outwards.
Design @1x Using Points
Points vs Pixels:
- @1x: 1pt = 1px
- @2x: 1pt = 4px (2x2)
- @3x: 1pt = 9px (3x3)
Always work in points (@1x).
Create a Set of Predefined Spacing Options
8pt Grid (T-shirt sizes):
XS: 8pt - Closely related elements
S: 16pt - Related elements
M: 24pt - Component padding
L: 32pt - Grid gutters, section gaps
XL: 48pt - Large section gaps
XXL: 80pt - Page section paddingWhy 8pt?
- Many screen sizes divisible by 8
- More flexibility than 10pt
- For detailed interfaces, use 4pt increments
Benefits:
- Simplified designs (less variation)
- Improved consistency
- Design faster (fewer options)
Space Elements Based on How Closely Related They Are
Key principle: More closely related = closer together
Application Method
1. Break interface into rectangles within rectangles 2. Start with XS (8pt) for innermost rectangles 3. Gradually increase spacing moving outwards
Example spacing progression:
- XS (8pt): Card text (closely related)
- M (24pt): Card padding, section content
- L (32pt): Between navigation links, between cards (grid gutters)
- XXL (80pt): Page section vertical padding
Create Spacing Rules
Example rules for consistency:
- M (24pt) internal padding for components
- L (32pt) gaps between columns
- XXL (80pt) vertical padding for sections
Be Generous with White Space
White space = empty space between/around elements (can be any colour/pattern).
Benefits:
- Easier to see groupings and hierarchy
- Easier to understand information
- Looks simpler, cleaner, more sophisticated
Tip: Instead of XS spacing option, consider using next one up.
Test: Use Squint Test - if you can't distinguish elements, increase spacing.
Align the Main Layout to a 12 Column Grid
Components:
- Columns (12) - vertical columns for aligning main elements
- Gutters - empty spaces between columns (separate/align content)
- Margins - empty space on left/right edges
Columns
- Align main containers to one or more columns
- Smaller elements inside don't need to align to grid
- Generally flexible width (percentages)
- 12 columns for large screens, fewer for mobile
Example: 3 cards spanning 4 columns each on desktop, stack on 4 columns on mobile.
Gutters
- Remain empty
- Narrower than columns
- Fixed width, wider on larger screens
- Example: L (32pt) on desktop, S (16pt) on mobile
Margins
- Prevent content hitting edges
- Fixed or flexible width
- Wider on larger screens
- Example: XXL (80pt) on desktop, S (16pt) on mobile
Why 12? Most common, sufficient flexibility, aligns with frontend frameworks.
Align Text to Improve Readability
Left-aligned text is easiest to read:
- Consistent left edge = anchor for eyes
- Maintain straight left edge with other elements (icons)
Align Horizontal Text to Baseline
- Baseline = invisible line text sits on
- Different sized text in same line: align to baseline (not vertical centre)
- Creates easier reading, neater design
Try to Avoid Using Multiple Alignments
Fewer alignments = simpler, neater.
Issues with multiple alignments:
- Eyes work harder moving around
- Looks messy
- Increases cognitive load
Tip: If you must use centre alignment, full-width buttons help both left/right handed users reach easily.
Keep Related Actions Close (Fitts's Law)
Fitts's Law: Closer and larger target = faster to select.
Application:
- Keep actions close to related elements
- Ensure sufficient target area (48pt x 48pt minimum)
- Move close buttons to start position (near menu icon)
- Increase size of menu items for faster/easier tapping
- Add visible borders to indicate target area
Ensure an Interface is Unbreakable
Don't just design for short text and small numbers.
Guidelines:
- Accommodate long data and edge cases
- Avoid hiding essential overflowing data
- Keep components flexible for content reflow
- Or decrease font sizes for long data
Account for translations: Text can grow significantly in other languages. German and Portuguese translations can increase text length by up to 75% compared to English. Always test layouts with longer strings.
Quick text boundary tests:
- Max height: Use "Åy" - combines the tallest ascender (Å) and deepest descender (y) of most Latin typefaces
- Max width: Fill with "WWW..." - W is the widest character in most fonts; if it fits, any other text will too
If hiding is necessary:
- Don't hide essential information
- Consider cropping in middle (not end) to help differentiate items
Use the Rule of Thirds for Photos
Divide photo into 3x3 grid = 4 focal points at intersections.
Application:
- Align key elements to grid
- Place main subject on focal point
- Don't centre subjects - creates rigid, still appearance
- Asymmetry introduces natural motion and flow
- Align horizon with horizontal grid
Especially effective for:
- Action shots (increases sense of motion)
- Landscapes (align horizon to grid)
Consider Optical Adjustments
Mathematical centering doesn't always look visually centered. Our eyes perceive shapes differently based on their weight distribution.
Common situations requiring optical adjustment:
- Play icons in circles: The triangle's point has less visual weight - shift it slightly right to look centered
- Text alignment: Text often needs
-0.05emnegative margin on the left to align optically with elements above - Icons next to text: May need 1-2px vertical shift to align with text baseline
- Button padding: Bottom padding often needs 1-2px extra to feel equal to top padding
This is an advanced technique that requires a trained eye. When something looks "off" despite being mathematically correct, optical adjustment is usually the answer.
Nested Border Radii
When an element with rounded corners contains another element with rounded corners (e.g. a card with a button inside), the outer radius should equal the inner radius plus the border/padding between them:
/* outer radius = inner radius + gap between them */
.card {
border-radius: 16px; /* 8px + 8px padding */
padding: 8px;
}
.card-inner {
border-radius: 8px;
}Matching inner and outer radii produces corners that thicken awkwardly. Adding the gap creates a consistent, parallel curve.
Use Logical Properties
Use margin-inline-start/end and block-size instead of margin-left/right and height. Logical properties honour writing direction and work correctly for RTL languages.
/* Physical (fragile) */
margin-left: 1rem;
margin-right: 1rem;
width: 100%;
height: 50vh;
/* Logical (robust) */
margin-inline-start: 1rem;
margin-inline-end: 1rem;
inline-size: 100%;
block-size: 50vh;Mapping (for horizontal-tb, ltr):
margin-left→margin-inline-startmargin-right→margin-inline-endmargin-top→margin-block-startmargin-bottom→margin-block-endwidth→inline-sizeheight→block-sizepadding-left→padding-inline-start
Shorthand: margin-inline, margin-block, padding-inline, padding-block
Apply box-sizing: border-box Globally
Makes calculating box dimensions predictable - padding and border are included in the element's total size.
*, *::before, *::after {
box-sizing: border-box;
}Without this, a 300px wide element with 20px padding becomes 340px. With border-box, the content area shrinks to accommodate padding within the 300px.
Use Contextual Spacing (The Stack Pattern)
Margin is a property of the relationship between two elements, not of an element itself. Style the context, not the individual element.
/* Don't: margin on individual elements */
p { margin-bottom: 1.5rem; } /* Creates orphan margin on last element */
/* Do: margin between adjacent siblings via parent */
.stack > * + * {
margin-block-start: 1.5rem;
}The * + * selector (the "owl") only applies margin where an element is preceded by a sibling - no orphan margins.
Nested stacks for varied spacing:
.stack-lg > * + * { margin-block-start: 3rem; }
.stack-sm > * + * { margin-block-start: 0.5rem; }Split a stack to push elements apart (e.g. card footer to bottom):
.stack {
display: flex;
flex-direction: column;
}
.stack > :nth-child(2) {
margin-block-end: auto; /* Pushes everything after this to the bottom */
}Implement a CSS Modular Scale with Custom Properties
Modular scale vs 8pt grid: The 8pt spacing scale (XS–XXL) is the primary system for component and layout spacing. The modular scale is an alternative for typography-driven layouts where all spacing derives from the type scale ratio. Pick one per project — don't mix both or spacing decisions become ambiguous.
Express your spacing scale as CSS custom properties derived from a single ratio. This creates visual harmony - all spacing values are mathematically related.
:root {
--ratio: 1.5;
--s-2: calc(var(--s-1) / var(--ratio));
--s-1: calc(var(--s0) / var(--ratio));
--s0: 1rem;
--s1: calc(var(--s0) * var(--ratio));
--s2: calc(var(--s1) * var(--ratio));
--s3: calc(var(--s2) * var(--ratio));
--s4: calc(var(--s3) * var(--ratio));
--s5: calc(var(--s4) * var(--ratio));
}Use these everywhere: padding: var(--s1), gap: var(--s0), margin-block-start: var(--s3). Change --ratio and the entire design scales harmoniously. The ratio of 1.5 mirrors a common line-height value, creating natural vertical rhythm.
Set a Global Measure Axiom
Cap line length globally using an exception-based approach:
* {
max-inline-size: 60ch;
}
html, body, div, header, nav, main, footer, section, aside {
max-inline-size: none;
}Container elements are excepted; text-bearing elements automatically get reasonable line lengths. Use a custom property for easy adjustment: --measure: 60ch.
Prefer Intrinsic Responsiveness Over Breakpoints
Layouts that respond to their own content are more robust than layouts controlled by viewport breakpoints. Use CSS that suggests rather than dictates layout.
The Sidebar Pattern
A sidebar that automatically stacks when space is tight:
.with-sidebar {
display: flex;
flex-wrap: wrap;
gap: var(--s1);
}
.with-sidebar > :first-child {
flex-basis: 15rem; /* sidebar width */
flex-grow: 1;
}
.with-sidebar > :last-child {
flex-basis: 0;
flex-grow: 999; /* takes remaining space */
min-inline-size: 50%; /* forces wrap when too narrow */
}The Switcher Pattern
Switches between horizontal and vertical layout based on available space:
.switcher {
display: flex;
flex-wrap: wrap;
gap: var(--s1);
}
.switcher > * {
flex-grow: 1;
flex-basis: calc((30rem - 100%) * 999);
/* 30rem = threshold: above it → horizontal, below → vertical */
}The Cluster Pattern
Wrapping horizontal groups (tags, buttons, navigation):
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--s1);
align-items: center;
}The Center Pattern
Horizontally centered content with a max-width for readability:
.center {
box-sizing: content-box;
max-inline-size: var(--measure);
margin-inline: auto;
padding-inline: var(--s1);
}The Cover Pattern
A vertically centered element within a minimum height container (hero sections):
.cover {
display: flex;
flex-direction: column;
min-block-size: 100vh;
padding: var(--s1);
}
.cover > * { margin-block: var(--s1); }
.cover > .centered { margin-block: auto; } /* Vertically centers this child */The Grid Pattern
Auto-filling grid that adapts column count to available space:
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
gap: var(--s1);
}The min(250px, 100%) prevents overflow on narrow screens.
The Frame Pattern
Crop any content to an aspect ratio:
.frame {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.frame > img,
.frame > video {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}Responsive Images
Images are often the heaviest assets on a page. Use modern HTML attributes to serve the right image at the right size without layout shifts.
Prevent Layout Shift with aspect-ratio
When an image loads, the browser doesn't know its dimensions until the file arrives — causing content below to jump. Reserve space upfront:
<img src="hero.webp" alt="..." width="1200" height="675"
loading="lazy" decoding="async">The width and height attributes let modern browsers calculate the aspect ratio before loading. For CSS-controlled sizing, use aspect-ratio explicitly:
.hero-image {
aspect-ratio: 16 / 9;
inline-size: 100%;
object-fit: cover;
}Serve Responsive Sizes with srcset
Don't send a 2400px image to a 375px phone. Use srcset and sizes to let the browser choose:
<img
src="photo-800.webp"
srcset="photo-400.webp 400w,
photo-800.webp 800w,
photo-1200.webp 1200w,
photo-1600.webp 1600w"
sizes="(min-width: 960px) 50vw, 100vw"
alt="Descriptive alt text"
loading="lazy"
decoding="async"
>srcsetlists available files with their pixel widths (wdescriptor)sizestells the browser how wide the image will display at each breakpoint- The browser picks the smallest file that satisfies the display size and screen density
Use <picture> for Art Direction
When you need different crops or aspect ratios at different breakpoints (not just different sizes), use <picture>:
<picture>
<source media="(min-width: 960px)" srcset="hero-wide.webp" type="image/webp">
<source media="(min-width: 960px)" srcset="hero-wide.jpg">
<source srcset="hero-square.webp" type="image/webp">
<img src="hero-square.jpg" alt="..." loading="lazy" decoding="async">
</picture>Image Format Priority
1. AVIF — smallest files, best quality, ~93% browser support 2. WebP — good compression, ~97% support 3. JPEG/PNG — universal fallback
Use <picture> with type to serve modern formats with fallbacks. For most projects, WebP alone is sufficient.
Lazy Loading
Add loading="lazy" to images below the fold. The browser defers loading until the image approaches the viewport.
Do NOT lazy-load:
- Hero images and above-the-fold content (these should load immediately)
- Images critical to Largest Contentful Paint (LCP)
Do lazy-load:
- Gallery images, card thumbnails, anything below the fold
decoding="async"can be added to all images — it prevents the image decode from blocking the main thread
Always Provide Meaningful Alt Text
Every <img> must have an alt attribute. The text should describe the content or function of the image, not its appearance.
<!-- Decorative: empty alt (screen readers skip it) -->
<img src="divider.svg" alt="">
<!-- Informative: describe the content -->
<img src="chart.png" alt="Sales revenue increased 40% from Q1 to Q3 2024">
<!-- Functional: describe the action -->
<a href="/home"><img src="logo.svg" alt="Acme Corp home page"></a>Use Container Queries for Component-Level Responsiveness
Media queries respond to the viewport. Container queries (Baseline 2023) respond to the parent container's size — making components truly self-contained and reusable.
/* 1. Declare a containment context */
.card-container {
container-type: inline-size;
}
/* 2. Style based on the container's width, not the viewport */
.card {
display: grid;
gap: var(--s0);
}
@container (min-width: 400px) {
.card {
grid-template-columns: 150px 1fr;
}
}When to use container queries vs media queries:
- Container queries — component layout decisions (card layout, sidebar widget, embedded component). The component adapts to whatever space it's given, regardless of viewport size
- Media queries — page-level layout decisions (number of columns, navigation style, overall page structure)
Practical principle: If a component might appear in different contexts (full-width page, narrow sidebar, modal), it should use container queries. The same card component then works everywhere without overrides.
Container queries support all the same syntax as media queries (min-width, max-width, ranges) and can be named for clarity:
.sidebar { container: sidebar / inline-size; }
@container sidebar (min-width: 300px) {
.widget { /* wider layout */ }
}Use Subgrid for Consistent Nested Alignment
Subgrid (Baseline 2023) lets child elements participate in their parent's grid tracks — solving the long-standing problem of aligning content across sibling cards.
The problem without subgrid: When cards have varying content lengths, their internal elements (title, body, footer) don't align across the row. Each card's grid is independent.
/* Parent grid */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
gap: var(--s1);
}
/* Each card inherits the parent's row tracks */
.card {
display: grid;
grid-row: span 3; /* card spans 3 implicit row tracks */
grid-template-rows: subgrid; /* inherit parent's row sizing */
}Now all cards' titles, bodies, and footers align horizontally across the row — even when content lengths differ. No fixed heights, no JavaScript measurement.
Use subgrid when:
- Cards or list items need their internal elements to align across a row
- Form labels and inputs need to align across a grid
- Any nested content needs to participate in the parent's track sizing
Detect Input Method with Interaction Media Queries
Screen size does not reveal input method. A 13-inch Surface has a touchscreen; a 10-inch iPad can have a keyboard and trackpad. Use pointer and hover media queries (Baseline 2018) to adapt interaction patterns to the actual input device.
| Query | Matches | Typical Devices |
|---|---|---|
pointer: fine | Accurate primary pointer | Mouse, trackpad, stylus |
pointer: coarse | Imprecise primary pointer | Finger on touchscreen |
hover: hover | Primary input can hover | Mouse, trackpad |
hover: none | Primary input cannot hover | Touchscreen |
Gate Hover Effects Behind Capability
On touchscreens, tapping an element triggers :hover styles that "stick" after the tap. Gate hover effects behind the combined query:
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px oklch(0% 0 0 / 0.12);
}
}The combined query (hover: hover) and (pointer: fine) is the most reliable pattern — using hover: hover alone can match Android devices that emulate hover via long-press.
Always Provide a Focus Alternative
Every :hover interaction must have a :focus or :focus-within equivalent. Keyboard users never see hover states.
/* Reveal supplementary info on hover OR focus */
.card .extra-info { visibility: hidden; }
@media (hover: hover) and (pointer: fine) {
.card:is(:hover, :focus-within) .extra-info {
visibility: visible;
}
}
/* On touch devices, show the info by default */
@media (hover: none) {
.card .extra-info { visibility: visible; }
}Enlarge Touch Targets for Coarse Pointers
Design touch-friendly as the default. Only enlarge for pointer: coarse — never shrink for pointer: fine. Fitts's Law applies regardless of input device.
@media (pointer: coarse) {
button, a {
min-height: 48px;
}
input[type="checkbox"],
input[type="radio"] {
width: 1.625rem;
height: 1.625rem;
}
}any-pointer and any-hover
The primary pointer/hover queries test only the primary input device. The any-pointer and any-hover variants test all available input devices — useful for detecting mixed setups like a laptop with touchscreen:
/* Device has fine pointer AND coarse pointer (e.g. laptop with touchscreen) */
@media (pointer: fine) and (any-pointer: coarse) {
button {
min-height: 48px; /* User might switch to touch */
}
}Caveats: These queries are hints, not guarantees. The W3C spec intentionally leaves "primary pointer" determination to the browser. Known inconsistencies:
- iOS Safari always reports
pointer: coarse, even with an external mouse or trackpad connected - Some Android devices report
pointer: finedue to virtual mouse drivers - Convertible laptops may switch primary pointer when a keyboard is detached or attached
Never hide critical content or functionality behind hover interactions. Treat these queries as progressive enhancement — the baseline experience must work without hover.
Handle Device Safe Areas
Modern devices have hardware features that obscure screen edges — notches, Dynamic Islands, rounded corners, camera cutouts, home indicators, gesture navigation bars. Use env(safe-area-inset-*) (Baseline 2020) to keep interactive content visible.
Enable Edge-to-Edge Layout
By default, the browser insets the viewport to avoid obstructions, and all env() values are 0px. To take control yourself, opt in with viewport-fit=cover:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">This lets backgrounds and decorative content bleed to the screen edge while you position interactive content within the safe area using CSS.
The max() Pattern
The most robust approach — ensures at least your design's minimum padding, but accommodates larger safe area insets when present:
.content {
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
}In portrait on a non-notched device, safe-area-inset-left is 0px, so max() resolves to 1rem. In landscape on a notched iPhone, the inset may be 47px, so max() uses that instead.
Fixed Bottom Navigation
Bottom-fixed elements are the most common safe area issue — the home indicator or gesture bar sits directly on top of them.
/* Background extends behind home indicator; content is padded above it */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 0.75rem max(0.5rem, env(safe-area-inset-right))
calc(0.75rem + env(safe-area-inset-bottom, 0px))
max(0.5rem, env(safe-area-inset-left));
}Fixed Top Header
.top-header {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: calc(0.75rem + env(safe-area-inset-top, 0px))
max(1rem, env(safe-area-inset-right))
0.75rem
max(1rem, env(safe-area-inset-left));
}Guidelines
- Apply to specific elements (fixed headers, bottom bars, main content) — not globally on
body. Global padding wastes screen space and prevents full-bleed backgrounds. - Handle landscape orientation. When a notched phone rotates, the notch moves to a side —
safe-area-inset-leftorsafe-area-inset-rightbecomes significant. Themax()pattern handles this automatically. - Provide fallback values:
env(safe-area-inset-bottom, 0px)— the second argument is the fallback for browsers or devices with no insets. - Avoid double-insetting. Apply safe area padding at one level only — not on both a wrapper and its child.
- On Chrome Android, prefer
bottompositioning overpadding-bottomfor dynamic insets — the value changes during scroll as the gesture bar retracts, and padding changes trigger layout recalculation.
Use Relative Units for Accessible, Scalable Layouts
Avoid px for font sizes - it overrides the user's browser font size preference.
When to use which unit:
- rem - Block-level sizing (font-size, margin, padding). Relative to root font size. Use for headings:
font-size: 2.5rem - em - Inline/contextual sizing. Scales with parent. Use for icon sizing:
width: 0.75em; height: 0.75em - ch - Line length (measure).
max-inline-size: 65ch - % and fr - Flexible layouts. Grid tracks, flex-basis
- vw/vh - Viewport-relative. Use sparingly with
calc():font-size: calc(1rem + 0.5vw) - px - Only for borders, shadows, and fine details that shouldn't scale
Scale the entire interface proportionally:
@media (min-width: 960px) {
:root { font-size: 125%; }
}All rem-based values scale automatically.
Chapter Summary
1. Group related elements using containers, proximity, similarity, or continuity — don't default to cards when spacing or background tints suffice 2. Create clear visual hierarchy using size, colour, contrast, spacing, position, depth 3. Interfaces = rectangles within rectangles with margin, padding, border (box model) 4. Create predefined spacing options in 8pt increments; space based on relationship 5. Align to 12-column grid; avoid multiple different alignments 6. Use logical properties (margin-inline-start) instead of physical properties (margin-left) 7. Use contextual spacing (Stack pattern with * + *) - style relationships, not individual elements 8. Prefer intrinsic responsive patterns (flex-wrap, minmax) over @media breakpoints 9. Use responsive images (srcset, sizes, loading="lazy") and prevent layout shift with aspect-ratio 10. Use container queries for component-level responsiveness; media queries for page-level layout 11. Use subgrid to align nested content across sibling elements 12. Use pointer/hover media queries to adapt interaction to input method — never hide content behind hover 13. Use env(safe-area-inset-*) with viewport-fit=cover for notches, home indicators, and gesture bars 14. Use relative units (rem, em, ch) for accessible, scalable layouts
Typography
Learn a system of logical guidelines to make text beautiful and easy to read.
Typeface Classifications
Serif - Decorative tails/feet at ends of letters. Traditional, classic, formal mood. Some legible at small sizes, others better for large.
Sans Serif - No decorative tails. Modern, simple. Highly legible at all sizes. Safe, neutral option for most interfaces.
Script - Based on handwriting. Low legibility - not suitable for small sizes. Can convey formal or casual mood at large sizes.
Display - Wide range of decorative styles. Designed for large sizes only. Strong character, good for conveying different moods.
Monospaced - Every character takes same horizontal space. Good for code and numbers (easier to compare).
Use a Single Sans Serif Typeface
Safest for most interface designs.
Reasons
1. Legibility
- Sans serif most legible
- Main purpose of interface text: clearly communicate information
- If not legible = harder to read, understand, use
2. Neutrality
- Don't convey strong mood/personality
- Fits most brand personalities
- Content is focal point, not typeface
- Less chance of unsuitable choice
3. Simplicity
- Less character and detail
- Complicated typefaces = distracting, increased cognitive load
- Less is more with typography
Choosing Functional Text Typefaces
For UI/functional text (labels, captions, navigation, data), prioritise:
- Open, distinct letterforms — capital I, lowercase l, and number 1 must be easily distinguishable
- Large x-height for legibility at small sizes (but not so large that n and h become hard to tell apart)
- Generous default letterspacing — don't compensate with
letter-spacing; choose a correctly designed font - Sturdy weights — avoid light weights for UI text; thin strokes blur or dissolve at small sizes
- Look for typefaces designed for signage or interfaces (e.g. Fira Sans, Source Sans, Roboto, Inter)
Evoke Emotion Using a Second Typeface for Headings
As you get more confident, try second typeface for HEADINGS ONLY.
Typeface Moods:
- Sans serif - neutral, minimal, modern
- Serif - traditional, established, classic
- Rounded sans serif - fun, soft, playful
- Casual script - personal, handmade
- Formal script - formal, feminine, elegant
- Light sans serif - chic, modern, luxurious
Combining Typefaces
Limit to 2-3 typefaces maximum. Every typeface must serve a clear, distinct purpose (body, display, functional).
How to find good pairings: 1. Start with an anchor — choose your body text typeface first, then find companions that complement it 2. Match underlying structure — pair typefaces with similar skeletons (e.g. both rational, both dynamic) but different expression 3. Use contrast for distinction — pair serif with sans-serif, or light with heavy, to create clear hierarchy 4. Check similar metrics — look for comparable x-heights, cap heights, and overall colour (density) 5. Try superfamilies first — families with serif + sans-serif siblings (e.g. Questa Sans + Questa Serif) are guaranteed to harmonise 6. Consider same designer — type designers leave personal stylistic thumbprints across their work
Avoid: Pairing typefaces that are too similar (e.g. two geometric sans-serifs) — they create uncomfortable "almost the same" tension without clear distinction.
Workhorses vs Personalities
Workhorses (Helvetica, Futura, Proxima Nova) are versatile but need skill to make distinctive. Look to display styles within their superfamily for variety.
Personalities (typefaces with strong inherent character) do most of the work for you but suit fewer contexts. Favour personalities for display text — they make it easier to create distinctive designs.
Tips for Choosing a Sans Serif Typeface
- Choose popular, tried and tested typefaces
- Look for variety of weights (light, regular, medium, semibold, bold)
- Look for taller lowercase letters (x-height) and greater letter spacing = more legible at small sizes
- Get inspiration from companies known for good design
- Ensure multi-language support if needed
- Look for OpenType features
- When in doubt, use default system typeface
Normalize Font Sizes with font-size-adjust
Different fonts at the same font-size can appear vastly different in visual size due to varying x-heights (the height of lowercase letters). font-size-adjust normalizes this.
Use cases:
- Fallback fonts: Prevent layout shift when web fonts load
- Mixing fonts: Make a serif heading font and sans-serif body font work together with one type scale
/* The value is the x-height ratio (x-height / font-size) */
body {
font-family: "Custom Font", Arial, sans-serif;
font-size-adjust: 0.5; /* Normalize based on x-height */
}How to find the value: 1. Measure your primary font's x-height ratio 2. Apply it - fallback fonts will scale to match visually
Browser support: ~90% (Chrome 127+, Firefox 118+, Safari 17+)
Limit Font Weights and Ensure Clear Distinction
Don't use all available weights - adds noise and clutter. More importantly: avoid weights that are too similar to distinguish.
Problematic combinations (too close together):
- Light vs Thin
- Book vs Regular
- Semibold vs Bold
Guidelines:
- Typically 2, max 3 weights with clear visual distinction
- Common useful set: Regular (400), Medium (500), Bold (700)
- Very thin or thick weights - headings/large text only (difficult at small sizes)
- Each weight should serve a clear purpose in your hierarchy
Use a Type Scale to Set Font Sizes
Logical way to create balanced font sizes that work together.
How to Create
1. Start with base font size (body text) 2. Multiply by scale factor for larger sizes
Popular Type Scales (smallest to largest)
- 1.067 – Minor Second
- 1.125 – Major Second
- 1.200 – Minor Third
- 1.250 – Major Third
- 1.333 – Perfect Fourth
- 1.414 – Augmented Fourth
- 1.500 – Perfect Fifth
- 1.618 – Golden Ratio
Example (1.200 Minor Third, base 16px)
Heading 1: 40px / 48px line-height / bold
Heading 2: 32px / 40px line-height / bold
Heading 3: 24px / 32px line-height / bold
Heading 4: 20px / 28px line-height / bold
Body: 16px / 24px line-height / regular
Small: 14px / 20px line-height / regularTips:
- Round to nearest whole number
- Try to make line-heights divisible by 4 (aligns to 4pt grid)
- Adjust as needed once confident
Small vs Large Type Scales
Small scales (e.g., Major Second):
- Less difference between sizes
- Better for complex apps, tools, dashboards
Large scales (e.g., Perfect Fifth):
- Larger differences between sizes
- Better for simpler interfaces, marketing sites
Responsive Type Scales
Instead of just scaling individual font sizes, scale the type scale ratio itself based on viewport width:
| Viewport | Scale Ratio | H1 | H2 | H3 | Body |
|---|---|---|---|---|---|
| 320px | 1.2 (Minor Third) | 35px | 29px | 24px | 17px |
| 1024px | (interpolated) | 50px | 39px | 31px | 19px |
| 1500px | 1.33 (Perfect Fourth) | 63px | 47px | 36px | 20px |
Why this works:
- Headings can be dramatically larger on desktop (63px H1) without being too big on mobile (35px)
- The hierarchy stays proportional at all sizes
- Smooth fluid interpolation - no breakpoint jumps
Fluid Typography with clamp()
Use clamp() for font sizes that scale smoothly:
/* clamp(minimum, preferred, maximum) */
h1 {
font-size: clamp(35px, 5vw + 1rem, 63px);
}
h2 {
font-size: clamp(29px, 4vw + 0.5rem, 47px);
}
.prose {
font-size: clamp(17px, 1.5vw + 0.5rem, 20px);
}Benefits:
- No hard jumps at breakpoints
- Smooth scaling between screen sizes
- Headings get proportionally bigger with more space
Note: For UI elements like buttons and labels, fixed sizes (14px) often work better for consistency.
Distinguish UI Text from Body Text
UI text (buttons, labels, navigation, table cells) and body text (articles, descriptions, long-form content) have different requirements.
UI Text: 14px is Fine
For compact UI components, 14px works well and is common practice (Tailwind's base size):
- Buttons, labels, navigation items
- Table cells, form labels, badges
- Sidebar content, metadata
14px keeps UI components compact - important when combined with padding.
Body Text: Scale Responsively
For long-form reading content, don't leave text hard at 16px - scale it:
- Mobile (360-440px): 16-18px — scales across phone viewports
- Desktop (640px+): 16-20px — scales with viewport; smaller screens (11-13") stay closer to 16px
.prose {
font-size: clamp(1rem, 0.4rem + 2.5vw, 1.125rem); /* 16→18px across 360–440px */
}
@media (min-width: 640px) {
.prose {
font-size: clamp(1.125rem, 0.95rem + 0.25vw, 1.25rem); /* 18→20px across 1120–1920px */
}
}Input Fields: 16px Minimum on Mobile
iOS Safari auto-zooms inputs below 16px. See Forms chapter for details.
The key insight: avoid both tiny text that strains eyes and oversized text that wastes mobile screen space.
Use at Least 1.5 Line Height for Long Body Text
Line height = vertical distance between two lines of text.
For accessibility and readability:
- Minimum 1.5 (150%) for body text
- Keep between 1.5 and 2
Benefits:
- Prevents rereading same line
- More comfortable to read
Tips:
- Longer lines = taller line height
- Darker/heavier typefaces = taller line height
- Typefaces that look larger = taller line height
Always Use Unitless line-height Values
Use unitless values (e.g. 1.5) instead of units (e.g. 24px or 1.5em):
/* Good - child elements compute their own line-height */
body { line-height: 1.5; }
/* Bad - computed value (e.g. 24px) is inherited, not the ratio */
body { line-height: 1.5em; }With unitless values, a child element with font-size: 32px will compute line-height: 48px (32 × 1.5). With 1.5em, it inherits the parent's computed 24px, causing lines to overlap.
Set Paragraph Spacing to 1.5x Line Height
The gap between paragraphs should be noticeably larger than the gap between lines within a paragraph. A reliable rule: set paragraph spacing (margin) to 1.5 times the line spacing.
p {
line-height: 1.5; /* 24px at 16px font-size */
margin-block-start: 0;
margin-block-end: 1.5em; /* ~36px = 1.5 × 24px */
}This creates a clear visual break between paragraphs while keeping the text block cohesive. Keep paragraphs to roughly 5 lines max for comfortable reading.
Decrease Line Height as Font Size Increases
Large text doesn't need 1.5 line height.
Reason: Line height is relative to font size - same percentage creates larger actual gap on bigger text.
Example:
- Heading at 24px with 1.6 line height = large gap
- Change to 1.3 line height for consistent gap
Ensure Ideal Line Length
Optimal: 40-80 characters per line (including spaces)
Too long:
- Hard to gauge where line starts/ends
- Eyes get lost tracking back
Too short:
- Eyes stressed from frequent travel back
Guidelines:
- Don't use full page width for text
- Align text block to left or centre of page
- Especially important for long body text
Use ch Units for Line Length
The ch unit equals the width of the "0" character in the current font. This makes the 40-80 character guideline directly implementable:
.prose {
max-width: 65ch; /* ~65 characters per line */
}This automatically adapts to different font sizes and typefaces.
Left Align Text
English read left to right, downwards in F-pattern.
Left-aligned = easiest to read:
- Each line starts at same left edge
- Consistent anchor for eyes
Don't Centre Align Long Body Text
- Starting point changes each line
- Eyes work harder to find start
- OK for headings and short text
Don't Justify Without Hyphenation
Plain justified text creates uneven word spacing and "rivers" of white space. If you must justify:
.justified-prose {
text-align: justify;
hyphens: auto; /* 97% support — set lang attribute on <html> */
hyphenate-limit-chars: 6 3 2; /* Min 6 chars, 3 before break, 2 after */
}Fine-grained hyphenation controls (hyphenate-limit-lines, hyphenate-limit-zone, hyphenate-limit-last) have limited browser support (~80%, no Safari). Use them as progressive enhancement:
.justified-prose {
/* Progressive enhancement — ignored by browsers that don't support them */
hyphenate-limit-lines: 2;
hyphenate-limit-zone: 8%;
hyphenate-limit-last: always;
}When justification can work: Long-form content with generous measure (60+ characters), combined with hyphenation. Still not recommended for short lines or narrow columns.
When to avoid entirely: Short text, narrow columns, dyslexia-sensitive contexts. Left-aligned text is always the safer default.
Avoid Multiple Text Alignments
Harder to follow, looks messy.
Kerning and Letter Spacing
Enable Kerning
Kerning adjusts spacing between specific character pairs (e.g. AV, To, Wa). Always enable it:
body {
font-kerning: normal; /* Enable OpenType kerning */
}Letter Spacing (Tracking) Guidelines
Decrease for large, bold, or wide text:
.display-heading {
letter-spacing: -0.02em; /* Tighten large/bold text ~2-3% */
}Increase for ALL CAPS and long digit strings:
.uppercase-label {
text-transform: uppercase;
letter-spacing: 0.05em; /* Open up ~5% for caps */
}Turn off ligatures when letterspacing:
.spaced-text {
letter-spacing: 0.05em;
font-variant-ligatures: no-common-ligatures; /* Ligatures look wrong when spaced */
}Never letterspace lowercase body text without good cause — it damages word shapes and reduces readability.
Use OpenType Features for Polished Typography
Modern fonts contain advanced features that improve readability and professional appearance. Use CSS font-variant and font-feature-settings to activate them.
Small Caps
Use for abbreviations and words set in uppercase inside body text so they blend in better. Always use real small caps - never fake them by scaling down capitals.
/* Real small caps */
.abbreviation {
font-variant-caps: all-small-caps;
font-feature-settings: "c2sc", "smcp";
}Fake small caps (scaled-down capitals) have thinner strokes and look out of place next to regular text.
Figure Styles
Figure styles split into two groups:
Old-style vs Lining figures:
- Old-style figures have varying heights (some descend below baseline) - better for body text as they blend in
- Lining figures are all the same height - better for headings and UI elements
.body-text { font-variant-numeric: oldstyle-nums; }
.ui-numbers { font-variant-numeric: lining-nums; }Proportional vs Tabular figures:
- Proportional figures have varying widths (natural spacing) - good for running text
- Tabular figures all take the same horizontal space - essential for tables, prices, and anywhere numbers need to align vertically
.price-table { font-variant-numeric: tabular-nums; }
.prose { font-variant-numeric: proportional-nums; }Ligatures
Common ligatures (fi, fl, ff, ffl, ffi) improve legibility in body text. Enabled by default in most browsers - don't disable them.
Discretionary ligatures are more ornamental. Use sparingly and only for decorative headings:
.decorative-heading {
font-variant-ligatures: discretionary-ligatures;
font-feature-settings: "dlig";
}Proper Punctuation and Typographic Marks
Use the correct typographic marks:
- Hyphen (-) connects words: "five-dollar"
- En dash (\u2013) replaces "to": "6\u20135 p.m."
- Em dash (\u2014) indicates a break in thought: "Why is typography important?\u200A\u2014\u200AIt can also be used as an indicator of a break."
- Curly quotation marks (\u201c \u201d \u2018 \u2019) for prose - straight marks (' ") are for code only
Additional typographic marks:
- Proper minus (
−/ −) for negative numbers — not a hyphen - Proper ellipsis (
…/ …) — one character, not three dots - Multiplication (
×/ ×) — not the letter x - Non-breaking space (
) — between values and units (100 km), initials (J. K. Rowling), and between last two words of headings (prevents widows) - Thin space (
 ) — between nested quotation marks and around em dashes
Prevent Faux Bold and Italic
Browsers synthesise bold/italic when the required font file isn't loaded. Faux bold smears outlines; faux italic mechanically slants the roman. Both look wrong.
/* Prevent browser synthesis - only use real font files */
@font-face {
font-family: 'MyFont';
src: url('myfont-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
/* If you don't have an italic, explicitly prevent synthesis */
.no-synthesis {
font-synthesis: none;
}Always ensure you have font files for every weight and style you use. If you specify font-weight: bold but only loaded the regular weight, the browser will fake it.
Table Typography
Tables are text to be read — apply typographic care.
Number formatting:
- Use tabular lining numerals in data tables (equal-width digits that align vertically)
- Right-align numbers so decimal points and digit places line up
- Left-align text columns
- Align column headings with their data (right-align headers above number columns)
Reduce visual clutter:
- Minimise borders, fills, and rules — use white space to separate rows and columns
- Alternating row colours are rarely needed if spacing is adequate
- Horizontal rules between rows are often sufficient; vertical rules almost never needed
.data-table {
font-variant-numeric: tabular-nums lining-nums;
border-collapse: collapse;
}
.data-table td.number {
text-align: right;
font-variant-numeric: tabular-nums lining-nums;
}Display Text and Headlines
Sizing Display Text
For display text that scales with the viewport, use vmin units for consistent sizing across portrait and landscape orientations:
.hero-heading {
font-size: clamp(2rem, 8vmin, 5rem);
}Optical Sizing
Different sizes of text benefit from different font characteristics. Enable automatic optical sizing when available:
body {
font-optical-sizing: auto; /* Browser adjusts for rendered size */
}At small sizes: increased x-height, wider spacing, thicker strokes for legibility. At large sizes: finer details, tighter spacing, higher contrast for elegance.
Hanging Punctuation for Display Text
For large quoted text, pull punctuation into the margin so the text edge aligns visually. The CSS hanging-punctuation property exists but only Safari supports it (~14%). Use a negative text-indent instead:
blockquote.display {
text-indent: -0.4em; /* Hang opening quote mark — works everywhere */
}Drop Caps
Use initial-letter for drop caps (~91% support, no Firefox). Provide a fallback for unsupported browsers:
.article > p:first-of-type::first-letter {
/* Fallback for browsers without initial-letter */
float: left;
font-size: 3.2em;
line-height: 0.8;
margin-right: 0.1em;
font-weight: bold;
}
@supports (initial-letter: 3) {
.article > p:first-of-type::first-letter {
float: none;
font-size: inherit;
line-height: inherit;
initial-letter: 3; /* Span 3 lines */
}
}Prevent Widows in Headings
A single word on the last line of a heading looks orphaned. Use CSS text-wrap: balance (~87% support) for headings and text-wrap: pretty (~78% support) for paragraphs:
h1, h2, h3, h4, h5, h6 {
text-wrap: balance; /* Equalises line lengths in headings */
}
p {
text-wrap: pretty; /* Avoids orphan words on last line */
}For browsers without support, these properties degrade gracefully to normal wrapping. As an additional safeguard, insert between the last two words of critical headings.
Vertical Rhythm
Use the body text line-height as the fundamental spacing unit. All vertical spacing — paragraph margins, heading spacing, padding — should be multiples of this base unit.
:root {
--baseline: 1.5rem; /* 24px if root is 16px */
}
h2 { margin-block: calc(var(--baseline) * 2) var(--baseline); }
h3 { margin-block: calc(var(--baseline) * 1.5) calc(var(--baseline) * 0.5); }
p { margin-block: 0 var(--baseline); }This creates a predictable, harmonious rhythm throughout the page. Embedded media (images, videos) may break the rhythm — that's acceptable; resume the rhythm after.
Ensure Text on Photos is Legible
Common mistake: placing text directly on photos.
Contrast requirements:
- Small text (≤18px): 4.5:1 minimum
- Large text (>18px bold OR >24px regular): 3:1 minimum
Solutions
Linear gradient overlay:
- Dark grey, 90% opacity at bottom, 0% halfway up
- Add text shadow
Semi-transparent overlay:
- Dark grey, 50% opacity over entire photo
- Add text shadow
Blurred semi-transparent overlay:
- Add blur effect for easier reading
Solid text background:
- Popular for video captions
- White text on dark grey background
Avoid Light Grey and Pure Black Text
Light grey text:
- Accessibility issue - many can't read or find difficult
- Always aim for 4.5:1 contrast minimum
Pure black text:
- Too high contrast causes eye strain and fatigue
- Black: 0% brightness, White: 100%
- Large difference makes eyes work harder
- Use accessible dark grey instead
Chapter Summary
1. Limit font weights: typically 2, max 3 (e.g. Regular 400, Medium 500, Bold 700) in a single sans serif typeface 2. Use type scale to create predefined font sizes that work together 3. Use 1.5+ line height (unitless values) for long body text; decrease line height as font size increases 4. Ensure 40-80 characters per line for readability 5. Left align text for optimal readability (F-pattern); only justify with hyphenation 6. Enable kerning; tighten large text, open up ALL CAPS; turn off ligatures when letterspacing 7. Use OpenType features: real small caps, tabular figures for data, common ligatures, proper typographic marks 8. Prevent faux bold/italic — load all needed font weights and styles; use font-synthesis: none 9. Apply vertical rhythm — use body line-height as base spacing unit 10. Combine typefaces with purpose: match structure, contrast expression, limit to 2-3 families