ultimate-ui
Use this skill when building user interfaces that need to look polished, modern, and intentional - not like AI-generated slop. Triggers on UI design tasks including component styling, layout decisions, color choices, typography, spacing, responsive design, dark mode, accessibility, animations, landing pages, onboarding flows, data tables, navigation patterns, and any question about making a UI look professional. Covers CSS, Tailwind, and framework-agnostic design principles.
engineering uiuxdesigncsstailwindaccessibilityWhat is ultimate-ui?
Use this skill when building user interfaces that need to look polished, modern, and intentional - not like AI-generated slop. Triggers on UI design tasks including component styling, layout decisions, color choices, typography, spacing, responsive design, dark mode, accessibility, animations, landing pages, onboarding flows, data tables, navigation patterns, and any question about making a UI look professional. Covers CSS, Tailwind, and framework-agnostic design principles.
ultimate-ui
ultimate-ui is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex, and 1 more. Building user interfaces that need to look polished, modern, and intentional - not like AI-generated slop.
Quick Facts
| Field | Value |
|---|---|
| Category | engineering |
| Version | 0.1.0 |
| Platforms | claude-code, gemini-cli, openai-codex, mcp |
| License | MIT |
How to Install
- Make sure you have Node.js installed on your machine.
- Run the following command in your terminal:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill ultimate-ui- The ultimate-ui skill is now available in your AI coding agent (Claude Code, Gemini CLI, OpenAI Codex, etc.).
Overview
A comprehensive design system knowledge base for building UIs that feel crafted by a senior designer, not generated by a prompt. This skill encodes specific, opinionated rules - exact spacing values, proven color ratios, real typography scales, and battle-tested component patterns. Every recommendation is actionable with concrete CSS/Tailwind values, not vague advice like "make it clean."
The difference between AI slop and a polished UI comes down to constraint and restraint - fewer colors used with intention, consistent spacing from a scale, typography that creates hierarchy without screaming, and micro-interactions that feel responsive without being distracting.
Tags
ui ux design css tailwind accessibility
Platforms
- claude-code
- gemini-cli
- openai-codex
- mcp
Related Skills
Pair ultimate-ui with these complementary skills:
Frequently Asked Questions
What is ultimate-ui?
Use this skill when building user interfaces that need to look polished, modern, and intentional - not like AI-generated slop. Triggers on UI design tasks including component styling, layout decisions, color choices, typography, spacing, responsive design, dark mode, accessibility, animations, landing pages, onboarding flows, data tables, navigation patterns, and any question about making a UI look professional. Covers CSS, Tailwind, and framework-agnostic design principles.
How do I install ultimate-ui?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill ultimate-ui in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support ultimate-ui?
This skill works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.
Maintainers
Generated from AbsolutelySkilled
SKILL.md
Ultimate UI
A comprehensive design system knowledge base for building UIs that feel crafted by a senior designer, not generated by a prompt. This skill encodes specific, opinionated rules - exact spacing values, proven color ratios, real typography scales, and battle-tested component patterns. Every recommendation is actionable with concrete CSS/Tailwind values, not vague advice like "make it clean."
The difference between AI slop and a polished UI comes down to constraint and restraint - fewer colors used with intention, consistent spacing from a scale, typography that creates hierarchy without screaming, and micro-interactions that feel responsive without being distracting.
When to use this skill
Trigger this skill when the user:
- Asks to build or style a UI component (button, card, form, table, nav)
- Needs help with layout, spacing, or grid decisions
- Wants to implement dark mode or theme switching
- Asks about typography, font choices, or text styling
- Needs accessible and WCAG-compliant designs
- Wants landing page, onboarding, or conversion-focused layouts
- Asks about animations, transitions, or micro-interactions
- Needs help with responsive design or mobile navigation
- Wants feedback patterns (toasts, tooltips, loading states)
- Asks to make something "look better" or "more professional"
Do NOT trigger this skill for:
- Backend logic, API design, or database schema questions
- Brand identity or logo design (this is implementation, not branding)
Key principles
Use a spacing scale, never arbitrary values - Pick a base unit (4px or 8px) and only use multiples: 4, 8, 12, 16, 24, 32, 48, 64, 96. Tailwind's default scale does this. Random padding like
13pxor27pxis the #1 tell of amateur UI.Limit your palette to 1 primary + 1 neutral + 1 accent - More colors = more chaos. Use 5-7 shades of your primary (50-900), a full neutral gray scale, and one accent for destructive/success states. Never more than 3 hues on a single screen.
Create hierarchy through contrast, not decoration - Size, weight, color, and spacing create hierarchy. You should never need borders, shadows, AND color differences simultaneously. One or two signals per level of hierarchy.
Every interactive element needs 4 states - Default, hover, active/pressed, and disabled. If you skip any state, the UI feels broken. Focus states are mandatory for accessibility.
Whitespace is a feature, not wasted space - Generous padding makes UIs feel premium. Cramped UIs feel cheap. When in doubt, add more space. The content-to-whitespace ratio should favor whitespace.
Consistency beats novelty - Use the same border-radius everywhere (pick one: 6px, 8px, or 12px). Same shadow scale. Same transition timing. Inconsistency is what makes AI-generated UIs look "off."
Use real icons, never emojis - Unicode emojis (e.g. ✅, ⚡, 🔥, 📊) render inconsistently across operating systems and browsers, cannot be styled with CSS (no size, color, or stroke control), break visual consistency, and hurt accessibility. Always use a proper icon library - Lucide React (recommended), React Icons, Heroicons, Phosphor, or Font Awesome. Icons from these libraries are SVG-based, styleable, consistent, and accessible.
Core concepts
The 8px grid - All spacing, sizing, and layout decisions snap to an 8px grid. Component heights: 32px (small), 40px (medium), 48px (large). Padding: 8px, 12px, 16px, 24px. Gaps: 8px, 16px, 24px, 32px. This single rule eliminates 80% of "why does this look wrong" problems.
Visual weight - Every element has visual weight determined by size, color darkness, border thickness, and shadow. A page should have one clear heavyweight (the CTA or primary content), with everything else progressively lighter. Squint at your page - if nothing stands out, your hierarchy is flat.
The 60-30-10 rule - 60% dominant color (background/neutral), 30% secondary (cards, sections), 10% accent (CTAs, active states). This ratio works for any color scheme and prevents the "everything is colorful" trap.
Optical alignment - Mathematical center doesn't always look centered. Text in buttons needs 1-2px more padding on top visually. Icons next to text need optical adjustment. Always trust your eyes over the inspector.
Progressive disclosure - Don't show everything at once. Start with the essential action, reveal complexity on demand. This applies to forms (multi-step > one long form), settings (basic > advanced), and navigation (primary > secondary > tertiary).
Common tasks
Style a button hierarchy
Every app needs 3 button levels: primary (filled), secondary (outlined), and ghost (text-only). Never use more than one primary button per visual section.
/* Primary - solid fill, high contrast */
.btn-primary {
background: var(--color-primary-600);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 500;
font-size: 14px;
border: none;
transition: background 150ms ease, transform 100ms ease;
}
.btn-primary:hover { background: var(--color-primary-700); }
.btn-primary:active { transform: scale(0.98); }
/* Secondary - outlined */
.btn-secondary {
background: transparent;
color: var(--color-primary-600);
padding: 10px 20px;
border: 1.5px solid var(--color-primary-200);
border-radius: 8px;
font-weight: 500;
font-size: 14px;
transition: border-color 150ms ease, background 150ms ease;
}
.btn-secondary:hover {
border-color: var(--color-primary-400);
background: var(--color-primary-50);
}
/* Ghost - text only */
.btn-ghost {
background: transparent;
color: var(--color-gray-600);
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 500;
font-size: 14px;
}
.btn-ghost:hover { background: var(--color-gray-100); }Button height should be 36px (sm), 40px (md), or 48px (lg). Never smaller than 36px for touch targets.
Set up a type scale
Use a modular scale with ratio 1.25 (major third). Base size: 16px.
:root {
--text-xs: 0.75rem; /* 12px - captions, labels */
--text-sm: 0.875rem; /* 14px - secondary text, metadata */
--text-base: 1rem; /* 16px - body text */
--text-lg: 1.125rem; /* 18px - lead paragraphs */
--text-xl: 1.25rem; /* 20px - card titles */
--text-2xl: 1.5rem; /* 24px - section headings */
--text-3xl: 1.875rem; /* 30px - page titles */
--text-4xl: 2.25rem; /* 36px - hero subheading */
--text-5xl: 3rem; /* 48px - hero heading */
--leading-tight: 1.25; /* headings */
--leading-normal: 1.5; /* body text */
--leading-relaxed: 1.75; /* small text, captions */
}Limit to 2 font families max: one for headings (Inter, Manrope, or a geometric sans), one for body (same or a humanist like Source Sans). Using 3+ fonts is a red flag.
Build a responsive layout with CSS Grid
/* Content-first responsive grid - no media queries needed */
.grid-auto {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
gap: 24px;
}
/* Holy grail layout */
.page-layout {
display: grid;
grid-template-columns: minmax(240px, 1fr) minmax(0, 3fr) minmax(200px, 1fr);
gap: 32px;
max-width: 1280px;
margin: 0 auto;
padding: 0 24px;
}
/* Stack on mobile */
@media (max-width: 768px) {
.page-layout {
grid-template-columns: 1fr;
}
}Max content width: 1280px for apps, 720px for reading content. Never let text lines exceed 75 characters.
Implement dark mode properly
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #6b7280;
--border: #e5e7eb;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--border: #334155;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
}Never just invert colors. Dark mode backgrounds should be dark blue-gray (#0f172a, #1e293b), not pure black. Reduce white text to #f1f5f9 (not #ffffff) to prevent eye strain. Shadows need higher opacity in dark mode.
Add a toast notification system
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
animation: slide-up 200ms ease-out;
z-index: 50;
}
.toast-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.toast-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.toast-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
@keyframes slide-up {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}Auto-dismiss success toasts after 3-5s. Error toasts should persist until dismissed. Stack multiple toasts with 8px gap. Max 3 visible at once.
Create a data table
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
text-align: left;
padding: 12px 16px;
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
border-bottom: 2px solid var(--border);
}
.table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
.table tr:hover td {
background: var(--bg-secondary);
}Right-align numbers. Left-align text. Don't stripe rows AND add hover - pick one. Fixed headers for tables taller than the viewport. Add horizontal scroll wrapper for mobile, never let tables overflow.
Anti-patterns / common mistakes
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Using pure black (#000) on white (#fff) | Too harsh, causes eye strain, looks unnatural | Use #111827 on #fff or #f1f5f9 on #0f172a |
| Different border-radius on every component | Destroys visual consistency, looks auto-generated | Pick one radius (8px) and use it everywhere |
| Shadows on everything | Visual noise, no hierarchy, feels heavy | Reserve shadows for elevated elements (modals, dropdowns, cards) |
| Rainbow of colors | No hierarchy, overwhelming, unprofessional | Max 3 hues: primary, neutral, accent. 60-30-10 rule |
| Tiny click targets on mobile | Fails WCAG, frustrates users, increases errors | Minimum 44x44px touch targets (48px preferred) |
| Animating everything | Distracting, feels gimmicky, hurts performance | Only animate what changes state. 150-300ms transitions max |
| Centering everything | Kills readability, looks like a PowerPoint slide | Left-align body text. Center only hero headlines and CTAs |
| Inconsistent spacing | Most obvious tell of unpolished UI | Use a 4/8px spacing scale. Same gap everywhere for same context |
| Using emojis as icons | Render differently across OS/browsers, cannot be styled, break visual consistency, poor a11y | Use a real icon library: Lucide React, React Icons, Heroicons, Phosphor, or Font Awesome |
Gotchas
CSS custom properties in dark mode require explicit overrides at the right scope - Setting
--bg-primaryon:rootworks, but if a component is inside a portal or shadow DOM, it may not inherit the theme variables. Always test theme switching in modals, dropdowns, and third-party widget wrappers.Tailwind's
purge/contentconfig missing component paths causes production CSS to be empty - In a monorepo or when UI components live outside thesrc/directory, Tailwind will strip their classes from the production bundle. Every path that contains Tailwind classes must be listed incontentintailwind.config.js.transform: scale()on buttons clips focus rings and overflow shadows - Usingscale(0.98)on:activeis a common polish trick, but if the button hasbox-shadowfor a focus ring, the shadow gets clipped by the parent's overflow. Useoutline-offsetinstead ofbox-shadowfor focus indicators on transformed elements.min-height: 100vhbreaks on mobile Safari - Mobile browsers include the browser chrome in100vh, causing content to be cut off below the fold. Usemin-height: 100dvh(dynamic viewport height) for full-screen layouts on mobile. Add a100vhfallback for older browsers.Grid
auto-fillvsauto-fitproduces visually different results on sparse grids -auto-fillcreates empty columns to fill the row;auto-fitcollapses them so items stretch. Usingauto-fillwhen you expect items to fill the width produces a grid that stops at the last item with empty whitespace. Useauto-fitfor responsive grids that should expand to fill.
References
For detailed guidance on specific UI topics, read the relevant file
from the references/ folder:
references/buttons-and-icons.md- Button hierarchy, icon sizing, icon-text pairing, statesreferences/color-and-theming.md- Color theory, palette generation, dark/light mode, semantic tokensreferences/visual-hierarchy.md- F/Z patterns, focal points, emphasis techniques, whitespacereferences/grids-spacing-and-layout.md- Grid systems, spacing scales, max-widths, layout patternsreferences/onboarding.md- First-run experience, progressive disclosure, empty states, tutorialsreferences/tables.md- Data tables, sorting, pagination, responsive tables, number formattingreferences/typography.md- Type scales, font pairing, line height, measure, vertical rhythmreferences/accessibility.md- WCAG 2.2, ARIA patterns, keyboard nav, screen readers, contrastreferences/performance.md- Core Web Vitals, image optimization, font loading, lazy loadingreferences/responsiveness-and-mobile-nav.md- Breakpoints, mobile-first, touch targets, navigationreferences/landing-pages.md- Hero sections, CTAs, social proof, conversion patterns, foldreferences/shadows-and-borders.md- Elevation scale, border usage, card design, dividersreferences/feedback-and-status.md- Toasts, tooltips, modals, loading states, empty states, errorsreferences/micro-animations.md- Motion principles, transitions, hover effects, scroll animationsreferences/forms-and-inputs.md- Text inputs, selects, checkboxes, radios, toggles, file upload, validationreferences/navigation.md- Sidebars, tabs, breadcrumbs, command palettes, mega menus, paginationreferences/dashboards.md- KPI cards, chart containers, filter bars, dashboard grids, real-time updatesreferences/images-and-media.md- Avatars, galleries, carousels, video, aspect ratios, placeholdersreferences/cards-and-lists.md- Card variants, list views, infinite scroll, virtualization, skeletonsreferences/microcopy-and-ux-writing.md- Button labels, error messages, empty states, confirmation copyreferences/scroll-patterns.md- Sticky elements, scroll-snap, infinite scroll, scrollbar stylingreferences/design-tokens.md- Token naming, CSS custom properties, theme architecture, multi-brand
Only load a references file if the current task requires it - they are long and will consume context.
References
accessibility.md
Accessibility
WCAG 2.2 quick reference
Level AA requirements (the standard target):
- Color contrast: 4.5:1 normal text, 3:1 large text (18px+ or 14px bold+)
- Touch targets: minimum 24x24px (44x44px recommended)
- Focus indicators: visible, 2px+ ring, 3:1 contrast against background
- Text resize: page must work at 200% zoom
- Motion: respect
prefers-reduced-motion
Semantic HTML (do this first)
Use the right element for the job - this gets you a11y for free.
- Use
<button>for actions,<a href>for navigation. NEVER<div>/<span>withonClick - Use
<nav>,<main>,<header>,<footer>,<aside>landmarks - Use
<h1>-<h6>in order, never skip levels - Use
<ul>/<ol>for lists,<table>for tabular data - Use
<label>withfor=attribute for every form input - Use
<fieldset>+<legend>for radio/checkbox groups
<!-- Bad -->
<div class="btn" onClick={handleSave}>Save</div>
<span onClick={goHome}>Home</span>
<!-- Good -->
<button type="button" onClick={handleSave}>Save</button>
<a href="/">Home</a>ARIA patterns
Only add ARIA when semantic HTML is not enough.
Icon-only buttons
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true" focusable="false">...</svg>
</button>Expandable toggles / accordions
<button aria-expanded="false" aria-controls="section-1">
Section title
</button>
<div id="section-1" hidden>...</div>Update aria-expanded and toggle hidden on click.
Dynamic content announcements
<!-- Polite: announces after current speech finishes (toasts, status) -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
Form saved successfully
</div>
<!-- Assertive: interrupts immediately (errors only) -->
<div aria-live="assertive" role="alert">
Session expired. Please log in again.
</div>Modal dialogs
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<h2 id="dialog-title">Confirm deletion</h2>
<p id="dialog-desc">This action cannot be undone.</p>
...
</div>Navigation current page
<nav aria-label="Main">
<a href="/" aria-current="page">Home</a>
<a href="/about">About</a>
</nav>Decorative elements
<!-- Decorative icon: hide from screen readers -->
<svg aria-hidden="true" focusable="false">...</svg>
<!-- Decorative image -->
<img src="divider.png" alt="" />Keyboard navigation
- Tab order must match visual order - never use positive
tabindex(only0or-1) - All interactive elements must be reachable by keyboard
Escapecloses modals, dropdowns, popovers, and drawersEnter/Spaceactivates buttons;Enterfollows links- Arrow keys navigate within components (tabs, menus, radio groups, sliders)
- Focus trap inside modals:
Tabcycles through focusable elements within the modal only - Provide a "Skip to main content" link as the first focusable element on the page
<!-- Skip link: visually hidden until focused -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content" tabindex="-1">...</main>.skip-link {
position: absolute;
top: -100%;
left: 0;
}
.skip-link:focus {
top: 0;
}Focus trap implementation (modals)
function trapFocus(modalEl) {
const focusable = modalEl.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex="0"]'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
modalEl.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
first.focus(); // move focus into modal on open
}Focus styles
/* Modern focus-visible approach: only show ring for keyboard users */
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
border-radius: 2px;
}
/* Remove focus ring for mouse/pointer users */
:focus:not(:focus-visible) {
outline: none;
}
/* High contrast mode support */
@media (forced-colors: active) {
:focus-visible {
outline: 2px solid ButtonText;
}
}prefers-reduced-motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}In JavaScript, check before animating:
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReduced) {
element.animate([...], { duration: 300 });
}Form accessibility
<!-- Every input needs a visible label -->
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input
id="email"
type="email"
aria-required="true"
aria-describedby="email-error"
/>
<span id="email-error" role="alert" aria-live="polite">
<!-- Populated by JS on validation -->
</span>
<!-- Group related fields -->
<fieldset>
<legend>Notification preferences</legend>
<label><input type="checkbox" name="email-notif" /> Email</label>
<label><input type="checkbox" name="sms-notif" /> SMS</label>
</fieldset>Rules:
- Placeholder is NOT a label - always use
<label> - Link error message to input with
aria-describedby - Mark required fields with
aria-required="true"AND a visible indicator - Provide inline validation, not just on submit
- On submit errors, move focus to an error summary at top of form
Image accessibility
<!-- Informative image: describe what it conveys, not what it looks like -->
<img src="chart.png" alt="Q3 revenue grew 24% year-over-year to $4.2M" />
<!-- Decorative image: empty alt, not omitted -->
<img src="hero-bg.jpg" alt="" />
<!-- Complex image: link to long description -->
<img src="org-chart.png" alt="Company org chart" aria-describedby="org-desc" />
<p id="org-desc">The CEO reports to the board. Three VPs report to the CEO: ...</p>
<!-- Meaningful icon -->
<svg role="img" aria-label="Warning">...</svg>Color and contrast
- Never use color alone to convey information - add icons, patterns, or text labels
- Test with color blindness simulators (Chrome DevTools > Rendering > Emulate vision)
- Contrast ratios:
- Normal text (< 18px, or < 14px bold): 4.5:1 minimum
- Large text (18px+ or 14px+ bold): 3:1 minimum
- UI components and focus rings: 3:1 minimum
/* Accessible error state: color + icon + text, not color alone */
.input-error {
border-color: #d93025; /* red, but also accompanied by error text below */
}
/* Pair with: <span>Error: ...</span> */Tools: Chrome DevTools contrast checker, axe DevTools browser extension, Colour Contrast Analyser app.
Screen reader testing
| Screen reader | Platform | Shortcut |
|---|---|---|
| VoiceOver | macOS | Cmd+F5 |
| VoiceOver | iOS | Triple-click home/side |
| NVDA (free) | Windows | Install from nvaccess.org |
| TalkBack | Android | Volume up + down hold |
Common things to verify:
- All images have meaningful alt text
- All buttons and links have descriptive labels (not just "click here")
- Heading order is logical (
h1>h2>h3) - Dynamic content changes are announced via
aria-live - Reading order matches visual order
Common accessibility mistakes
| Mistake | Problem | Fix |
|---|---|---|
<div onClick={...}> |
Not keyboard accessible, no role announced | Use <button> |
| Placeholder as label | Disappears on input, fails contrast | Add visible <label> |
tabindex="2" |
Breaks natural tab order | Only use tabindex="0" or tabindex="-1" |
aria-label on <div> |
ARIA label has no effect without a role | Add role or use semantic element |
Missing alt attribute |
Screen reader reads file name | Always set alt="" at minimum |
| Color-only error state | Color-blind users miss errors | Add icon or text alongside color |
| Auto-playing animation | Triggers vestibular disorders | Respect prefers-reduced-motion |
| Focus moves to top on modal close | Confusing for keyboard users | Return focus to the trigger element |
aria-hidden on focused element |
Removes element from a11y tree while still focusable | Remove aria-hidden or tabindex="-1" |
| Tooltip on hover only | Not accessible by keyboard or touch | Trigger on focus as well as hover |
buttons-and-icons.md
Buttons and Icons
Button hierarchy
- 3 levels: Primary (filled), Secondary (outlined), Ghost (text-only)
- Only 1 primary button per visual section
- Destructive buttons: red variant of primary, use sparingly
- Size scale: sm (32-36px height), md (40px), lg (48px)
- Padding formula: vertical = (height - lineHeight) / 2, horizontal = height * 0.5
- Min-width: 80px to prevent tiny buttons
Button states (ALL required)
All 5 states must be covered - missing any one of them is a bug, not a style choice.
Default
Base appearance. Background, border, text color all at resting values.
Hover
Darken background by 1 shade (e.g. bg-blue-600 -> bg-blue-700). For outlined/ghost, add a light background fill.
Active / Pressed
Scale down slightly + darken one more shade: transform: scale(0.98) + bg-blue-800. Gives tactile feedback.
Focus-visible
Use outline, not box-shadow, for accessibility (box-shadow is clipped by overflow:hidden parents).
/* Correct */
button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Wrong - gets clipped */
button:focus-visible {
box-shadow: 0 0 0 2px #3b82f6;
}Disabled
button:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}Complete CSS example - primary button
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 80px;
height: 40px;
padding: 0 20px;
border: none;
border-radius: 6px;
background-color: #2563eb; /* blue-600 */
color: #ffffff;
font-size: 14px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background-color 120ms ease, transform 80ms ease;
}
.btn-primary:hover {
background-color: #1d4ed8; /* blue-700 */
}
.btn-primary:active {
background-color: #1e40af; /* blue-800 */
transform: scale(0.98);
}
.btn-primary:focus-visible {
outline: 2px solid #3b82f6; /* blue-500 */
outline-offset: 2px;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}Icon sizing
Match icon optical size to text size - mixing sizes creates visual imbalance:
| Text size | Icon size | Stroke width |
|---|---|---|
| 12px | 14px | 2px |
| 14px | 16px | 2px |
| 16px | 20px | 2px |
| 20px | 24px | 1.5px |
- Icon-only buttons need a minimum 44x44px touch target even if the icon itself is 20px - use padding to expand the hit area
- Stroke width: 1.5px for 24px icons, 2px for 20px and smaller
- Popular libraries: Lucide React (recommended), Heroicons, Phosphor, React Icons, Font Awesome
- Never use unicode emojis as icons (e.g. ✅, ⚡, 🔥, 📊, ❌). Emojis render inconsistently across OS and browsers, cannot be styled with CSS (no color, size, or stroke control), and hurt accessibility. Always use SVG icons from a real icon library
Icon + text pairing
- Icon goes LEFT of text for actions: Save, Delete, Edit, Add
- Icon goes RIGHT for navigation/direction: Next, External link, Dropdown arrow
- Gap between icon and text: 6px for sm/md buttons, 8px for lg buttons
- Always center vertically with flexbox - never use manual margin/padding to nudge icons
/* Correct */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Wrong - brittle, breaks on different line heights */
.btn svg {
margin-top: 2px;
}- Icon color: inherit from text color by default (
currentColor), or use a slightly muted tone for decorative icons
Button groups
Connected (segmented control style):
.btn-group .btn:not(:first-child):not(:last-child) {
border-radius: 0;
}
.btn-group .btn:first-child {
border-radius: 6px 0 0 6px;
}
.btn-group .btn:last-child {
border-radius: 0 6px 6px 0;
}
/* Collapse double borders */
.btn-group .btn + .btn {
margin-left: -1px;
}Separated:
.btn-group-separated {
display: flex;
gap: 8px;
}- Icon-only button groups: keep all buttons equal width, consistent icon sizing across all
Loading state buttons
- Replace text with spinner, but keep the button the same width - use
min-widthset to the button's natural width - Disable pointer events and interaction during loading
- Spinner size: 16px for sm/md, 20px for lg, always centered
- Pattern: keep original label visible, add spinner to the left, hide the icon if one was present
<!-- Default -->
<button class="btn-primary">
<SaveIcon size={16} />
Save changes
</button>
<!-- Loading -->
<button class="btn-primary" disabled>
<Spinner size={16} />
Save changes
</button>Tailwind examples
Primary button
<button class="inline-flex items-center justify-content-center gap-1.5 min-w-[80px] h-10 px-5 rounded-md bg-blue-600 text-white text-sm font-medium transition-colors hover:bg-blue-700 active:bg-blue-800 active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none">
Save
</button>Secondary (outlined) button
<button class="inline-flex items-center justify-center gap-1.5 min-w-[80px] h-10 px-5 rounded-md border border-gray-300 bg-white text-gray-700 text-sm font-medium transition-colors hover:bg-gray-50 hover:border-gray-400 active:bg-gray-100 active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none">
Cancel
</button>Ghost button
<button class="inline-flex items-center justify-center gap-1.5 min-w-[80px] h-10 px-5 rounded-md bg-transparent text-gray-700 text-sm font-medium transition-colors hover:bg-gray-100 active:bg-gray-200 active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none">
Learn more
</button>Icon-only button
<!-- 44x44px touch target, 20px icon -->
<button class="inline-flex items-center justify-center w-11 h-11 rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none" aria-label="Settings">
<SettingsIcon size={20} />
</button>Button with icon + text
<!-- Action: icon on the left -->
<button class="inline-flex items-center justify-center gap-1.5 h-10 px-5 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 active:bg-blue-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
<PlusIcon size={16} />
Add item
</button>
<!-- Navigation: icon on the right -->
<button class="inline-flex items-center justify-center gap-1.5 h-10 px-5 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 active:bg-blue-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
Next
<ArrowRightIcon size={16} />
</button>Disabled state
<button class="... opacity-50 cursor-not-allowed pointer-events-none" disabled>
Submit
</button>Loading state
<button class="inline-flex items-center justify-center gap-1.5 min-w-[120px] h-10 px-5 rounded-md bg-blue-600 text-white text-sm font-medium opacity-80 cursor-not-allowed pointer-events-none" disabled>
<Spinner size={16} class="animate-spin" />
Saving...
</button>Common mistakes
- Buttons too small: under 36px height fails usability; under 44px fails touch target guidelines (WCAG 2.5.5)
- Color alone for hierarchy: primary vs secondary must differ structurally (filled vs outlined) - color alone is insufficient for accessibility
- Missing focus-visible: skipping focus styles breaks keyboard navigation;
:focusalone fires on click too, use:focus-visible <a>styled as button or<button>styled as link: use the correct element -<button>for actions,<a>for navigation- Inconsistent border-radius: buttons must match the radius used across the rest of the UI (cards, inputs, modals)
- No transition: state changes (hover, active) without
transitionfeel jarring; usetransition-colors 120ms ease - Icon and text misaligned: always use
display: flex; align-items: center- never nudge withmargin-top - Loading state changes button width: set
min-widthequal to the resting button width to prevent layout shift - Using emojis as icons or status indicators: emojis are images controlled by the OS, not the app - they break theming, dark mode, consistent sizing, and screen reader announcements. Use SVG icons from Lucide, Heroicons, Phosphor, React Icons, or Font Awesome instead
cards-and-lists.md
Cards and Lists
Card anatomy
- Container: border OR shadow (not both), border-radius, consistent padding 16-24px
- Title: 16-18px semibold; Description: 14px regular, secondary color, 2-3 lines max
- Metadata: 12-13px muted; Actions: bottom
.card { border-radius: 8px; padding: 20px; background: #fff; display: flex; flex-direction: column; gap: 8px; }
.card__title { font-size: 17px; font-weight: 600; line-height: 1.4; color: #111827; margin: 0; }
.card__description { font-size: 14px; color: #6b7280; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; margin: 0; }
.card__meta { font-size: 12px; color: #9ca3af; display: flex; align-items: center; gap: 6px; }
.card__actions { margin-top: auto; padding-top: 12px; display: flex; gap: 8px; }Card variants
/* Flat - border only */
.card--flat { border: 1px solid #e5e7eb; box-shadow: none; }
/* Raised - shadow only */
.card--raised { border: none; box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); }
/* Image top */
.card--image { padding: 0; overflow: hidden; border: 1px solid #e5e7eb; }
.card--image .card__image { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; }
.card--image .card__body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 8px; }
/* Horizontal - image left, content right */
.card--horizontal { flex-direction: row; padding: 0; overflow: hidden; border: 1px solid #e5e7eb; }
.card--horizontal .card__image { width: 120px; min-width: 120px; object-fit: cover; display: block; }
.card--horizontal .card__body { padding: 16px; display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
/* Interactive - entire card clickable */
.card--interactive { cursor: pointer; text-decoration: none; color: inherit; display: flex; flex-direction: column; transition: box-shadow 200ms ease, transform 200ms ease; }
.card--interactive:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.08); transform: translateY(-2px); }
.card--interactive .card__link { pointer-events: none; } /* avoid nested interactive elements */
/* Stat/metric */
.card--stat { padding: 20px 24px; border: 1px solid #e5e7eb; }
.card__stat-value { font-size: 32px; font-weight: 700; color: #111827; line-height: 1; margin: 0 0 4px; }
.card__stat-label { font-size: 13px; color: #6b7280; font-weight: 500; margin: 0; }
.card__stat-trend { font-size: 12px; font-weight: 600; margin-top: 8px; }
.card__stat-trend--up { color: #16a34a; }
.card__stat-trend--down { color: #dc2626; }Card grid layouts
/* Auto-fill responsive */
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 20px; }
/* Fixed 3-column with breakpoints */
.card-grid--fixed { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 1024px) { .card-grid--fixed { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .card-grid--fixed { grid-template-columns: 1fr; } }
/* Masonry via CSS columns */
.card-grid--masonry { columns: 3 280px; column-gap: 20px; }
.card-grid--masonry > .card { break-inside: avoid; margin-bottom: 20px; }List views
/* Simple border-bottom list */
.list { list-style: none; margin: 0; padding: 0; }
.list__item { border-bottom: 1px solid #e5e7eb; }
.list__item:last-child { border-bottom: none; }
/* Card list - each item is a horizontal card */
.list--card { display: flex; flex-direction: column; gap: 12px; list-style: none; margin: 0; padding: 0; }
.list--card .list__item { border: 1px solid #e5e7eb; border-radius: 8px; }
/* Compact */
.list--compact .list__item-inner { padding: 8px 12px; font-size: 13px; }
/* Selectable */
.list__item--selectable { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; transition: background 150ms ease; }
.list__item--selectable:hover { background: #f9fafb; }
.list__item--selectable[aria-selected="true"] { background: #eff6ff; }List item anatomy
/* Three-zone layout: left (avatar/icon) | content (title+desc) | right (meta/action) */
.list-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; min-height: 64px; }
.list-item__left { flex: 0 0 40px; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
.list-item__content { flex: 1; min-width: 0; } /* min-width: 0 enables text truncation */
.list-item__title { font-size: 14px; font-weight: 500; color: #111827; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 0; }
.list-item__desc { font-size: 13px; color: #6b7280; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 2px 0 0; }
.list-item__right { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; font-size: 12px; color: #9ca3af; }Infinite scroll
const sentinel = document.querySelector('#load-more-sentinel');
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isLoading && !isExhausted) loadMore();
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);.load-sentinel { height: 1px; width: 100%; }
.load-more-indicator { text-align: center; padding: 20px; font-size: 14px; color: #6b7280; }
.load-end-message { text-align: center; padding: 24px; font-size: 13px; color: #9ca3af; border-top: 1px solid #f3f4f6; }Virtualized lists
- Only render visible items + buffer (3-5 above/below viewport)
- Libraries:
react-virtual,@tanstack/virtual - Virtualize when: >100 items or complex per-item rendering
- Set explicit
itemHeightfor best performance
Load more patterns
| Pattern | Best for |
|---|---|
| "Load more" button | Search results, directories - user-controlled, better SEO |
| Pagination | Data tables, admin views - predictable, bookmarkable |
| Infinite scroll | Social feeds, media galleries - passive consumption |
.btn-load-more { display: block; margin: 24px auto 0; padding: 10px 24px; font-size: 14px; font-weight: 500; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; color: #374151; cursor: pointer; transition: background 150ms ease, border-color 150ms ease; }
.btn-load-more:hover { background: #f9fafb; border-color: #9ca3af; }Card hover effects - pick ONE, do not combine
/* 1. Shadow increase */
.card--hover-shadow { box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: box-shadow 200ms ease; }
.card--hover-shadow:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.08); }
/* 2. Lift */
.card--hover-lift { transition: transform 200ms ease, box-shadow 200ms ease; }
.card--hover-lift:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
/* 3. Image zoom - scale inside overflow:hidden */
.card--hover-zoom .card__image-wrap { overflow: hidden; }
.card--hover-zoom .card__image { transition: transform 300ms ease; }
.card--hover-zoom:hover .card__image { transform: scale(1.03); }
/* 4. Border color */
.card--hover-border { border: 1px solid #e5e7eb; transition: border-color 200ms ease; }
.card--hover-border:hover { border-color: #3b82f6; }Card skeleton loading
@keyframes skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.skeleton-card { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.skeleton-card__image { width: 100%; aspect-ratio: 16 / 9; background: #e5e7eb; animation: skeleton-pulse 1.5s ease-in-out infinite; }
.skeleton-card__body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 10px; }
.skeleton-bar { height: 12px; border-radius: 4px; background: #e5e7eb; animation: skeleton-pulse 1.5s ease-in-out infinite; }
.skeleton-bar--title { height: 16px; width: 60%; }
.skeleton-bar--desc-1 { width: 100%; }
.skeleton-bar--desc-2 { width: 80%; }
.skeleton-bar--meta { height: 10px; width: 40%; }Empty collection state
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; color: #6b7280; }
.empty-state__illustration { width: 80px; height: 80px; margin-bottom: 16px; opacity: 0.5; }
.empty-state__title { font-size: 16px; font-weight: 600; color: #374151; margin: 0 0 6px; }
.empty-state__body { font-size: 14px; color: #6b7280; margin: 0 0 20px; max-width: 320px; }Scenarios: No items yet (CTA to add), no search results (adjust filters), error loading (retry button).
Responsive card behavior
@media (max-width: 640px) {
.card-grid { grid-template-columns: 1fr; }
.card--horizontal { flex-direction: column; }
.card--horizontal .card__image { width: 100%; height: 160px; }
/* Horizontal scroll carousel on mobile */
.card-carousel { display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
.card-carousel::-webkit-scrollbar { display: none; }
.card-carousel > .card { flex: 0 0 280px; scroll-snap-align: start; }
}Common card/list mistakes
- Varying card heights in a grid - use
min-heighton card body or fixedaspect-ratiofor images - No hover state on interactive cards - every clickable card must have a visible hover effect
- Clickable card with links inside - nested interactive elements break accessibility
- No loading state - content popping in without skeletons feels broken
- Too much text on cards - use
-webkit-line-clamp: 3to enforce 2-3 line max - Inconsistent card padding - pick one value and apply everywhere
- No empty state - a blank grid leaves users confused
color-and-theming.md
Color and Theming
The 60-30-10 Rule
- 60% dominant - background, neutral surfaces
- 30% secondary - cards, sidebars, secondary surfaces
- 10% accent - CTAs, active states, links
:root {
/* 60% - dominant backgrounds */
--color-bg-primary: #f8fafc; /* slate-50 */
--color-bg-secondary: #f1f5f9; /* slate-100 */
/* 30% - secondary surfaces */
--color-surface-card: #ffffff;
--color-surface-sidebar: #f1f5f9;
/* 10% - accent */
--color-accent: #4f46e5; /* indigo-600 */
--color-accent-hover: #4338ca; /* indigo-700 */
}Building a Palette
Start with ONE brand color and generate 10 shades using HSL manipulation.
Strategy:
- Fix the hue (e.g.,
243for indigo) - 50-400: keep saturation moderate, increase lightness toward 100%
- 500: base color, full saturation
- 600-900: increase saturation slightly, decrease lightness toward 10%
HSL formulas for a brand hue of 243:
| Shade | HSL | Hex | Usage |
|---|---|---|---|
| 50 | hsl(243, 60%, 97%) |
#f5f3ff |
Page backgrounds, tints |
| 100 | hsl(243, 65%, 93%) |
#ede9fe |
Hover backgrounds |
| 200 | hsl(243, 68%, 85%) |
#c4b5fd |
Focus rings, subtle fill |
| 300 | hsl(243, 72%, 74%) |
#a78bfa |
Decorative, disabled icons |
| 400 | hsl(243, 76%, 63%) |
#818cf8 |
Links in dark mode |
| 500 | hsl(243, 80%, 55%) |
#6366f1 |
Base brand color |
| 600 | hsl(243, 83%, 47%) |
#4f46e5 |
Primary CTA, links |
| 700 | hsl(243, 86%, 40%) |
#4338ca |
Hover state on CTA |
| 800 | hsl(243, 88%, 30%) |
#3730a3 |
Active/pressed state |
| 900 | hsl(243, 90%, 20%) |
#312e81 |
Text on light backgrounds |
Neutral gray (tinted, not pure): use your brand hue at 3-5% saturation.
/* Pure gray: hsl(0, 0%, L%) - AVOID */
/* Tinted neutral: hsl(243, 4%, L%) - USE THIS */
--neutral-50: hsl(243, 4%, 98%); /* #f8f8fb */
--neutral-100: hsl(243, 4%, 95%); /* #f1f1f6 */
--neutral-200: hsl(243, 4%, 89%); /* #e2e2ea */
--neutral-300: hsl(243, 4%, 78%); /* #c5c5d0 */
--neutral-400: hsl(243, 4%, 63%); /* #9e9eac */
--neutral-500: hsl(243, 4%, 48%); /* #787885 */
--neutral-600: hsl(243, 4%, 36%); /* #5a5a65 */
--neutral-700: hsl(243, 5%, 26%); /* #403f4c */
--neutral-800: hsl(243, 6%, 16%); /* #272631 */
--neutral-900: hsl(243, 7%, 10%); /* #17161f */Semantic Color Tokens
Map palette values to semantic names. Never use raw palette tokens in components - always go through semantic tokens.
:root {
/* Backgrounds */
--color-bg-primary: #f8f8fb; /* neutral-50 */
--color-bg-secondary: #f1f1f6; /* neutral-100 */
--color-bg-tertiary: #e2e2ea; /* neutral-200 */
/* Text */
--color-text-primary: #17161f; /* neutral-900 */
--color-text-secondary: #5a5a65; /* neutral-600 */
--color-text-tertiary: #9e9eac; /* neutral-400 */
/* Borders */
--color-border: #e2e2ea; /* neutral-200 */
--color-border-strong: #c5c5d0; /* neutral-300 */
/* Status */
--color-success: #16a34a; /* green-600 */
--color-warning: #d97706; /* amber-600 */
--color-error: #dc2626; /* red-600 */
--color-info: #2563eb; /* blue-600 */
}Dark Mode Implementation
Rules:
- NEVER just invert colors - inversion breaks hue relationships and contrast
- Use dark blue-grays for backgrounds, not pure black (
#000000) - Reduce text brightness to
#f1f5f9, not#ffffff(prevents eye strain) - Increase shadow opacity from
0.1to0.3-0.5 - Use lighter border colors - borders need more contrast on dark surfaces
- Slightly reduce image brightness:
filter: brightness(0.9) contrast(0.95)
Dark backgrounds to use:
#0f172a(slate-950) - deepest background layer#1e293b(slate-800) - primary page background#334155(slate-700) - cards, elevated surfaces#475569(slate-600) - hover states, secondary surfaces
[data-theme="dark"],
@media (prefers-color-scheme: dark) {
/* Backgrounds */
--color-bg-primary: #1e293b; /* slate-800 */
--color-bg-secondary: #0f172a; /* slate-950 */
--color-bg-tertiary: #334155; /* slate-700 */
/* Text - reduced brightness, never pure white */
--color-text-primary: #f1f5f9; /* slate-100 */
--color-text-secondary: #94a3b8; /* slate-400 */
--color-text-tertiary: #64748b; /* slate-500 */
/* Borders - more visible on dark */
--color-border: #334155; /* slate-700 */
--color-border-strong: #475569; /* slate-600 */
/* Status - shift to lighter shades for contrast on dark */
--color-success: #4ade80; /* green-400 */
--color-warning: #fbbf24; /* amber-400 */
--color-error: #f87171; /* red-400 */
--color-info: #60a5fa; /* blue-400 */
/* Shadows need more opacity in dark mode */
--shadow-sm: 0 1px 2px hsl(0 0% 0% / 0.4);
--shadow-md: 0 4px 6px hsl(0 0% 0% / 0.45);
--shadow-lg: 0 10px 15px hsl(0 0% 0% / 0.5);
/* Slightly dim images */
img {
filter: brightness(0.9) contrast(0.95);
}
}Theme Switching
Structure: :root defines light defaults, [data-theme="dark"] overrides, @media is the fallback.
/* 1. Light defaults on :root */
:root {
--color-bg-primary: #f8f8fb;
--color-text-primary: #17161f;
/* ... full token set */
}
/* 2. Manual dark override via data attribute */
[data-theme="dark"] {
--color-bg-primary: #1e293b;
--color-text-primary: #f1f5f9;
/* ... full dark token set */
}
/* 3. System preference fallback (no JS required) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg-primary: #1e293b;
--color-text-primary: #f1f5f9;
/* ... full dark token set */
}
}// Theme toggle - store preference, respect system default
const STORAGE_KEY = 'theme-preference';
function getPreferredTheme() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
}
// On page load - apply before paint to prevent flash
applyTheme(getPreferredTheme());
// Listen for system changes when no manual preference is set
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem(STORAGE_KEY)) {
applyTheme(e.matches ? 'dark' : 'light');
}
});Contrast and Readability
WCAG requirements:
- AA (minimum): 4.5:1 for normal text, 3:1 for large text (18px+ regular, or 14px+ bold)
- AAA (enhanced): 7:1 for normal text, 4.5:1 for large text
Perceptually uniform manipulation: use oklch() or oklab() instead of hsl() when fine-tuning accessible contrast - HSL is not perceptually uniform so equal lightness steps look unequal.
/* oklch(lightness chroma hue) - perceptually uniform */
--color-accent-light: oklch(0.95 0.02 264); /* very light */
--color-accent-base: oklch(0.55 0.18 264); /* 4.5:1 on white */
--color-accent-dark: oklch(0.35 0.15 264); /* 7:1 on white */Common failing combos - never use these:
#9ca3af(gray-400) on#ffffff- ratio ~2.7:1, fails AA#fbbf24(amber-400) on#ffffff- ratio ~2.1:1, fails AA#60a5fa(blue-400) on#ffffff- ratio ~3.0:1, fails AA for normal text#d1d5db(gray-300) on#ffffff- ratio ~1.6:1, fails everything
Safe swap: shift to -600 variants in light mode and -400 variants in dark mode to reliably clear AA.
Color in Context
| Context | Light mode | Hex | Dark mode | Hex |
|---|---|---|---|---|
| Links | primary-600 |
#4f46e5 |
primary-400 |
#818cf8 |
| Link hover | primary-700 |
#4338ca |
primary-300 |
#a78bfa |
| Success | green-600 |
#16a34a |
green-400 |
#4ade80 |
| Error | red-600 |
#dc2626 |
red-400 |
#f87171 |
| Warning | amber-600 |
#d97706 |
amber-400 |
#fbbf24 |
| Info | blue-600 |
#2563eb |
blue-400 |
#60a5fa |
| Disabled text | gray-400 |
#9ca3af |
gray-600 |
#4b5563 |
| Disabled bg | gray-100 |
#f3f4f6 |
gray-800 |
#1f2937 |
Notes:
- Success: use
green-600/green-400, never neon green (#00ff00) - Error: use muted red, not bright red -
red-600is#dc2626, not#ff0000 - Warning: amber reads better than yellow - yellow fails contrast on white at any shade
Common Color Mistakes
Too many hues - stick to 1 brand hue + 1 neutral + status colors. More than 3 hues looks unfocused.
Pure gray neutrals -
hsl(0, 0%, 50%)looks flat and disconnected from your brand. Always add 3-5% saturation of your brand hue.Same shade, different purpose - if your border and your disabled text both use
gray-300, they will compete. Assign distinct shades to distinct roles.Skipping contrast checks - use the browser DevTools accessibility panel or whocanuse.com before shipping. Check all state variants (hover, focus, disabled).
Dark mode as afterthought - if you define tokens once and invert at the end, you will break contrast relationships. Define dark mode tokens alongside light from the start.
Hardcoding hex in components - always reference
--color-text-primary, never#17161fdirectly. Tokens are the contract; components consume them.
dashboards.md
Dashboards
Dashboard layout
- Sidebar (240-280px) + main content area
- Main content: top bar (filters, date range, title) + grid of widgets
- Widget grid: CSS Grid with auto-fill, varied sizes
- Max content width: 1440px; padding: 24-32px around content area
.dashboard-shell { display: flex; min-height: 100vh; background-color: #f4f5f7; }
.dashboard-sidebar {
width: 260px;
flex-shrink: 0;
background-color: #1e2235;
color: #c8ccd8;
display: flex;
flex-direction: column;
transition: width 0.2s ease;
}
.dashboard-sidebar.collapsed { width: 64px; }
.dashboard-main { flex: 1; min-width: 0; display: flex; flex-direction: column; max-width: 1440px; }
.dashboard-content { padding: 24px 32px; flex: 1; }KPI / stat cards
- Number: 28-32px, font-weight 700, color #111827
- Label: 12px, color #6b7280, font-weight 600, uppercase + letter-spacing
- Trend: green (#16a34a) up, red (#dc2626) down; grid: repeat(4,1fr) desktop
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 28px; }
.kpi-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.kpi-label { font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.06em; }
.kpi-value { font-size: 30px; font-weight: 700; color: #111827; line-height: 1; }
.kpi-trend { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; }
.kpi-trend--up { color: #16a34a; }
.kpi-trend--down { color: #dc2626; }
.kpi-trend__label { font-size: 12px; color: #9ca3af; margin-left: 4px; font-weight: 400; }Chart containers
- Title: 16px font-weight 600, subtitle: 13px #6b7280; chart area: min-height 200px
- Same border/radius as KPI cards; skeleton on load, empty state when no data
.chart-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-card__header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
.chart-card__title { font-size: 16px; font-weight: 600; color: #111827; }
.chart-card__subtitle { font-size: 13px; color: #6b7280; margin-top: 2px; }
.chart-card__area { min-height: 200px; flex: 1; }
.chart-card__legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; font-size: 12px; color: #6b7280; }
.chart-card__legend-item { display: flex; align-items: center; gap: 6px; }
.chart-card__legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.chart-skeleton {
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: skeleton-sweep 1.4s ease infinite;
border-radius: 8px;
min-height: 200px;
}
@keyframes skeleton-sweep {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.chart-empty { min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #9ca3af; font-size: 14px; }Data widgets
- Activity list: 36px avatar, 13px description, 11px timestamp #9ca3af
- Notification: unread dot 8px #3b82f6; "View all" link 13px #3b82f6 at bottom
.activity-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 0; border-bottom: 1px solid #f3f4f6; }
.activity-item:last-child { border-bottom: none; padding-bottom: 0; }
.activity-avatar { width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; background-color: #e5e7eb; overflow: hidden; }
.activity-body { flex: 1; min-width: 0; }
.activity-description { font-size: 13px; color: #374151; line-height: 1.4; }
.activity-description strong { font-weight: 600; color: #111827; }
.activity-timestamp { font-size: 11px; color: #9ca3af; margin-top: 2px; }
.notification-unread-dot { width: 8px; height: 8px; border-radius: 50%; background-color: #3b82f6; flex-shrink: 0; margin-top: 5px; }
.widget-footer-link { display: block; text-align: center; font-size: 13px; font-weight: 500; color: #3b82f6; padding-top: 12px; border-top: 1px solid #f3f4f6; margin-top: 8px; text-decoration: none; }
.widget-footer-link:hover { color: #2563eb; }Filter bar
- Horizontal bar, sticky top, 48px height, background #fff, border-bottom #e5e7eb
- Applied filter chips: background #eff6ff, color #1d4ed8, border #bfdbfe, border-radius 8px
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 32px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 20;
}
.filter-chip { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; background-color: #eff6ff; color: #1d4ed8; font-size: 12px; font-weight: 500; border-radius: 8px; border: 1px solid #bfdbfe; white-space: nowrap; }
.filter-chip__remove { width: 14px; height: 14px; border-radius: 50%; cursor: pointer; opacity: 0.6; transition: opacity 0.15s; }
.filter-chip__remove:hover { opacity: 1; }
.filter-clear-all { font-size: 13px; color: #6b7280; background: none; border: none; cursor: pointer; text-decoration: underline; }
.filter-clear-all:hover { color: #374151; }Date range controls
- Presets: Today / Last 7d / Last 30d / This month / Custom
- Active preset: background #1d4ed8 color #fff; inactive: border #d1d5db
.date-range-selector { display: flex; align-items: center; gap: 4px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 3px; }
.date-range-preset { font-size: 12px; font-weight: 500; color: #374151; padding: 5px 10px; border-radius: 6px; border: none; background: transparent; cursor: pointer; white-space: nowrap; transition: background 0.15s, color 0.15s; }
.date-range-preset:hover { background: #e5e7eb; }
.date-range-preset.active { background: #1d4ed8; color: #fff; }
.date-range-custom-inputs input[type="date"] { font-size: 12px; border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; color: #111827; }Dashboard grid patterns
/* Equal auto-fill grid */
.widget-grid--equal { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
/* Mixed-size grid */
.widget-grid--mixed { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: minmax(180px, auto); gap: 20px; }
.widget--wide { grid-column: span 2; }
.widget--tall { grid-row: span 2; }
.widget--full { grid-column: 1 / -1; }
/* Named areas (3-col) */
.widget-grid--named {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
grid-template-areas:
"kpi1 kpi2 kpi3"
"chart chart activity"
"table table map";
}
.widget--kpi1 { grid-area: kpi1; } .widget--kpi2 { grid-area: kpi2; } .widget--kpi3 { grid-area: kpi3; }
.widget--chart { grid-area: chart; } .widget--activity { grid-area: activity; }
.widget--table { grid-area: table; } .widget--map { grid-area: map; }
/* Masonry with fixed row tracks */
.widget-grid--masonry { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: 80px; gap: 16px; }
.widget--masonry-sm { grid-row: span 2; }
.widget--masonry-md { grid-row: span 3; }
.widget--masonry-lg { grid-row: span 4; }Responsive dashboard
@media (max-width: 1023px) {
.dashboard-sidebar { width: 64px; }
.dashboard-sidebar .nav-label { display: none; }
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.widget-grid--mixed { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 767px) {
.dashboard-shell { flex-direction: column; }
.dashboard-sidebar { width: 100%; height: 56px; flex-direction: row; align-items: center; padding: 0 16px; }
.dashboard-content { padding: 16px; }
.kpi-grid { grid-template-columns: 1fr; }
.kpi-grid--scroll-mobile { display: flex; overflow-x: auto; gap: 12px; padding-bottom: 8px; scrollbar-width: none; }
.kpi-grid--scroll-mobile::-webkit-scrollbar { display: none; }
.kpi-grid--scroll-mobile .kpi-card { min-width: 180px; flex-shrink: 0; }
.widget-grid--mixed { grid-template-columns: 1fr; }
.widget--wide, .widget--tall { grid-column: span 1; grid-row: span 1; }
.filter-bar { padding: 8px 16px; }
}Real-time updates
@keyframes flash-positive {
0%, 60% { background-color: #dcfce7; color: #15803d; }
100% { background-color: transparent; color: inherit; }
}
@keyframes flash-negative {
0%, 60% { background-color: #fee2e2; color: #b91c1c; }
100% { background-color: transparent; color: inherit; }
}
.kpi-value--updated-up { animation: flash-positive 1.2s ease forwards; border-radius: 4px; padding: 0 4px; }
.kpi-value--updated-down { animation: flash-negative 1.2s ease forwards; border-radius: 4px; padding: 0 4px; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.live-indicator { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: #9ca3af; }
.live-indicator__dot { width: 7px; height: 7px; border-radius: 50%; background-color: #22c55e; animation: pulse-dot 2s ease-in-out infinite; }
.updated-label { font-size: 12px; color: #9ca3af; }Dashboard header
- Height: 60px; sticky top z-index 30; left: title 20px/700 + breadcrumb; right: action buttons + 32px avatar
.dashboard-header { height: 60px; display: flex; align-items: center; justify-content: space-between; padding: 0 32px; background: #fff; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; position: sticky; top: 0; z-index: 30; }
.dashboard-header__title { font-size: 20px; font-weight: 700; color: #111827; line-height: 1; }
.dashboard-header__breadcrumb { font-size: 12px; color: #6b7280; display: flex; align-items: center; gap: 4px; }
.dashboard-header__action-btn { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; color: #374151; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 6px 14px; cursor: pointer; transition: background 0.15s; }
.dashboard-header__action-btn:hover { background: #f3f4f6; }
.dashboard-header__avatar { width: 32px; height: 32px; border-radius: 50%; background-color: #e5e7eb; cursor: pointer; overflow: hidden; }Empty / error states
.dashboard-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80px 24px; text-align: center; gap: 12px; }
.dashboard-empty__icon { width: 48px; height: 48px; color: #d1d5db; }
.dashboard-empty__heading { font-size: 20px; font-weight: 600; color: #111827; }
.dashboard-empty__description { font-size: 14px; color: #6b7280; max-width: 360px; line-height: 1.5; }
.dashboard-empty__cta { margin-top: 8px; padding: 10px 20px; font-size: 14px; font-weight: 600; background: #1d4ed8; color: #fff; border-radius: 8px; border: none; cursor: pointer; }
.dashboard-empty__cta:hover { background: #1e40af; }
.widget-error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 140px; color: #9ca3af; font-size: 13px; }
.widget-error__retry { font-size: 13px; font-weight: 500; color: #3b82f6; background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
.widget-error__retry:hover { background: #eff6ff; }Common dashboard mistakes
- Too many KPIs (max 4-6 on first view)
- Charts without context (no comparison, no trend)
- No loading states per widget (whole-page spinner is bad)
- Fixed layouts that break on tablet
- Tiny text to fit more data (use scrollable widgets instead)
- No date range context shown to user
design-tokens.md
Design Tokens and Theming Architecture
What are design tokens
- Named values for design decisions (colors, spacing, typography, shadows)
- Single source of truth - change once, update everywhere
- Enable theming (light/dark, multi-brand); in CSS: custom properties
Token naming convention
Three-tier naming:
- Global/primitive: raw values (
--blue-500: #3b82f6) - Semantic/alias: purpose-based (
--color-primary: var(--blue-500)) - Component: scoped (
--btn-bg: var(--color-primary))
Rules: always use semantic tokens in components (never primitives directly). Pattern: --{category}-{property}-{variant}-{state}
Complete token system
Color tokens - Primitives
:root {
/* Gray scale */
--gray-50: #f9fafb; --gray-100: #f3f4f6; --gray-200: #e5e7eb; --gray-300: #d1d5db;
--gray-400: #9ca3af; --gray-500: #6b7280; --gray-600: #4b5563; --gray-700: #374151;
--gray-800: #1f2937; --gray-900: #111827;
/* Primary (indigo) */
--primary-50: #eef2ff; --primary-100: #e0e7ff; --primary-200: #c7d2fe;
--primary-300: #a5b4fc; --primary-400: #818cf8; --primary-500: #6366f1;
--primary-600: #4f46e5; --primary-700: #4338ca; --primary-800: #3730a3; --primary-900: #312e81;
/* Red */
--red-50: #fef2f2; --red-100: #fee2e2; --red-400: #f87171;
--red-500: #ef4444; --red-600: #dc2626; --red-700: #b91c1c;
/* Green */
--green-50: #f0fdf4; --green-100: #dcfce7; --green-400: #4ade80;
--green-500: #22c55e; --green-600: #16a34a; --green-700: #15803d;
/* Amber */
--amber-50: #fffbeb; --amber-100: #fef3c7; --amber-400: #fbbf24;
--amber-500: #f59e0b; --amber-600: #d97706; --amber-700: #b45309;
/* Blue */
--blue-50: #eff6ff; --blue-100: #dbeafe; --blue-400: #60a5fa;
--blue-500: #3b82f6; --blue-600: #2563eb; --blue-700: #1d4ed8;
}Color tokens - Semantic (light theme)
:root {
--color-bg-primary: #ffffff; --color-bg-secondary: var(--gray-50); --color-bg-tertiary: var(--gray-100);
--color-text-primary: var(--gray-900); --color-text-secondary: var(--gray-600);
--color-text-tertiary: var(--gray-400); --color-text-inverted: #ffffff;
--color-border: var(--gray-200); --color-border-strong: var(--gray-400);
--color-ring: var(--primary-500);
--color-surface: #ffffff; --color-surface-elevated: #ffffff;
--color-interactive-primary: var(--primary-600);
--color-interactive-primary-hover: var(--primary-700);
--color-interactive-primary-active: var(--primary-800);
--color-interactive-destructive: var(--red-600);
--color-interactive-destructive-hover: var(--red-700);
--color-success: var(--green-600); --color-success-bg: var(--green-50); --color-success-text: var(--green-700);
--color-warning: var(--amber-500); --color-warning-bg: var(--amber-50); --color-warning-text: var(--amber-700);
--color-error: var(--red-600); --color-error-bg: var(--red-50); --color-error-text: var(--red-700);
--color-info: var(--blue-600); --color-info-bg: var(--blue-50); --color-info-text: var(--blue-700);
}Color tokens - Dark theme overrides
[data-theme="dark"] {
--color-bg-primary: var(--gray-900); --color-bg-secondary: var(--gray-800); --color-bg-tertiary: var(--gray-700);
--color-text-primary: var(--gray-50); --color-text-secondary: var(--gray-400); --color-text-tertiary: var(--gray-500);
--color-text-inverted: var(--gray-900);
--color-border: var(--gray-700); --color-border-strong: var(--gray-500);
--color-ring: var(--primary-400);
--color-surface: var(--gray-800); --color-surface-elevated: var(--gray-700);
--color-interactive-primary: var(--primary-500);
--color-interactive-primary-hover: var(--primary-400);
--color-interactive-primary-active: var(--primary-300);
--color-interactive-destructive: var(--red-500);
--color-interactive-destructive-hover: var(--red-400);
--color-success: var(--green-400); --color-success-bg: rgba(34,197,94,0.1); --color-success-text: var(--green-400);
--color-warning: var(--amber-400); --color-warning-bg: rgba(245,158,11,0.1); --color-warning-text: var(--amber-400);
--color-error: var(--red-400); --color-error-bg: rgba(239,68,68,0.1); --color-error-text: var(--red-400);
--color-info: var(--blue-400); --color-info-bg: rgba(59,130,246,0.1); --color-info-text: var(--blue-400);
}
/* System dark as fallback when no data-theme attribute */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg-primary: var(--gray-900); --color-bg-secondary: var(--gray-800); --color-bg-tertiary: var(--gray-700);
--color-text-primary: var(--gray-50); --color-text-secondary: var(--gray-400); --color-text-tertiary: var(--gray-500);
--color-border: var(--gray-700); --color-border-strong: var(--gray-500);
--color-surface: var(--gray-800); --color-surface-elevated: var(--gray-700);
--color-interactive-primary: var(--primary-500);
}
}Spacing tokens
:root {
--space-0: 0px; --space-0-5: 2px; --space-1: 4px; --space-1-5: 6px; --space-2: 8px;
--space-3: 12px; --space-4: 16px; --space-5: 20px; --space-6: 24px; --space-8: 32px;
--space-10: 40px; --space-12: 48px; --space-16: 64px; --space-20: 80px; --space-24: 96px;
}Typography tokens
:root {
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.125rem;
--text-xl: 1.25rem; --text-2xl: 1.5rem; --text-3xl: 1.875rem; --text-4xl: 2.25rem; --text-5xl: 3rem;
--font-regular: 400; --font-medium: 500; --font-semibold: 600; --font-bold: 700;
--leading-none: 1; --leading-tight: 1.25; --leading-snug: 1.375; --leading-normal: 1.5;
--leading-relaxed: 1.75; --leading-loose: 2;
--tracking-tighter: -0.05em; --tracking-tight: -0.025em; --tracking-normal: 0em;
--tracking-wide: 0.025em; --tracking-wider: 0.05em; --tracking-widest: 0.1em;
}Shadow, Border, Motion, Z-index tokens
:root {
--shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05);
--shadow-md: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
--shadow-xl: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
--shadow-2xl: 0 25px 50px -12px rgba(0,0,0,0.25);
--shadow-inner: inset 0 2px 4px 0 rgba(0,0,0,0.05);
--radius-none: 0px; --radius-sm: 0.125rem; --radius-md: 0.375rem; --radius-lg: 0.5rem;
--radius-xl: 0.75rem; --radius-2xl: 1rem; --radius-full: 9999px;
--border-width: 1px; --border-width-strong: 2px;
--duration-instant: 0ms; --duration-fast: 100ms; --duration-normal: 200ms;
--duration-slow: 300ms; --duration-slower: 500ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1); --ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--z-base: 0; --z-raised: 1; --z-dropdown: 10; --z-sticky: 20;
--z-overlay: 30; --z-modal: 40; --z-toast: 50; --z-tooltip: 60;
}Theme structure
:rootfor light theme (default);[data-theme="dark"]for explicit toggle@media (prefers-color-scheme: dark)as system fallback (only when nodata-themeattribute)- Only semantic tokens change between themes, primitives stay the same
Multi-brand theming
.brand-a { --color-interactive-primary: #0d9488; --color-interactive-primary-hover: #0f766e; --color-ring: #0d9488; }
.brand-b { --color-interactive-primary: #e11d48; --color-interactive-primary-hover: #be123c; --color-ring: #e11d48; }Keep spacing, typography, motion consistent across brands - only colors change.
CSS file structure
styles/
tokens/ colors.css spacing.css typography.css shadows.css motion.css z-index.css
themes/ light.css dark.css
base.css (CSS reset + global styles using tokens)Tailwind integration
module.exports = {
theme: { extend: {
colors: {
'bg-primary': 'var(--color-bg-primary)', 'bg-secondary': 'var(--color-bg-secondary)',
'text-primary': 'var(--color-text-primary)', 'text-secondary': 'var(--color-text-secondary)',
'border': 'var(--color-border)', 'interactive-primary': 'var(--color-interactive-primary)',
'success': 'var(--color-success)', 'warning': 'var(--color-warning)',
'error': 'var(--color-error)', 'info': 'var(--color-info)',
},
borderRadius: { sm: 'var(--radius-sm)', md: 'var(--radius-md)', lg: 'var(--radius-lg)', xl: 'var(--radius-xl)', full: 'var(--radius-full)' },
boxShadow: { sm: 'var(--shadow-sm)', md: 'var(--shadow-md)', lg: 'var(--shadow-lg)', xl: 'var(--shadow-xl)' },
}},
}Using tokens in components
/* Correct - semantic tokens only */
.card {
background-color: var(--color-surface-elevated);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-md);
color: var(--color-text-primary);
}
/* Wrong - never hardcode or use primitives in components */
.card-bad {
background-color: #ffffff; /* breaks dark mode */
border: 1px solid var(--gray-200); /* use --color-border */
border-radius: 8px; /* use --radius-lg */
}Common token mistakes
- Using primitive tokens directly in components (breaks theming)
- Too many tokens - only token repeated design decisions
- Forgetting to add new semantic tokens to dark theme override
- Not using tokens for z-index (leads to z-index wars)
- Adding dark tokens only to
@mediaquery but not[data-theme="dark"](breaks explicit toggle)
feedback-and-status.md
Feedback and Status
Toast notifications
- Position: bottom-right (desktop), bottom-center (mobile)
- Types: success (green), error (red), warning (amber), info (blue)
- Auto-dismiss: success 3-5s, info 5s, warning 8s, error never (manual dismiss)
- Max visible: 3, stack with 8px gap
- Width: 320-420px, z-index: 50
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column-reverse;
gap: 8px;
z-index: 50;
max-width: 420px;
}
.toast {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-left: 4px solid transparent;
animation: toast-in 200ms ease-out;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.toast--success { border-left-color: #16a34a; }
.toast--error { border-left-color: #dc2626; }
.toast--warning { border-left-color: #d97706; }
.toast--info { border-left-color: #2563eb; }
.toast__message { font-size: 14px; color: #111827; line-height: 1.5; }
.toast__dismiss {
flex-shrink: 0;
color: #9ca3af;
background: none;
border: none;
cursor: pointer;
}Tooltips
- Show delay: 300ms, hide delay: 100ms
- Dark bg (#1f2937), white text, 6px 10px padding, 6px radius
- Max width: 240px, z-index: 40
.tooltip-trigger { position: relative; display: inline-flex; }
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #ffffff;
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
max-width: 240px;
text-align: center;
pointer-events: none;
z-index: 40;
opacity: 0;
transition: opacity 100ms ease;
}
.tooltip-trigger:hover .tooltip { opacity: 1; transition-delay: 300ms; }
/* Arrow */
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #1f2937;
}Modals / Dialogs
- Width: 480px (sm), 640px (md), 800px (lg)
- Max-height: 85vh, scroll internal body
- Close on: X button, Escape, overlay click (non-critical)
- Mobile: bottom sheet or full-screen
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 60;
animation: overlay-in 150ms ease-out;
}
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
.modal {
background: #ffffff;
border-radius: 12px;
width: 100%;
max-width: 640px;
max-height: 85vh;
display: flex;
flex-direction: column;
animation: modal-in 150ms ease-out;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid #f3f4f6;
}
.modal__body { padding: 20px 24px; overflow-y: auto; flex: 1; }
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px 20px;
border-top: 1px solid #f3f4f6;
}
/* Mobile: bottom sheet */
@media (max-width: 640px) {
.modal-overlay { align-items: flex-end; padding: 0; }
.modal { border-radius: 16px 16px 0 0; max-height: 90vh; }
}
body.modal-open { overflow: hidden; }Confirmation dialogs
- Use for destructive actions ONLY (delete, remove, unsubscribe)
- Title: "Delete project?" not "Are you sure?"
- Description: explain the consequence clearly
- Actions: "Cancel" (secondary) + "Delete" (destructive red)
Loading states
Spinner
.spinner {
display: inline-block;
border-radius: 50%;
border: 2px solid #e5e7eb;
border-top-color: #2563eb;
animation: spin 0.8s linear infinite;
}
.spinner--sm { width: 16px; height: 16px; }
.spinner--md { width: 24px; height: 24px; }
.spinner--lg { width: 40px; height: 40px; border-width: 3px; }
@keyframes spin { to { transform: rotate(360deg); } }Always pair spinners with a text label: "Loading messages..." not just a spinner.
Skeleton screens
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.skeleton {
background: #e5e7eb;
border-radius: 4px;
animation: skeleton-pulse 1.5s ease infinite;
}
.skeleton--text { height: 14px; }
.skeleton--title { height: 20px; }
.skeleton--avatar { width: 40px; height: 40px; border-radius: 50%; }
.skeleton--image { height: 180px; border-radius: 8px; }Progress bar
.progress {
height: 4px;
background: #e5e7eb;
border-radius: 9999px;
overflow: hidden;
}
.progress__bar {
height: 100%;
background: #2563eb;
border-radius: 9999px;
transition: width 300ms ease;
}
/* Indeterminate */
.progress--indeterminate .progress__bar {
width: 40%;
animation: progress-slide 1.4s ease infinite;
}
@keyframes progress-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}Empty states
Four types:
- First-use: "No projects yet" + CTA to create
- No results: "No matches" + clear filters button
- Error: "Something went wrong" + retry
- Completed: "All caught up!" + positive message
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 48px 24px;
gap: 12px;
}
.empty-state__icon { width: 48px; height: 48px; color: #9ca3af; }
.empty-state__title { font-size: 16px; font-weight: 600; color: #111827; }
.empty-state__description { font-size: 14px; color: #6b7280; max-width: 320px; }Status badges and indicators
/* Dot indicator */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot--green { background: #16a34a; }
.status-dot--yellow { background: #d97706; }
.status-dot--red { background: #dc2626; }
.status-dot--gray { background: #9ca3af; }
/* Badge pill */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.badge--green { background: #dcfce7; color: #15803d; }
.badge--red { background: #fee2e2; color: #b91c1c; }
.badge--yellow { background: #fef9c3; color: #a16207; }
.badge--gray { background: #f3f4f6; color: #4b5563; }
.badge--blue { background: #dbeafe; color: #1d4ed8; }Inline form validation
.field__input {
border: 1.5px solid #d1d5db;
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
transition: border-color 150ms ease;
}
.field__input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
outline: none;
}
.field--error .field__input { border-color: #dc2626; }
.field--success .field__input { border-color: #16a34a; }
.field__error { font-size: 12px; color: #dc2626; margin-top: 4px; }Validate on blur, not on change. Never use red placeholder text.
Notification badges
.notif-wrapper { position: relative; display: inline-flex; }
.notif-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9999px;
background: #dc2626;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 18px;
text-align: center;
border: 2px solid #ffffff;
}Use "9+" for counts over 9, "99+" for over 99.
Common feedback mistakes
- Using
window.alertinstead of toasts - Modal for non-critical information (use toast instead)
- No loading state (user thinks nothing happened)
- Error messages that don't explain what to do
- Tooltip for essential information (not discoverable enough)
- Success toast for every tiny action (only for meaningful completions)
- Multiple modals stacked (never nest modals)
forms-and-inputs.md
Forms and Inputs
Text input styling
- Height: 40px (md), 36px (sm), 48px (lg); padding: 8px 12px; font-size: 14px
- Border: 1.5px solid #d1d5db, border-radius 6px
- Placeholder: #9ca3af - never as a label replacement
- Focus: primary border + 3px ring (rgba of primary at 0.15)
- States: default, focus, error (red), success (green), disabled (opacity 0.5 + bg #f9fafb)
.input {
display: block;
width: 100%;
height: 40px;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
color: #111827;
background: #ffffff;
border: 1.5px solid #d1d5db;
border-radius: 6px;
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.input::placeholder { color: #9ca3af; }
.input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
.input--sm { height: 36px; font-size: 13px; padding: 6px 10px; }
.input--lg { height: 48px; font-size: 15px; padding: 10px 14px; }
.input--error { border-color: #dc2626; }
.input--error:focus { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15); }
.input--success { border-color: #16a34a; }
.input--success:focus { border-color: #16a34a; box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15); }
.input:disabled, .input--disabled { opacity: 0.5; background: #f9fafb; cursor: not-allowed; }Labels
- Always visible above input (not floating, not placeholder-only)
- Font size: 14px, font-weight 500; gap 4-6px between label and input
- Always associate with
for=attribute
.field { display: flex; flex-direction: column; gap: 5px; }
.label { font-size: 14px; font-weight: 500; color: #111827; line-height: 1.4; }
.label__required { color: #dc2626; margin-left: 3px; }
.label__optional { font-size: 12px; font-weight: 400; color: #9ca3af; margin-left: 4px; }
.field__error-message { font-size: 12px; color: #dc2626; margin-top: 4px; display: flex; align-items: center; gap: 4px; }Select / dropdown
.select-wrapper { position: relative; display: inline-block; width: 100%; }
.select {
display: block;
width: 100%;
height: 40px;
padding: 8px 36px 8px 12px;
font-size: 14px;
color: #111827;
background: #ffffff;
border: 1.5px solid #d1d5db;
border-radius: 6px;
outline: none;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.select:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
.select:disabled { opacity: 0.5; background: #f9fafb; cursor: not-allowed; }
.select-wrapper::after {
content: '';
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236b7280'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: contain;
pointer-events: none;
}Checkboxes and radios
- Size: 18px; checked: primary fill (checkbox) or primary dot (radio); focus ring 3px
- Use
<fieldset>+<legend>for groups; 10px gap between items
.checkbox-label, .radio-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: #111827;
user-select: none;
}
.checkbox-input, .radio-input { position: absolute; opacity: 0; width: 0; height: 0; }
.checkbox-control {
flex-shrink: 0;
width: 18px;
height: 18px;
border: 1.5px solid #d1d5db;
border-radius: 4px;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease;
}
.checkbox-input:checked + .checkbox-control { background: #4f46e5; border-color: #4f46e5; }
.checkbox-input:checked + .checkbox-control::after {
content: '';
width: 10px; height: 6px;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(-45deg) translateY(-1px);
display: block;
}
.checkbox-input:focus-visible + .checkbox-control { box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2); }
.radio-control {
flex-shrink: 0;
width: 18px;
height: 18px;
border: 1.5px solid #d1d5db;
border-radius: 9999px;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.radio-input:checked + .radio-control { border-color: #4f46e5; }
.radio-input:checked + .radio-control::after { content: ''; width: 8px; height: 8px; border-radius: 9999px; background: #4f46e5; display: block; }
.radio-input:focus-visible + .radio-control { box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2); }
fieldset.field-group { border: none; padding: 0; margin: 0; }
fieldset.field-group legend { font-size: 14px; font-weight: 500; color: #111827; margin-bottom: 8px; }
.field-group__items { display: flex; flex-direction: column; gap: 10px; }Toggle / switch
- Width 44px, height 24px, thumb 20px; track: #d1d5db off, #4f46e5 on; transition 200ms
.toggle-label { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; font-size: 14px; color: #111827; }
.toggle-input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle-track { position: relative; width: 44px; height: 24px; border-radius: 9999px; background: #d1d5db; transition: background 200ms ease; flex-shrink: 0; }
.toggle-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 9999px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.2); transition: transform 200ms ease; }
.toggle-input:checked + .toggle-track { background: #4f46e5; }
.toggle-input:checked + .toggle-track::after { transform: translateX(20px); }
.toggle-input:focus-visible + .toggle-track { box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2); }
.toggle-input:disabled + .toggle-track { opacity: 0.5; cursor: not-allowed; }Textarea
.textarea {
display: block;
width: 100%;
min-height: 80px;
padding: 8px 12px;
font-size: 14px;
font-family: inherit;
line-height: 1.5;
color: #111827;
background: #ffffff;
border: 1.5px solid #d1d5db;
border-radius: 6px;
outline: none;
resize: vertical;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.textarea:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
.textarea::placeholder { color: #9ca3af; }
/* Auto-resize via JS: set height to scrollHeight on input */Search input
.search-wrapper { position: relative; display: flex; align-items: center; width: 100%; }
.search-icon { position: absolute; left: 12px; width: 16px; height: 16px; color: #9ca3af; pointer-events: none; }
.search-input {
width: 100%;
height: 40px;
padding: 8px 36px 8px 36px;
font-size: 14px;
color: #111827;
background: #f9fafb;
border: 1.5px solid #d1d5db;
border-radius: 9999px; /* use 6px to match system */
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
}
.search-input:focus { background: #fff; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
.search-clear { position: absolute; right: 10px; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border: none; background: none; cursor: pointer; color: #9ca3af; border-radius: 9999px; padding: 0; }
.search-clear:hover { color: #374151; }
.search-clear:not([data-visible]) { display: none; }File upload
.file-input-native { position: absolute; opacity: 0; width: 0; height: 0; }
.file-trigger-btn { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 16px; font-size: 14px; font-weight: 500; color: #4f46e5; background: #eef2ff; border: 1.5px solid #c7d2fe; border-radius: 6px; cursor: pointer; transition: background 150ms ease; }
.file-trigger-btn:hover { background: #e0e7ff; border-color: #a5b4fc; }
.drop-zone { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 32px 24px; border: 2px dashed #d1d5db; border-radius: 8px; background: #f9fafb; text-align: center; cursor: pointer; transition: border-color 150ms ease, background 150ms ease; }
.drop-zone:hover, .drop-zone--drag-over { border-color: #6366f1; background: #eef2ff; }
.drop-zone__text { font-size: 14px; color: #6b7280; }
.drop-zone__text strong { color: #4f46e5; }Form layout patterns
.form { max-width: 480px; width: 100%; }
.form__fields { display: flex; flex-direction: column; gap: 18px; }
.form__section + .form__section { margin-top: 32px; }
.form__row--2col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form__row--side-label { display: grid; grid-template-columns: 160px 1fr; gap: 12px; align-items: center; }
.form__row--side-label .label { text-align: right; padding-top: 2px; }
.form--inline { display: flex; gap: 8px; align-items: flex-start; }
.form--inline .input { flex: 1; }
.form__actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
@media (max-width: 640px) {
.form__row--2col, .form__row--side-label { grid-template-columns: 1fr; }
.form__row--side-label .label { text-align: left; }
.form__actions { flex-direction: column-reverse; }
.form__actions .btn { width: 100%; }
}Multi-step forms
- Progress: numbered steps or dots; one logical group per step; back button always available
- Validate each step before allowing next; 3-5 steps max
.step-progress { display: flex; align-items: center; gap: 0; margin-bottom: 32px; }
.step-dot { width: 28px; height: 28px; border-radius: 9999px; background: #e5e7eb; color: #6b7280; font-size: 13px; font-weight: 600; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 200ms ease, color 200ms ease; }
.step-dot--active { background: #4f46e5; color: #fff; }
.step-dot--complete { background: #16a34a; color: #fff; }
.step-connector { flex: 1; height: 2px; background: #e5e7eb; transition: background 200ms ease; }
.step-connector--complete { background: #16a34a; }Validation patterns
- Validate on blur, not on keystroke; show success state on valid blur
- Show errors on submit attempt; scroll + focus first error field
input.addEventListener('blur', () => validateField(input));
form.addEventListener('submit', (e) => {
e.preventDefault();
const errors = validateAllFields();
if (errors.length) {
const first = document.querySelector('.input--error');
first?.scrollIntoView({ behavior: 'smooth', block: 'center' });
first?.focus();
return;
}
submitForm();
});.input--success {
border-color: #16a34a;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%2316a34a'%3E%3Cpath d='M3 8l3.5 3.5L13 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px;
padding-right: 34px;
}Common form mistakes
- Placeholder as label - disappears on focus, fails accessibility
- Validating on every keystroke - shows errors before user finishes
- No visible focus states - fails keyboard/accessibility requirements
- Submit button with no loading state - user double-submits
- Showing error states before user has interacted with the field
- No
<fieldset>/<legend>for radio/checkbox groups - Tiny touch targets for checkboxes/radios (minimum 44x44px tap target)
- Missing
for=on labels or unlabeled inputs
grids-spacing-and-layout.md
Grids, Spacing, and Layout
The 8px Spacing Scale
Base unit: 8px. All spacing values are multiples:
| Value | Multiplier | CSS custom property | Tailwind class | Use case |
|---|---|---|---|---|
| 4px | 0.5 | --spacing-1: 4px |
gap-1 |
Icon-text gap, inline elements |
| 8px | 1 | --spacing-2: 8px |
gap-2 |
Form field gaps, list item padding |
| 12px | 1.5 | --spacing-3: 12px |
gap-3 |
Button padding, small card padding |
| 16px | 2 | --spacing-4: 16px |
gap-4 |
Card padding, component gaps |
| 24px | 3 | --spacing-6: 24px |
gap-6 |
Section padding, grid gaps |
| 32px | 4 | --spacing-8: 32px |
gap-8 |
Between components |
| 48px | 6 | --spacing-12: 48px |
gap-12 |
Section breaks |
| 64px | 8 | --spacing-16: 64px |
gap-16 |
Major section gaps |
| 96px | 12 | --spacing-24: 96px |
gap-24 |
Page section separation |
:root {
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-6: 24px;
--spacing-8: 32px;
--spacing-12: 48px;
--spacing-16: 64px;
--spacing-24: 96px;
}Container and Max-Widths
| Context | Max-width | Notes |
|---|---|---|
| Full app layout | 1440px | Outermost shell |
| Content area | 1280px | Main page content |
| Reading content | 720px | 65-75 characters per line |
| Narrow forms | 480px | Login, signup, settings sub-forms |
Always center with margin: 0 auto.
Horizontal padding by breakpoint:
- Mobile (< 640px):
padding-inline: 16px - Tablet (640px - 1023px):
padding-inline: 24px - Desktop (>= 1024px):
padding-inline: 32px
.container {
width: 100%;
max-width: 1280px;
margin-inline: auto;
padding-inline: 16px;
}
@media (min-width: 640px) {
.container { padding-inline: 24px; }
}
@media (min-width: 1024px) {
.container { padding-inline: 32px; }
}
.container--content { max-width: 1280px; }
.container--reading { max-width: 720px; }
.container--narrow { max-width: 480px; }CSS Grid Patterns
1. Auto-fill Responsive Card Grid
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
gap: 24px;
}2. Sidebar + Main (two-column)
.sidebar-main {
display: grid;
grid-template-columns: 280px 1fr;
gap: 32px;
}3. Sidebar + Main + Aside (three-column)
.three-column {
display: grid;
grid-template-columns: 240px 1fr 240px;
gap: 32px;
}4. Dashboard Grid with Named Areas
.dashboard {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 64px 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar content";
min-height: 100vh;
}
.dashboard__sidebar { grid-area: sidebar; }
.dashboard__topbar { grid-area: topbar; }
.dashboard__content { grid-area: content; }5. Equal Three-Column Grid
.equal-columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}Flexbox Patterns
When to use flex vs grid:
- Flex: single-axis layouts (nav items, button groups, inline icon+label combos)
- Grid: two-axis layouts, or when column/row sizing is content-driven across tracks
1. Horizontal Nav Bar
.nav {
display: flex;
align-items: center;
gap: 8px;
padding-inline: 24px;
height: 64px;
}
.nav__logo { margin-inline-end: auto; }2. Card - Image Left, Content Right
.media-card {
display: flex;
align-items: flex-start;
gap: 16px;
}
.media-card__image {
flex: 0 0 120px;
width: 120px;
aspect-ratio: 1;
object-fit: cover;
border-radius: 8px;
}
.media-card__body {
flex: 1 1 0;
min-width: 0; /* prevents text overflow */
}3. Centering (the right way)
/* Absolute center - both axes */
.center-both {
display: flex;
align-items: center;
justify-content: center;
}
/* Vertical center only */
.center-vertical {
display: flex;
align-items: center;
}4. Space-Between Header
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-block: 16px;
}
.page-header__actions {
display: flex;
align-items: center;
gap: 8px;
}Common Layout Patterns
1. Holy Grail (header + sidebar + main + footer)
.holy-grail {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 64px 1fr auto;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
min-height: 100vh;
}
.holy-grail__header { grid-area: header; }
.holy-grail__sidebar { grid-area: sidebar; }
.holy-grail__main { grid-area: main; padding: 32px; }
.holy-grail__footer { grid-area: footer; }2. Dashboard (sidebar nav + top bar + grid content)
.app-shell {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 64px 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar content";
min-height: 100vh;
}
.app-shell__sidebar { grid-area: sidebar; overflow-y: auto; }
.app-shell__topbar { grid-area: topbar; position: sticky; top: 0; z-index: 10; }
.app-shell__content { grid-area: content; padding: 32px; overflow-y: auto; }3. Marketing Page (full-width sections, centered content)
.marketing-section {
width: 100%;
padding-block: 96px;
padding-inline: 32px;
}
.marketing-section__inner {
max-width: 1280px;
margin-inline: auto;
}
.marketing-section--hero .marketing-section__inner {
max-width: 720px;
text-align: center;
}4. Settings Page (sidebar nav + form content)
.settings-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 48px;
max-width: 1024px;
margin-inline: auto;
padding: 48px 32px;
}
.settings-layout__form {
max-width: 480px;
}5. Blog / Docs (narrow centered content)
.prose-layout {
max-width: 720px;
margin-inline: auto;
padding-block: 64px;
padding-inline: 24px;
}
.prose-layout p,
.prose-layout li {
line-height: 1.75;
}Responsive Breakpoints
| Name | Min-width | Typical device |
|---|---|---|
| sm | 640px | Large phone landscape |
| md | 768px | Tablet portrait |
| lg | 1024px | Tablet landscape / small laptop |
| xl | 1280px | Desktop |
| 2xl | 1536px | Large desktop |
Mobile-first rule: Write base styles for mobile (< 640px), then override with min-width media queries.
/* Base: mobile */
.grid { grid-template-columns: 1fr; gap: 16px; }
/* sm */
@media (min-width: 640px) { .grid { grid-template-columns: repeat(2, 1fr); gap: 24px; } }
/* lg */
@media (min-width: 1024px) { .grid { grid-template-columns: repeat(3, 1fr); gap: 32px; } }Gap Consistency Rules
- Same context = same gap. Every card in a grid uses identical gap values.
- Tighter gap = more related:
- 4px - 8px: icon and its label, button icon and text
- 16px: form fields within a group
- 24px: cards in a grid, items in a list
- 48px: between distinct sections on a page
- Vertical rhythm: use a single consistent
gapormargin-bottomvalue for all paragraphs and headings within a content block (1.5rem or 24px is standard).
Common Spacing Mistakes
- Using
margininstead ofgapfor flex/grid child spacing - gap is always preferred. - Different
paddingvalues on cards in the same grid (pick one and use it everywhere). - Arbitrary pixel values not on the 8px scale (e.g.,
padding: 11pxormargin: 22px). - Insufficient whitespace between sections - sections visually merge into one block; use at least 48px-96px between major sections.
- Too-tight padding on mobile - always ensure at least 16px horizontal padding on small screens.
- Mixing
margin-topandmargin-bottomon siblings - pick one direction (prefermargin-bottom) to avoid margin collapse surprises.
images-and-media.md
Images and Media
Avatars
- Sizes: 24px (inline), 32px (list), 40px (card), 48px (profile), 64px (large), 96-128px (hero)
- Always circular; fallback: initials on color background (color from name hash)
- Status dot: 8-12px, bottom-right, border matching bg; Group stack: -8px overlap, "+N" pill
.avatar { display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; overflow: hidden; flex-shrink: 0; font-weight: 600; text-transform: uppercase; color: #fff; background-color: #6366f1; }
.avatar--24 { width: 24px; height: 24px; font-size: 10px; }
.avatar--32 { width: 32px; height: 32px; font-size: 12px; }
.avatar--40 { width: 40px; height: 40px; font-size: 14px; }
.avatar--48 { width: 48px; height: 48px; font-size: 16px; }
.avatar--64 { width: 64px; height: 64px; font-size: 20px; }
.avatar--96 { width: 96px; height: 96px; font-size: 28px; }
.avatar--128 { width: 128px; height: 128px; font-size: 36px; }
.avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-wrapper { position: relative; display: inline-flex; }
.avatar-status { position: absolute; bottom: 1px; right: 1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #fff; background-color: #22c55e; }
.avatar-status--away { background-color: #f59e0b; }
.avatar-status--offline { background-color: #9ca3af; }
.avatar-status--busy { background-color: #ef4444; }
.avatar-group { display: inline-flex; align-items: center; }
.avatar-group .avatar-wrapper { margin-left: -8px; }
.avatar-group .avatar-wrapper:first-child { margin-left: 0; }
.avatar-group .avatar-wrapper:nth-child(1) { z-index: 4; }
.avatar-group .avatar-wrapper:nth-child(2) { z-index: 3; }
.avatar-group .avatar-wrapper:nth-child(3) { z-index: 2; }
.avatar-group__overflow { display: inline-flex; align-items: center; justify-content: center; margin-left: -8px; width: 32px; height: 32px; border-radius: 50%; background-color: #e5e7eb; color: #374151; font-size: 11px; font-weight: 600; border: 2px solid #fff; }Responsive Images
- Always:
max-width: 100%; height: auto; display: block - Use
aspect-ratioCSS to prevent layout shift;srcsetfor resolution switching;<picture>for art direction loading="lazy"for below-fold;loading="eager" fetchpriority="high"for hero
img { max-width: 100%; height: auto; display: block; }
.img-container { position: relative; width: 100%; overflow: hidden; border-radius: 8px; }
.img-container img { width: 100%; height: 100%; object-fit: cover; }Image Aspect Ratios
- 16:9 - video, hero banners; 4:3 - products, blog thumbnails; 1:1 - avatars, galleries; 3:2 - landscape
.ratio-16-9 { aspect-ratio: 16 / 9; }
.ratio-4-3 { aspect-ratio: 4 / 3; }
.ratio-1-1 { aspect-ratio: 1 / 1; }
.ratio-3-2 { aspect-ratio: 3 / 2; }
/* Padding-bottom fallback (older browsers) */
.ratio-16-9-legacy { position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; }
.ratio-16-9-legacy > * { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }Image Placeholders / Loading
/* Blur-up technique */
.blur-up { filter: blur(20px); transition: filter 400ms ease; }
.blur-up.is-loaded { filter: blur(0); }
/* Dominant color placeholder - set background-color inline from extracted color */
.img-placeholder { background-color: #e5e7eb; position: relative; }
/* Skeleton pulse */
@keyframes skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.img-skeleton { background-color: #e5e7eb; border-radius: 4px; animation: skeleton-pulse 1.5s ease-in-out infinite; }Image Galleries
/* Equal grid gallery */
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
.gallery-grid__item { aspect-ratio: 1 / 1; overflow: hidden; border-radius: 4px; cursor: pointer; }
.gallery-grid__item img { width: 100%; height: 100%; object-fit: cover; transition: transform 300ms ease; }
.gallery-grid__item:hover img { transform: scale(1.05); }
/* Masonry gallery (CSS columns) */
.gallery-masonry { columns: 3 200px; column-gap: 8px; }
.gallery-masonry__item { break-inside: avoid; margin-bottom: 8px; border-radius: 4px; overflow: hidden; }
.gallery-masonry__item img { width: 100%; height: auto; display: block; }
/* Lightbox */
.lightbox { position: fixed; inset: 0; z-index: 1000; background-color: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center; }
.lightbox__img { max-width: 90vw; max-height: 90vh; object-fit: contain; border-radius: 4px; }
.lightbox__close { position: absolute; top: 16px; right: 16px; background: none; border: none; color: #fff; font-size: 32px; cursor: pointer; line-height: 1; }
.lightbox__prev, .lightbox__next { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,0.15); border: none; color: #fff; font-size: 24px; padding: 12px 16px; cursor: pointer; border-radius: 4px; transition: background 200ms; }
.lightbox__prev { left: 16px; }
.lightbox__next { right: 16px; }
.lightbox__prev:hover, .lightbox__next:hover { background: rgba(255,255,255,0.3); }Carousels
- Use
scroll-snapfor native smooth behavior; show partial next item (~40px peek) - Auto-play: NO for content carousels; OK for testimonials/backgrounds
- Swipe support is free with scroll-snap;
aria-roledescription="carousel"per slide
.carousel { position: relative; overflow: hidden; }
.carousel__track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
gap: 16px;
padding: 0 16px;
scrollbar-width: none;
}
.carousel__track::-webkit-scrollbar { display: none; }
.carousel__slide { flex: 0 0 calc(100% - 40px); scroll-snap-align: start; border-radius: 8px; overflow: hidden; }
/* Dots */
.carousel__dots { display: flex; justify-content: center; gap: 6px; margin-top: 12px; }
.carousel__dot { width: 8px; height: 8px; border-radius: 50%; background-color: #d1d5db; border: none; padding: 0; cursor: pointer; transition: background-color 200ms, width 200ms; }
.carousel__dot.is-active { background-color: #6366f1; width: 24px; border-radius: 4px; }
/* Arrows */
.carousel__arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; background: rgba(255,255,255,0.9); border: 1px solid #e5e7eb; border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.12); transition: background 200ms; }
.carousel__arrow:hover { background: #fff; }
.carousel__arrow--prev { left: 8px; }
.carousel__arrow--next { right: 8px; }Video
aspect-ratio: 16/9container; poster image before play; lazy load: show<img>poster, swap to<video>on click
.video-container { position: relative; aspect-ratio: 16 / 9; width: 100%; overflow: hidden; border-radius: 8px; background-color: #000; }
.video-container video, .video-container iframe { width: 100%; height: 100%; object-fit: cover; display: block; }
.video-play-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; background-color: rgba(0,0,0,0.25); transition: background-color 200ms; }
.video-play-overlay:hover { background-color: rgba(0,0,0,0.4); }
.video-play-btn { width: 64px; height: 64px; border-radius: 50%; background-color: rgba(255,255,255,0.9); display: flex; align-items: center; justify-content: center; transition: transform 200ms, background-color 200ms; }
.video-play-overlay:hover .video-play-btn { transform: scale(1.1); background-color: #fff; }
.video-play-btn::after { content: ''; display: block; width: 0; height: 0; border-top: 12px solid transparent; border-bottom: 12px solid transparent; border-left: 20px solid #1f2937; margin-left: 4px; }Icons as Images
- SVG inline for scalable, colorable icons (
currentColor); icon sprite for many icons - Size to match text: 16px with 14px text, 20px with 16px text, 24px with 20px text
- Decorative:
aria-hidden="true"; meaningful:role="img"+aria-label
.icon { display: inline-block; flex-shrink: 0; vertical-align: middle; fill: currentColor; }
.icon--16 { width: 16px; height: 16px; }
.icon--20 { width: 20px; height: 20px; }
.icon--24 { width: 24px; height: 24px; }Background Images / Hero
.hero { position: relative; width: 100%; aspect-ratio: 16 / 5; overflow: hidden; display: flex; align-items: flex-end; }
.hero__img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; z-index: 0; }
.hero__overlay { position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.5) 70%, rgba(0,0,0,0.75) 100%); z-index: 1; }
.hero__content { position: relative; z-index: 2; padding: 24px 32px; color: #fff; }Image Optimization
- Format: AVIF > WebP > JPEG (photos); SVG > PNG (graphics)
- Max sizes: hero ~200KB, thumbnails 20-50KB, avatars 5-10KB
- Always set
width+heightto prevent CLS; use CDN with auto-format/resize (Cloudinary, imgix)
Common Image Mistakes
- No
alttext (or meaningless alt like "image") - Missing
width/heightattributes causing layout shift - Loading all images eagerly (lazy load below-fold)
- Using PNG for photos (use JPEG/WebP)
- Carousels that auto-play content users need to read
- Tiny avatars without fallback (broken image icon shown)
- Background images without sufficient text contrast overlay
landing-pages.md
Landing Pages
The proven section order
- Hero (above the fold) - headline + subheadline + CTA + visual
- Social proof (logos, testimonials, numbers)
- Problem/pain statement
- Solution/features (3-4 features with icons)
- How it works (3 steps)
- Detailed features or use cases
- Testimonials/case studies
- Pricing (if applicable)
- FAQ
- Final CTA (repeat hero CTA)
Hero section
- Headline: 1 line, 6-12 words, specific value proposition
- Subheadline: 1-2 lines, supporting detail
- CTA: 1 primary button, optionally 1 secondary
- Visual: screenshot, illustration, or demo video
- Padding: 80-120px vertical
Good headlines: specific + benefit-focused ("Deploy in 30 seconds, not 30 minutes") Bad headlines: vague + feature-focused ("The next-generation deployment platform")
.hero {
padding: 96px 0;
}
.hero__inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
align-items: center;
}
.hero__headline {
font-size: clamp(2rem, 4vw, 3.25rem);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
}
.hero__subheadline {
font-size: 1.125rem;
line-height: 1.6;
color: var(--color-text-secondary);
max-width: 480px;
}
.hero__visual img {
width: 100%;
border-radius: 12px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.12);
}
/* Centered variant */
.hero--centered .hero__inner {
grid-template-columns: 1fr;
text-align: center;
justify-items: center;
}
@media (max-width: 768px) {
.hero { padding: 56px 0; }
.hero__inner { grid-template-columns: 1fr; gap: 40px; }
}CTA design
- Verb + object: "Start free trial", "Get started" - never "Submit" or "Click here"
- Primary CTA: 48px height, primary color, high contrast
- One CTA per section, repeat at bottom
- Urgency without manipulation: "Free 14-day trial, no credit card"
.btn--cta {
height: 48px;
padding: 0 28px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
background: var(--color-primary);
color: #fff;
border: none;
box-shadow: 0 1px 3px rgba(79, 70, 229, 0.4);
transition: background 0.15s ease, transform 0.1s ease;
}
.btn--cta:hover {
background: var(--color-primary-hover);
transform: translateY(-1px);
}Social proof patterns
Logo bar
.logo-bar__logos {
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
flex-wrap: wrap;
}
.logo-bar__logos img {
height: 28px;
filter: grayscale(1) opacity(0.6);
transition: filter 0.2s ease;
}
.logo-bar__logos img:hover {
filter: grayscale(0) opacity(1);
}Testimonial cards
- Quote + name + title + company + avatar, 3 cards in a row (1 on mobile)
.testimonials__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.testimonial-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
.testimonial-card__author {
display: flex;
align-items: center;
gap: 12px;
}
.testimonial-card__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
@media (max-width: 640px) {
.testimonials__grid { grid-template-columns: 1fr; }
}Stats row
.stats__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
text-align: center;
}
.stat__number {
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: 700;
color: var(--color-primary);
}
.stat__label {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-top: 6px;
}Feature sections
Three-column features
.features__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 40px;
}
.feature-card__icon {
width: 48px;
height: 48px;
border-radius: 10px;
background: rgba(79, 70, 229, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
margin-bottom: 16px;
}
.feature-card__title {
font-size: 1.0625rem;
font-weight: 600;
margin-bottom: 8px;
}
.feature-card__description {
font-size: 0.9375rem;
line-height: 1.65;
color: var(--color-text-secondary);
}
@media (max-width: 640px) {
.features__grid { grid-template-columns: 1fr; }
}Alternating feature rows
Image + text, alternating sides. Use order to flip on even rows.
.feature-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 80px;
align-items: center;
padding: 64px 24px;
}
.feature-row:nth-child(even) .feature-row__content { order: 1; }
.feature-row:nth-child(even) .feature-row__visual { order: 0; }
@media (max-width: 768px) {
.feature-row { grid-template-columns: 1fr; gap: 32px; }
}Feature bento grid
.bento__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 220px;
gap: 16px;
}
.bento-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 28px;
display: flex;
flex-direction: column;
justify-content: flex-end;
transition: box-shadow 0.2s ease;
}
.bento-card:hover { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); }
.bento-card--wide { grid-column: span 2; }
.bento-card--tall { grid-row: span 2; }
@media (max-width: 768px) {
.bento__grid { grid-template-columns: 1fr; grid-auto-rows: auto; }
.bento-card--wide, .bento-card--tall { grid-column: span 1; grid-row: span 1; }
}Pricing section
- 2-3 tiers, highlight recommended, annual/monthly toggle
.pricing__cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
max-width: 1100px;
margin: 0 auto;
align-items: start;
}
.pricing-card {
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 32px;
}
.pricing-card--recommended {
border-color: var(--color-primary);
border-width: 2px;
box-shadow: 0 8px 32px rgba(79, 70, 229, 0.12);
position: relative;
}
.pricing-card__badge {
position: absolute;
top: -13px;
left: 50%;
transform: translateX(-50%);
background: var(--color-primary);
color: #fff;
font-size: 0.75rem;
font-weight: 600;
padding: 3px 14px;
border-radius: 99px;
}
.pricing-card__amount {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.pricing-card__features li::before {
content: '\2713';
color: var(--color-primary);
font-weight: 700;
}
@media (max-width: 1024px) {
.pricing__cards { grid-template-columns: 1fr; max-width: 420px; }
}FAQ section - accessible accordion
.faq__list details { border-bottom: 1px solid var(--color-border); }
.faq__list summary {
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
font-weight: 600;
cursor: pointer;
}
.faq__list summary::-webkit-details-marker { display: none; }
.faq__list summary::after {
content: '+';
font-size: 1.25rem;
transition: transform 0.2s ease;
}
.faq__list details[open] summary::after { transform: rotate(45deg); }
.faq__answer { padding: 0 0 20px; line-height: 1.7; color: var(--color-text-secondary); }Page-level section CSS
.section { width: 100%; padding: 96px 0; }
.section__inner { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
.section:nth-child(even) { background: var(--color-surface-subtle, #f9fafb); }
.section__eyebrow {
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-primary);
margin-bottom: 12px;
}
.section__title {
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
}
/* Sticky frosted header */
.lp-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border);
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
}
html { scroll-behavior: smooth; }
@media (max-width: 768px) { .section { padding: 56px 0; } }Common landing page mistakes
- Headline is about the company, not the user's problem
- Too many CTAs competing (pick ONE primary action)
- Wall of text instead of scannable sections
- No social proof
- CTA below the fold with no reason to scroll
- Feature list without benefits (users care about outcomes)
- Stock photos instead of product screenshots
micro-animations.md
Micro-animations and Interactions
Animation principles for UI
- Duration: 150-300ms for most UI transitions. Under 100ms feels instant. Over 500ms feels slow.
- Easing: ease-out for entrances, ease-in for exits, ease-in-out for state changes. NEVER use linear for UI (feels robotic).
- Purpose: every animation must serve a purpose - showing state change, providing feedback, or guiding attention.
- Restraint: animate the minimum needed. If everything moves, nothing stands out.
Timing reference
| Action | Duration | Easing |
|---|---|---|
| Button hover/press | 100-150ms | ease |
| Dropdown open | 150-200ms | ease-out |
| Modal open | 200-250ms | ease-out |
| Modal close | 150-200ms | ease-in |
| Toast enter | 200ms | ease-out |
| Toast exit | 150ms | ease-in |
| Page transition | 200-300ms | ease-in-out |
| Accordion expand | 200-250ms | ease-out |
| Color/bg change | 150ms | ease |
Essential micro-interactions
Button feedback
- Hover: darken bg (150ms), cursor pointer
- Active: scale(0.98) or darken further (100ms)
- Loading: disable + spinner replace
- CSS for button transitions
Hover effects
- Cards: translateY(-2px) + shadow increase
- Links: underline slide-in or color change
- Icons: scale(1.1) or color change
- Images: scale(1.03) inside overflow:hidden container
- CSS examples for each
Toggle/switch
- Thumb slides left/right (200ms ease)
- Background color changes
- Width: 44px, height: 24px, thumb: 20px
- CSS for animated toggle
Accordion/collapse
- Height auto animation using grid-template-rows: 0fr -> 1fr (200ms)
- Chevron icon rotates 180deg
- CSS for accessible animated accordion
Dropdown/popover
- Enter: fade in + slide down 4-8px (150ms ease-out)
- Exit: fade out (100ms ease-in)
- Scale from 0.95 -> 1 for popover feel
- CSS with @keyframes
Tab switching
- Active indicator slides with transform (200ms)
- Content crossfade or slide
- CSS for animated tab indicator
CSS transitions reference
Provide reusable transition custom properties:
:root {
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
}Keyframe animations
Fade in up (for content appearing)
- translateY(16px) + opacity 0 -> translateY(0) + opacity 1
- 200ms ease-out
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}Scale in (for modals, popovers)
- scale(0.95) + opacity 0 -> scale(1) + opacity 1
- 150ms ease-out
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}Pulse (for skeleton loading)
- opacity 1 -> 0.5 -> 1
- 1.5s ease infinite
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}Spin (for loading spinners)
- rotate(0) -> rotate(360deg)
- 0.8s linear infinite
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}Shake (for error feedback)
- translateX(-4px, 4px, -4px, 0)
- 300ms ease
@keyframes shake {
0% { transform: translateX(0); }
25% { transform: translateX(-4px); }
50% { transform: translateX(4px); }
75% { transform: translateX(-4px); }
100% { transform: translateX(0); }
}Slide in from right (for sheets, panels)
- translateX(100%) -> translateX(0)
- 250ms ease-out
@keyframes slide-in-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}Scroll-triggered animations
- Use IntersectionObserver (not scroll events)
- Animate when entering viewport, not every scroll
- Common: fade-in, slide-up, stagger children
- Keep it subtle - large motions on scroll feel gimmicky
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('[data-animate]').forEach((el) => observer.observe(el));[data-animate] {
opacity: 0;
transform: translateY(16px);
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
[data-animate].animate-in {
opacity: 1;
transform: translateY(0);
}Staggered animations
- Children appear one by one with delay increment
- Delay: index * 50-100ms
- Max total stagger: 300-500ms (don't keep users waiting)
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 50ms; }
.list-item:nth-child(3) { animation-delay: 100ms; }
/* Or set via JS: el.style.animationDelay = `${index * 50}ms`; */prefers-reduced-motion
- ALWAYS respect this setting
- Remove animations, keep instant state changes
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Common animation mistakes
- Animating layout properties (width, height, top, left) - use transform instead
- Duration too long (over 300ms for simple interactions)
- Bounce/spring on everything (one bounce effect max per page)
- Animation on page load for every element (pick 1-2 hero elements)
- Not respecting prefers-reduced-motion
- Using linear easing for UI transitions
- Animating color with transition: all (animate specific properties)
microcopy-and-ux-writing.md
Microcopy and UX Writing
Core principles
- Be specific, not vague ("Save changes" not "Submit")
- Be concise (every word must earn its place)
- Use the user's language, not technical jargon
- Lead with the action or benefit
- Be consistent (same action = same word everywhere)
Button labels
- Use verb + noun: "Create project", "Delete account", "Send message"
- Primary actions: specific verb ("Save changes" not "OK")
- Cancel: always "Cancel", never "No" or "Abort"
- Destructive: name the thing being destroyed ("Delete project" not "Delete")
- Loading state: "Saving..." / "Sending..." (present participle)
- Success state: "Saved" / "Sent" (past tense, brief)
Good/bad examples table:
| Bad | Good | Why |
|---|---|---|
| Submit | Save changes | Specific action |
| Click here | Learn more | Describes destination |
| OK | Confirm payment | Names the consequence |
| Yes/No | Keep draft / Delete draft | No ambiguity |
| Send | Send invitation | Names what's being sent |
Error messages
Formula: What happened + Why + What to do next
Good examples:
- "Email address is already in use. Try signing in instead."
- "Password must be at least 8 characters."
- "Connection lost. Check your internet and try again."
- "File too large. Maximum size is 10MB."
Bad examples:
- "Error" (what error?)
- "Invalid input" (what's invalid?)
- "Something went wrong" (with no next step)
- "Error code: 422" (meaningless to users)
Rules:
- Never blame the user ("You entered an invalid email" -> "Please enter a valid email")
- Use plain language, not error codes
- Always provide a next step or recovery action
- Be specific about constraints ("8 characters" not "too short")
Empty states
Formula: What this area is for + Why it's empty + How to fill it
Templates by type:
- First use: "[Thing] you create will appear here. [CTA to create first one]"
- No results: "No [things] match your search. Try different keywords or [clear filters]."
- All done: "You're all caught up! No new [things] to review."
- Error: "Couldn't load [things]. [Retry button]"
Good examples:
- "No projects yet. Create your first project to get started." + [Create project] button
- "No messages match 'design review'. Try a different search."
- "All tasks complete! Enjoy your day."
Form labels and help text
- Label: noun or short noun phrase ("Email address", "Password")
- Help text: below the field, gray, explains constraints or format
- "Must be at least 8 characters with one number"
- "We'll send a confirmation to this address"
- Placeholder: example value only, never as a label
- Good placeholder: "jane@example.com"
- Bad placeholder: "Enter your email" (that's a label)
Confirmation dialogs
Title: Action + object ("Delete this project?") Body: Explain consequence ("This will permanently delete the project and all its data. This action cannot be undone.") Actions: [Cancel] [Delete project] (destructive button names the action)
Never: "Are you sure?" as the title. Be specific about what's happening.
Success messages
- Brief: "Project created" / "Changes saved" / "Invitation sent"
- Include next step if relevant: "Project created. Invite your team to get started."
- Toast for routine actions, full page for milestones (account created, onboarding complete)
Loading text
- Name what's loading: "Loading messages..." not "Loading..."
- For long operations: show progress ("Importing 42 of 128 contacts...")
- Skeleton screens don't need text (the layout itself communicates)
Onboarding copy
- Welcome: "Welcome to [Product]! Let's get you set up." (warm, brief)
- Step descriptions: one sentence per step explaining value, not mechanics
- Skip option: "Skip for now" (implies they can come back)
- Completion: "You're all set! Here's your [dashboard/workspace]."
Tooltip text
- One sentence max, no period
- Explain what the element does, not what it is
- Good: "Copy link to clipboard"
- Bad: "This is the copy button"
Dates and times
- Relative for recent: "Just now", "5 min ago", "2 hours ago", "Yesterday"
- Absolute for older: "Jan 15, 2024" or "Jan 15, 2024 at 3:30 PM"
- Switch from relative to absolute at ~1 week
- Always show full date on hover/tooltip
Numbers and data
- Use commas: 1,234 not 1234
- Abbreviate large numbers: 1.2M, 450K
- Currency: $49.99 (always 2 decimal places)
- Percentages: 42% (no decimal) or 42.5% (one decimal max)
- "0 results" not "no results" when showing counts
Tone guide
- Neutral and helpful for most UI
- Celebratory for achievements/milestones (but not excessive)
- Calm and direct for errors (never alarming)
- Never sarcastic, never cute at the expense of clarity
- Avoid exclamation marks (max 1 per page, for genuine celebration)
Common microcopy mistakes
- Generic button labels ("Submit", "OK", "Click here")
- Error messages without recovery steps
- Empty states with just "No data"
- Placeholder text as the only label
- "Are you sure?" confirmations
- Tech jargon in user-facing text ("null", "404", "invalid parameter")
- Inconsistent terminology (saying "delete" in one place, "remove" in another for the same action)
- All-caps for emphasis (use bold or color instead)
navigation.md
Navigation
Top navigation bar (header)
- Height: 56-64px; sticky top with backdrop-filter blur
- Logo left, nav links center/right, actions far right
- Active link: primary color or underline indicator
.nav-header {
position: sticky;
top: 0;
z-index: 100;
height: 60px;
display: flex;
align-items: center;
padding: 0 24px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.nav-header__logo { margin-right: auto; }
.nav-header__links { display: flex; align-items: center; gap: 4px; }
.nav-header__link { padding: 6px 12px; border-radius: 6px; font-size: 14px; font-weight: 500; color: #6b7280; text-decoration: none; transition: color 0.15s, background 0.15s; }
.nav-header__link:hover { background: #f3f4f6; color: #111827; }
.nav-header__link--active { color: #2563eb; background: #eff6ff; }
.nav-header__actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }Sidebar navigation
- Width: 240-280px, collapsible to 64px (icon-only)
- Active: primary bg tint + primary text, or left border indicator
- Tooltip on hover when collapsed; nested items: indent 16px
.sidebar {
position: fixed;
top: 0; left: 0;
height: 100vh;
width: 260px;
background: #fff;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
transition: width 0.2s ease;
overflow: hidden;
z-index: 50;
}
.sidebar--collapsed { width: 64px; }
.sidebar__brand { height: 60px; padding: 0 16px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; }
.sidebar__brand-label { font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden; opacity: 1; transition: opacity 0.15s; }
.sidebar--collapsed .sidebar__brand-label { opacity: 0; width: 0; }
.sidebar__nav { flex: 1; padding: 12px 8px; overflow-y: auto; }
.sidebar__item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: 8px; font-size: 14px; font-weight: 500; color: #4b5563; cursor: pointer; text-decoration: none; white-space: nowrap; transition: background 0.15s, color 0.15s; position: relative; }
.sidebar__item:hover { background: #f3f4f6; color: #111827; }
.sidebar__item--active { background: #eff6ff; color: #2563eb; }
.sidebar__item--active-border { color: #2563eb; background: #eff6ff; border-left: 3px solid #2563eb; padding-left: 9px; }
.sidebar__item-icon { flex-shrink: 0; width: 20px; height: 20px; }
.sidebar__item-label { opacity: 1; transition: opacity 0.15s; }
.sidebar--collapsed .sidebar__item-label { opacity: 0; width: 0; overflow: hidden; }
.sidebar__item--nested { padding-left: 28px; font-size: 13px; }
/* Tooltip when collapsed */
.sidebar--collapsed .sidebar__item:hover::after {
content: attr(data-label);
position: absolute;
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
background: #1f2937;
color: #fff;
font-size: 12px;
font-weight: 500;
padding: 4px 10px;
border-radius: 6px;
white-space: nowrap;
z-index: 200;
pointer-events: none;
}
.sidebar__footer { padding: 12px 8px; border-top: 1px solid #e5e7eb; flex-shrink: 0; }Horizontal tabs
- Active indicator: bottom border (2-3px) or pill background; height: 40-48px
- Scroll horizontally on overflow (mobile);
aria-role="tablist"+aria-selected
.tabs { display: flex; border-bottom: 1px solid #e5e7eb; position: relative; }
.tab { padding: 10px 16px; font-size: 14px; font-weight: 500; color: #6b7280; cursor: pointer; border: none; background: none; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color 0.15s, border-color 0.15s; white-space: nowrap; }
.tab:hover { color: #374151; }
.tab--active { color: #2563eb; border-bottom-color: #2563eb; }
/* Pill variant */
.tabs--pill { background: #f3f4f6; padding: 4px; border-radius: 10px; gap: 2px; border-bottom: none; }
.tabs--pill .tab { border-radius: 7px; border-bottom: none; margin-bottom: 0; padding: 6px 14px; }
.tabs--pill .tab--active { background: #fff; color: #111827; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }
/* Animated sliding indicator */
.tabs--animated { position: relative; }
.tabs__indicator { position: absolute; bottom: -1px; height: 2px; background: #2563eb; border-radius: 2px 2px 0 0; transition: left 0.2s ease, width 0.2s ease; }
@media (max-width: 640px) {
.tabs { overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; }
.tabs::-webkit-scrollbar { display: none; }
}Vertical tabs (settings pages)
.tabs--vertical { display: flex; gap: 24px; }
.tabs--vertical .tab-list { width: 220px; flex-shrink: 0; display: flex; flex-direction: column; gap: 2px; }
.tabs--vertical .tab { display: flex; align-items: center; padding: 8px 14px; border-radius: 8px; border-bottom: none; margin-bottom: 0; width: 100%; text-align: left; }
.tabs--vertical .tab--active { background: #eff6ff; color: #2563eb; }
.tabs--vertical .tab-panels { flex: 1; min-width: 0; }Breadcrumbs
.breadcrumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; font-size: 13px; color: #6b7280; }
.breadcrumbs__item { display: flex; align-items: center; gap: 4px; }
.breadcrumbs__link { color: #6b7280; text-decoration: none; transition: color 0.15s; }
.breadcrumbs__link:hover { color: #2563eb; text-decoration: underline; }
.breadcrumbs__separator { color: #d1d5db; font-size: 12px; user-select: none; }
.breadcrumbs__current { color: #111827; font-weight: 600; }Pagination
- Show "Showing 1-10 of 243"; current page: primary bg pill
- Max 7 visible page numbers with ellipsis; disabled state at boundaries
.pagination { display: flex; align-items: center; gap: 4px; font-size: 14px; }
.pagination__btn { min-width: 36px; height: 36px; padding: 0 8px; border-radius: 8px; border: 1px solid #e5e7eb; background: #fff; color: #374151; font-size: 14px; font-weight: 500; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s, border-color 0.15s, color 0.15s; }
.pagination__btn:hover:not(:disabled) { background: #f3f4f6; border-color: #d1d5db; }
.pagination__btn--active { background: #2563eb; border-color: #2563eb; color: #fff; }
.pagination__btn:disabled { opacity: 0.4; cursor: not-allowed; }
.pagination__ellipsis { min-width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; color: #9ca3af; user-select: none; }
.pagination__summary { margin-left: 16px; font-size: 13px; color: #6b7280; }Command palette / search modal
- Trigger: Cmd+K / Ctrl+K; max-width 560px; keyboard navigable (arrow keys)
- Grouped results with section headers; highlight matching text; close on Escape
.command-palette-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 300; display: flex; align-items: flex-start; justify-content: center; padding-top: 80px; }
.command-palette { width: 100%; max-width: 560px; background: #fff; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.25); overflow: hidden; }
.command-palette__input-wrap { display: flex; align-items: center; padding: 14px 16px; border-bottom: 1px solid #e5e7eb; gap: 10px; }
.command-palette__input { flex: 1; border: none; outline: none; font-size: 16px; color: #111827; background: transparent; }
.command-palette__results { max-height: 360px; overflow-y: auto; padding: 8px 0; }
.command-palette__group-label { padding: 6px 16px 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: #9ca3af; }
.command-palette__result { display: flex; align-items: center; gap: 10px; padding: 8px 16px; font-size: 14px; color: #374151; cursor: pointer; transition: background 0.1s; }
.command-palette__result:hover, .command-palette__result--focused { background: #f3f4f6; }
.command-palette__result mark { background: #fef08a; color: inherit; border-radius: 2px; padding: 0 1px; }
.command-palette__hint { padding: 10px 16px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; display: flex; gap: 16px; }
.command-palette__hint kbd { background: #f3f4f6; border: 1px solid #d1d5db; border-radius: 4px; padding: 1px 5px; font-size: 11px; color: #4b5563; }Mega menu
.mega-menu-wrap { position: relative; }
.mega-menu {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(4px);
min-width: 640px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.12);
padding: 24px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
z-index: 200;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.mega-menu--open { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
.mega-menu__col-label { font-size: 11px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: #9ca3af; margin-bottom: 10px; }
.mega-menu__link { display: block; padding: 6px 0; font-size: 14px; color: #374151; text-decoration: none; transition: color 0.15s; }
.mega-menu__link:hover { color: #2563eb; }Segmented control
.segmented-control { display: inline-flex; background: #f3f4f6; border-radius: 10px; padding: 3px; gap: 2px; position: relative; }
.segmented-control__option { padding: 6px 16px; font-size: 13px; font-weight: 500; color: #6b7280; border-radius: 8px; cursor: pointer; border: none; background: none; transition: color 0.15s; position: relative; z-index: 1; }
.segmented-control__option--active { color: #111827; }
.segmented-control__thumb { position: absolute; top: 3px; height: calc(100% - 6px); background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); transition: left 0.2s ease, width 0.2s ease; z-index: 0; }Navigation state management
- Active state must reflect current URL/route; use
aria-current="page"for screen readers - Highlight parent nav item when on child page
- Preserve scroll position in sidebar on navigation; URL should always reflect nav state
Common navigation mistakes
- Too many top-level items (max 5-7 in main nav)
- No active state indicator
- Sidebar that doesn't collapse on tablet
- Dropdown menus that close on slight mouse movement (add 100-150ms delay)
- No keyboard navigation support
- Breadcrumbs that don't match actual hierarchy
onboarding.md
Onboarding
First-run experience principles
- Get the user to value as fast as possible (under 60 seconds to first "aha")
- Ask only what you absolutely need upfront
- Show, don't tell - interactive > text explanation
- Let users skip and come back later
- Celebrate first completion (confetti, success message)
Progressive disclosure pattern
- Start with the essential 20% of features
- Reveal complexity as users demonstrate competence
- Use contextual tooltips (not upfront tours) for advanced features
- "Learn more" links, not forced tutorials
Onboarding flow patterns
1. Welcome wizard (multi-step setup)
- 3-5 steps max
- Progress indicator (dots or numbered steps)
- Each step has ONE clear action
- Allow skip on optional steps
- Structure: Welcome -> Profile/Config -> First action -> Success
/* Step indicator */
.wizard-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
}
.wizard-step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-border);
transition: background 0.2s, transform 0.2s;
}
.wizard-step-dot.active {
background: var(--color-primary);
transform: scale(1.3);
}
.wizard-step-dot.completed {
background: var(--color-primary);
}
/* Wizard card */
.wizard-card {
max-width: 480px;
margin: 0 auto;
padding: 40px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
}
.wizard-card__title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.wizard-card__description {
color: var(--color-text-muted);
margin-bottom: 28px;
}
.wizard-card__actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 32px;
}
.wizard-card__skip {
font-size: 0.875rem;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}2. Empty state onboarding
- Every empty state is an onboarding opportunity
- Structure: illustration + headline + description + CTA
- The CTA should be the primary action ("Create your first project")
- Show sample data or templates as alternative to blank slate
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
min-height: 320px;
}
.empty-state__illustration {
width: 120px;
height: 120px;
margin-bottom: 24px;
opacity: 0.7;
}
.empty-state__title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 8px;
color: var(--color-text);
}
.empty-state__description {
font-size: 0.9375rem;
color: var(--color-text-muted);
max-width: 360px;
margin-bottom: 28px;
line-height: 1.5;
}
.empty-state__cta {
display: inline-flex;
align-items: center;
gap: 8px;
}
.empty-state__secondary {
margin-top: 16px;
font-size: 0.875rem;
color: var(--color-text-muted);
}3. Contextual tooltips
- Appear on first visit to a new feature area
- One at a time, never a barrage
- Dismissible, with "don't show again"
- Point to the actual UI element
.onboarding-tooltip {
position: absolute;
z-index: 1000;
max-width: 280px;
padding: 16px;
background: var(--color-primary-dark, #1e3a5f);
color: #fff;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
/* Arrow pointing down toward the target element */
.onboarding-tooltip::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-top-color: var(--color-primary-dark, #1e3a5f);
border-bottom: none;
}
.onboarding-tooltip__title {
font-size: 0.9375rem;
font-weight: 600;
margin-bottom: 6px;
}
.onboarding-tooltip__body {
font-size: 0.875rem;
line-height: 1.5;
opacity: 0.9;
}
.onboarding-tooltip__footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 14px;
}
.onboarding-tooltip__dismiss {
font-size: 0.8125rem;
opacity: 0.75;
background: none;
border: none;
color: inherit;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
/* Backdrop highlight for the targeted element */
.onboarding-highlight {
position: relative;
z-index: 999;
border-radius: 4px;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.5), 0 0 0 9999px rgba(0, 0, 0, 0.4);
}4. Checklist pattern
- Show a getting-started checklist with 4-6 items
- Items complete as user takes actions
- Progress bar fills up
- Disappears or minimizes after all items done
.onboarding-checklist {
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 20px;
max-width: 360px;
background: var(--color-surface);
}
.onboarding-checklist__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.onboarding-checklist__title {
font-size: 1rem;
font-weight: 600;
}
.onboarding-checklist__progress-bar {
height: 6px;
background: var(--color-border);
border-radius: 3px;
margin-bottom: 18px;
overflow: hidden;
}
.onboarding-checklist__progress-fill {
height: 100%;
background: var(--color-primary);
border-radius: 3px;
transition: width 0.4s ease;
}
.checklist-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--color-border);
}
.checklist-item:last-child {
border-bottom: none;
}
.checklist-item__check {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.2s, background 0.2s;
}
.checklist-item.completed .checklist-item__check {
background: var(--color-success);
border-color: var(--color-success);
color: #fff;
}
.checklist-item.completed .checklist-item__label {
text-decoration: line-through;
color: var(--color-text-muted);
}Empty states - types and copy
| Type | Headline template | CTA label | Icon guidance |
|---|---|---|---|
| First-use | "You haven't created any {X} yet" | "Create your first {X}" | Outline illustration of the object |
| Search/filter empty | "No {X} match your filters" | "Clear filters" | Magnifying glass outline |
| Error empty | "Something went wrong" | "Try again" | Warning triangle outline |
| Completed/caught up | "All caught up!" | "View archive" | Checkmark circle outline |
Use simple outline illustrations (2px stroke, brand color). Avoid sad faces or error icons for first-use states - keep the tone inviting.
Form patterns for onboarding
- Multi-step over a single long form (reduces cognitive load)
- One question per screen for critical info (Typeform style)
- Show progress (step 2 of 4)
- Pre-fill from context where possible
- Inline validation - green checkmark on valid field
- Smart defaults > empty fields
Skeleton screens
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.skeleton {
background: var(--color-border);
border-radius: 4px;
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
.skeleton--text {
height: 1em;
width: 100%;
margin-bottom: 8px;
}
.skeleton--text.short { width: 60%; }
.skeleton--text.medium { width: 80%; }
.skeleton--avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.skeleton--image {
width: 100%;
height: 200px;
border-radius: 8px;
}
.skeleton--button {
height: 36px;
width: 120px;
border-radius: 6px;
}Match skeleton layout exactly to the real content layout so the transition is seamless.
Success celebrations
- Confetti: Use for major milestones only (first project created, paid plan activated). Use a lightweight library like
canvas-confetti. Fire once, never loop. - Checkmark animation: SVG path animation for completed task / saved item. CSS
stroke-dasharraytrick. - Success toast: Brief (3s) toast for routine completions ("Project saved").
- Green state change: Input border turns green with checkmark icon for valid inline fields.
/* Animated checkmark */
@keyframes draw-check {
from { stroke-dashoffset: 48; }
to { stroke-dashoffset: 0; }
}
.success-check-icon path {
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: draw-check 0.4s ease forwards 0.1s;
}Common onboarding mistakes
- Forcing a 10-step tutorial before the user can do anything
- Showing all features at once (feature overload)
- No empty states - blank page with no guidance
- Generic empty states ("No data" with no CTA)
- Tooltips that cover the element they describe
- Not allowing users to skip
- No way to replay onboarding or re-read tips
- Celebrating trivial actions (cheapens major milestones)
- Asking for credit card before demonstrating value
performance.md
Performance
Core Web Vitals targets
- LCP (Largest Contentful Paint): < 2.5s (what users see as "loaded")
- INP (Interaction to Next Paint): < 200ms (responsiveness)
- CLS (Cumulative Layout Shift): < 0.1 (visual stability)
Image optimization
- Use next-gen formats: WebP (95% support), AVIF (85% support, smaller)
- Provide srcset for responsive images
- Always set width and height (prevents CLS)
- Lazy load below-fold images:
loading="lazy" - Eager load above-fold hero image:
loading="eager" fetchpriority="high" - Use
<picture>for art direction or format fallback - SVG for icons and logos (scalable, tiny file size)
- Max hero image: ~200KB. Thumbnails: ~20-50KB
<!-- Hero image: eager + high priority -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img
src="hero.jpg"
alt="Hero description"
width="1200"
height="600"
loading="eager"
fetchpriority="high"
>
</picture>
<!-- Below-fold image: lazy + responsive srcset -->
<picture>
<source
srcset="card-400.avif 400w, card-800.avif 800w"
type="image/avif"
>
<source
srcset="card-400.webp 400w, card-800.webp 800w"
type="image/webp"
>
<img
src="card-800.jpg"
srcset="card-400.jpg 400w, card-800.jpg 800w"
sizes="(max-width: 600px) 400px, 800px"
alt="Card description"
width="800"
height="450"
loading="lazy"
>
</picture>Font loading
- Use
font-display: swapto prevent FOIT (flash of invisible text) - Preload critical fonts in
<head> - Use WOFF2 format only (best compression, 95%+ support)
- Subset fonts to needed character sets (latin only if applicable)
- Variable fonts: one file covers all weights - prefer
Inter Variable,Geist, etc. - Self-host fonts instead of Google Fonts (avoids extra DNS lookup, better privacy)
- Limit to 2 font files max (1 body + 1 heading, or 1 variable font)
<!-- Preload in <head> before stylesheet -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900; /* variable font range */
font-display: swap;
}CSS performance
- Avoid expensive properties on scroll:
box-shadow,filter,backdrop-filter - Use
transformandopacityfor animations (GPU-accelerated, no layout reflow) will-change: transformonly on elements about to animate - remove after animation- Contain paint on isolated components:
contain: layout paint - Use
content-visibility: autofor long off-screen sections - Avoid
@importin CSS (blocks parallel loading) - use<link>tags instead
/* Isolated widget - browser skips layout/paint outside this box */
.widget {
contain: layout paint;
}
/* Long page sections below fold - skip rendering until near viewport */
.section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* estimated height to prevent CLS */
}
/* Animation - only use transform/opacity */
.card:hover {
transform: translateY(-4px);
opacity: 0.9;
transition: transform 200ms ease, opacity 200ms ease;
}JavaScript and rendering
- Defer non-critical JS:
<script defer src="..."> - Async for independent scripts (analytics, etc.):
<script async src="..."> - Avoid layout thrashing: batch DOM reads then writes (never interleave)
- Use
requestAnimationFramefor visual updates IntersectionObserverfor lazy loading and scroll effects (not scroll events)ResizeObserveroverwindow.resize(element-level, no global overhead)- Debounce input/scroll/resize handlers: 150-300ms
// Layout thrashing - BAD
elements.forEach(el => {
const h = el.offsetHeight; // read
el.style.height = h + 10 + 'px'; // write - forces reflow each iteration
});
// Batched reads then writes - GOOD
const heights = elements.map(el => el.offsetHeight); // all reads
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; // all writes
});
// Debounce utility
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
window.addEventListener('resize', debounce(handleResize, 200));Lazy loading patterns
- Images: native
loading="lazy"(use this by default for all below-fold images) - Components:
React.lazy()+Suspense - Routes: code-split per route (Next.js does this automatically)
- Below-fold sections:
IntersectionObservertrigger
// IntersectionObserver for lazy-loading a component or triggering animation
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target); // stop watching after first trigger
}
});
},
{ rootMargin: '100px' } // start loading 100px before entering viewport
);
document.querySelectorAll('.lazy-section').forEach(el => observer.observe(el));// React lazy component
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<Skeleton height={300} />}>
<HeavyChart />
</Suspense>
);
}Preventing CLS
- Always set
widthandheighton images (or useaspect-ratio) - Reserve space for ads/embeds with
min-height - Use skeleton/placeholder instead of content popping in
- Never insert content above existing content after load
- Font fallback with
size-adjustto prevent text shift when swap occurs - Use
aspect-ratiofor media containers
/* Media container that reserves space */
.video-wrapper {
aspect-ratio: 16 / 9;
width: 100%;
background: #f0f0f0; /* placeholder color */
}
/* Font fallback to reduce CLS from font swap */
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107%; /* tweak until metrics match your web font */
ascent-override: 90%;
descent-override: 22%;
}
body {
font-family: 'Inter', 'Inter-fallback', sans-serif;
}Loading states
Hierarchy of loading patterns (best to worst UX):
- Skeleton screens - match layout shape, pulse animation
- Content placeholder - gray blocks approximating content
- Spinner with context - "Loading messages..."
- Generic spinner - worst, no context for the user
/* Skeleton screen with pulse animation */
.skeleton {
background: #e0e0e0;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.5) 50%,
transparent 100%
);
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* Usage */
.skeleton-text { height: 1em; margin-bottom: 0.5em; }
.skeleton-avatar { width: 40px; height: 40px; border-radius: 50%; }
.skeleton-card { height: 200px; border-radius: 8px; }Performance budget
| Asset | Ideal | Acceptable |
|---|---|---|
| Total page | < 1MB | < 2MB |
| JavaScript | < 300KB gzipped | < 500KB gzipped |
| CSS | < 50KB gzipped | < 100KB gzipped |
| Fonts | < 100KB total | < 150KB total |
| Images | Lazy load all below fold; compress aggressively |
Monitoring
- Lighthouse CI - add to pipeline, fail on regression
- web-vitals library - real user monitoring (RUM) in production
- Chrome DevTools Performance panel - identify long tasks and layout thrashing
- Network panel - look for render-blocking resources (red bar in waterfall)
// web-vitals RUM snippet
import { onCLS, onINP, onLCP } from 'web-vitals';
function sendToAnalytics({ name, value, id }) {
navigator.sendBeacon('/analytics', JSON.stringify({ name, value, id }));
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);Common performance mistakes
| Mistake | Fix |
|---|---|
| Not lazy loading below-fold images | Add loading="lazy" to all below-fold <img> |
| Loading all fonts upfront | Preload only critical font, defer the rest |
| CSS-in-JS runtime overhead | Prefer static CSS, Tailwind, or CSS Modules |
Animating top/left/width/height |
Use transform: translate/scale instead |
No image width/height attributes |
Always set dimensions - prevents CLS |
| Third-party scripts blocking render | Load with async or defer, or use Partytown |
| Not code-splitting large JS bundles | Split by route, lazy-load heavy components |
window.resize without debounce |
Debounce at 150-300ms or use ResizeObserver |
responsiveness-and-mobile-nav.md
Responsiveness and Mobile Navigation
Mobile-first approach
Write base CSS for mobile (320px+), add min-width media queries for larger screens. Never use max-width queries (leads to override chains).
/* Base = mobile */
.container { padding: 16px; flex-direction: column; }
/* Expand for larger */
@media (min-width: 768px) {
.container { padding: 24px; flex-direction: row; }
}Breakpoints
| Name | Width | Target |
|---|---|---|
| sm | 640px | Large phones landscape |
| md | 768px | Tablets portrait |
| lg | 1024px | Tablets landscape, small laptops |
| xl | 1280px | Desktop |
| 2xl | 1536px | Large desktop |
Touch targets
- Minimum: 44x44px (Apple HIG) / 48x48px (Material Design)
- Spacing between targets: minimum 8px
- Thumb zone: bottom 1/3 of screen is easiest to reach
- Place primary actions in bottom zone on mobile
Responsive patterns
Content reflow
/* Sidebar: full-width mobile, fixed on desktop */
.layout { display: flex; flex-direction: column; }
.sidebar { width: 100%; }
@media (min-width: 1024px) {
.layout { flex-direction: row; }
.sidebar { width: 260px; flex-shrink: 0; }
}
/* Grid: 1 col -> 2 -> 3 -> 4 */
.grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 768px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .grid { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1280px) { .grid { grid-template-columns: repeat(4, 1fr); } }Responsive typography
/* Fluid headings with clamp() */
h1 { font-size: clamp(1.75rem, 1rem + 3vw, 3rem); }
h2 { font-size: clamp(1.375rem, 0.75rem + 2vw, 2.25rem); }
h3 { font-size: clamp(1.125rem, 0.5rem + 1.5vw, 1.75rem); }Responsive spacing
.section { padding: clamp(16px, 4vw, 48px); }Responsive images
<img
src="image-800.jpg"
srcset="image-400.jpg 400w, image-800.jpg 800w, image-1600.jpg 1600w"
sizes="(min-width: 1024px) 800px, 100vw"
alt="Description"
style="max-width: 100%; height: auto;"
/>Mobile navigation patterns
1. Bottom tab bar
- 4-5 items max, 56-64px height, fixed to bottom
- Active: filled icon + label + primary color
- Safe area padding for notched phones
.bottom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
background: #ffffff;
border-top: 1px solid #e5e7eb;
padding-bottom: env(safe-area-inset-bottom);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #6b7280;
font-size: 0.75rem;
min-height: 44px;
}
.tab-item.active { color: #2563eb; }
@media (min-width: 1024px) { .bottom-tab-bar { display: none; } }2. Hamburger menu
- Slide-in from left, dark overlay behind
- Close on overlay tap or X button, focus trap when open
.nav-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
z-index: 40;
}
.nav-overlay.open { opacity: 1; visibility: visible; }
.nav-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(80vw, 320px);
background: #ffffff;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 50;
overflow-y: auto;
padding: 24px 16px;
}
.nav-drawer.open { transform: translateX(0); }
@media (min-width: 1024px) { .nav-overlay, .nav-drawer { display: none; } }3. Bottom sheet
.bottom-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #ffffff;
border-radius: 16px 16px 0 0;
padding: 8px 16px 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
z-index: 50;
max-height: 90vh;
overflow-y: auto;
}
.bottom-sheet.open { transform: translateY(0); }
.bottom-sheet-handle {
width: 40px;
height: 4px;
background: #d1d5db;
border-radius: 9999px;
margin: 0 auto 16px;
}Responsive component adaptations
/* Cards: vertical mobile, horizontal desktop */
.card { display: flex; flex-direction: column; }
@media (min-width: 768px) {
.card { flex-direction: row; }
.card-image { width: 200px; flex-shrink: 0; }
}
/* Tables: horizontal scroll on mobile */
.table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* Forms: stacked mobile, side labels desktop */
.form-field { display: flex; flex-direction: column; gap: 4px; }
@media (min-width: 768px) {
.form-field { flex-direction: row; align-items: baseline; gap: 16px; }
.form-field label { width: 160px; flex-shrink: 0; text-align: right; }
}
/* Modals: full-screen mobile, centered desktop */
@media (max-width: 767px) { .modal { position: fixed; inset: 0; border-radius: 0; } }
/* Dropdowns: bottom sheet mobile, positioned desktop */
.dropdown-menu { position: fixed; left: 0; right: 0; bottom: 0; border-radius: 16px 16px 0 0; }
@media (min-width: 768px) {
.dropdown-menu {
position: absolute;
inset: auto;
top: calc(100% + 4px);
border-radius: 8px;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
}Testing responsive
- Chrome DevTools device mode (Cmd+Shift+M)
- Test at: 320px, 375px, 768px, 1024px, 1440px
- Test orientation changes
- Test with actual devices - DevTools is not a perfect substitute
Common responsive mistakes
- Using
pxfor everything - useremfor text,pxonly for borders/shadows - Hiding content on mobile instead of reorganizing it
- Tiny touch targets - buttons and links below 44px
- Horizontal scroll on body - check for overflow caused by wide elements
- Not testing between breakpoints - worst layouts often appear at ~900px
- Fixed-width sidebars that don't collapse on mid-size screens
scroll-patterns.md
Scroll Patterns
Smooth scrolling
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
element.scrollIntoView({ behavior: prefersReduced ? 'auto' : 'smooth', block: 'start' });Sticky elements
- Sticky header:
position: sticky; top: 0 - Sticky sidebar:
top: <header-height + gap>(e.g.top: 72px) - Sticky table header:
position: sticky; top: 0; z-index: 1on<thead> th - Sticky bottom bar (mobile):
position: sticky; bottom: 0 - Add shadow when stuck - detect with
IntersectionObserveron a sentinel element
.site-header { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow 0.2s ease; }
.site-header.is-stuck { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
.sidebar { position: sticky; top: 72px; max-height: calc(100vh - 72px); overflow-y: auto; }
.data-table thead th { position: sticky; top: 0; z-index: 1; background: #f5f5f5; border-bottom: 2px solid #ddd; }const sentinel = document.querySelector('.header-sentinel');
const header = document.querySelector('.site-header');
const observer = new IntersectionObserver(
([entry]) => header.classList.toggle('is-stuck', !entry.isIntersecting),
{ threshold: 0 }
);
observer.observe(sentinel);Scroll-snap
- Container:
scroll-snap-type: x mandatory(ory,proximity) - Children:
scroll-snap-align: start(orcenter,end) - Add
overscroll-behavior: containto prevent page scroll bleeding
/* Horizontal card carousel */
.carousel {
display: flex;
gap: 16px;
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
scroll-padding: 0 24px;
padding: 0 24px;
-webkit-overflow-scrolling: touch;
}
.carousel__item { flex: 0 0 280px; scroll-snap-align: start; border-radius: 8px; background: #fff; }
/* Full-page vertical sections */
.page-sections { height: 100vh; overflow-y: scroll; scroll-snap-type: y mandatory; }
.section { height: 100vh; scroll-snap-align: start; }Scrollbar styling
/* Firefox */
.scrollable { scrollbar-width: thin; scrollbar-color: #999 #f0f0f0; }
/* WebKit */
.scrollable::-webkit-scrollbar { width: 8px; height: 8px; }
.scrollable::-webkit-scrollbar-track { background: #f0f0f0; border-radius: 4px; }
.scrollable::-webkit-scrollbar-thumb { background: #999; border-radius: 4px; }
.scrollable::-webkit-scrollbar-thumb:hover { background: #666; }
/* Hide but keep scrolling */
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }Infinite scroll
<ul class="feed-list" id="feed-list"></ul>
<div class="sentinel" id="scroll-sentinel" aria-hidden="true"></div>
<div class="loading-indicator" id="loading" hidden>Loading...</div>.sentinel { height: 1px; margin-top: -200px; } /* trigger 200px before bottom */
.loading-indicator { display: flex; justify-content: center; padding: 24px; }
.skeleton-item { height: 80px; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.4s infinite; border-radius: 8px; margin-bottom: 12px; }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }let page = 1, loading = false;
const observer = new IntersectionObserver(
async ([entry]) => {
if (!entry.isIntersecting || loading) return;
loading = true;
loadingEl.hidden = false;
await fetchAndAppendItems(++page);
loadingEl.hidden = true;
loading = false;
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
// Stop: observer.unobserve(sentinel);Pagination vs Load More vs Infinite Scroll
| Pattern | Best for | Pros | Cons |
|---|---|---|---|
| Pagination | Search results, catalogs | SEO, shareable URLs, predictable | More clicks |
| Load more | Social feeds, comments | Simple, controllable | No URL state |
| Infinite scroll | Feeds, discovery | Effortless browsing | No footer, hard to bookmark |
Back to top button
- Show after scrolling ~2 viewport heights; fixed bottom-right 48px circle; fade in/out
.back-to-top { position: fixed; bottom: 32px; right: 32px; width: 48px; height: 48px; border-radius: 50%; background: #333; color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.2); opacity: 0; pointer-events: none; transition: opacity 0.3s ease, transform 0.3s ease; z-index: 200; }
.back-to-top.is-visible { opacity: 1; pointer-events: auto; }
.back-to-top:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.25); }const backToTop = document.querySelector('.back-to-top');
window.addEventListener('scroll', () => {
backToTop.classList.toggle('is-visible', window.scrollY > window.innerHeight * 2);
}, { passive: true });
backToTop.addEventListener('click', () => {
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: prefersReduced ? 'auto' : 'smooth' });
});Scroll-linked animations (use sparingly)
.reading-progress { position: fixed; top: 0; left: 0; height: 3px; background: #0070f3; width: 0%; z-index: 999; transition: width 0.1s linear; }
.fade-in-section { opacity: 0; transform: translateY(20px); transition: opacity 0.5s ease, transform 0.5s ease; }
.fade-in-section.is-visible { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .fade-in-section { opacity: 1; transform: none; transition: none; } }window.addEventListener('scroll', () => {
progressBar.style.width = `${(window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100}%`;
}, { passive: true });
const fadeObserver = new IntersectionObserver(
(entries) => entries.forEach(e => e.target.classList.toggle('is-visible', e.isIntersecting)),
{ threshold: 0.1 }
);
document.querySelectorAll('.fade-in-section').forEach(el => fadeObserver.observe(el));Horizontal scroll with fade edges
.h-scroll-wrapper { position: relative; }
.h-scroll-wrapper::before, .h-scroll-wrapper::after { content: ''; position: absolute; top: 0; bottom: 0; width: 40px; pointer-events: none; z-index: 1; }
.h-scroll-wrapper::before { left: 0; background: linear-gradient(to right, #fff 0%, transparent 100%); }
.h-scroll-wrapper::after { right: 0; background: linear-gradient(to left, #fff 0%, transparent 100%); }
.h-scroll { display: flex; gap: 12px; overflow-x: auto; scroll-padding: 0 24px; padding: 0 24px 12px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.h-scroll::-webkit-scrollbar { display: none; }
.h-scroll > * { flex: 0 0 auto; }Overflow handling
.modal-body { overflow-y: auto; max-height: calc(90vh - 120px); overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
body.modal-open { overflow: hidden; }Scroll restoration
history.scrollRestoration = 'manual';
window.addEventListener('beforeunload', () => {
sessionStorage.setItem(`scroll:${location.pathname}`, window.scrollY);
});
window.addEventListener('load', () => {
const saved = sessionStorage.getItem(`scroll:${location.pathname}`);
if (saved) window.scrollTo({ top: parseInt(saved, 10), behavior: 'auto' });
});Virtual scrolling
- For lists with >100 items or complex per-item rendering
- Only render visible items + buffer (10-20 above/below viewport)
- Libraries:
@tanstack/virtual,react-virtuoso; requires known/estimated item heights
import { useVirtualizer } from '@tanstack/react-virtual';
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 10,
});Common scroll mistakes
- Scroll hijacking - overriding native scroll behavior disrupts users
- No sticky header shadow - users can't tell it's sticky without a visual cue
- Infinite scroll with no footer access - always provide a way to reach the footer
- Horizontal scroll with no hint - show partial item or gradient to signal more content
- JS scroll listeners instead of IntersectionObserver - always prefer
IntersectionObserveror{ passive: true } - Not preserving scroll position in SPAs - breaks the back-button mental model
shadows-and-borders.md
Shadows and Borders
Shadow elevation scale
Define a 5-level shadow scale (like Material Design but subtler). Use rgba with low opacity:
Level 0 (flat): no shadow, border only Level 1 (raised): subtle card shadow - 0 1px 2px rgba(0,0,0,0.05) Level 2 (elevated): interactive card hover - 0 4px 6px rgba(0,0,0,0.07) Level 3 (floating): dropdown, popover - 0 10px 15px rgba(0,0,0,0.1) Level 4 (overlay): modal, dialog - 0 20px 25px rgba(0,0,0,0.15)
:root {
--shadow-0: none;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-2: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-3: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-4: 0 20px 25px rgba(0, 0, 0, 0.15);
}Multi-layer shadows (the secret to realistic shadows)
Single box-shadow looks flat. Use 2-3 stacked:
/* Realistic card shadow */
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 4px 8px rgba(0, 0, 0, 0.06),
0 12px 24px rgba(0, 0, 0, 0.04);
/* Elevated card (hover state) */
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.05),
0 8px 16px rgba(0, 0, 0, 0.08),
0 20px 32px rgba(0, 0, 0, 0.06);
/* Dropdown / popover */
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.06),
0 8px 20px rgba(0, 0, 0, 0.1),
0 24px 48px rgba(0, 0, 0, 0.08);
/* Modal / dialog */
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.07),
0 12px 28px rgba(0, 0, 0, 0.13),
0 32px 64px rgba(0, 0, 0, 0.1);Shadow in dark mode
Shadows are barely visible on dark backgrounds. Use these strategies:
- Increase opacity: 0.3-0.5 instead of 0.05-0.15
- Use subtle lighter border instead of shadow
- Use a soft light glow (positive spread, light color)
/* Dark mode shadow alternatives */
.dark .card {
/* Strategy 1: higher opacity shadow */
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3),
0 4px 8px rgba(0, 0, 0, 0.4);
}
.dark .card-border {
/* Strategy 2: border instead of shadow */
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.dark .card-glow {
/* Strategy 3: subtle light glow */
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 16px rgba(0, 0, 0, 0.5);
}Border usage
- Default border color: gray-200 light mode, gray-700 dark mode
- Border width: 1px for most cases, 2px for emphasis only
- Border-bottom only for horizontal dividers (no full borders)
- Avoid border-top + border-bottom together (creates double lines)
:root {
--border-color: #e5e7eb; /* gray-200 */
--border-color-dark: #374151; /* gray-700 */
--border-width: 1px;
--border-width-emphasis: 2px;
}
.divider {
border: none;
border-bottom: var(--border-width) solid var(--border-color);
}Border radius
Pick ONE value and use it everywhere:
- 4px: sharp, technical (code editors, data tools)
- 6px: balanced, professional (dashboards, SaaS)
- 8px: friendly, modern (consumer apps, marketing)
- 12px: soft, playful (mobile apps, casual products)
- Full radius (9999px): pills (tags, badges, avatars)
:root {
--radius-sm: 4px;
--radius-md: 8px; /* your main radius */
--radius-lg: 12px;
--radius-full: 9999px;
}Card design patterns
Flat card (border only)
- 1px border, no shadow
- Cleanest look, good for dense layouts
- Hover: subtle shadow or border color change
.card-flat {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
background: #ffffff;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.card-flat:hover {
border-color: #9ca3af; /* gray-400 */
box-shadow: var(--shadow-1);
}Raised card (shadow)
- No border, subtle shadow
- Feels floating, good for standalone items
- Hover: increase shadow level
.card-raised {
border: none;
border-radius: var(--radius-md);
padding: 16px;
background: #ffffff;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 4px 8px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
}
.card-raised:hover {
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.05),
0 8px 16px rgba(0, 0, 0, 0.09),
0 20px 32px rgba(0, 0, 0, 0.06);
}Ghost card (minimal)
- No border, no shadow
- Just padding and content grouping
- Hover: add subtle background
.card-ghost {
border: none;
border-radius: var(--radius-md);
padding: 16px;
background: transparent;
transition: background-color 0.15s ease;
}
.card-ghost:hover {
background-color: #f9fafb; /* gray-50 */
}Dividers and separators
- Horizontal rule: 1px solid, border color
- Use gap + no divider when possible (whitespace beats lines)
- Section dividers: full-width, margin: 32px 0 to 48px 0
- List dividers: inset (aligned with text, not edge-to-edge)
- Vertical dividers: 1px wide, height auto, in flex layouts
/* Section divider */
.divider-section {
border: none;
border-top: 1px solid var(--border-color);
margin: 40px 0;
}
/* Inset list divider */
.divider-inset {
border: none;
border-top: 1px solid var(--border-color);
margin: 0 0 0 16px; /* inset from left edge */
}
/* Vertical divider in flex layout */
.divider-vertical {
width: 1px;
background-color: var(--border-color);
align-self: stretch;
flex-shrink: 0;
}Ring and outline patterns
- Focus rings: 2px solid primary, 2px offset
- Selected state: ring + primary-50 background
- Input focus: ring instead of border change
/* Focus ring */
.focusable:focus-visible {
outline: 2px solid #3b82f6; /* primary blue */
outline-offset: 2px;
}
/* Selected state */
.item-selected {
outline: 2px solid #3b82f6;
outline-offset: 2px;
background-color: #eff6ff; /* blue-50 */
}
/* Input focus (ring replaces border) */
.input {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
outline: none;
}When to use shadow vs border vs background
Decision tree:
- Separating content at same level? -> Border or whitespace
- Elevating content above page? -> Shadow
- Grouping content together? -> Background color
- Interactive hover state? -> Shadow increase or background change
- Never use all three simultaneously
Common shadow/border mistakes
- Different border-radius values across the page (pick one and be consistent)
- Shadows on every element (too heavy, reserve for elevated content)
- Dark borders in dark mode (invisible or too harsh - use rgba white instead)
- Border-radius on only some corners randomly
- Using box-shadow instead of outline for focus (bad for accessibility)
- Putting borders inside borders (card with bordered internal sections)
tables.md
Tables
Base table styling
Complete CSS for a clean data table:
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
padding: 12px 16px;
text-align: left;
border-bottom: 2px solid var(--border-color);
}
.table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.table tbody tr:hover {
background-color: var(--bg-hover, rgba(0, 0, 0, 0.03));
}No outer border - it looks cleaner without one.
Column alignment
- Text columns: left-aligned (always)
- Number columns: right-aligned (always, for decimal alignment)
- Status/badge columns: left-aligned
- Action columns: right-aligned
- Checkbox columns: center-aligned, 48px width
.col-number { text-align: right; }
.col-action { text-align: right; }
.col-checkbox {
text-align: center;
width: 48px;
}Sortable columns
.table th.sortable {
cursor: pointer;
user-select: none;
}
.table th.sortable:hover {
color: var(--text-primary);
}
.table th.sort-active {
color: var(--text-primary);
}
.sort-icon {
display: inline-block;
margin-left: 4px;
opacity: 0.4;
font-size: 10px;
}
.sort-active .sort-icon {
opacity: 1;
}- Default sort: ascending on first click, toggle on subsequent clicks
- Show a chevron icon in the header; make it directional when active
Row selection
.table tr.selected td {
background-color: var(--primary-50);
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.table tr.selected td {
background-color: rgba(var(--primary-rgb), 0.2);
}
}
.table th.checkbox-col input[type="checkbox"]:indeterminate {
opacity: 0.6;
}- Checkbox in first column
- Show bulk action bar when one or more rows are selected
- Header checkbox uses indeterminate state for partial selection
Responsive tables
Three approaches - pick based on context:
1. Horizontal scroll wrapper (best for data-heavy tables)
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}2. Stack on mobile - each row becomes a card
@media (max-width: 640px) {
.table thead { display: none; }
.table tr {
display: block;
margin-bottom: 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px;
}
.table td {
display: flex;
justify-content: space-between;
border-bottom: none;
padding: 6px 8px;
}
.table td::before {
content: attr(data-label);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
}3. Priority columns - hide less important columns on mobile, show in expandable row
@media (max-width: 640px) {
.col-secondary { display: none; }
}
.row-expand-content {
display: none;
padding: 8px 16px;
background: var(--bg-subtle);
}
.row-expanded .row-expand-content {
display: block;
}Pagination
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
font-size: 14px;
color: var(--text-secondary);
}
.pagination-controls {
display: flex;
align-items: center;
gap: 4px;
}
.pagination-btn {
min-width: 32px;
height: 32px;
padding: 0 6px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: none;
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
}
.pagination-btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.pagination-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.pagination-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-size-select {
margin-left: 8px;
font-size: 14px;
}Label: "Showing 1-10 of 243 results". Page size options: 10, 25, 50, 100.
Empty state
.table-empty {
text-align: center;
padding: 48px 24px;
color: var(--text-secondary);
}
.table-empty-icon {
font-size: 32px;
margin-bottom: 12px;
opacity: 0.4;
}
.table-empty-title {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 6px;
}
.table-empty-description {
font-size: 14px;
margin-bottom: 16px;
}- "No results found" for filtered empty state
- "No items yet" for a truly empty collection
- Always include an action button when there is something the user can do
Loading state
.skeleton-row td {
padding: 12px 16px;
}
.skeleton-cell {
height: 16px;
border-radius: 4px;
background: linear-gradient(
90deg,
var(--bg-subtle) 25%,
var(--bg-hover) 50%,
var(--bg-subtle) 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}Show 3-5 skeleton rows. For a refresh (data already visible), use an overlay spinner instead of replacing rows with skeletons. Never show an empty table while loading.
Row actions
.row-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.table tr:hover .row-actions,
.table tr:focus-within .row-actions {
opacity: 1;
}
@media (max-width: 640px) {
.row-actions { opacity: 1; }
}
.row-action-btn {
width: 32px;
height: 32px;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.row-action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.row-action-btn.destructive:hover {
background: var(--error-50);
color: var(--error);
}Show on hover on desktop, always visible on mobile. Use a "More" dropdown for 3+ actions. Destructive actions must trigger a confirmation dialog.
Number formatting
- Currency: right-aligned, consistent decimal places (
$1,234.56) - Percentages: right-aligned, 1-2 decimal places (
12.5%) - Dates: consistent format (
Jan 15, 2024or2024-01-15- pick one per product) - Large numbers: abbreviate (
1.2M,450K) or use commas - never mix styles in the same table
Table variants
1. Simple list table (no borders, minimal styling)
.table-simple td,
.table-simple th {
border-bottom: none;
padding: 8px 0;
}2. Striped table (alternate row backgrounds, no hover)
.table-striped tbody tr:nth-child(even) td {
background-color: var(--bg-subtle);
}
.table-striped tbody tr:hover td {
background-color: var(--bg-subtle); /* no hover change */
}3. Bordered table (all cell borders, for spreadsheet-like data)
.table-bordered td,
.table-bordered th {
border: 1px solid var(--border-color);
}4. Compact table (8px padding, for dense data)
.table-compact td,
.table-compact th {
padding: 8px 12px;
font-size: 13px;
}Common table mistakes
- Not right-aligning numbers - breaks decimal alignment and looks unprofessional
- Combining stripes AND hover - pick one interaction pattern
- No fixed header for long scrollable tables
- Using tables for layout - use CSS Grid instead
- Shrinking text to fit more columns - let the table scroll horizontally
- No empty state - a blank table is confusing
- No loading state - never flash an empty table before data arrives
typography.md
Typography
Type Scale (1.25 ratio - Major Third)
All sizes use a 1.25 multiplier. Base is 16px = 1rem.
:root {
--text-xs: 0.75rem; /* 12px - captions, footnotes, timestamps */
--text-sm: 0.875rem; /* 14px - secondary labels, helper text */
--text-base: 1rem; /* 16px - body text, default */
--text-md: 1.125rem; /* 18px - large body, lead paragraphs */
--text-lg: 1.25rem; /* 20px - card titles, small headings */
--text-xl: 1.5rem; /* 24px - section headings (h3) */
--text-2xl: 1.875rem; /* 30px - page sub-headings (h2) */
--text-3xl: 2.25rem; /* 36px - page titles (h1) */
--text-4xl: 3rem; /* 48px - hero display text */
}Line-height per size:
| Token | Size | line-height | Usage |
|---|---|---|---|
--text-xs |
12px | 1.75 | Captions, timestamps |
--text-sm |
14px | 1.6 | Helper text, secondary labels |
--text-base |
16px | 1.5 | Body copy, default |
--text-md |
18px | 1.5 | Lead paragraphs |
--text-lg |
20px | 1.4 | Card titles, nav items |
--text-xl |
24px | 1.3 | Section headings (h3) |
--text-2xl |
30px | 1.25 | Sub-headings (h2) |
--text-3xl |
36px | 1.2 | Page titles (h1) |
--text-4xl |
48px | 1.1 | Hero display |
:root {
--leading-xs: 1.75;
--leading-sm: 1.6;
--leading-base: 1.5;
--leading-md: 1.5;
--leading-lg: 1.4;
--leading-xl: 1.3;
--leading-2xl: 1.25;
--leading-3xl: 1.2;
--leading-4xl: 1.1;
}Line Height Rules
- Headings (24px+):
line-height: 1.2to1.3- tighter, more impact - Body text (14-18px):
line-height: 1.5to1.6- comfortable reading - Small text (12-13px):
line-height: 1.6to1.75- must open up more to stay legible - Single-line elements (buttons, inputs):
line-height: 1with verticalpaddinginstead - Rule: smaller text needs proportionally more line-height to remain scannable
/* Single-line interactive elements */
.button {
line-height: 1;
padding-block: 0.625rem; /* 10px top/bottom */
}
.input {
line-height: 1;
padding-block: 0.5rem; /* 8px top/bottom */
}Font Pairing
Max 2 font families per project. Load only weights you use.
| # | Heading | Body | Character |
|---|---|---|---|
| 1 | Inter | Inter | Safe default, variable font, covers all weights |
| 2 | Manrope | Inter | Geometric + neutral, great for dashboards |
| 3 | Space Grotesk | DM Sans | Modern tech, SaaS products |
| 4 | Playfair Display | Source Sans 3 | Editorial, content-heavy sites |
| 5 | JetBrains Mono | - | Code always, non-negotiable |
/* Pairing 3 - Modern tech (Space Grotesk + DM Sans) */
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=DM+Sans:wght@400;500&display=swap');
:root {
--font-heading: 'Space Grotesk', system-ui, sans-serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}Rules:
- Never use more than 2 families
- Same-category pairing (sans + sans) works when weights differ significantly
- Never pair two visually similar fonts (e.g. Inter + Roboto)
- Always define a system-ui fallback
Font Weight Usage
:root {
--weight-regular: 400; /* Body text, descriptions, paragraphs */
--weight-medium: 500; /* Buttons, labels, nav items, secondary headings */
--weight-semibold: 600; /* Card titles, section headings */
--weight-bold: 700; /* Page titles, hero text */
}Usage:
body { font-weight: var(--weight-regular); }
.btn, label { font-weight: var(--weight-medium); }
h3, h4, h5 { font-weight: var(--weight-semibold); }
h1, h2 { font-weight: var(--weight-bold); }
/* Display text at 40px+ only */
.hero-display {
font-size: var(--text-4xl);
font-weight: 300; /* Thin weights only at this size */
}Never use weight 300 for body text - it fails readability at small sizes. Reserve thin weights for display text at 40px+.
Measure (Line Length)
:root {
--measure: 65ch; /* Optimal paragraph width */
--measure-wide: 80ch; /* Code blocks */
--measure-narrow: 45ch; /* Pull quotes, sidebars */
}
p, li, blockquote {
max-width: var(--measure);
}
pre, code {
max-width: var(--measure-wide);
}- Optimal reading: 45-75 characters per line
65chis the sweet spot for paragraphs- For wider layouts, increase margins or use columns - never widen the text column
- Code blocks can go to
80chsince scanning is different from reading prose
Letter Spacing
:root {
--tracking-tight: -0.02em; /* Large headings 24px+ */
--tracking-normal: 0em; /* Body text default */
--tracking-wide: 0.01em; /* Small text 12px */
--tracking-widest: 0.05em; /* All-caps labels */
}
h1, h2, h3 {
letter-spacing: var(--tracking-tight); /* Tighten large text */
}
body {
letter-spacing: var(--tracking-normal);
}
.label-caps {
text-transform: uppercase;
letter-spacing: var(--tracking-widest); /* Must widen caps */
font-size: var(--text-xs);
}
.caption {
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}Vertical Rhythm
Consistent spacing creates visual coherence. Base unit: 1rem (16px).
:root {
--rhythm: 1.5rem; /* Matches body line-height of 1.5 */
}
p {
margin-block-end: var(--rhythm); /* 1x rhythm between paragraphs */
}
h1, h2, h3, h4 {
margin-block-start: calc(var(--rhythm) * 2); /* 2x above heading */
margin-block-end: calc(var(--rhythm) * 0.5); /* 0.5x below - keep close to content */
}
ul, ol {
line-height: var(--leading-base); /* Match body line-height */
}
li + li {
margin-block-start: 0.375rem; /* 6px - mid-range of 4-8px */
}CSS Font Loading
<!-- Preload the critical body font -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<!-- Preload heading font if different -->
<link rel="preload" href="/fonts/space-grotesk-subset.woff2" as="font" type="font/woff2" crossorigin>/* Body font - swap prevents FOIT (flash of invisible text) */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900; /* Variable font range */
font-display: swap;
}
/* Decorative font - optional skips if slow (shows fallback permanently) */
@font-face {
font-family: 'Playfair Display';
src: url('/fonts/playfair-display.woff2') format('woff2');
font-display: optional;
}Rules:
font-display: swapfor body/heading fonts (prevents invisible text)font-display: optionalfor decorative or non-critical fonts- Always
crossoriginon preload for fonts served from CDN - Subset to latin if not supporting other scripts - saves 60-80% file size
- Variable fonts: one file covers all weights (smaller total download than separate weight files)
Text Styling Patterns
/* Links - underline OR color, not both decorations */
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Truncation - single line */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Truncation - multi-line (2 lines example) */
.clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Inline code */
code {
font-family: var(--font-mono);
font-size: 0.875em; /* Slightly smaller than context */
background-color: var(--color-surface-subtle);
padding: 0.1em 0.35em;
border-radius: 0.25rem;
}
/* Labels / badges */
.label {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-widest);
line-height: 1;
padding: 0.25rem 0.5rem;
}
/* Placeholder */
::placeholder {
color: var(--color-text-subtle); /* Lighter, never same as input value */
/* Never use placeholder as a label - it disappears on focus */
}Common Typography Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| More than 6-7 unique font sizes | Visual noise, no hierarchy | Use the defined scale tokens only |
| Body text under 14px | Fails accessibility, strains eyes | Minimum --text-sm (14px) for body |
| Body text over 18px | Feels oversized, breaks measure | Cap body at --text-md (18px) |
| Heading and body sizes too close | No visual hierarchy | Maintain at least 1.5x size ratio between levels |
| Centered body text | Hard to track line starts when reading | Center only headings and short labels (under 3 words) |
| Line length over 80ch on desktop | Eye fatigue, hard to track return | Enforce max-width: 65ch on prose |
| Missing letter-spacing on caps | Cramped, hard to read | Always add letter-spacing: 0.05em to text-transform: uppercase |
| Using 300 weight for body | Fails at small sizes, low contrast feel | 400 minimum for body text |
visual-hierarchy.md
Visual Hierarchy
The 5 tools of hierarchy (in order of impact)
- Size - larger = more important
- Color/contrast - darker or brand-colored = more important
- Weight - bolder = more important
- Spacing - more whitespace around it = more important
- Position - top-left (LTR) gets seen first
F-pattern and Z-pattern
- F-pattern: text-heavy pages (articles, docs). Users scan top bar, then left column
- Z-pattern: minimal pages (landing pages, hero sections). Eye goes top-left -> top-right -> bottom-left -> bottom-right
- Design implications: put CTAs at the end of the Z, put navigation in the F's top bar
Z-pattern: F-pattern:
[====== -> ======] [============]
\ [====]
[====== -> ======] [====]Creating focal points
- One primary focal point per screen/section (never zero, rarely two)
- Use size + color + whitespace together for maximum emphasis
- Squint test: blur your eyes - the focal point should still be obvious
/* Hero section with a clear focal point */
.hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 96px 24px;
text-align: center;
}
/* Focal point: headline dominates via size + weight + spacing */
.hero__headline {
font-size: 48px;
font-weight: 700;
line-height: 1.15;
color: var(--color-text-primary);
max-width: 720px;
margin-bottom: 24px;
}
/* Subtext clearly subordinate */
.hero__subtext {
font-size: 18px;
font-weight: 400;
color: var(--color-text-secondary);
max-width: 560px;
margin-bottom: 40px;
}
/* CTA stands out via color contrast, not competing with headline */
.hero__cta {
background: var(--color-brand);
color: #fff;
font-size: 16px;
font-weight: 600;
padding: 14px 32px;
border-radius: 8px;
}Whitespace as hierarchy tool
- More whitespace = more importance/separation
- The ratio matters more than absolute values
| Level | Gap range | Use case |
|---|---|---|
| Section | 48-96px | Between major page sections |
| Component | 16-32px | Between related components (cards in a grid) |
| Element | 4-12px | Between tightly related elements (label + input) |
/* Spacing scale - define once, use consistently */
:root {
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 32px;
--space-xl: 48px;
--space-2xl: 80px;
}
.page-section + .page-section {
margin-top: var(--space-2xl); /* 80px - section-level gap */
}
.card-grid {
gap: var(--space-lg); /* 32px - component-level gap */
}
.form-field label + input {
margin-top: var(--space-xs); /* 4px - element-level gap */
}Text hierarchy levels
Create exactly 4-5 levels, no more:
:root {
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
}
/* Level 1 - Page title */
.text-h1 {
font-size: 36px; /* 30-48px range */
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.2;
}
/* Level 2 - Section heading */
.text-h2 {
font-size: 24px; /* 20-24px range */
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.3;
}
/* Level 3 - Subsection / card title */
.text-h3 {
font-size: 18px; /* 16-18px range */
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.4;
}
/* Level 4 - Body text */
.text-body {
font-size: 15px; /* 14-16px range */
font-weight: 400;
color: var(--color-text-primary);
line-height: 1.6;
}
/* Level 5 - Caption / metadata */
.text-caption {
font-size: 12px; /* 12-13px range */
font-weight: 400;
color: var(--color-text-secondary);
line-height: 1.5;
}Card hierarchy
- Cards create visual grouping - elements inside a card are related
- Three levels of elevation, pick the right one per context:
/* Flat - use for low-emphasis grouping inside an already-elevated surface */
.card--flat {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 20px;
background: #fff;
}
/* Raised - default card on a white/light background */
.card--raised {
border-radius: 8px;
padding: 20px;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
}
/* Elevated - modals, popovers, or feature callouts */
.card--elevated {
border-radius: 12px;
padding: 24px;
background: #fff;
box-shadow: 0 10px 24px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06);
}- Never nest cards inside cards - max 1 level of card nesting
- Card padding: 16-24px, consistent within a page
De-emphasis techniques
Use these to push secondary content out of the focal path:
/* 1. Secondary text color - for metadata, timestamps, labels */
.text-muted {
color: var(--color-text-secondary); /* #6b7280 */
}
/* 2. Reduced opacity - for disabled or inactive states */
.state-disabled {
opacity: 0.4;
pointer-events: none;
}
/* 3. Smaller size - for supplemental info */
.text-supporting {
font-size: 12px;
color: var(--color-text-secondary);
}
/* 4. On-demand disclosure - hide secondary content until needed */
.details-panel {
display: none;
}
.details-panel.is-open {
display: block;
}
/* 5. Reduced saturation - for background UI chrome */
.nav-icon {
color: #9ca3af; /* desaturated, not competing with content */
}
.nav-icon.is-active {
color: var(--color-brand);
}Common hierarchy mistakes
| Mistake | Why it fails | Fix |
|---|---|---|
| Everything is bold | Nothing stands out if everything is emphasized | Reserve font-weight: 700 for level 1 only |
| No clear CTA | User doesn't know what to do next | One prominent action per screen |
| Competing focal points | Two large/colorful elements fight each other | One hero element, others clearly subordinate |
| Border + shadow + color for separation | Visual noise, not clarity | Pick 1-2 separation techniques max |
| Heading and body same size | No scannable structure | At least 4px difference between adjacent levels; prefer 6-8px+ |
| Icon-only navigation | Position context lost, no F/Z anchor | Pair icons with labels for primary nav |
Quick audit checklist
Before shipping a UI, verify:
- Squint test passes - focal point is obvious at a glance
- Only 4-5 distinct text sizes used on the page
-
font-weight: 700used sparingly (level 1 heading only, or CTA label) - Section gaps are noticeably larger than component gaps
- There is exactly one primary CTA visible per screen
- Secondary information uses
text-secondarycolor, not primary - No cards nested inside cards
- Elevation level matches content importance (flat < raised < elevated)
Frequently Asked Questions
What is ultimate-ui?
Use this skill when building user interfaces that need to look polished, modern, and intentional - not like AI-generated slop. Triggers on UI design tasks including component styling, layout decisions, color choices, typography, spacing, responsive design, dark mode, accessibility, animations, landing pages, onboarding flows, data tables, navigation patterns, and any question about making a UI look professional. Covers CSS, Tailwind, and framework-agnostic design principles.
How do I install ultimate-ui?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill ultimate-ui in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support ultimate-ui?
ultimate-ui works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.