absolute-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 absolute-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.
Quick Start
- Open your terminal or command prompt
- Run:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill absolute-ui - Start your AI coding agent (Claude Code, Cursor, Gemini CLI, or any supported agent)
- The absolute-ui skill is now active and ready to use
absolute-ui
absolute-ui is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex, and mcp. The most comprehensive UI design knowledge base for AI coding agents - 25 reference guides covering everything from typography and color theory to dashboard design and micro-animations.
Quick Facts
| Field | Value |
|---|---|
| Category | engineering |
| Version | 0.1.0 |
| Platforms | claude-code, gemini-cli, openai-codex, mcp |
| License | MIT |
| References | 25 deep-dive guides |
| Evals | 19 test cases |
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 absolute-ui- The absolute-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.
The difference between AI slop and a polished UI comes down to intent, restraint, and context. Every recommendation ships with concrete CSS/Tailwind values, not vague advice like "make it clean."
What makes this skill different
- Anti-AI-slop by design - Explicitly flags and avoids generic AI-generated patterns (Inter everywhere, purple gradients, symmetric layouts, flat backgrounds)
- Design thinking before code - Choose aesthetic direction, define user intent, pick context-specific styles before writing a single line of CSS
- 25 reference guides - Deep-dive files loaded on demand covering every UI topic from typography to dashboards
- Product-type-aware - Maps 35 product types (SaaS, fintech, gaming, healthcare...) to specific style, color, and font recommendations
- Style catalog - 25 named UI styles (glassmorphism, brutalism, aurora, etc.) with effects, best-for, and quick-pick tables
Reference Guides
| Guide | Coverage |
|---|---|
| Typography | Type scales, 22 font pairings by aesthetic + context, anti-slop font rules, line-height as spacing |
| Color & Theming | HSL/OKLCH workflows, 3-shade layering, dark mode (double-the-distance rule), card depth recipe |
| Shadows & Borders | 3-shade depth system, raised vs recessed shadows, nested border-radius formula |
| Grids & Layout | 8px scale, flex vs grid decision rule, grid-breaking compositions, responsive layout thinking |
| Visual Hierarchy | Deemphasize to emphasize, gestalt grouping, decorative elements as context |
| Micro-animations | Orchestrated page reveals, scroll storytelling, 3D transforms, SVG path animations |
| Atmosphere & Texture | Gradient meshes, noise/grain overlays, glassmorphism, geometric patterns |
| Style Catalog | 25 UI styles with effects, best-for, and quick-pick table |
| Product Type Guide | 35 product types mapped to style, colors, fonts, and landing patterns |
| Landing Pages | Hero patterns, CTAs, social proof, pricing, FAQ, creative patterns (scroll storytelling, bento grid) |
| Dashboards | 4 core components, popover vs modal vs page, optimistic UI, chart mistakes |
| Navigation | Sidebar, tabs, breadcrumbs, command palettes, mega menus with images |
| Forms & Inputs | Text inputs, selects, toggles, file upload, multi-step forms, validation |
| Cards & Lists | Card variants, list views, infinite scroll, virtualization, skeletons |
| Buttons & Icons | Button hierarchy, chips/tags, icon sizing (= line-height), states |
| Accessibility | WCAG 2.2, ARIA patterns, keyboard nav, screen readers, contrast |
| Design Tokens | Token naming, CSS setup order, reusable section patterns, theming |
| Feedback & Status | Toasts, tooltips, modals, loading states, empty states, errors |
| Images & Media | Avatars, galleries, progressive blur overlay, responsive images |
| Onboarding | First-run experience, checklists, empty states, celebrations |
| Performance | Core Web Vitals, image optimization, font loading, CLS prevention |
| Responsiveness | Breakpoints, mobile-first, touch targets, mobile navigation |
| Scroll Patterns | Sticky elements, scroll-snap, infinite scroll, scrollbar styling |
| Microcopy & UX Writing | Don't Make Me Think, button labels, error messages, friendly copy |
| Tables | Sorting, pagination, responsive tables, number formatting |
Key Principles
- Use a spacing scale, never arbitrary values
- Limit palette to 1 primary + 1 neutral + 1 accent
- Create hierarchy through contrast, not decoration - deemphasize to emphasize
- Every interactive element needs 4 states
- Whitespace is a feature - start with too much, reduce until right
- Consistency within a project, personality across projects
- Use real SVG icons, never emojis
- Backgrounds need atmosphere - no flat solid colors
Pre-delivery Checklist
- Visual: Consistent spacing, single border-radius, max 3 hues, 4-5 text sizes
- Interaction: Hover/active/focus states on everything, 44px+ touch targets
- Dark mode: No pure black, double the layer distance, shadows adjusted
- Layout: Max-width enforced, mobile-first, safe-area padding
- Accessibility: 4.5:1 contrast, focus rings, semantic HTML, keyboard navigable
- Personality: Fonts match aesthetic direction, colors fit product context
- States: Empty, loading, success, error - not just the happy path
- Flow: Each screen answers "how did the user get here?" and "what next?"
Tags
ui ux design css tailwind accessibility dark-mode typography color-theory responsive animations landing-pages dashboards design-system
Platforms
- claude-code
- gemini-cli
- openai-codex
- mcp
Related Skills
Pair absolute-ui with these complementary skills:
Frequently Asked Questions
What is absolute-ui?
A comprehensive UI design knowledge base that makes AI coding agents produce polished, professional interfaces instead of generic AI slop. It covers typography, color theory, layout systems, animations, dark mode, accessibility, and 20+ more topics with concrete CSS values and opinionated design rules.
How is this different from just knowing CSS?
CSS tells you how to implement. absolute-ui tells you what to implement and why. It encodes design decisions - which fonts to pair for a fintech app, how many shades your dark mode needs, when to break the grid, how to create depth with shadows. It's the design taste layer that sits above technical knowledge.
How do I install absolute-ui?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill absolute-ui in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support absolute-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
Absolute 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)
Design thinking
Before writing CSS, commit to an aesthetic direction. The #1 cause of generic-looking UIs is starting with code instead of intent.
- Start from user intent, not structure - Don't begin with headers, footers, or layout scaffolding. Ask: "What is the user trying to do?" If they're searching for accommodations, a search bar is the natural starting point. Only expand UI as user intent expands. For many pages, the core is a heading, an input, and a button - that's all you needed.
- Choose a tone - Pick one that fits the context: brutalist, editorial, retro-futuristic, organic, luxury, playful, industrial, art deco, soft/pastel, minimalist-sharp. These are starting points - blend and invent your own. See
references/style-catalog.mdfor 25 concrete options. - Define what's memorable - What's the one visual choice someone will remember? An unusual color, dramatic typography, a bold layout break, atmospheric texture?
- Creativity is connecting, not inventing - Study top-tier existing designs in your domain. Gather 3-5 inspirations, note what you like about each, then combine those elements in your own way. Step away before designing - new ideas emerge when you return.
- Vary between projects - Every design should feel different. If your last 3 outputs used the same fonts, colors, and layout patterns, you're producing slop.
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. Key insight: to emphasize something, often deemphasize competing elements instead of making the target louder.
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. Workflow: start with too much spacing, view the design as a whole, then reduce until it feels right. Users scan the entire UI before focusing on details - space that feels excessive when you're zoomed into one element looks correct at page level.
Consistency within, personality across - Within a project, use the same border-radius, shadow scale, and transition timing everywhere. But each project should have its own distinct personality - different fonts, colors, and spatial feel. Consistency without character is how AI slop is made.
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.
Backgrounds need atmosphere - Flat solid-color backgrounds (#fff, #f9fafb) are the default of every AI-generated UI. Use subtle gradient meshes, noise/grain overlays, geometric patterns, or tinted surfaces to create depth. Texture should be felt, not seen - if you notice it consciously, it's too much.
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).
Gestalt: similarity and proximity - The brain processes the whole before the parts. Use consistent shape, size, and color to signal "these belong together" (similarity). Use spacing to group related elements and separate unrelated ones (proximity). If the design isn't scannable within seconds, the gestalt is broken.
Depth for character - Use shadows to replace solid borders, subtle gradients to replace flat fills, and cards to elevate bland elements. The closer something feels to the user (higher elevation), the more attention it attracts. One accent gradient or colored shadow can add excitement without complexity.
Context overrides tags - Not all H1s should be the same size. An H3 might be larger than an H2 in a different context. HTML tags define semantics; visual hierarchy depends on what the user needs to see first in that specific layout.
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. Pair a distinctive display font with a refined body font - avoid defaulting to Inter, Roboto, or Space Grotesk. See
references/typography.mdfor aesthetic-specific pairings.
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 |
| Generic font stack (Inter, Roboto, Arial) | Screams "AI generated this", zero personality | Choose fonts that match an aesthetic direction. See references/typography.md |
| Purple/indigo gradient on white | The #1 AI-generated color cliche | Pick context-specific brand colors. Finance: navy+gold. Creative: coral+teal |
| Predictable symmetric layouts everywhere | Every section is centered 3-column grid, looks templated | Use asymmetry, overlapping elements, and grid-breaking for marketing pages |
| Flat solid-color backgrounds | No atmosphere, no depth, feels like a wireframe | Add gradient meshes, subtle grain, or geometric patterns. See references/atmosphere-and-texture.md |
| Multi-hue gradients (blue+green, etc.) | Clashing colors, looks amateur | If using gradients, stick to lighter/darker shades of the SAME hue. Or just use a flat color |
| Redundant UI elements | Arrows that duplicate swipe, borders on already-differentiated elements | Remove anything that doesn't add function. Each element must earn its place |
| AI-repeated KPIs / metrics | Same stats shown 2-3 times on one page | Show each metric once, in the most relevant location |
| Every action as a visible button | Cards with 4+ exposed buttons, overwhelming | Collapse secondary actions into a triple-dot context menu |
| Gradient profile circles with initials | Screams "AI placeholder", never seen in production apps | Use real avatar upload with initials as fallback (flat bg, no gradient) |
| Sparse create forms as full pages | AI gives a form 3 fields but an entire page of space | Use a modal for simple forms, collapse advanced options by default |
| Landing pages with only text + icons | No visual proof the product exists, feels like a template | Use real product screenshots (skewed, shadowed) instead of generic icons |
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.Design for real content, not perfect content - What happens when a title is 3x longer than expected? When an icon sits on a bright image? When a username has 40 characters? Truncate long text with
text-overflow: ellipsis, add contrast backgrounds behind icons on images, and test with real-world data, not lorem ipsum. Edge cases are where amateur UIs break.
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-brandreferences/atmosphere-and-texture.md- Gradient meshes, noise/grain, glassmorphism, geometric patterns, depth effectsreferences/style-catalog.md- 25 UI styles (glassmorphism, brutalism, aurora, etc.) with effects, best-for, quick-pick tablereferences/product-type-guide.md- 35 product types mapped to style, colors, fonts, and landing patternsreferences/palette-recipes.md- 4 production-ready OKLCH palettes (SaaS, e-commerce, editorial, fintech), color-mix(), hue referencereferences/animation-libraries.md- Framer Motion, GSAP, spring physics, easing library, performance rules
Only load a references file if the current task requires it - they are long and will consume context.
Pre-delivery checklist
Before shipping any UI, verify:
- Visual: Consistent spacing scale, single border-radius value, max 3 color hues, 4-5 text sizes only
- Interaction: All interactive elements have hover/active/focus states, 44px+ touch targets on mobile
- Dark mode: No pure black, sufficient contrast, shadows adjusted, images dimmed
- Layout: Content max-width enforced, mobile-first responsive, safe-area padding on mobile
- Accessibility: 4.5:1 contrast, focus-visible rings, semantic HTML, ARIA where needed, keyboard navigable
- Personality: Fonts match aesthetic direction (not generic defaults), colors fit the product context
- States: Every screen has empty, loading, success, and error states - not just the happy path
- Flow: Each screen answers "how did the user get here?" and "what do they need next?"
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 |
animation-libraries.md
Animation Libraries & Advanced Techniques
Companion to micro-animations.md - this reference covers JavaScript animation
libraries, spring physics, and production patterns for complex motion work.
1. Standard Easing Library
Material Design easing curves as CSS custom properties:
:root {
/* Material Design standard curves */
--ease-standard: cubic-bezier(0.4, 0.0, 0.2, 1); /* default for most */
--ease-decelerate: cubic-bezier(0.0, 0.0, 0.2, 1); /* entering elements */
--ease-accelerate: cubic-bezier(0.4, 0.0, 1.0, 1); /* exiting elements */
/* Expressive curves */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* playful overshoot */
--ease-bounce: cubic-bezier(0.68, -0.55, 0.27, 1.55); /* cartoon bounce */
--ease-snappy: cubic-bezier(0.2, 0, 0, 1); /* quick, decisive */
/* Duration tokens */
--duration-instant: 100ms; /* button press, toggle */
--duration-fast: 150ms; /* hover, color change */
--duration-normal: 250ms; /* modal open, dropdown */
--duration-slow: 350ms; /* page transition, complex animation */
--duration-slower: 500ms; /* orchestrated sequence max */
}When to use each:
--ease-standard- state changes, most transitions--ease-decelerate- elements entering the screen (ease-out)--ease-accelerate- elements leaving the screen (ease-in)--ease-spring- playful UI, toggle states, card interactions--ease-snappy- decisive actions, tab switches, nav highlights
2. Spring Physics Parameters
For Framer Motion and CSS spring():
// Framer Motion spring configs
const springs = {
// Gentle - tooltips, subtle movements
gentle: { type: "spring", stiffness: 120, damping: 20 },
// Default - most UI interactions
default: { type: "spring", stiffness: 300, damping: 25 },
// Snappy - toggles, switches, quick feedback
snappy: { type: "spring", stiffness: 500, damping: 30 },
// Bouncy - playful interactions, celebrations
bouncy: { type: "spring", stiffness: 400, damping: 15 },
// Heavy - large elements, page transitions
heavy: { type: "spring", stiffness: 200, damping: 35 },
};Rules of thumb:
- Higher stiffness = faster movement
- Higher damping = less oscillation (bounce)
stiffness: 300-500, damping: 25-35covers 90% of UI needs- Ratio matters: stiffness/damping > 15 = noticeable bounce
3. Framer Motion Patterns (React)
Enter/Exit with AnimatePresence
import { motion, AnimatePresence } from "framer-motion";
// Modal with enter/exit
function Modal({ isOpen, children }) {
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
className="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
{/* Modal content */}
<motion.div
className="modal"
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
);
}Layout Animations
Auto-animate position and size changes:
// List with automatic reorder animation
function List({ items }) {
return (
<ul>
{items.map((item) => (
<motion.li
key={item.id}
layout /* enables layout animation */
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ layout: { type: "spring", stiffness: 300, damping: 30 } }}
>
{item.name}
</motion.li>
))}
</ul>
);
}Staggered Children
const container = {
hidden: {},
show: { transition: { staggerChildren: 0.05 } },
};
const item = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0 },
};
function StaggeredList({ items }) {
return (
<motion.ul variants={container} initial="hidden" animate="show">
{items.map((i) => (
<motion.li key={i.id} variants={item}>{i.name}</motion.li>
))}
</motion.ul>
);
}Framer Motion Gotchas
AnimatePresencerequires a uniquekeyon each direct childlayoutanimations can conflict with CSS transforms - avoid both on same element- Clean up animation subscriptions in useEffect returns
exitanimations only work when the component is a direct child ofAnimatePresence
4. GSAP Basics
For complex timeline sequences:
import { gsap } from "gsap";
// Simple timeline
const tl = gsap.timeline({ defaults: { ease: "power2.out", duration: 0.5 } });
tl.from(".hero__title", { y: 30, opacity: 0 })
.from(".hero__subtitle", { y: 20, opacity: 0 }, "-=0.3") // overlap by 0.3s
.from(".hero__cta", { y: 20, opacity: 0, scale: 0.95 }, "-=0.2")
.from(".hero__visual", { x: 40, opacity: 0 }, "-=0.4");Scroll-Driven Animation
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
gsap.from(".section__content", {
scrollTrigger: {
trigger: ".section",
start: "top 80%",
},
y: 40,
opacity: 0,
duration: 0.6,
stagger: 0.1,
});When GSAP > Framer Motion
- Complex multi-step timelines with precise overlap control
- Scroll-driven animations with scrubbing
- Animating SVG paths, morphing
- When you need
.from(),.to(),.fromTo()flexibility
When Framer Motion > GSAP
- React component enter/exit animations
- Layout animations (automatic position/size)
- Gesture-driven interactions (drag, hover, tap)
- When you want declarative animation in JSX
5. Performance Rules
- Only animate
transformandopacityfor 60fps - everything else triggers layout/paint will-changehints: use sparingly, only on elements about to animate, remove after- CSS transitions for simple state changes, JS animation for complex sequences
- On mobile: reduce animation complexity, disable parallax, respect
prefers-reduced-motion - GSAP cleanup: always
kill()timelines and ScrollTriggers on component unmount
// React cleanup pattern for GSAP
useEffect(() => {
const ctx = gsap.context(() => {
// all GSAP animations here
}, containerRef);
return () => ctx.revert(); // kills all animations in scope
}, []);// Reduced motion media query hook
function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const handler = (e) => setReduced(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return reduced;
} atmosphere-and-texture.md
Atmosphere and Texture
Why Flat Backgrounds Feel Generic
Solid white or gray backgrounds are the number one tell of AI-generated UI. Every default template, every quick prototype, every "just ship it" screen lands on #ffffff or #f5f5f5 and calls it done. The result: an interface that feels like a wireframe someone forgot to finish.
Real designed interfaces use subtle texture, gradients, or patterns to create atmosphere. The background is not just a container - it sets the emotional tone. Compare any Stripe page to a generic SaaS template. The difference is almost entirely in the background treatment.
The goal: make backgrounds feel intentional and crafted without distracting from content.
Gradient Meshes
Layer multiple radial-gradient or conic-gradient calls to create organic, mesh-like backgrounds. The key is low opacity and wide spread so blobs blend softly.
Cool tone (default/professional)
.gradient-mesh-cool {
background:
radial-gradient(at 20% 80%, hsla(210, 100%, 70%, 0.3) 0%, transparent 50%),
radial-gradient(at 80% 20%, hsla(340, 100%, 70%, 0.2) 0%, transparent 50%),
radial-gradient(at 50% 50%, hsla(260, 100%, 80%, 0.15) 0%, transparent 60%),
hsl(220, 20%, 97%);
}Warm tone (creative/friendly)
.gradient-mesh-warm {
background:
radial-gradient(at 30% 70%, hsla(30, 100%, 70%, 0.25) 0%, transparent 50%),
radial-gradient(at 70% 30%, hsla(350, 90%, 65%, 0.2) 0%, transparent 50%),
radial-gradient(at 60% 80%, hsla(45, 100%, 75%, 0.15) 0%, transparent 55%),
hsl(35, 25%, 97%);
}Dark mode mesh
.gradient-mesh-dark {
background:
radial-gradient(at 20% 80%, hsla(210, 100%, 50%, 0.15) 0%, transparent 50%),
radial-gradient(at 80% 20%, hsla(280, 80%, 50%, 0.1) 0%, transparent 50%),
radial-gradient(at 50% 50%, hsla(200, 100%, 40%, 0.08) 0%, transparent 60%),
hsl(220, 25%, 8%);
}Tips:
- Keep individual blob opacity between 0.08 and 0.3
- Place blobs at different positions (20/80, 80/20, 50/50) to avoid symmetry
- The final solid color in the stack is your fallback - always include it
Noise and Grain Overlays
Grain adds analog warmth. Two techniques:
SVG filter approach (inline, no external files)
.grain-filter {
position: relative;
isolation: isolate;
}
.grain-filter::after {
content: '';
position: absolute;
inset: 0;
filter: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
mix-blend-mode: overlay;
z-index: 1;
}SVG data URI approach (repeating tile)
.grain-tile::after {
content: '';
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") repeat;
background-size: 256px 256px;
opacity: 0.04;
pointer-events: none;
mix-blend-mode: overlay;
}Combining grain with gradient mesh
.atmosphere {
position: relative;
background:
radial-gradient(at 20% 80%, hsla(210, 100%, 70%, 0.3) 0%, transparent 50%),
radial-gradient(at 80% 20%, hsla(340, 100%, 70%, 0.2) 0%, transparent 50%),
hsl(220, 20%, 97%);
}
.atmosphere::after {
content: '';
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") repeat;
background-size: 256px 256px;
opacity: 0.03;
pointer-events: none;
mix-blend-mode: overlay;
}Glassmorphism and Layered Transparencies
Beyond basic backdrop-filter: blur(10px) - here is how to make glass panels look real.
Frosted glass with tinted background
.glass-panel {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}Light mode glass (tinted white)
.glass-light {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(16px) saturate(1.8);
-webkit-backdrop-filter: blur(16px) saturate(1.8);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 12px;
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}Multiple transparency layers for depth
.layered-glass {
position: relative;
}
.layered-glass::before {
content: '';
position: absolute;
inset: -1px;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0.05) 100%
);
border-radius: inherit;
z-index: -1;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
padding: 1px;
}Key details:
- Always include
-webkit-backdrop-filterfor Safari saturate(1.5)makes colors behind the glass pop - without it, blur looks washed out- The
1px solid rgba(255, 255, 255, 0.1)border creates a visible edge on the glass inset 0 1px 0 rgba(255, 255, 255, 0.5)adds a highlight line on the top edge
Geometric Patterns as Backgrounds
CSS-only patterns that add structure without images.
Dot grid
.dot-grid {
background-image: radial-gradient(circle, #00000008 1px, transparent 1px);
background-size: 24px 24px;
}
/* Dark mode */
.dark .dot-grid {
background-image: radial-gradient(circle, #ffffff06 1px, transparent 1px);
}Line grid
.line-grid {
background-image:
linear-gradient(to right, #00000006 1px, transparent 1px),
linear-gradient(to bottom, #00000006 1px, transparent 1px);
background-size: 40px 40px;
}Diagonal stripes
.diagonal-stripes {
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
#00000004 10px,
#00000004 11px
);
}Combining pattern with gradient
.patterned-hero {
background:
radial-gradient(circle, #00000008 1px, transparent 1px),
radial-gradient(at 30% 70%, hsla(210, 100%, 70%, 0.2) 0%, transparent 50%),
hsl(220, 20%, 97%);
background-size: 24px 24px, 100% 100%, 100% 100%;
}Dramatic Shadows for Depth
Move beyond generic gray shadows. Colored shadows and large diffuse spreads create real depth.
Colored shadows matching content
.hero-image {
box-shadow:
0 20px 60px -10px rgba(var(--brand-rgb), 0.3),
0 40px 100px -20px rgba(0, 0, 0, 0.15);
}
/* For a blue-themed element */
.card-blue {
box-shadow:
0 10px 40px -8px rgba(59, 130, 246, 0.25),
0 4px 12px -2px rgba(0, 0, 0, 0.08);
}Large diffuse shadows for hero sections
.hero-element {
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.03),
0 2px 4px rgba(0, 0, 0, 0.04),
0 12px 24px rgba(0, 0, 0, 0.06),
0 48px 80px -12px rgba(0, 0, 0, 0.12);
}Glow effect for dark mode CTAs
.dark .cta-button {
box-shadow:
0 0 20px rgba(99, 102, 241, 0.4),
0 0 60px rgba(99, 102, 241, 0.15);
transition: box-shadow 0.3s ease;
}
.dark .cta-button:hover {
box-shadow:
0 0 30px rgba(99, 102, 241, 0.5),
0 0 80px rgba(99, 102, 241, 0.2);
}Dark Mode Atmosphere
Dark mode is where texture shines most because contrast works in your favor.
Subtle colored glow behind key elements
.dark .feature-card {
position: relative;
}
.dark .feature-card::before {
content: '';
position: absolute;
inset: -20px;
background: radial-gradient(
ellipse at center,
hsla(210, 100%, 50%, 0.08) 0%,
transparent 70%
);
z-index: -1;
pointer-events: none;
}Gradient borders using background-clip
.gradient-border {
position: relative;
background: hsl(220, 25%, 10%);
border-radius: 12px;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
hsla(210, 100%, 70%, 0.3),
hsla(280, 100%, 70%, 0.1),
transparent 60%
);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
pointer-events: none;
}Ambient light effect on page
.dark-page {
background:
radial-gradient(ellipse 80% 60% at 50% -10%, hsla(220, 80%, 50%, 0.12) 0%, transparent 60%),
hsl(220, 25%, 6%);
min-height: 100vh;
}When Texture Helps vs When Clean is Better
| Context | Recommendation | Reason |
|---|---|---|
| Marketing / landing pages | Texture strongly recommended | Character and brand personality |
| Hero sections | Gradient mesh + grain | Sets the visual tone for the page |
| Onboarding / empty states | Subtle pattern or gradient | Makes empty space feel intentional |
| Blog / content pages | Minimal - maybe a faint gradient | Content is the focus |
| Dense data UIs (dashboards) | Keep clean, no texture | Texture competes with data density |
| Tables and forms | No texture | Readability is paramount |
| Modals and dialogs | Glass or subtle background | Creates layered depth |
| Dark mode in general | Texture adds the most value | Flat dark surfaces feel like voids |
Rule of thumb: texture should be felt, not seen. If you notice it consciously, it is too much.
Common Texture Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Grain opacity above 0.06 | Visible noise, looks like a rendering artifact | Keep opacity between 0.02 and 0.05 |
| Gradient blobs too saturated | Background competes with content for attention | Keep individual blob opacity under 0.3 |
| Mixing too many techniques | Gradient + grain + pattern + glass = visual chaos | Pick one primary technique, one subtle accent |
| Not testing on low-contrast displays | Subtle effects vanish or bloom unpredictably | Test on a low-quality laptop screen |
| Texture behind text without contrast | Text becomes hard to read over gradients | Ensure WCAG AA contrast at every point in the gradient |
| Using texture on every surface | Feels overwhelming, loses the intentional quality | Reserve texture for hero areas and backgrounds, keep content surfaces clean |
Forgetting pointer-events: none on overlays |
Grain or pattern pseudo-elements block clicks | Always add pointer-events: none to decorative overlays |
Skipping -webkit-backdrop-filter |
Glass effects break in Safari | Always include the prefixed version alongside the standard property |
| Animation on gradient meshes | Constant movement is distracting and costly | Static meshes work - animate only on hover or scroll if at all |
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
Quick rule: icon size = text line-height. If your text has a 24px line-height, make icons 24px. This ensures perfect vertical alignment without fiddling. Most icons are too large by default - match them to line-height, not font-size.
Detailed matching for optical balance:
| 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
Chips / Tags
Chips are NOT buttons. They're thinner, never use primary CTA color, and serve as filters, breadcrumbs, or status indicators.
Sizing rule: vertical padding = 1/2 or 1/4 of horizontal padding. If horizontal is 16px, vertical is 4-8px. This keeps chips visibly thinner than buttons. Use auto-layout (flexbox) so chips adapt to content length.
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px; /* vertical = 1/3 of horizontal */
border-radius: 9999px; /* pill shape */
font-size: 13px;
font-weight: 500;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
}
.chip--active {
background: var(--color-primary-50);
color: var(--color-primary-600);
}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 */
}Color Boldness
Timid, evenly-distributed palettes are the hallmark of AI-generated UIs. Bold design uses dominant colors with sharp accents.
The AI slop palette: light gray background, indigo/purple primary, generic blue links, faint pastel accents. You've seen it on every AI-generated landing page. Don't do this.
Rules for distinctive color:
- Commit to a dominant hue - let it be felt. A teal-dominant dashboard, a warm amber editorial site, a deep forest green SaaS. The 60% should have personality, not just be gray.
- Sharp accents over soft gradients - a single high-saturation accent color against muted surroundings creates more visual punch than gradient washes
- Context-specific palettes - a finance app should feel different from a creative tool. A health product different from a developer tool. Let the domain inform the palette.
- Vary between projects - if your last 3 projects all used indigo/purple, you're in a rut. Try warm tones (amber, terracotta, olive), cool tones (teal, cyan, slate-blue), or neutrals with a single bright accent.
/* AVOID: the generic AI palette */
--primary: #6366f1; /* indigo - every AI project uses this */
--bg: #f9fafb; /* gray-50 - zero personality */
/* BETTER: context-specific, bold */
/* Finance/Trust: deep navy + gold accent */
--primary: #1e3a5f;
--accent: #d4a843;
--bg: #f5f3ef;
/* Creative tool: warm coral + teal */
--primary: #e85d4a;
--accent: #2a9d8f;
--bg: #faf6f1;
/* Developer tool: forest green + lime */
--primary: #1a4a3a;
--accent: #84cc16;
--bg: #f0f4f1;Tailwind shortcut for tinted backgrounds (impossible to mess up): light mode = use the 50 shade as bg + 500 as accent. Dark mode = 950 as bg + 300 as accent. Works for every Tailwind color. Example: blue-50 bg + blue-500 accent in light mode; blue-950 bg + blue-300 accent in dark mode.
Building a Palette
Use HSL (or OKLCH), not hex/RGB. Hex and RGB make shade creation guesswork. HSL makes it math: fix hue and saturation, vary lightness. Three similar grays in hex look random in code; in HSL they're obviously related.
Quick neutral palette from scratch: set saturation to 0 (pure neutral). For dark mode backgrounds: 0% lightness (base), 5% (cards/surfaces), 10% (raised elements). Lighter = elevated = more important. For text: ~90% lightness for headings (not 100% - pure white is harsh), ~60% for secondary. To create light mode: subtract each lightness from 100 as a starting point, then manually adjust. Swap layer order - the base should be darkest in dark mode, lightest in light mode.
Name tokens by relative weight, not by mode: use bg-dark (deepest layer) and bg-light (raised surface) - these names work in both dark and light themes.
Quick color harmony formula: pick your primary hue, then move ±60° on the color wheel for tertiary/accent colors. This creates a 120° arc - the natural distance between primary colors. Example: primary at 220° → tertiary at 160° (teal) and accent at 280° (purple). Change just the hue value to test entirely different palettes in seconds.
Brand color shades - start with ONE brand hue and generate 10 shades using HSL:
- 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 */
}Product Design Color Depth
Product UIs need more neutrals than landing pages. Minimum: 4 background layers, 2 stroke colors, 3 text variants. Plus hover states.
- Borders: use ~85% white, not black - defines edges without overpowering. Black borders are harsh; subtle gray strokes are professional.
- Button darkness = importance - ghost (lightest) → secondary (~90-95% white) → primary (brand) → black with white text (most important). Hierarchy through value, not hue.
- OKLCH for chart palettes - set constant lightness + chroma, increment hue by 25-30° per color. Gets perceptually uniform brightness across the spectrum. Solves "bright green looks more neon than bright blue."
- OKLCH theming shortcut - for any neutral: drop lightness 0.03, increase chroma 0.02, set hue to desired color. Instant themed version of any design.
Dark Mode Implementation
Rules:
- NEVER just invert colors - inversion breaks hue relationships and contrast
- Double the distance - light mode backgrounds differ by ~2% brightness. Dark mode needs 4-6% between layers because dark colors look more similar. Reflecting your light palette directly will lose all distinction.
- 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 - Surfaces always get lighter as they elevate - no exceptions in dark mode
- 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);
}
}Card depth recipe (dark mode): combine a lighter top border ("highlight" - simulates light from above), a gradient using your bg shades, and dual-layer shadows (darker+shorter + lighter+longer). This sells the illusion of physical depth:
.card-depth {
background: linear-gradient(to bottom, var(--bg-light), var(--bg-mid));
border: 1px solid var(--border);
border-top-color: var(--highlight); /* lighter top edge = light from above */
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.3), /* darker, shorter */
0 8px 16px rgba(0, 0, 0, 0.15); /* lighter, longer */
}In light mode, the highlight becomes white (lightness: 100%), the border blends into the card background, and the shadow becomes the primary depth cue.
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.
OKLCH: The Modern Color Format
HSL's biggest flaw: lightness steps don't look uniform. A 10% lightness jump at high saturation looks different than at low saturation. OKLCH fixes this with perceptually uniform steps - Tailwind v4 uses it as the default.
OKLCH values: oklch(lightness chroma hue)
- Lightness: 0 (black) to 1 (white) - perceptually uniform
- Chroma: 0 (gray) to ~0.4 (max saturation) - for UI work, rarely above 0.15-0.2
- Hue: 0 to 360, same as HSL
/* Neutral palette in OKLCH */
--bg-dark: oklch(0.15 0 0); /* base layer */
--bg-mid: oklch(0.20 0 0); /* cards, surfaces */
--bg-light: oklch(0.25 0 0); /* raised elements */
--text-primary: oklch(0.90 0 0); /* headings - not 1.0, too harsh */
--text-secondary: oklch(0.65 0 0); /* body text */
/* Adding brand hue - just set chroma > 0 */
--bg-dark: oklch(0.15 0.01 250); /* barely tinted */
--primary: oklch(0.55 0.18 250); /* full brand color */
--accent: oklch(0.70 0.15 30); /* warm accent */When to use OKLCH over HSL: generating shade ramps (more uniform steps), fine-tuning contrast ratios, or when using Tailwind v4+. HSL is fine for quick prototyping and simple palettes.
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
Neutral Balance and Color Restraint
Just like whitespace lets elements breathe, using mostly neutrals lets the few colored elements stand out. This is "neutral balance."
- Backgrounds should stay in the background - almost never use bright colors for backgrounds. Start with neutral gray bg + white/light foreground.
- Icons need no color by default - their job is to be recognizable symbols. Reserve icon color only for status: active tab, selected state, notification dot.
- Extending a limited brand palette - if you only have one brand color, rotate ±30° on the hue wheel for analogous colors, or go across for complementary. This gives you a chart palette, chip colors, and accent variations from a single starting hue.
- Element states through color alone - hover: slightly lighter/brighter. Active/pressed: slightly darker. Disabled: desaturate. Often no other visual cue is needed.
- When card backgrounds add clutter, use borders instead - a simple 1px border on white/transparent cards is often cleaner than colored backgrounds competing for attention.
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.Defaulting to indigo/purple - it's the #1 AI-generated color choice.
#6366f1and#4f46e5appear in almost every AI-built interface. If you catch yourself reaching for indigo as the brand color without a specific reason, stop and pick something that serves the project's actual context and audience.
dashboards.md
Dashboards
If your dashboard requires a PhD to operate, it's too complex. Do one thing well - focus the main area on what's most important to the user's specific domain (project status for PM, investments for finance, link metrics for analytics).
Dashboard typography is tighter than marketing pages. Smaller font sizes, less spacing between size levels, stricter grid adherence. You're packing more information into the same space.
The 4 Core Dashboard Components
Master these four and you can build virtually any dashboard page:
- Lists and tables - Most common. Separation via space, dividers, or background color (pick one). Good tables need search, filter, and sort - displaying data is only half the battle.
- Cards - Charts, KPIs, toasts, notifications. Keep margins well-spaced. Use outlines in dark mode, background colors in light mode.
- User inputs - Forms, settings, modals with form fields. Can appear inside cards or tables.
- Tabs - Add views without cluttering the sidebar. Different perspectives on related data without leaving context.
Popover vs Modal vs New Page
| Pattern | When to use | Blocking? | Example |
|---|---|---|---|
| Popover | Simple context, quick action | No - click away to dismiss | Display settings, sort options |
| Modal | Complex but related to current page | Yes - must confirm/cancel | Create new item, edit form |
| New page | Permanent/large context | N/A - full navigation | Item detail view, deep settings |
For modals: use a toast notification to confirm changes since the user can't see the page while editing. For new pages: always provide a back button or breadcrumb.
Optimistic UI
Make dashboards feel snappy by assuming server success. Delete an item? Remove it instantly from the UI, don't wait for the server response. Show a toast with an "undo" option in case of failure. No awkward loading pauses between actions.
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
- Charts overdesigned for aesthetics: rounded bar tops make values hard to read, missing axis labels, more bars than data points. Simple > pretty for data visualization
- Removing axis labels or legends to make charts "cleaner" - users can't interpret the data without them
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
- Never use fixed values (like
blackor40px) directly in components. Always use variables. Makes future changes a one-line edit instead of a search-and-replace across the entire codebase.
CSS Setup Order
When starting a project, set up styles in this order:
- Reset - Remove default margins/paddings, set
box-sizing: border-box - Color variables - Background, text, brand, semantic colors
- Font variables - Font families, sizes, weights, line heights
- Spacing variables - Multiples of 4/8px scale
- Element defaults - Style headings, links, buttons with variables (not fixed values)
- Utility classes - Container, text alignment, flex helpers, padding/margin helpers
- Component styles - Scoped to each component
Spot Repeating Patterns
Real websites are built from surprisingly few section components. A typical marketing site uses ~2-3 base sections repeated with different content:
- Hero section - headline, subtext, CTA, visual
- Two-column section - image on one side, text on the other (same component with
flex-direction: row-reversefor alternating rows) - Card grid - repeating cards with icon, title, description
Design your page, then identify which sections repeat. Extract those into components with variables/props. Build once, reuse everywhere.
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 */
}Component Variants with CVA
Class Variance Authority (CVA) creates type-safe component variants - the bridge between design tokens and component code:
import { cva, type VariantProps } from "class-variance-authority";
const button = cva(
// Base classes (always applied)
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
{
variants: {
intent: {
primary: "bg-brand-600 text-white hover:bg-brand-700",
secondary: "border border-gray-200 bg-white hover:bg-gray-50",
ghost: "hover:bg-gray-100",
destructive: "bg-red-600 text-white hover:bg-red-700",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
intent: "primary",
size: "md",
},
}
);
// Usage: <button className={button({ intent: "secondary", size: "lg" })} />
type ButtonProps = VariantProps<typeof button>;CVA keeps variant logic co-located with the component, prevents class string sprawl, and gives TypeScript autocomplete for valid variant combinations.
Compound Components
For complex components with multiple sub-parts that share state (tabs, accordions, selects):
// Usage: natural, readable API
<Tabs defaultValue="general">
<Tabs.List>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="billing">Billing</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general">...</Tabs.Content>
<Tabs.Content value="billing">...</Tabs.Content>
</Tabs>Why compound > mega-prop: A single <Tabs items={[...]} /> with 20+ props becomes impossible to customize. Compound components give consumers control over structure, styling, and composition while the parent manages shared state via React Context.
Rule: If a component has more than 8-10 props, it probably needs to be decomposed into compound sub-components.
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;
}Spacing Workflow and Principles
The 3-value shortcut: for most UIs you only need three spacing values:
- 0.5rem (8px) - closely related elements (icon + text, title + subtitle)
- 1rem (16px) - padding, button groups, same-group items
- 1.5-2rem (24-32px) - separating distinct groups/sections
Workflow: group then separate. Break the UI into logical groups first. Apply small spacing within groups. Apply larger spacing between groups. This is the core of spacing design.
Inner spacing < outer spacing, always. The gap between an icon and text inside a button must be smaller than the button's padding. This applies to any container: inner gaps should never exceed container padding.
Optical weight in buttons: vertical padding should be LESS than horizontal padding. Text has more visual noise horizontally (varying letter widths like I vs W) than vertically (constrained by cap height). Equal padding makes buttons look bloated. Rule of thumb: vertical padding = X, horizontal = 2X or 3X.
Consistency > correctness. Even imperfect spacing looks acceptable if it's consistent throughout. Inconsistent spacing is the #1 tell of unpolished UI.
Border-radius should match padding optically. If padding is 1rem, use border-radius from the same scale (0.5rem, 0.75rem, 1rem). They create visual harmony together.
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;
}Breaking the Grid
Predictable, symmetric layouts are the visual signature of AI-generated UIs. For marketing pages, landing pages, and creative contexts, intentional grid-breaking creates visual interest.
Important: Grid-breaking is for expressive contexts (landing pages, marketing sites, portfolios). App UIs (dashboards, forms, data tables) should stay systematic and predictable.
Asymmetric Two-Column
/* 40/60 split - content left, visual right */
.asymmetric {
display: grid;
grid-template-columns: 2fr 3fr;
gap: 64px;
align-items: center;
}
/* 30/70 split - narrow sidebar emphasis */
.asymmetric--narrow {
grid-template-columns: 1fr 2.5fr;
}
@media (max-width: 768px) {
.asymmetric,
.asymmetric--narrow {
grid-template-columns: 1fr;
}
}Overlapping Elements
/* Image overlapping a content card */
.overlap-section {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
}
.overlap-section__image {
border-radius: 16px;
z-index: 1;
}
.overlap-section__content {
background: var(--color-surface);
border-radius: 16px;
padding: 48px;
margin-left: -64px; /* overlap into image column */
z-index: 2;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
}Off-Grid Accent Elements
/* Decorative element that breaks the container */
.section-with-accent {
position: relative;
overflow: visible; /* allow children to escape */
}
.accent-blob {
position: absolute;
width: 300px;
height: 300px;
border-radius: 50%;
background: radial-gradient(circle, hsla(260, 80%, 60%, 0.15), transparent 70%);
top: -100px;
right: -80px;
pointer-events: none;
z-index: 0;
}
/* Rotated accent card */
.accent-card--tilted {
transform: rotate(-2deg);
transition: transform 0.3s ease;
}
.accent-card--tilted:hover {
transform: rotate(0deg);
}Full-Bleed Elements
/* Element that breaks out of a centered container */
.container { max-width: 1200px; margin: 0 auto; }
.full-bleed {
width: 100vw;
margin-left: calc(-50vw + 50%);
padding: 96px 0;
}
.full-bleed__inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}Diagonal Flow
/* Angled section divider */
.angled-section {
position: relative;
padding: 120px 0 96px;
background: var(--color-surface-alt);
}
.angled-section::before {
content: '';
position: absolute;
top: -40px;
left: 0;
right: 0;
height: 80px;
background: inherit;
clip-path: polygon(0 50%, 100% 0, 100% 100%, 0 100%);
}Responsive Layout Thinking
Before writing CSS, map your layout as a parent-child tree. Every design breaks into rows and columns - responsive design is about dynamically moving boxes between them as screen width changes.
Sketch breakpoints first. Rough sketches of mobile/tablet/desktop layouts before coding. Don't figure out responsive behavior in the code editor - sunk cost fallacy will trap you into "good enough" solutions.
Default rule: use flexbox for everything. Switch to grid only when you specifically need rigid, equal-size structure (card grids, data layouts). Flexbox is the "cool parent" - children choose their size. Grid is the "strict parent" - children obey.
The go-to flex shorthand: flex: 1 1 auto - items start at their natural size, then grow/shrink based on available space. This single line handles most responsive needs.
Grid's superpower: equal-size cards regardless of column count. A 3-column grid where one card drops to 2 columns? Cards stay the same width. With flex, they'd stretch unevenly.
Flexbox Patterns
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; }
/* Progressive blur - modern alternative to solid gradient overlay */
.hero__blur {
position: absolute;
inset: 0;
z-index: 1;
backdrop-filter: blur(0px);
mask-image: linear-gradient(to bottom, transparent 30%, black 80%);
-webkit-mask-image: linear-gradient(to bottom, transparent 30%, black 80%);
}Overlay options (from least to most sophisticated):
- Full-screen dark overlay - simple but dulls the entire image
- Linear gradient to solid - shows image at top, readable text at bottom
- Progressive blur + gradient - shows image clearly, smoothly blurs into readable area (most modern)
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);
}For a more modern look, use a marquee animation with logos sliding continuously, faded out at edges with a gradient mask + progressive blur. Found on 80%+ of SaaS sites.
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; } }Creative landing patterns
Beyond the standard section-based layouts, consider these for distinctive landing pages:
- Scroll-Triggered Storytelling - Full-page sections with scroll-snap, narrative progression, cinematic transitions
- Horizontal Scroll Journey - Side-scrolling sections for product timelines, before/after, or process flows
- Bento Grid Showcase - Mixed-size grid cells highlighting features at different visual weights
- Before-After Transformation - Split-screen slider or scroll-driven comparison for visual impact
- Immersive / Interactive Experience - WebGL, 3D product configurator, or interactive demos as the hero
- AI-Driven Dynamic Landing - Personalized content blocks, adaptive CTAs, context-aware sections
- Waitlist / Coming Soon - Minimal: headline + email capture + countdown. Social proof via signup count
See references/style-catalog.md for choosing the right visual style for each pattern.
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`; */Orchestrated Page Reveals
One well-choreographed page-load sequence creates more delight than scattered micro-interactions. Plan the entrance as a story: what appears first, what follows, what lands last.
Hero entrance sequence
/* Each element enters in narrative order */
.hero__eyebrow {
animation: fade-in-up 400ms ease-out both;
animation-delay: 0ms;
}
.hero__headline {
animation: fade-in-up 500ms ease-out both;
animation-delay: 100ms;
}
.hero__subtext {
animation: fade-in-up 400ms ease-out both;
animation-delay: 250ms;
}
.hero__cta {
animation: fade-in-up 400ms ease-out both;
animation-delay: 400ms;
}
.hero__visual {
animation: scale-in 600ms cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: 300ms;
}
/* The visual enters with a different animation type -
mixing animation styles creates more interest than
uniform fade-in-up on everything */Key principles for orchestration
- Lead with text, follow with visuals - text loads fast, images may not
- Max total sequence: 600-800ms - beyond that, it feels slow
- Mix animation types - headline slides up, visual scales in, accent fades. Uniform motion is boring
- One hero sequence per page - don't orchestrate every section
- Below-fold sections: simpler entrance (single fade-in-up on scroll), not full choreography
Scroll-Triggered Storytelling
Build sequences where scroll position reveals content in narrative order. Each section's entrance should feel intentional, not just "fade in when visible."
/* Progressive reveal on scroll - each child animates when parent enters viewport */
.story-section {
opacity: 0;
transform: translateY(24px);
transition: opacity 500ms ease-out, transform 500ms ease-out;
}
.story-section.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Children stagger within the section */
.story-section.is-visible .story-item:nth-child(1) { transition-delay: 0ms; }
.story-section.is-visible .story-item:nth-child(2) { transition-delay: 80ms; }
.story-section.is-visible .story-item:nth-child(3) { transition-delay: 160ms; }
.story-item {
opacity: 0;
transform: translateY(12px);
transition: opacity 400ms ease-out, transform 400ms ease-out;
}
.story-section.is-visible .story-item {
opacity: 1;
transform: translateY(0);
}// Scroll observer that triggers section animations
const sectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
sectionObserver.unobserve(entry.target);
}
});
},
{ threshold: 0.15, rootMargin: '0px 0px -60px 0px' }
);
document.querySelectorAll('.story-section').forEach((el) => sectionObserver.observe(el));3D Transforms and Card Flip
CSS can break the flat 2D plane with perspective and 3D transforms.
/* 3D container - perspective = distance from user's eyes to screen */
.card-3d {
perspective: 800px; /* 100px = nose-to-screen, 800px = arm's length */
transform-style: preserve-3d; /* children keep their 3D positions */
}
/* Card flip - front and back stacked with position absolute */
.card-face {
position: absolute;
inset: 0;
backface-visibility: hidden; /* hide face when rotated away */
transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.card-back {
transform: rotateY(180deg); /* starts facing away */
}
.card-3d.is-flipped .card-front { transform: rotateY(180deg); }
.card-3d.is-flipped .card-back { transform: rotateY(360deg); }The custom cubic-bezier (0.34, 1.56, 0.64, 1) overshoots and settles back, giving the flip a sense of physical weight and momentum.
SVG Path Animations
Drawing/tracing effect with stroke-dasharray + stroke-dashoffset:
.icon-path {
stroke-dasharray: 1; /* one giant dash covers whole path */
stroke-dashoffset: 1; /* pushed off-screen = hidden */
animation: draw 1.5s ease-out forwards;
}
@keyframes draw {
to { stroke-dashoffset: 0; } /* pull dash into view */
}Set pathLength="1" on the SVG path element to normalize the length, making dash values simple (0 = hidden, 1 = fully drawn).
Dynamic Animations with JavaScript
For animations where coordinates are unknown at author time (e.g., "fly to cart"):
// Get start and destination positions
const from = product.getBoundingClientRect();
const to = cartIcon.getBoundingClientRect();
const dx = to.left - from.left;
const dy = to.top - from.top;
// Clone, position, and animate
const clone = product.cloneNode(true);
clone.style.cssText = `position:fixed;top:${from.top}px;left:${from.left}px;z-index:999;`;
document.body.appendChild(clone);
clone.animate([
{ transform: 'translate(0,0) scale(1)', opacity: 1 },
{ transform: `translate(${dx}px,${dy}px) scale(0.1)`, opacity: 0 }
], { duration: 700, easing: 'ease-in' }).onfinish = () => {
clone.remove();
updateCartCount(); // update number AFTER item "lands"
};Key: delay state updates (cart count, success message) until the animation finishes. Timing sells the illusion.
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)
- Scattered micro-interactions without a cohesive motion story - plan one orchestrated sequence instead
- Same animation type on every element (all fade-in-up) - mix slide, scale, and fade for visual variety
microcopy-and-ux-writing.md
Microcopy and UX Writing
Don't Make Me Think
Every click, every scroll, every field should feel effortless. If users have to pause and ask "is this a button?", "where's the menu?", or "what happens if I click this?" - the design has already failed.
Key behaviors to design for:
- Users click the first reasonable option, not the perfect one. They scan, click, and if it doesn't work, go back to try again. Make the best option the most obvious one so they don't have to backtrack.
- Follow conventions. Navigation at top or side. Buttons look like buttons. Magnifying glass = search. Cart = checkout. Sticking to patterns isn't boring - it's good design. Users feel comfortable when they know what to expect.
- Simple ≠ minimal to the point of useless. Sometimes you need many elements (product pages, dashboards, comparison tables). The goal is making complex information scannable, not removing essential elements. Pack information densely when needed, but make it easy to scan with hierarchy, filters, and grouping.
- Eliminate unnecessary navigation layers. Each additional click is a thinking moment. If users must go Men → Shoes → Oxford, try collapsing to Men → (dropdown showing all categories directly). Fewer clicks to the goal = better UX.
- Map user flows before designing. Diagram the shortest path to the user's objective. Identify where thinking happens, then reduce those moments with search, filters, sorting, and smart defaults.
- Underline clickable text. If text is interactive but doesn't look like a link, users won't find it. Underline, color, or both - make clickability obvious.
- Validate with usability testing. Get a target user to complete a specific task on your prototype. Then on the competition. If they perform better elsewhere, your design needs work. Performance comparison convinces even rigid stakeholders.
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)
- Friendly > corporate: "We sweat the details" beats "We take pride in our attention to detail." Natural, conversational language makes designs feel human, not AI-generated. Corporate jargon is the textual equivalent of flat gray backgrounds.
- 404 pages are personality opportunities - users don't belong there, so have fun. Quizzes, animations, branded characters, playful messages. The one page where you can be maximally quirky.
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; }Images in mega-menu dropdowns - Instead of plain link lists, include preview images or thumbnails alongside navigation links. Gives users visual context of where they're going before clicking. Rising SaaS trend (Intercom, Pipe, etc.). Even small sites benefit - a screenshot thumbnail next to "Analytics" is more inviting than just the word.
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
palette-recipes.md
Palette Recipes
Ready-to-paste color palettes using modern CSS color functions. Practical recipes - not theory.
Section 1: CSS color-mix() for Tints and Shades
The modern way to create tints/shades without manual calculation:
/* Tint: mix with white */
--color-primary-light: color-mix(in oklch, var(--color-primary), white 30%);
/* Shade: mix with black */
--color-primary-dark: color-mix(in oklch, var(--color-primary), black 20%);
/* Transparent version */
--color-primary-ghost: color-mix(in oklch, var(--color-primary), transparent 85%);
/* Hover state: darken by 10% */
.btn:hover {
background: color-mix(in oklch, var(--color-primary), black 10%);
}color-mix(in oklch, ...) is preferred over in srgb for perceptually uniform results. Supported in all modern browsers (Chrome 111+, Firefox 113+, Safari 16.2+).
Fallback pattern for older browsers:
.btn {
background: #4f46e5; /* fallback hex */
background: oklch(0.55 0.18 264); /* oklch for modern */
}Section 2: Four Production-Ready Palette Recipes
Each recipe includes: full primitive scale in OKLCH, semantic token map, and dark mode overrides. All contrast pairs are WCAG AA compliant.
Recipe 1: SaaS / Productivity (Indigo-Neutral)
Calm, trustworthy, familiar. Indigo brand on cool blue-gray neutral.
:root {
/* Brand primitives (hue 264) */
--brand-50: oklch(0.97 0.03 264);
--brand-100: oklch(0.93 0.06 264);
--brand-200: oklch(0.87 0.09 264);
--brand-300: oklch(0.78 0.13 264);
--brand-400: oklch(0.68 0.16 264);
--brand-500: oklch(0.55 0.18 264); /* primary */
--brand-600: oklch(0.47 0.18 264); /* CTA */
--brand-700: oklch(0.40 0.16 264); /* hover */
--brand-800: oklch(0.33 0.13 264);
--brand-900: oklch(0.25 0.10 264);
/* Neutral (cool gray, hue 264 at 0.01 chroma) */
--gray-50: oklch(0.98 0.005 264);
--gray-100: oklch(0.95 0.005 264);
--gray-200: oklch(0.90 0.008 264);
--gray-300: oklch(0.82 0.008 264);
--gray-400: oklch(0.68 0.010 264);
--gray-500: oklch(0.55 0.010 264);
--gray-600: oklch(0.44 0.010 264);
--gray-700: oklch(0.36 0.012 264);
--gray-800: oklch(0.27 0.012 264);
--gray-900: oklch(0.18 0.015 264);
/* Semantic */
--color-bg: var(--gray-50);
--color-surface: white;
--color-text: var(--gray-900);
--color-text-muted: var(--gray-500);
--color-border: var(--gray-200);
--color-primary: var(--brand-600);
--color-primary-hover: var(--brand-700);
}
[data-theme="dark"] {
--color-bg: var(--gray-900);
--color-surface: var(--gray-800);
--color-text: var(--gray-100);
--color-text-muted: var(--gray-400);
--color-border: var(--gray-700);
--color-primary: var(--brand-400);
--color-primary-hover: var(--brand-300);
}Recipe 2: E-Commerce / Consumer (Emerald-Warm)
Friendly, energetic. Green trust + orange urgency on warm neutrals.
:root {
/* Brand primitives (hue 160 - emerald) */
--brand-50: oklch(0.97 0.03 160);
--brand-100: oklch(0.93 0.05 160);
--brand-200: oklch(0.87 0.08 160);
--brand-300: oklch(0.78 0.12 160);
--brand-400: oklch(0.68 0.15 160);
--brand-500: oklch(0.58 0.16 160);
--brand-600: oklch(0.50 0.15 160);
--brand-700: oklch(0.42 0.13 160);
--brand-800: oklch(0.34 0.10 160);
--brand-900: oklch(0.26 0.08 160);
/* Accent (hue 40 - orange, complementary) */
--accent-500: oklch(0.70 0.16 40);
--accent-600: oklch(0.62 0.17 40);
/* Neutral (warm gray, hue 60 at 0.01 chroma) */
--gray-50: oklch(0.98 0.005 60);
--gray-100: oklch(0.95 0.008 60);
--gray-200: oklch(0.90 0.008 60);
--gray-500: oklch(0.55 0.010 60);
--gray-800: oklch(0.27 0.010 60);
--gray-900: oklch(0.18 0.012 60);
--color-bg: var(--gray-50);
--color-surface: white;
--color-primary: var(--brand-600);
--color-accent: var(--accent-600);
}Recipe 3: Editorial / Content (Slate-Serif)
Minimal, typographic. Muted brand, maximum reading focus.
:root {
/* Brand (hue 220 - slate blue, low chroma) */
--brand-500: oklch(0.55 0.08 220);
--brand-600: oklch(0.47 0.10 220);
--brand-700: oklch(0.40 0.10 220);
/* Neutral (pure cool gray) */
--gray-50: oklch(0.98 0.003 220);
--gray-100: oklch(0.95 0.003 220);
--gray-200: oklch(0.90 0.005 220);
--gray-500: oklch(0.55 0.005 220);
--gray-800: oklch(0.27 0.008 220);
--gray-900: oklch(0.18 0.010 220);
--color-bg: white;
--color-surface: var(--gray-50);
--color-text: var(--gray-900);
--color-primary: var(--brand-600);
}Recipe 4: Fintech / Enterprise (Navy-Gold)
Authoritative, premium. Navy trust + gold accent.
:root {
/* Brand (hue 250 - navy) */
--brand-500: oklch(0.40 0.12 250);
--brand-600: oklch(0.32 0.10 250);
--brand-700: oklch(0.25 0.08 250);
--brand-900: oklch(0.15 0.05 250);
/* Accent (hue 85 - gold) */
--accent-400: oklch(0.78 0.14 85);
--accent-500: oklch(0.70 0.15 85);
--accent-600: oklch(0.62 0.14 85);
/* Neutral (cool blue-gray) */
--gray-50: oklch(0.98 0.005 250);
--gray-100: oklch(0.95 0.005 250);
--gray-800: oklch(0.27 0.012 250);
--gray-900: oklch(0.18 0.015 250);
--color-bg: var(--gray-50);
--color-primary: var(--brand-600);
--color-accent: var(--accent-500);
}
[data-theme="dark"] {
--color-bg: var(--brand-900);
--color-surface: oklch(0.20 0.06 250);
--color-text: var(--gray-100);
--color-primary: oklch(0.65 0.14 250);
--color-accent: var(--accent-400);
}Section 3: Quick Hue Reference
A compact table for common brand hues:
| Color | OKLCH Hue | Example Use |
|---|---|---|
| Red | 25 | Destructive, urgency, food |
| Orange | 50 | Energy, warmth, CTA |
| Amber/Gold | 85 | Premium, achievement, finance |
| Green | 160 | Success, eco, health, money |
| Teal | 190 | Trust, calm, productivity |
| Blue | 240 | Trust, corporate, info |
| Indigo | 264 | SaaS, tech, default |
| Purple | 300 | Creative, AI, luxury |
| Pink | 350 | Social, romance, playful |
To adapt any recipe: swap the brand hue value while keeping lightness and chroma values the same.
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 |
product-type-guide.md
Product Type Design Guide
Maps product types to concrete design recommendations - the bridge between "I'm building X" and knowing exactly which style, colors, fonts, and layout to use.
How to use this reference
- Identify the product type closest to the user's project
- Apply the style, colors, typography, and landing pattern from the table
- Follow the key rule - it captures the single most important design constraint
- Combine with other reference files (typography.md, color-and-theming.md, landing-pages.md) for implementation details
SaaS (General)
| Aspect | Recommendation |
|---|---|
| Style | Glassmorphism + Flat Design |
| Colors | Trust blue (#2563EB) + orange CTA (#EA580C), light bg (#F8FAFC) |
| Typography | Professional sans: Plus Jakarta Sans or Outfit |
| Landing | Hero + Features + CTA |
| Key rule | Balance modern feel with clarity. Focus on CTAs. |
E-commerce
| Aspect | Recommendation |
|---|---|
| Style | Vibrant & Block-based |
| Colors | Success green (#059669) + urgency orange (#EA580C) |
| Typography | Clean readable: Rubik + Nunito Sans |
| Landing | Feature-Rich Showcase |
| Key rule | Visual hierarchy for conversions. |
E-commerce Luxury
| Aspect | Recommendation |
|---|---|
| Style | Liquid Glass + Glassmorphism |
| Colors | Premium black (#1C1917) + gold accent (#A16207) |
| Typography | Serif heading (Cormorant/Cinzel) + sans body |
| Landing | Storytelling-Driven |
| Key rule | Elegance, premium imagery. |
Fintech / Crypto
| Aspect | Recommendation |
|---|---|
| Style | Glassmorphism + Dark Mode OLED |
| Colors | Gold (#F59E0B) + purple tech (#8B5CF6), dark bg (#0F172A) |
| Typography | Futuristic sans: Orbitron, Exo 2 |
| Landing | Conversion-Optimized |
| Key rule | Security perception, real-time data. |
Healthcare App
| Aspect | Recommendation |
|---|---|
| Style | Neumorphism + Accessible & Ethical |
| Colors | Calm cyan (#0891B2) + health green (#059669) |
| Typography | Accessible: Figtree, Lexend |
| Landing | Social Proof-Focused |
| Key rule | Accessibility mandatory, calming aesthetic. |
Educational App
| Aspect | Recommendation |
|---|---|
| Style | Claymorphism + Micro-interactions |
| Colors | Playful indigo (#4F46E5) + energetic orange (#EA580C) |
| Typography | Friendly: Fredoka + Nunito |
| Landing | Storytelling-Driven |
| Key rule | Engagement, age-appropriate. |
Creative Agency
| Aspect | Recommendation |
|---|---|
| Style | Brutalism + Motion-Driven |
| Colors | Bold pink (#EC4899) + cyan (#0891B2) |
| Typography | Avant-garde: Syne + Manrope |
| Landing | Storytelling-Driven |
| Key rule | Differentiation, wow-factor. |
Portfolio / Personal
| Aspect | Recommendation |
|---|---|
| Style | Motion-Driven + Minimalism |
| Colors | Monochrome (#18181B) + blue accent (#2563EB) |
| Typography | Distinctive display + clean body |
| Landing | Storytelling-Driven |
| Key rule | Personality shines through. |
Gaming
| Aspect | Recommendation |
|---|---|
| Style | 3D & Hyperrealism + Retro-Futurism |
| Colors | Neon purple (#7C3AED) + rose (#F43F5E), dark bg (#0F0F23) |
| Typography | Bold display: Russo One, Chakra Petch |
| Landing | Feature-Rich Showcase |
| Key rule | Immersion, performance critical. |
Financial Dashboard
| Aspect | Recommendation |
|---|---|
| Style | Dark Mode OLED |
| Colors | Dark bg (#020617), green indicators (#22C55E), trust blue accents |
| Typography | Data-optimized: IBM Plex Sans, Fira Sans |
| Landing | N/A (app-first) |
| Key rule | High contrast, real-time accuracy. |
Analytics Dashboard
| Aspect | Recommendation |
|---|---|
| Style | Data-Dense + Minimalism |
| Colors | Blue (#1E40AF) + amber highlights (#D97706) |
| Typography | Clean sans + monospace for data |
| Landing | N/A (app-first) |
| Key rule | Clarity over aesthetics. |
AI / Chatbot Platform
| Aspect | Recommendation |
|---|---|
| Style | AI-Native UI + Minimalism |
| Colors | AI purple (#7C3AED) + cyan (#0891B2) |
| Typography | Clean, minimal: Plus Jakarta Sans |
| Landing | Interactive Product Demo |
| Key rule | Conversational UI, streaming text, minimal chrome. |
Social Media App
| Aspect | Recommendation |
|---|---|
| Style | Vibrant & Block + Motion-Driven |
| Colors | Vibrant rose (#E11D48) + engagement blue (#2563EB) |
| Typography | Friendly bold sans |
| Landing | Feature-Rich Showcase |
| Key rule | Engagement, retention. |
Productivity Tool
| Aspect | Recommendation |
|---|---|
| Style | Flat Design + Micro-interactions |
| Colors | Teal (#0D9488) + action orange (#EA580C) |
| Typography | Functional clean: Outfit, Work Sans |
| Landing | Interactive Product Demo |
| Key rule | Speed, efficiency. |
Restaurant / Food
| Aspect | Recommendation |
|---|---|
| Style | Vibrant & Block + Motion |
| Colors | Appetizing red (#DC2626) + warm gold (#A16207) |
| Typography | Elegant: Playfair Display SC + Karla |
| Landing | Hero-Centric + Conversion |
| Key rule | Food photography, online ordering. |
Travel / Tourism
| Aspect | Recommendation |
|---|---|
| Style | Aurora UI + Motion-Driven |
| Colors | Sky blue (#0EA5E9) + adventure orange (#EA580C) |
| Typography | Inspiring display + clean body |
| Landing | Storytelling + Hero-Centric |
| Key rule | Destination showcase, mobile-first. |
Real Estate
| Aspect | Recommendation |
|---|---|
| Style | Glassmorphism + Minimalism |
| Colors | Trust teal (#0F766E) + professional blue (#0369A1) |
| Typography | Premium: Cinzel + Josefin Sans |
| Landing | Hero-Centric + Feature-Rich |
| Key rule | Property imagery, map integration. |
Fitness / Gym
| Aspect | Recommendation |
|---|---|
| Style | Vibrant & Block + Dark Mode |
| Colors | Energy orange (#F97316) + success green (#22C55E), dark bg (#1F2937) |
| Typography | Bold condensed: Barlow Condensed + Barlow |
| Landing | Feature-Rich |
| Key rule | Motivational, progress tracking. |
Music Streaming
| Aspect | Recommendation |
|---|---|
| Style | Dark Mode OLED + Vibrant |
| Colors | Deep dark (#1E1B4B) + play green (#22C55E) |
| Typography | Clean modern |
| Landing | Feature-Rich |
| Key rule | Audio player UX, album art integration. |
Podcast Platform
| Aspect | Recommendation |
|---|---|
| Style | Dark Mode + Minimalism |
| Colors | Dark (#1E1B4B) + warm orange (#F97316) |
| Typography | Clean sans |
| Landing | Storytelling-Driven |
| Key rule | Audio player, episode discovery. |
Dating App
| Aspect | Recommendation |
|---|---|
| Style | Vibrant & Block + Motion |
| Colors | Romantic rose (#E11D48) + warm orange (#EA580C) |
| Typography | Friendly rounded sans |
| Landing | Social Proof |
| Key rule | Profile cards, match animations, safety. |
Legal Services
| Aspect | Recommendation |
|---|---|
| Style | Trust & Authority + Minimalism |
| Colors | Authority navy (#1E3A8A) + trust gold (#B45309) |
| Typography | Serif authority: EB Garamond + Lato |
| Landing | Trust & Authority |
| Key rule | Credibility, professional imagery. |
Banking / Finance
| Aspect | Recommendation |
|---|---|
| Style | Minimalism + Accessible |
| Colors | Trust navy (#0F172A) + premium gold (#A16207) |
| Typography | Professional: IBM Plex Sans |
| Landing | Trust & Authority |
| Key rule | Security-first, accessibility critical. |
Insurance
| Aspect | Recommendation |
|---|---|
| Style | Trust & Authority + Flat |
| Colors | Security blue (#0369A1) + protected green (#16A34A) |
| Typography | Trustworthy sans |
| Landing | Conversion + Trust |
| Key rule | Quote calculator, trust signals. |
Non-profit / Charity
| Aspect | Recommendation |
|---|---|
| Style | Accessible & Ethical + Organic Biophilic |
| Colors | Compassion blue (#0891B2) + action orange (#EA580C) |
| Typography | Warm readable: Lora + Raleway |
| Landing | Storytelling + Trust |
| Key rule | Impact stories, donation flow. |
Wedding / Event
| Aspect | Recommendation |
|---|---|
| Style | Soft UI + Aurora UI |
| Colors | Romantic pink (#DB2777) + elegant gold (#A16207) |
| Typography | Elegant: Great Vibes accents + Cormorant Infant |
| Landing | Storytelling + Social Proof |
| Key rule | Portfolio gallery, planning tools. |
Smart Home / IoT
| Aspect | Recommendation |
|---|---|
| Style | Glassmorphism + Dark Mode |
| Colors | Dark tech (#1E293B) + status green (#22C55E) |
| Typography | Clean sans + mono for data |
| Landing | Interactive Product Demo |
| Key rule | Real-time controls, device status. |
Knowledge Base / Docs
| Aspect | Recommendation |
|---|---|
| Style | Minimalism + Accessible |
| Colors | Neutral gray (#475569) + link blue (#2563EB) |
| Typography | Highly readable: Inter |
| Landing | FAQ/Documentation |
| Key rule | Search-first, clear navigation. |
Job Board / Recruitment
| Aspect | Recommendation |
|---|---|
| Style | Flat Design + Minimalism |
| Colors | Professional blue (#0369A1) + success green (#16A34A) |
| Typography | Clean professional sans |
| Landing | Conversion + Feature-Rich |
| Key rule | Search/filter, company profiles. |
Marketplace (P2P)
| Aspect | Recommendation |
|---|---|
| Style | Vibrant & Block + Flat |
| Colors | Trust purple (#7C3AED) + transaction green (#16A34A) |
| Typography | Clean readable |
| Landing | Feature-Rich + Social Proof |
| Key rule | Trust badges, reviews. |
Logistics / Delivery
| Aspect | Recommendation |
|---|---|
| Style | Minimalism + Flat |
| Colors | Tracking blue (#2563EB) + delivery orange (#EA580C) |
| Typography | Clean, data-friendly |
| Landing | Feature-Rich + Conversion |
| Key rule | Real-time tracking, map integration. |
Hotel / Hospitality
| Aspect | Recommendation |
|---|---|
| Style | Liquid Glass + Minimalism |
| Colors | Luxury navy (#1E3A8A) + gold (#A16207) |
| Typography | Elegant serif heading + clean body |
| Landing | Hero-Centric + Social Proof |
| Key rule | Room booking, luxury imagery. |
B2B Service
| Aspect | Recommendation |
|---|---|
| Style | Trust & Authority + Minimal |
| Colors | Professional navy (#0F172A) + blue CTA (#0369A1) |
| Typography | Professional sans |
| Landing | Feature-Rich |
| Key rule | Credibility, ROI messaging. |
Government / Public
| Aspect | Recommendation |
|---|---|
| Style | Accessible & Ethical + Minimalism |
| Colors | High-contrast navy (#0F172A) + blue (#0369A1) |
| Typography | Maximum readability: Lexend, Atkinson Hyperlegible |
| Landing | Minimal & Direct |
| Key rule | WCAG AAA mandatory. |
Mental Health App
| Aspect | Recommendation |
|---|---|
| Style | Neumorphism + Accessible |
| Colors | Calming lavender (#8B5CF6) + wellness green (#059669) |
| Typography | Calm, accessible fonts |
| Landing | Social Proof |
| Key rule | Calming aesthetic, privacy-first, crisis resources. |
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);
}
}Fluid Typography
Use clamp() to create font sizes that scale smoothly between breakpoints without media queries:
/* clamp(minimum, preferred, maximum) */
h1 { font-size: clamp(2rem, 1rem + 3vw, 3.5rem); }
h2 { font-size: clamp(1.5rem, 0.8rem + 2vw, 2.5rem); }
h3 { font-size: clamp(1.25rem, 0.9rem + 1vw, 1.75rem); }
body { font-size: clamp(1rem, 0.9rem + 0.25vw, 1.125rem); }The formula: clamp(min, preferred, max) where preferred is typically base + viewport-unit. The middle value controls the scaling speed - higher vw = faster scaling.
Fluid spacing with clamp:
:root {
--space-section: clamp(3rem, 2rem + 4vw, 6rem);
--space-component: clamp(1.5rem, 1rem + 2vw, 3rem);
}Gotcha: The middle value in clamp() must include a fixed unit (like 1rem + 3vw), not just 3vw alone. Pure viewport units break zoom accessibility (WCAG 1.4.4).
Container Queries
Media queries respond to the viewport. Container queries respond to the parent element's size - better for reusable components.
/* 1. Define the container */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
/* 2. Query the container, not the viewport */
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px;
}
}
@container card (min-width: 600px) {
.card__title { font-size: 1.25rem; }
}When to use container queries vs media queries:
- Container queries: reusable components (cards, widgets) that appear in different-width contexts
- Media queries: page-level layout changes (sidebar collapse, grid column count)
Gotcha: container-type must be set on the PARENT, not the element being styled. Setting it on the element itself won't work.
Viewport Units Reference
| Unit | Meaning | Use when |
|---|---|---|
vw |
Viewport width | Fluid typography, full-bleed elements |
vh |
Viewport height (includes mobile chrome) | Avoid for mobile full-screen |
dvh |
Dynamic viewport height (adjusts with mobile chrome) | Full-screen mobile layouts |
svh |
Small viewport height (smallest possible) | Elements that must never be cut off |
lvh |
Large viewport height (largest possible) | Background sizing |
cqw |
Container query width | Fluid sizing within containers |
cqi |
Container query inline size | Same as cqw for horizontal writing |
Always use dvh instead of vh for mobile full-screen layouts. 100vh on mobile includes the browser chrome, cutting off content. 100dvh adjusts dynamically.
/* Mobile full-screen hero */
.hero {
min-height: 100vh; /* fallback */
min-height: 100dvh; /* modern */
}CSS Logical Properties
Use logical properties instead of physical ones for better RTL support and modern CSS:
| Physical (avoid) | Logical (prefer) |
|---|---|
margin-left |
margin-inline-start |
margin-right |
margin-inline-end |
padding-left/right |
padding-inline |
padding-top/bottom |
padding-block |
width |
inline-size |
height |
block-size |
text-align: left |
text-align: start |
border-left |
border-inline-start |
Logical properties automatically flip for RTL languages. Use padding-inline and margin-block as your defaults.
Safe Area Insets
For devices with notches, rounded corners, or home indicators:
/* Fixed bottom navigation */
.bottom-nav {
position: fixed;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Fixed top header on notched devices */
.top-header {
padding-top: env(safe-area-inset-top, 0px);
}
/* Full-bleed content */
.full-bleed {
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}Always include env(safe-area-inset-bottom) on any fixed-bottom element. Without it, content gets hidden behind the iOS home indicator.
Also add <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> to enable safe-area-inset values.
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);
}Shadow color: gray, not black
Default tool shadows use rgba(0,0,0,0.1) which looks harsh. Instead, change the shadow color to a light gray and increase blur significantly. This produces softer, more natural shadows:
/* Harsh (default Figma/tool shadow) */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
/* Better: gray color + more blur */
box-shadow: 0 4px 16px rgba(100, 100, 110, 0.12);When in doubt, remove the shadow entirely. Most of the time, less visual noise = better design.
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;
}Nested border-radius rule: when a rounded element sits inside another rounded element, the inner radius must be smaller. Formula: inner radius = outer radius - gap between them. If outer is 30px and gap is 10px, inner should be 20px. If the gap exceeds the outer radius, use 0 (no rounding). Without this, nested corners look uneven - the distance increases at curves even though straight edges match.
Exception: pill shapes (9999px) don't need this adjustment since the distance is constant all the way around.
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;
}The 3-Shade Layering System
Depth is the easiest way to fix boring UIs. The recipe: create 3 shades of the same color (increment lightness by ~0.05-0.1), then layer them.
:root {
--bg-dark: hsl(220 10% 8%); /* page base - deepest layer */
--bg: hsl(220 10% 13%); /* cards, sections - middle */
--bg-light: hsl(220 10% 18%); /* interactive/important - top */
}- Dark = page background (everything sits on top of this)
- Base = cards, sections, containers
- Light = interactive elements, selected states, buttons, important items
Lighter = elevated = closer to user = more important. Then add shadows to sell the illusion.
When color layers are enough, remove borders. If an element is already differentiated by background shade, a border is redundant. Drop it for a cleaner look.
Raised vs Recessed Shadows
Shadows aren't just for elevation - you can push elements DOWN too.
/* Raised: light inset on top + dark shadow below (elevated, important) */
.raised {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06), /* light from above */
0 2px 4px rgba(0, 0, 0, 0.2); /* dark shadow below */
}
/* Recessed: dark inset on top + light inset below (pushed in, contained) */
.recessed {
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.3), /* dark pushes top down */
inset 0 -1px 0 rgba(255, 255, 255, 0.05); /* light rim at bottom */
}Use raised for cards, selected tabs, CTAs, elevated elements. Use recessed for inputs, progress bar tracks, tables, and wells/containers. The top-light/bottom-dark pattern simulates light coming from above, which our eyes expect.
Hover Shadow Escalation
Small shadow at rest, bigger on hover. One of the cheapest ways to add interactivity:
.card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
}
.card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}This works best in light mode. Don't ignore light mode just because you prefer dark - it's the default for most users.
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
- Elements already differentiated by bg shade? -> Drop the border
- 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)
- Keeping borders on elements that are already differentiated by background shade (redundant)
- Flat design with no depth variation (everything on same plane = boring)
- Ignoring light mode depth (shadows are more visible and effective in light themes)
style-catalog.md
UI Style Catalog
25 production-ready UI styles with decision guidance. For each: what it looks like, key techniques, when to use it, and when to skip it.
Quick-Pick Table
| Building... | Start with | Alternative |
|---|---|---|
| SaaS / B2B | Glassmorphism + Flat | Minimalism |
| E-commerce | Vibrant & Block-based | Motion-Driven |
| E-commerce Luxury | Liquid Glass | Glassmorphism |
| Fintech / Crypto | Dark Mode OLED + Glassmorphism | Cyberpunk UI |
| Healthcare | Neumorphism + Accessible | Soft UI |
| Educational | Claymorphism + Micro-interactions | Vibrant & Block |
| Creative Agency | Brutalism + Motion-Driven | Retro-Futurism |
| Portfolio | Motion-Driven + Minimalism | Brutalism |
| Gaming | 3D & Hyperrealism + Retro-Futurism | Cyberpunk UI |
| AI / Chatbot | AI-Native UI + Minimalism | Zero Interface |
| Dashboard (Finance) | Dark Mode OLED | Minimalism |
| Dashboard (Analytics) | Data-Dense + Minimalism | Dark Mode OLED |
| Social Media | Vibrant & Block-based + Motion | Aurora UI |
| Productivity | Flat Design + Micro-interactions | Minimalism |
| Restaurant / Food | Vibrant & Block + Motion | Claymorphism |
| Travel / Tourism | Aurora UI + Motion | Vibrant & Block |
| Real Estate | Glassmorphism + Minimalism | Motion-Driven |
| Music / Podcast | Dark Mode OLED | Vibrant & Block |
| Government / Legal | Accessible & Ethical + Minimalism | Trust & Authority |
| Non-profit | Accessible & Ethical + Organic Biophilic | Storytelling-Driven |
| Wellness / Spa | Soft UI + Neumorphism | Organic Biophilic |
1. Minimalism
Clean, functional, maximum whitespace. Let the content breathe.
Key effects: Subtle shadows, minimal color (1-2 accents), generous padding (24-48px), system fonts or a single sans-serif family. High line-height (1.6-1.8).
CSS signature: max-width: 720px; margin: 0 auto; padding: 2rem;
Best for: Dashboards, documentation, enterprise apps, developer tools.
Avoid when: Marketing pages need visual punch or emotional impact.
2. Glassmorphism
Frosted glass panels with transparency and layered depth.
Key effects: backdrop-filter: blur(16px), semi-transparent backgrounds rgba(255,255,255,0.15), thin borders rgba(255,255,255,0.1), stacked translucent layers.
CSS signature: background: rgba(255,255,255,0.1); backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.18);
Best for: SaaS, real estate, modern dashboards, overlay panels.
Avoid when: Content-heavy text pages, accessibility-critical contexts (contrast issues), older browser targets.
3. Brutalism
Raw, intentional ugliness as aesthetic. Anti-design on purpose.
Key effects: Thick borders (2-4px solid black), harsh offset shadows, monospace fonts, high-contrast colors, zero border-radius, system-default cursor, visible grid lines.
CSS signature: border: 3px solid #000; box-shadow: 6px 6px 0 #000; font-family: monospace;
Best for: Creative agencies, portfolios, art galleries, experimental sites.
Avoid when: Healthcare, finance, government - anywhere trust and comfort matter.
4. Neumorphism
Soft embossed/debossed surfaces that feel tactile.
Key effects: Dual box-shadow (light from top-left, dark from bottom-right), soft background matching surrounding color, no harsh borders, inset shadows for pressed states.
CSS signature: box-shadow: 8px 8px 16px #d1d1d1, -8px -8px 16px #ffffff; background: #e0e0e0;
Best for: Healthcare, wellness, calculator UIs, simple control panels.
Avoid when: Dense data interfaces, accessibility audits (low contrast fails WCAG AA).
5. Claymorphism
3D clay-like elements with playful depth and soft color.
Key effects: Large border-radius (16-24px), colored inner shadows, soft pastel drop shadows, pastel palette, slight CSS rotation on cards for organic feel.
CSS signature: border-radius: 20px; background: #f5e6ff; box-shadow: inset 0 -4px 6px rgba(0,0,0,0.08), 8px 8px 20px rgba(0,0,0,0.1);
Best for: Educational platforms, kids apps, playful brands, onboarding flows.
Avoid when: Professional/enterprise contexts, fintech, legal.
6. Aurora UI
Gradient mesh backgrounds with flowing, shifting color.
Key effects: Layered radial-gradient blobs, animated color shifts via @keyframes, blur overlays, glass panels floating over aurora backgrounds.
CSS signature: background: radial-gradient(ellipse at 20% 50%, #7f5af0, transparent 50%), radial-gradient(ellipse at 80% 50%, #2cb67d, transparent 50%);
Best for: Travel, creative landing pages, modern SaaS marketing, event pages.
Avoid when: Data-dense interfaces, text-heavy documentation.
7. Dark Mode OLED
True dark with vibrant accents. Energy-efficient on OLED screens.
Key effects: #000000 or near-black (#0a0a0a) backgrounds, high-contrast white/light text, neon accent colors, subtle colored glows on interactive elements.
CSS signature: background: #000; color: #e0e0e0; --accent: #00f0ff;
Best for: Music, video, fintech, dashboards, developer tools, crypto platforms.
Avoid when: Bright/friendly consumer apps, brands targeting older demographics.
8. Retro-Futurism
80s sci-fi meets modern UI. Neon grids and synthwave palettes.
Key effects: Neon glow via text-shadow and box-shadow with color, scanline overlays (repeating-linear-gradient), perspective grid backgrounds, monospace/display fonts, CRT curvature via border-radius.
CSS signature: text-shadow: 0 0 10px #ff00ff, 0 0 40px #ff00ff; background: linear-gradient(transparent 50%, rgba(0,0,0,0.05) 50%);
Best for: Gaming, creative projects, tech art, music events.
Avoid when: Corporate, healthcare, government, accessibility-first.
9. Liquid Glass
Ultra-premium transparency with multi-layer refraction effects.
Key effects: Multi-layer backdrop-filter (blur + saturate + brightness), gradient borders via border-image or pseudo-elements, color-shifting backgrounds, depth through transparency stacking.
CSS signature: backdrop-filter: blur(20px) saturate(180%); background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0.05));
Best for: Luxury brands, premium products, high-end real estate, automotive.
Avoid when: Performance-constrained devices, accessibility-critical, content-first pages.
10. Flat Design
No shadows, no gradients, pure color and shape.
Key effects: Solid fills, clear typography hierarchy, icon-driven communication, bold primary colors, zero elevation, sharp edges or consistent small radius.
CSS signature: box-shadow: none; border-radius: 4px; background: #2563eb; color: #fff;
Best for: Productivity tools, design systems, documentation, internal tools.
Avoid when: Depth and visual hierarchy are critical, premium/luxury branding.
11. Vibrant & Block-based
Bold color blocks with clear section boundaries and strong contrast.
Key effects: Full-width colored sections, alternating block backgrounds, large typography (48-72px headlines), animated section transitions, strong color contrast between adjacent blocks.
CSS signature: background: #ff6b35; padding: 4rem 2rem; color: #fff; font-size: 3rem;
Best for: E-commerce, social media, restaurants, marketing pages, event sites.
Avoid when: Enterprise/clinical UIs, data-heavy dashboards.
12. Motion-Driven
Animation as the core UX feature, not decoration.
Key effects: Scroll-triggered reveals (IntersectionObserver), parallax layers, staggered entrance animations, page transitions, hover micro-animations, kinetic typography.
CSS signature: @keyframes fadeUp { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } }
Best for: Portfolios, creative agencies, landing pages, product launches.
Avoid when: Data-heavy apps, accessibility-first (respect prefers-reduced-motion).
13. Soft UI Evolution
Refined neumorphism with better contrast and usability.
Key effects: Subtle inner/outer shadows, muted backgrounds (not pure white/gray), gentle gradients, floating elements with light elevation, improved contrast ratios over classic neumorphism.
CSS signature: box-shadow: 4px 4px 10px #d0d0d0, -4px -4px 10px #ffffff; border: 1px solid rgba(0,0,0,0.06);
Best for: Productivity apps, remote work tools, wellness, settings panels.
Avoid when: Bold/expressive needs, high-density data interfaces.
14. 3D & Hyperrealism
Three-dimensional rendered elements creating immersive depth.
Key effects: CSS 3D transforms with perspective, realistic multi-layer shadows, depth via translateZ, Three.js or Spline integration for hero sections, parallax on mouse move.
CSS signature: perspective: 1000px; transform: rotateY(5deg) rotateX(2deg); transform-style: preserve-3d;
Best for: Gaming, product showcases, immersive experiences, automotive.
Avoid when: Content-first pages, mobile performance constraints, screen readers.
15. Organic Biophilic
Nature-inspired shapes, colors, and textures.
Key effects: Blob shapes via complex border-radius (e.g., 30% 70% 70% 30% / 30% 30% 70% 70%), earth tones (greens, browns, warm beige), leaf/plant SVG patterns, flowing curves, natural texture overlays.
CSS signature: border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%; background: #2d5016;
Best for: Wellness, non-profit, agriculture, eco brands, organic food.
Avoid when: Tech/corporate, fintech, anything requiring sharp precision.
16. Swiss Modernism 2.0
Strict grid, rational typography, mathematical precision.
Key effects: Rigid grid alignment (12-column with consistent gutter), Helvetica/Grotesk/Inter fonts, limited color (2-3 max), strong horizontal rules, uppercase labels with wide letter-spacing, generous negative space.
CSS signature: font-family: 'Helvetica Neue', sans-serif; letter-spacing: 0.1em; text-transform: uppercase; border-bottom: 2px solid #000;
Best for: Design systems, documentation, typography-forward sites, editorial.
Avoid when: Playful/casual brands, children's products, consumer apps.
17. Cyberpunk UI
Neon-drenched dark interfaces with HUD aesthetics.
Key effects: Neon borders with glow, glitch effects via clip-path animation, terminal/monospace fonts, HUD-style overlays with corner brackets, matrix/rain backgrounds, text-shadow neon glow.
CSS signature: border: 1px solid #0ff; box-shadow: 0 0 10px #0ff, inset 0 0 10px rgba(0,255,255,0.1); color: #0ff;
Best for: Crypto/web3, gaming, tech art, hackathon projects.
Avoid when: Mainstream consumer products, healthcare, government, accessibility.
18. AI-Native UI
Conversational, minimal chrome, context-aware surfaces.
Key effects: Streaming text animations (@keyframes typing), typing indicators, floating response cards, minimal/hidden navigation, contextual UI that appears on demand, progressive disclosure.
CSS signature: max-width: 640px; margin: 0 auto; animation: fadeIn 0.3s ease; with response bubbles.
Best for: Chatbots, AI tools, search interfaces, command palettes.
Avoid when: Complex multi-page apps, dense dashboards, e-commerce.
19. Kinetic Typography
Text as the primary visual element, animated and expressive.
Key effects: Large display text (80-120px+), CSS animations on individual characters (span wrapping), scroll-driven text transformations, variable font font-weight animation, text masking with video/images.
CSS signature: font-size: clamp(3rem, 10vw, 8rem); font-variation-settings: 'wght' var(--scroll-weight);
Best for: Portfolios, agencies, artistic landing pages, brand statements.
Avoid when: Content reading, documentation, data interfaces.
20. Bento Box Grid
Unequal grid cells arranged like a bento box - mixed sizes for visual interest.
Key effects: CSS Grid with span 2 cells, mixed card sizes, feature highlights in large cells, compact info in small cells, consistent gap spacing, rounded corners on all cells.
CSS signature: display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; with children using grid-column: span 2;
Best for: Feature showcases, dashboards, portfolio grids, app homepages (Apple-style).
Avoid when: Uniform content lists, text-heavy pages, sequential reading.
21. Zero Interface
Invisible UI - content fills the viewport, controls hide until needed.
Key effects: Minimal visible controls, gesture-based interaction, content fills viewport, controls appear on hover/tap/scroll, opacity transitions, full-bleed media.
CSS signature: nav { opacity: 0; transition: opacity 0.3s; } nav:hover, nav:focus-within { opacity: 1; }
Best for: Reading apps, media consumption, meditation, galleries, kiosk displays.
Avoid when: Complex workflows, first-time users, accessibility requirements.
22. Accessible & Ethical
WCAG AAA as a design principle, not an afterthought.
Key effects: High contrast (7:1+ ratio), large touch targets (48px minimum), clear focus indicators (3px outline offset), simple layouts, no motion by default (prefers-reduced-motion: reduce), semantic HTML structure.
CSS signature: *:focus-visible { outline: 3px solid #005fcc; outline-offset: 3px; } @media (prefers-reduced-motion: reduce) { * { animation: none !important; } }
Best for: Government, healthcare, education, public services. Layer other styles on top.
Avoid when: Never avoid this - it is a foundation, not a style choice.
23. Storytelling-Driven
Narrative scroll experience with cinematic pacing.
Key effects: Full-page sections, scroll-snap-type: y mandatory, progressive content reveal on scroll, parallax imagery, cinematic transitions between chapters, large hero images with text overlay.
CSS signature: scroll-snap-type: y mandatory; section { height: 100vh; scroll-snap-align: start; }
Best for: Non-profit campaigns, brand stories, case studies, annual reports, portfolios.
Avoid when: Utility-focused apps, dashboards, frequently-revisited pages.
24. Trust & Authority
Institutional credibility through conservative, professional design.
Key effects: Navy/slate/charcoal palette, serif headlines (Georgia, Playfair Display), testimonial blocks, trust badges and certifications, conservative grid layout, professional photography, ample white space.
CSS signature: font-family: 'Playfair Display', serif; color: #1a2744; border-left: 4px solid #1a2744;
Best for: Legal, insurance, banking, B2B enterprise, consulting firms.
Avoid when: Creative/youth brands, startups wanting to look disruptive.
25. Parallax Storytelling
Depth through scroll-speed differences creating layered visual narrative.
Key effects: background-attachment: fixed or JS-driven scroll speed multipliers, layered elements moving at different rates, scroll-triggered animations, full-bleed imagery between content sections.
CSS signature: background-attachment: fixed; background-size: cover; or transform: translateY(calc(var(--scroll) * 0.5));
Best for: Brand pages, product launches, immersive landing pages, travel sites.
Avoid when: Mobile (performance hit from background-attachment: fixed), accessibility-first, content that needs to be scannable.
Combining Styles
Most production UIs blend 2-3 styles. Rules for combining:
- Pick a base - Choose the structural style (Minimalism, Flat, Swiss Modernism, Bento Box)
- Add a surface treatment - Layer a visual style on top (Glassmorphism, Neumorphism, Dark Mode)
- Season with interaction - Add motion, micro-interactions, or scroll effects sparingly
- Always include Accessible & Ethical as a foundation layer regardless of other choices
Common safe combos: Minimalism + Glassmorphism, Dark Mode + Bento Box, Flat + Motion-Driven, Swiss Modernism + Trust & Authority, Aurora UI + Glassmorphism.
Risky combos to avoid: Brutalism + Trust & Authority, Cyberpunk + Healthcare, Neumorphism + Data-Dense, 3D + Mobile-first, Zero Interface + First-time Users.
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
Most UIs are just text and buttons. Typography is the 80/20 of UI design - master it and 80% of your UI quality is handled.
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;
}The Minimal Type Scale Alternative
You can build an entire UI with just 1-2 font sizes. Pick a base (14px or 16px), and go 2px up/down only when truly needed. Weight and color create more hierarchy than size:
- Same 16px at
weight: 700+color: hsl(0 0% 100%)looks like a heading - Same 16px at
weight: 400+color: hsl(0 0% 60%)looks like secondary text - Same 16px at
weight: 500+color: hsl(220 80% 60%)looks like a link
The impact of weight and color on perceived size is wild - three texts at identical pixel size can look like three different sizes. So before reaching for a bigger font size, first try increasing weight or darkening the color.
Practical workflow: set your base at 14 or 16px. Design everything at that size first. Only bump up for page titles and hero headings. Assign as CSS variables:
:root {
--text-sm: 0.875rem; /* 14px - when you need smaller */
--text-base: 1rem; /* 16px - default everything */
--text-lg: 1.125rem; /* 18px - titles, emphasis */
--text-display: 1.5rem; /* 24px+ - page titles only */
}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
- Line height as spacing: generous line-height acts as built-in
margin-bottomfor text elements. The "gap" between a card title and its metadata is often line-height, not a margin or padding. In most cases you don't need manual spacing between consecutive text blocks - line height does it for you. This is crucial for titles to "stand on their own" separate from surrounding text groups
/* 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.
| # | Aesthetic | Heading | Body | Character |
|---|---|---|---|---|
| 1 | Editorial | Fraunces | Satoshi | Warm serifs meet clean sans, literary feel |
| 2 | Brutalist | Neue Haas Grotesk | Akkurat | Raw, precise, no-nonsense |
| 3 | Playful | Cabinet Grotesk | General Sans | Rounded geometry, friendly energy |
| 4 | Luxury | Cormorant Garamond | Outfit | High-contrast serif meets modern sans |
| 5 | Technical | IBM Plex Mono | IBM Plex Sans | Engineered precision, developer tools |
| 6 | Organic | Recoleta | Switzer | Soft serifs, natural warmth |
| 7 | Art Deco | Clash Display | Supreme | Geometric drama, bold statements |
/* Pairing 1 - Editorial (Fraunces + Satoshi) */
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Satoshi:wght@400;500&display=swap');
:root {
--font-heading: 'Fraunces', Georgia, serif;
--font-body: 'Satoshi', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}Rules:
- Never use more than 2 families
- Pair a distinctive display font with a refined body font
- Contrast works: serif + sans, geometric + humanist, heavy + light
- Never pair two visually similar fonts (e.g. Inter + Roboto)
- Always define a system-ui or genre-appropriate fallback
- Each project should have its own typographic personality - never reuse the same pairing
Anti-Slop Font Rules
These fonts are overused in AI-generated UIs and should be avoided as primary choices:
| Font | Why it's overused | Use instead |
|---|---|---|
| Inter | Default in every AI tool, Figma, Vercel | Satoshi, General Sans, Switzer |
| Roboto | Android/Material default, zero personality | Outfit, Plus Jakarta Sans, Nunito Sans |
| Arial/Helvetica | System default, says "I didn't choose a font" | Neue Haas Grotesk (the original), Akkurat |
| Space Grotesk | Every AI SaaS landing page uses this | Cabinet Grotesk, Clash Grotesk, Familjen Grotesk |
| Poppins | Geometric sans that AI defaults to | General Sans, Cabinet Grotesk, Switzer |
| Open Sans | The "safe" Google Font pick | Source Sans 3, Nunito Sans, Plus Jakarta Sans |
The goal is not to blacklist fonts - Inter is genuinely good. The goal is to stop defaulting to the same 5 fonts on every project. Each design deserves its own typographic voice.
Rule: Before choosing fonts, define the aesthetic direction first (editorial, brutalist, playful, luxury, etc.), then pick fonts that serve that direction. Never start with "I'll just use Inter."
Context-Specific Pairings
When the product domain is known, use these industry-optimized pairings:
| Context | Heading | Body | Google Fonts | Notes |
|---|---|---|---|---|
| Wellness / Health | Lora | Raleway | Lora:wght@400;600;700 Raleway:wght@300;400;500 |
Organic curves meet elegant simplicity |
| Legal / Finance | EB Garamond | Lato | EB+Garamond:wght@400;500;600;700 Lato:wght@300;400;700 |
Serif authority, clean body text |
| Gaming / Esports | Russo One | Chakra Petch | Russo+One Chakra+Petch:wght@300;400;500;600;700 |
Impact display, techy body |
| Restaurant / Food | Playfair Display SC | Karla | Playfair+Display+SC:wght@400;700 Karla:wght@300;400;500;600;700 |
Small-caps menu elegance |
| Kids / Education | Baloo 2 | Comic Neue | Baloo+2:wght@400;500;600;700 Comic+Neue:wght@300;400;700 |
Playful, friendly, readable |
| Fashion / Avant-Garde | Syne | Manrope | Syne:wght@400;500;600;700 Manrope:wght@300;400;500;600;700 |
Distinctive creative headers |
| Crypto / Web3 | Orbitron | Exo 2 | Orbitron:wght@400;500;600;700 Exo+2:wght@300;400;500;600;700 |
Futuristic, digital currency |
| E-commerce | Rubik | Nunito Sans | Rubik:wght@300;400;500;600;700 Nunito+Sans:wght@300;400;500;600;700 |
Clean, conversion-focused readability |
| Academic / Research | Crimson Pro | Atkinson Hyperlegible | Crimson+Pro:wght@400;500;600;700 Atkinson+Hyperlegible:wght@400;700 |
Scholarly serif, maximum accessibility |
| Sports / Fitness | Barlow Condensed | Barlow | Barlow+Condensed:wght@400;500;600;700 Barlow:wght@300;400;500;600;700 |
Condensed impact headlines |
| Retro / Vintage | Abril Fatface | Merriweather | Abril+Fatface Merriweather:wght@300;400;700 |
High-impact vintage feel, display only |
| Bold / Marketing | Bebas Neue | Source Sans 3 | Bebas+Neue Source+Sans+3:wght@300;400;500;600;700 |
All-caps display + neutral body |
| Real Estate / Luxury | Cinzel | Josefin Sans | Cinzel:wght@400;500;600;700 Josefin+Sans:wght@300;400;500;600;700 |
Premium serif elegance |
| Medical / Healthcare | Figtree | Noto Sans | Figtree:wght@300;400;500;600;700 Noto+Sans:wght@300;400;500;700 |
Clean, highly accessible |
| Dashboard / Data | Fira Code | Fira Sans | Fira+Code:wght@400;500;600;700 Fira+Sans:wght@300;400;500;600;700 |
Mono-sans family cohesion |
These pairings complement the aesthetic-direction table above. The aesthetic table is for creative direction; this table is for domain-specific defaults when you already know the product type.
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
Deemphasize to emphasize
The most effective way to make something stand out is often to reduce the prominence of everything else. Instead of making the target bigger/bolder/brighter, lower the contrast of competing elements. Ask: "what's the first thing the user will look for?" Then deemphasize everything that isn't that.
Steps: start with color contrast on the primary element. If insufficient, reduce contrast on secondary info. Then increase font weight on primary. Then increase size. Zoom out after each change - if the target doesn't pop at page level, keep adjusting.
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);
}Decorative Elements as Context
Illustrations, doodles, icons, and images placed around hero text aren't decoration - they're context. A user should understand what your product does before reading a single word.
Rules for decorative elements:
- Maintain generous spacing around the core text - elements are context, not clutter
- Elements should draw the eye toward the center (the message). Trail off further from the focal point.
- Don't overdo it - if adding one more element feels cluttered, stop. Empty space is fine.
- Give each element a unique entrance animation (star rotates in, balloon flies up, card slides). Uniform fade-in on all decorative elements looks robotic.
- Match the vibe: realistic imagery for professional, doodles/illustrations for playful, blobs/gradients for creative
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 |
| Same size for all H1/H2/H3 tags | HTML tags define semantics, not visual hierarchy | Size based on context - an H3 can be larger than an H2 elsewhere |
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 absolute-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 absolute-ui?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill absolute-ui in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support absolute-ui?
absolute-ui works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.
Is absolute-ui free?
Yes, absolute-ui is completely free and open source under the MIT license. Install it with a single command and start using it immediately.
What is the difference between absolute-ui and similar tools?
absolute-ui is an AI agent skill that teaches your coding agent specialized software engineering knowledge. Unlike standalone tools, it integrates directly into claude-code, gemini-cli, openai-codex and other AI agents.
Can I use absolute-ui with Cursor or Windsurf?
absolute-ui works with any AI coding agent that supports the skills protocol, including Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini CLI, and 40+ more.