agentleFS
raphaelsalajaraphaelsalaja/userinterface-wiki/skills/AGENTS.md

userinterface-wiki / skills

AGENTS.md906 starsChanged 8 months agoMITView on GitHub

Version 3.0.0 raphael-salaja March 2026 Note: This document is mainly for agents and LLMs to follow when reviewing, generating, or refactoring UI code. Humans may also find it useful, but guidance here is optimized for automation and consistency by AI-assisted workflows. Comprehensive UI/UX best practices guide for web interfaces, designed for AI agents and LLMs. Contains 152 rules across 12 categories, prioritized by impact from critical (animation principles, timing functions) to incremental (morphing icons, typography). Each rule includes detailed explanations…

3 other projects use a version of this.

Adopted by

Most recently taken up by

  • Other versions3 projects use a version of this.

    Word for word · 3

    1. wibus-wee/cradle-app★ 75100% same
    2. julianromli/faiz-skills★ 16100% same
    3. jasonfesta/design-agents★ 1100% same

    Edited · 0

    None found.

    Wrote their own version · 0

    None found.

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

  • Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.

    Of the AGENTS.md files we read, the 31,774 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.

  • In this repository2 other files it gives its agents.

The file

Show its rules and text

We split this AGENTS.md into the rules it gives an agent

52 rules. About 0 are common, 52 are distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈52

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use exponential ramps, not linear, for natural decay.

    line 245
  2. Distinctive:

    Use springs (not easing) when overshoot-and-settle is needed.

    line 310
  3. Distinctive:

    Only one element should animate prominently at a time.

    line 342
  4. Distinctive:

    When velocity matters, use springs to preserve input energy.

    line 444
  5. Distinctive:

    If animation feels slow, shorten duration before adjusting curve.

    line 613
  6. Distinctive:

    When using usePresence, always call safeToRemove after async work.

    line 842
  7. Distinctive:

    Use popLayout mode for list reordering animations.

    line 944
  8. Distinctive:

    Use pseudo-elements for decorative content instead of extra DOM nodes.

    line 1051
  9. Distinctive:

    Use negative inset values to expand hit targets without extra markup.

    line 1140
  10. Distinctive:

    Remove view-transition-name after transition completes.

    line 1207
  11. Distinctive:

    Prefer View Transitions API over JavaScript animation libraries for page transitions.

    line 1230
  12. Distinctive:

    Use ::backdrop pseudo-element for dialog/popover backgrounds.

    line 1276
  13. Distinctive:

    Use ::placeholder for input placeholder styling, not wrapper elements.

    line 1298
  14. Distinctive:

    Use ::selection for text selection styling.

    line 1320
  15. Distinctive:

    Use ::marker to style list bullets without extra elements or background-image hacks.

    line 1333
  16. Distinctive:

    Use ::first-line for drop-cap-adjacent styling without JavaScript or hardcoded spans.

    line 1356
  17. Distinctive:

    Every audio cue must have a visual equivalent.

    line 1386
  18. Distinctive:

    Do not add sound to high-frequency interactions (typing, keyboard navigation).

    line 1490
  19. Distinctive:

    Do not add sound to decorative moments with no informational value.

    line 1540
  20. Distinctive:

    Default volume should be subtle, not loud.

    line 1607
  21. Distinctive:

    Check and resume suspended AudioContext before playing.

    line 1742
  22. Distinctive:

    Use exponential ramps for natural decay, not linear.

    line 1785
  23. Distinctive:

    Use filtered noise for clicks/taps, not oscillators.

    line 1834
  24. Distinctive:

    Use oscillators with pitch movement for tonal sounds (pops, confirmations).

    line 1855
  25. Distinctive:

    Core concept: Every icon is composed of exactly three SVG lines.

    line 1973
  26. Distinctive:

    Every icon MUST use exactly 3 lines.

    line 2000
  27. Distinctive:

    All icons must use the same viewBox (14x14 recommended).

    line 2055
  28. Distinctive:

    When transitioning between icons NOT in the same group, rotation should jump instantly.

    line 2142
  29. Distinctive:

    Use an outer animated div and an inner measured div.

    line 2246
  30. Distinctive:

    Use ResizeObserver to track element dimensions.

    line 2296
  31. Distinctive:

    Bad use cases: every container on the page, static layouts, elements that don't change size.

    line 2363
  32. Distinctive:

    Use a callback ref (not useRef) for measurement hooks so the observer attaches when the DOM node is ready.

    line 2367
  33. Distinctive:

    Add a small delay so the transition feels like it's catching up to the content.

    line 2393
  34. Distinctive:

    Use pseudo-elements or invisible padding to expand clickable areas beyond visible bounds.

    line 2441
  35. Distinctive:

    If you can't make something fast, make it feel fast with optimistic UI, skeletons, or progress indicators.

    line 2544
  36. Distinctive:

    Don't overwhelm users with everything at once.

    line 2606
  37. Distinctive:

    When multiple similar elements are present, the one that differs is most likely to be remembered (Von Restorff Effect).

    line 2795
  38. Distinctive:

    Every system has irreducible complexity (Tesler's Law).

    line 2869
  39. Distinctive:

    Remove anything that doesn't help the user complete their task.

    line 3000
  40. Distinctive:

    Don't prefetch everything visible in the viewport.

    line 3113
  41. Distinctive:

    Use tabular-nums for any numeric data that should align in columns.

    line 3208
  42. Distinctive:

    Use oldstyle-nums in body text so numbers blend with lowercase letters.

    line 3224
  43. Distinctive:

    Keep contextual alternates (calt) enabled.

    line 3250
  44. Distinctive:

    Leave font-optical-sizing at auto.

    line 3270
  45. Distinctive:

    Use text-wrap: balance on headings to make lines roughly equal length instead of one long line and a short orphan.

    line 3299
  46. Distinctive:

    Use text-underline-offset to push underlines below descenders so they look intentional.

    line 3315
  47. Distinctive:

    Use text-wrap: pretty for body text to reduce orphans.

    line 3387
  48. Distinctive:

    When nesting rounded elements, inner radius must equal outer radius minus the gap.

    line 3458
  49. Distinctive:

    All shadows must share the same offset direction to imply a single light source.

    line 3514
  50. Distinctive:

    Don't use arbitrary pixel values.

    line 3611
  51. Distinctive:

    When reviewing files, output findings as:

    line 3707
  52. Distinctive:

    After findings, output a summary:

    line 3720

≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

User Interface Wiki

Version 3.0.0 raphael-salaja March 2026

Note: This document is mainly for agents and LLMs to follow when reviewing, generating, or refactoring UI code. Humans may also find it useful, but guidance here is optimized for automation and consistency by AI-assisted workflows.


Abstract

Comprehensive UI/UX best practices guide for web interfaces, designed for AI agents and LLMs. Contains 152 rules across 12 categories, prioritized by impact from critical (animation principles, timing functions) to incremental (morphing icons, typography). Each rule includes detailed explanations and code examples comparing incorrect vs. correct implementations.


Table of Contents

  1. Animation Principles — CRITICAL
    • 1.1 User Animations Under 300ms
    • 1.2 Consistent Timing for Similar Elements
    • 1.3 No Entrance Animation on Context Menus
    • 1.4 Exponential Ramps for Natural Decay
    • 1.5 No Linear Easing for Motion
    • 1.6 Active State Scale Transform
    • 1.7 Subtle Squash and Stretch
    • 1.8 Springs for Overshoot and Settle
    • 1.9 Stagger Under 50ms Per Item
    • 1.10 Single Focal Point
    • 1.11 Dim Background for Focus
    • 1.12 Z-Index Layering for Animated Elements
  2. Timing Functions — HIGH
    • 2.1 Springs for Gesture-Driven Motion
    • 2.2 Springs for Interruptible Motion
    • 2.3 Springs Preserve Input Velocity
    • 2.4 Balanced Spring Parameters
    • 2.5 Easing for System State Changes
    • 2.6 Ease-Out for Entrances
    • 2.7 Ease-In for Exits
    • 2.8 Ease-In-Out for View Transitions
    • 2.9 Linear Easing Only for Progress
    • 2.10 Press and Hover 120-180ms
    • 2.11 Small State Changes 180-260ms
    • 2.12 Max 300ms for User Actions
    • 2.13 Shorten Duration Before Adjusting Curve
    • 2.14 No Animation for High-Frequency Interactions
    • 2.15 No Animation for Keyboard Navigation
    • 2.16 No Entrance Animation for Context Menus
  3. Exit Animations — HIGH
    • 3.1 AnimatePresence Wrapper Required
    • 3.2 Exit Prop Required Inside AnimatePresence
    • 3.3 Unique Keys in AnimatePresence Lists
    • 3.4 Exit Mirrors Initial for Symmetry
    • 3.5 useIsPresent in Child Component
    • 3.6 Call safeToRemove After Async Work
    • 3.7 Disable Interactions on Exiting Elements
    • 3.8 Mode "wait" Doubles Duration
    • 3.9 Mode "sync" Causes Layout Conflicts
    • 3.10 popLayout for List Reordering
    • 3.11 Propagate Prop for Nested AnimatePresence
    • 3.12 Coordinated Parent-Child Exit Timing
  4. CSS Pseudo Elements — MEDIUM
    • 4.1 Content Property Required for Pseudo-Elements
    • 4.2 Pseudo-Elements Over DOM Nodes
    • 4.3 Position Relative Parent for Pseudo-Elements
    • 4.4 Z-Index Layering for Pseudo-Elements
    • 4.5 Hit Target Expansion with Pseudo-Elements
    • 4.6 View Transition Name Required
    • 4.7 Unique View Transition Names
    • 4.8 Clean Up View Transition Names
    • 4.9 View Transitions Over JS Libraries
    • 4.10 Style View Transition Pseudo-Elements
    • 4.11 Use ::backdrop for Dialog Backgrounds
    • 4.12 Use ::placeholder for Input Styling
    • 4.13 Use ::selection for Text Styling
    • 4.14 Use ::marker for Custom List Bullets
    • 4.15 Use ::first-line for Typographic Treatments
  5. Audio Feedback — MEDIUM
    • 5.1 Visual Equivalent for Every Sound
    • 5.2 Toggle Setting to Disable Sounds
    • 5.3 Respect prefers-reduced-motion for Sound
    • 5.4 Independent Volume Control
    • 5.5 No Sound on High-Frequency Interactions
    • 5.6 Sound for Confirmations
    • 5.7 Sound for Errors and Warnings
    • 5.8 No Decorative Sound
    • 5.9 Informative Not Punishing Sound
    • 5.10 Preload Audio Files
    • 5.11 Subtle Default Volume
    • 5.12 Reset currentTime Before Replay
    • 5.13 Match Sound Weight to Action
    • 5.14 Sound Duration Matches Action Duration
  6. Sound Synthesis — MEDIUM
    • 6.1 Reuse Single AudioContext
    • 6.2 Resume Suspended AudioContext
    • 6.3 Clean Up Audio Nodes After Playback
    • 6.4 Exponential Decay for Natural Sound
    • 6.5 No Zero Target for Exponential Ramps
    • 6.6 Set Initial Value Before Ramp
    • 6.7 Noise for Percussive Sounds
    • 6.8 Oscillators for Tonal Sounds
    • 6.9 Bandpass Filter for Sound Character
    • 6.10 Click Duration 5-15ms
    • 6.11 Click Filter 3000-6000Hz
    • 6.12 Gain Under 1.0
    • 6.13 Filter Q Value 2-5
  7. Morphing Icons — LOW
    • 7.1 Icons Must Use Exactly Three Lines
    • 7.2 Use Collapsed Constant for Unused Lines
    • 7.3 Consistent ViewBox Size
    • 7.4 Shared Group for Rotational Variants
    • 7.5 Spring Physics for Rotation
    • 7.6 Reduced Motion Support for Icons
    • 7.7 Instant Jump for Non-Grouped Icons
    • 7.8 Round Stroke Line Caps
    • 7.9 Aria Hidden on Icon SVGs
  8. Container Animation — MEDIUM
    • 8.1 Two-Div Pattern for Animated Bounds
    • 8.2 Guard Against Zero on Initial Render
    • 8.3 Use ResizeObserver for Measurement
    • 8.4 Overflow Hidden on Animated Container
    • 8.5 Use Animated Bounds Sparingly
    • 8.6 Use Callback Ref for Measurement
    • 8.7 Add Delay for Natural Container Transitions
  9. Laws of UX — HIGH
    • 9.1 Size Interactive Targets for Easy Clicking
    • 9.2 Expand Hit Areas with Invisible Padding
    • 9.3 Minimize Choices to Reduce Decision Time
    • 9.4 Chunk Data into Groups of 5-9
    • 9.5 Respond Within 400ms
    • 9.6 Fake Speed When Actual Speed Isn't Possible
    • 9.7 Accept Messy Input, Output Clean Data
    • 9.8 Show What Matters Now, Reveal Complexity Later
    • 9.9 Use Familiar UI Patterns
    • 9.10 Visual Polish Increases Perceived Usability
    • 9.11 Group Related Elements Spatially
    • 9.12 Similar Elements Should Look Alike
    • 9.13 Use Boundaries to Group Related Content
    • 9.14 Make Important Elements Visually Distinct
    • 9.15 Place Key Items First or Last
    • 9.16 End Experiences with Clear Success States
    • 9.17 Move Complexity to the System
    • 9.18 Show Progress Toward Completion
    • 9.19 Show Incomplete State to Drive Completion
    • 9.20 Simplify Complex Visuals into Clear Forms
    • 9.21 Prioritize the Critical 20% of Features
    • 9.22 Minimize Extraneous Cognitive Load
    • 9.23 Visually Connect Related Elements
  10. Predictive Prefetching — MEDIUM
    • 10.1 Trajectory Prediction Over Hover Prefetching
    • 10.2 Prefetch by Intent, Not Viewport
    • 10.3 Use hitSlop to Trigger Predictions Earlier
    • 10.4 Fall Back Gracefully on Touch Devices
    • 10.5 Prefetch on Keyboard Navigation
    • 10.6 Use Predictive Prefetching Selectively
  11. Typography — MEDIUM
    • 11.1 Tabular Numbers for Data Display
    • 11.2 Oldstyle Numbers for Body Text
    • 11.3 Slashed Zero for Disambiguation
    • 11.4 Enable Contextual Alternates
    • 11.5 Use Disambiguation Stylistic Set for UI
    • 11.6 Keep Optical Sizing Auto
    • 11.7 Use Antialiased Font Smoothing
    • 11.8 Balance Headings with text-wrap
    • 11.9 Offset Underlines from Descenders
    • 11.10 Disable Font Synthesis for Missing Styles
    • 11.11 Use font-display swap
    • 11.12 Continuous Weight Values with Variable Fonts
    • 11.13 text-wrap pretty for Body Text
    • 11.14 Pair Justified Text with Hyphens
    • 11.15 Add Letter Spacing to Uppercase Text
    • 11.16 Use Typographic Fractions
  12. Visual Design — HIGH
    • 12.1 Concentric Border Radius for Nested Elements
    • 12.2 Layer Multiple Shadows for Realistic Depth
    • 12.3 Consistent Shadow Direction Across UI
    • 12.4 Use Neutral Colors for Shadows
    • 12.5 Shadow Size Indicates Elevation
    • 12.6 Animate Shadows via Pseudo-Element Opacity
    • 12.7 Use a Consistent Spacing Scale
    • 12.8 Use Semi-Transparent Borders
    • 12.9 Full Shadow Anatomy on Buttons

1. Animation Principles

Impact: CRITICAL — Disney's 12 principles adapted for web. Violations here produce the most noticeable quality issues.

1.1 User Animations Under 300ms

User-initiated animations must complete within 300ms.

Incorrect (exceeds 300ms limit):

.button { transition: transform 400ms; }

Correct (within 300ms):

.button { transition: transform 200ms; }

1.2 Consistent Timing for Similar Elements

Similar elements must use identical timing values.

Incorrect (inconsistent timing):

.button-primary { transition: 200ms; }
.button-secondary { transition: 150ms; }

Correct (consistent timing):

.button-primary { transition: 200ms; }
.button-secondary { transition: 200ms; }

1.3 No Entrance Animation on Context Menus

Context menus should not animate on entrance (exit only).

Incorrect (animates entrance):

<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />

Correct (exit only):

<motion.div exit={{ opacity: 0 }} />

1.4 Exponential Ramps for Natural Decay

Use exponential ramps, not linear, for natural decay.

Incorrect (linear ramp):

gain.gain.linearRampToValueAtTime(0, t + 0.05);

Correct (exponential ramp):

gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);

1.5 No Linear Easing for Motion

Linear easing should only be used for progress indicators, not motion.

Incorrect (linear for motion):

.card { transition: transform 200ms linear; }

Correct (linear for progress only):

.progress-bar { transition: width 100ms linear; }

1.6 Active State Scale Transform

Interactive elements must have active/pressed state with scale transform.

Incorrect (no active state):

.button:hover { background: var(--gray-3); }
/* Missing :active state */

Correct (active state present):

.button:active { transform: scale(0.98); }

1.7 Subtle Squash and Stretch

Squash/stretch deformation must be subtle (0.95-1.05 range).

Incorrect (excessive deformation):

<motion.div whileTap={{ scale: 0.8 }} />

Correct (subtle deformation):

<motion.div whileTap={{ scale: 0.98 }} />

1.8 Springs for Overshoot and Settle

Use springs (not easing) when overshoot-and-settle is needed.

Incorrect (easing for bounce):

<motion.div transition={{ duration: 0.3, ease: "easeOut" }} />

Correct (spring physics):

<motion.div transition={{ type: "spring", stiffness: 500, damping: 30 }} />

1.9 Stagger Under 50ms Per Item

Stagger delays must not exceed 50ms per item.

Incorrect (excessive stagger):

transition={{ staggerChildren: 0.15 }}

Correct (reasonable stagger):

transition={{ staggerChildren: 0.03 }}

1.10 Single Focal Point

Only one element should animate prominently at a time.

Incorrect (competing animations):

<motion.div animate={{ scale: 1.1 }} />
<motion.div animate={{ scale: 1.1 }} />

1.11 Dim Background for Focus

Modal/dialog backgrounds should dim to direct focus.

Incorrect (transparent overlay):

.overlay { background: transparent; }

Correct (dimmed overlay):

.overlay { background: var(--black-a6); }

1.12 Z-Index Layering for Animated Elements

Animated elements must respect z-index layering.

Incorrect (no z-index):

.tooltip { /* No z-index, may render behind other elements */ }

Correct (explicit z-index):

.tooltip { z-index: 50; }

2. Timing Functions

Impact: HIGH — Choosing the right timing function based on whether motion is user-driven, system-driven, or high-frequency.

Decision framework: Is this motion reacting to the user, or is the system speaking?

Motion TypeBest ChoiceWhy
User-driven (drag, flick, gesture)SpringSurvives interruption, preserves velocity
System-driven (state change, feedback)EasingClear start/end, predictable timing
Time representation (progress, loading)Linear1:1 relationship between time and progress
High-frequency (typing, fast toggles)NoneAnimation adds noise, feels slower

2.1 Springs for Gesture-Driven Motion

Gesture-driven motion (drag, flick, swipe) must use springs.

Incorrect (easing for drag):

<motion.div
  drag="x"
  transition={{ duration: 0.3, ease: "easeOut" }}
/>

Correct (spring for drag):

<motion.div
  drag="x"
  transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>

2.2 Springs for Interruptible Motion

Motion that can be interrupted must use springs.

Incorrect (easing for interruptible):

<motion.div
  animate={{ x: isOpen ? 200 : 0 }}
  transition={{ duration: 0.3 }}
/>

Correct (spring for interruptible):

<motion.div
  animate={{ x: isOpen ? 200 : 0 }}
  transition={{ type: "spring", stiffness: 400, damping: 25 }}
/>

2.3 Springs Preserve Input Velocity

When velocity matters, use springs to preserve input energy.

Incorrect (velocity ignored):

onDragEnd={(e, info) => {
  animate(target, { x: 0 }, { duration: 0.3 });
}}

Correct (velocity preserved):

onDragEnd={(e, info) => {
  animate(target, { x: 0 }, {
    type: "spring",
    velocity: info.velocity.x,
  });
}}

2.4 Balanced Spring Parameters

Spring parameters must be balanced; avoid excessive oscillation.

Incorrect (too bouncy):

transition={{
  type: "spring",
  stiffness: 1000,
  damping: 5,
}}

Correct (balanced):

transition={{
  type: "spring",
  stiffness: 500,
  damping: 30,
}}

2.5 Easing for System State Changes

System-initiated state changes should use easing curves.

Incorrect (spring for announcement):

<motion.div
  animate={{ y: 0 }}
  transition={{ type: "spring" }}
/>

Correct (easing for announcement):

<motion.div
  animate={{ y: 0 }}
  transition={{ duration: 0.2, ease: "easeOut" }}
/>

2.6 Ease-Out for Entrances

Entrances must use ease-out (arrive fast, settle gently).

Incorrect (ease-in for entrance):

.modal-enter { animation-timing-function: ease-in; }

Correct (ease-out for entrance):

.modal-enter { animation-timing-function: ease-out; }

2.7 Ease-In for Exits

Exits must use ease-in (build momentum before departure).

Incorrect (ease-out for exit):

.modal-exit { animation-timing-function: ease-out; }

Correct (ease-in for exit):

.modal-exit { animation-timing-function: ease-in; }

2.8 Ease-In-Out for View Transitions

View/mode transitions use ease-in-out for neutral attention.

Correct:

.page-transition { animation-timing-function: ease-in-out; }

2.9 Linear Easing Only for Progress

Linear easing only for progress bars and time representation.

Incorrect (linear for motion):

.card-slide { transition: transform 200ms linear; }

Correct (linear for progress):

.progress-bar { transition: width 100ms linear; }

2.10 Press and Hover 120-180ms

Press and hover interactions should use 120-180ms duration.

Incorrect (too slow):

.button:hover { transition: background-color 400ms; }

Correct (appropriate duration):

.button:hover { transition: background-color 150ms; }

2.11 Small State Changes 180-260ms

Small state changes should use 180-260ms duration.

Correct:

.toggle { transition: transform 200ms ease; }

2.12 Max 300ms for User Actions

User-initiated animations must not exceed 300ms.

Incorrect (exceeds limit):

<motion.div transition={{ duration: 0.5 }} />

Correct (within limit):

<motion.div transition={{ duration: 0.25 }} />

2.13 Shorten Duration Before Adjusting Curve

If animation feels slow, shorten duration before adjusting curve.

Incorrect (adjusting curve instead):

.element { transition: 400ms cubic-bezier(0, 0.9, 0.1, 1); }

Correct (shorter duration):

.element { transition: 200ms ease-out; }

2.14 No Animation for High-Frequency Interactions

High-frequency interactions should have no animation.

Incorrect (animated on every keystroke):

function SearchInput() {
  return (
    <motion.div animate={{ scale: [1, 1.02, 1] }}>
      <input onChange={handleSearch} />
    </motion.div>
  );
}

Correct (no animation):

function SearchInput() {
  return <input onChange={handleSearch} />;
}

2.15 No Animation for Keyboard Navigation

Keyboard navigation should be instant, no animation.

Incorrect (animated focus):

function Menu() {
  return items.map(item => (
    <motion.li
      whileFocus={{ scale: 1.05 }}
      transition={{ duration: 0.2 }}
    />
  ));
}

Correct (CSS focus-visible only):

function Menu() {
  return items.map(item => (
    <li className={styles.menuItem} />
  ));
}

2.16 No Entrance Animation for Context Menus

Context menus should not animate on entrance (exit only).

Incorrect (entrance animation):

<motion.div
  initial={{ opacity: 0, scale: 0.95 }}
  animate={{ opacity: 1, scale: 1 }}
  exit={{ opacity: 0 }}
/>

Correct (exit only):

<motion.div exit={{ opacity: 0, scale: 0.95 }} />

Quick reference:

InteractionTimingType
Drag releaseSpringstiffness: 500, damping: 30
Button press150msease
Modal enter200msease-out
Modal exit150msease-in
Page transition250msease-in-out
Progress barvarieslinear
Typing feedback0msnone

3. Exit Animations

Impact: HIGH — Correct AnimatePresence usage prevents layout shifts, stale interactions, and orphaned elements.

3.1 AnimatePresence Wrapper Required

Conditional motion elements must be wrapped in AnimatePresence.

Incorrect (no wrapper):

{isVisible && (
  <motion.div exit={{ opacity: 0 }} />
)}

Correct (wrapped):

<AnimatePresence>
  {isVisible && (
    <motion.div exit={{ opacity: 0 }} />
  )}
</AnimatePresence>

3.2 Exit Prop Required Inside AnimatePresence

Elements inside AnimatePresence should have exit prop defined.

Incorrect (missing exit):

<AnimatePresence>
  {isOpen && (
    <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
  )}
</AnimatePresence>

Correct (exit defined):

<AnimatePresence>
  {isOpen && (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
    />
  )}
</AnimatePresence>

3.3 Unique Keys in AnimatePresence Lists

Dynamic lists inside AnimatePresence must have unique keys.

Incorrect (index as key):

<AnimatePresence>
  {items.map((item, index) => (
    <motion.div key={index} exit={{ opacity: 0 }} />
  ))}
</AnimatePresence>

Correct (stable unique key):

<AnimatePresence>
  {items.map((item) => (
    <motion.div key={item.id} exit={{ opacity: 0 }} />
  ))}
</AnimatePresence>

3.4 Exit Mirrors Initial for Symmetry

Exit animation should mirror initial for symmetry.

Incorrect (asymmetric exit):

<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  exit={{ scale: 0 }}
/>

Correct (symmetric exit):

<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  exit={{ opacity: 0, y: 20 }}
/>

3.5 useIsPresent in Child Component

useIsPresent must be called from child of AnimatePresence, not parent.

Incorrect (hook in parent):

function Parent() {
  const isPresent = useIsPresent();
  return (
    <AnimatePresence>
      {show && <Child />}
    </AnimatePresence>
  );
}

Correct (hook in child):

function Child() {
  const isPresent = useIsPresent();
  return <motion.div data-exiting={!isPresent} />;
}

3.6 Call safeToRemove After Async Work

When using usePresence, always call safeToRemove after async work.

Incorrect (missing safeToRemove):

function AsyncComponent() {
  const [isPresent, safeToRemove] = usePresence();

  useEffect(() => {
    if (!isPresent) {
      cleanup();
    }
  }, [isPresent]);
}

Correct (safeToRemove called):

function AsyncComponent() {
  const [isPresent, safeToRemove] = usePresence();

  useEffect(() => {
    if (!isPresent) {
      cleanup().then(safeToRemove);
    }
  }, [isPresent, safeToRemove]);
}

3.7 Disable Interactions on Exiting Elements

Disable interactions on exiting elements using isPresent.

Incorrect (clickable during exit):

function Card() {
  const isPresent = useIsPresent();
  return <button onClick={handleClick}>Click</button>;
}

Correct (disabled during exit):

function Card() {
  const isPresent = useIsPresent();
  return (
    <button onClick={handleClick} disabled={!isPresent}>
      Click
    </button>
  );
}

3.8 Mode "wait" Doubles Duration

Mode "wait" nearly doubles animation duration; adjust timing accordingly.

Incorrect (too slow with wait):

<AnimatePresence mode="wait">
  <motion.div transition={{ duration: 0.3 }} />
</AnimatePresence>

Correct (halved timing):

<AnimatePresence mode="wait">
  <motion.div transition={{ duration: 0.15 }} />
</AnimatePresence>

3.9 Mode "sync" Causes Layout Conflicts

Mode "sync" causes layout conflicts; position exiting elements absolutely.

Incorrect (sync with layout competition):

<AnimatePresence mode="sync">
  {items.map(item => (
    <motion.div exit={{ opacity: 0 }}>{item}</motion.div>
  ))}
</AnimatePresence>

Correct (popLayout instead):

<AnimatePresence mode="popLayout">
  {items.map(item => (
    <motion.div exit={{ opacity: 0 }}>{item}</motion.div>
  ))}
</AnimatePresence>

3.10 popLayout for List Reordering

Use popLayout mode for list reordering animations.

Incorrect (default mode causes shifts):

<AnimatePresence>
  {items.map(item => <ListItem key={item.id} />)}
</AnimatePresence>

Correct (popLayout prevents shifts):

<AnimatePresence mode="popLayout">
  {items.map(item => <ListItem key={item.id} />)}
</AnimatePresence>

3.11 Propagate Prop for Nested AnimatePresence

Nested AnimatePresence must use propagate prop for coordinated exits.

Incorrect (children vanish instantly):

<AnimatePresence>
  {isOpen && (
    <motion.div exit={{ opacity: 0 }}>
      <AnimatePresence>
        {items.map(item => (
          <motion.div key={item.id} exit={{ scale: 0 }} />
        ))}
      </AnimatePresence>
    </motion.div>
  )}
</AnimatePresence>

Correct (propagate on both):

<AnimatePresence propagate>
  {isOpen && (
    <motion.div exit={{ opacity: 0 }}>
      <AnimatePresence propagate>
        {items.map(item => (
          <motion.div key={item.id} exit={{ scale: 0 }} />
        ))}
      </AnimatePresence>
    </motion.div>
  )}
</AnimatePresence>

3.12 Coordinated Parent-Child Exit Timing

Parent and child exit durations should be coordinated.

Incorrect (parent too fast):

<motion.div exit={{ opacity: 0 }} transition={{ duration: 0.1 }}>
  <motion.div exit={{ scale: 0 }} transition={{ duration: 0.5 }} />
</motion.div>

Correct (coordinated timing):

<motion.div exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
  <motion.div exit={{ scale: 0 }} transition={{ duration: 0.15 }} />
</motion.div>

Reference: Motion AnimatePresence Documentation


4. CSS Pseudo Elements

Impact: MEDIUM — Leveraging pseudo-elements and View Transitions to reduce DOM nodes and improve transitions.

4.1 Content Property Required for Pseudo-Elements

::before and ::after require content property to render.

Incorrect (missing content):

.button::before {
  position: absolute;
  background: var(--gray-3);
}

Correct (content set):

.button::before {
  content: "";
  position: absolute;
  background: var(--gray-3);
}

4.2 Pseudo-Elements Over DOM Nodes

Use pseudo-elements for decorative content instead of extra DOM nodes.

Incorrect (extra DOM node):

<button className={styles.button}>
  <span className={styles.background} />
  Click me
</button>

Correct (pseudo-element):

<button className={styles.button}>
  Click me
</button>
.button::before {
  content: "";
  /* decorative background */
}

4.3 Position Relative Parent for Pseudo-Elements

Parent must have position: relative for absolute pseudo-elements.

Incorrect (no position on parent):

.button::before {
  content: "";
  position: absolute;
  inset: 0;
}
/* .button has no position */

Correct (parent positioned):

.button {
  position: relative;
}

.button::before {
  content: "";
  position: absolute;
  inset: 0;
}

4.4 Z-Index Layering for Pseudo-Elements

Pseudo-elements need z-index to layer correctly with content.

Incorrect (covers button text):

.button::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--gray-3);
}

Correct (layered behind):

.button {
  position: relative;
  z-index: 1;
}

.button::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--gray-3);
  z-index: -1;
}

4.5 Hit Target Expansion with Pseudo-Elements

Use negative inset values to expand hit targets without extra markup.

Incorrect (wrapper for hit target):

<div className={styles.wrapper}>
  <a className={styles.link}>Link</a>
</div>

Correct (pseudo-element expansion):

.link {
  position: relative;
}

.link::before {
  content: "";
  position: absolute;
  inset: -8px -12px;
}

4.6 View Transition Name Required

Elements participating in view transitions need view-transition-name.

Incorrect (no transition name):

document.startViewTransition(() => {
  targetImg.src = newSrc;
});

Correct (transition name assigned):

sourceImg.style.viewTransitionName = "card";
document.startViewTransition(() => {
  sourceImg.style.viewTransitionName = "";
  targetImg.style.viewTransitionName = "card";
});

4.7 Unique View Transition Names

Each view-transition-name must be unique on the page during transition.

Incorrect (duplicate names):

.card {
  view-transition-name: card;
}
/* Multiple cards with same name */

Correct (unique per element):

element.style.viewTransitionName = `card-${id}`;

4.8 Clean Up View Transition Names

Remove view-transition-name after transition completes.

Incorrect (stale name):

sourceImg.style.viewTransitionName = "card";
document.startViewTransition(() => {
  targetImg.style.viewTransitionName = "card";
});

Correct (name cleaned up):

sourceImg.style.viewTransitionName = "card";
document.startViewTransition(() => {
  sourceImg.style.viewTransitionName = "";
  targetImg.style.viewTransitionName = "card";
});

4.9 View Transitions Over JS Libraries

Prefer View Transitions API over JavaScript animation libraries for page transitions.

Incorrect (JS-based transition):

import { motion } from "motion/react";

function ImageLightbox() {
  return (
    <motion.img layoutId="hero" />
  );
}

Correct (native View Transition):

function openLightbox(img: HTMLImageElement) {
  img.style.viewTransitionName = "hero";
  document.startViewTransition(() => {
    // Native browser transition
  });
}

4.10 Style View Transition Pseudo-Elements

Style view transition pseudo-elements for custom animations.

Incorrect (default crossfade only):

document.startViewTransition(() => { /* ... */ });

Correct (custom animation):

::view-transition-group(card) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}

4.11 Use ::backdrop for Dialog Backgrounds

Use ::backdrop pseudo-element for dialog/popover backgrounds.

Incorrect (extra overlay node):

<>
  <div className={styles.overlay} onClick={close} />
  <dialog className={styles.dialog}>{children}</dialog>
</>

Correct (native ::backdrop):

dialog::backdrop {
  background: var(--black-a6);
  backdrop-filter: blur(4px);
}

4.12 Use ::placeholder for Input Styling

Use ::placeholder for input placeholder styling, not wrapper elements.

Incorrect (custom placeholder node):

<div className={styles.inputWrapper}>
  {!value && <span className={styles.placeholder}>Enter text...</span>}
  <input value={value} />
</div>

Correct (native ::placeholder):

input::placeholder {
  color: var(--gray-9);
  opacity: 1;
}

4.13 Use ::selection for Text Styling

Use ::selection for text selection styling.

Correct:

::selection {
  background: var(--blue-a5);
  color: var(--gray-12);
}

4.14 Use ::marker for Custom List Bullets

Use ::marker to style list bullets without extra elements or background-image hacks.

Incorrect (background image hack):

li {
  list-style: none;
  background: url("bullet.svg") no-repeat 0 4px;
  padding-left: 20px;
}

Correct (native ::marker):

li::marker {
  color: var(--gray-8);
  font-size: 0.8em;
}

4.15 Use ::first-line for Typographic Treatments

Use ::first-line for drop-cap-adjacent styling without JavaScript or hardcoded spans.

Incorrect (manual span):

<p>
  <span className={styles["first-line"]}>The opening line</span>
  is styled differently from the rest.
</p>

Correct (native ::first-line):

.article p:first-of-type::first-line {
  font-variant-caps: small-caps;
  font-weight: var(--font-weight-medium);
}

Reference: MDN Pseudo-elements Reference, View Transitions API


5. Audio Feedback

Impact: MEDIUM — When and how to use sound in UI, covering accessibility, appropriateness, and implementation.

5.1 Visual Equivalent for Every Sound

Every audio cue must have a visual equivalent; sound never replaces visual feedback.

Incorrect (sound without visual):

function SubmitButton({ onClick }) {
  const handleClick = () => {
    playSound("success");
    onClick();
  };
}

Correct (sound with visual):

function SubmitButton({ onClick }) {
  const [status, setStatus] = useState("idle");

  const handleClick = () => {
    playSound("success");
    setStatus("success");
    onClick();
  };

  return <button data-status={status}>Submit</button>;
}

5.2 Toggle Setting to Disable Sounds

Provide explicit toggle to disable sounds in settings.

Incorrect (no way to disable):

function App() {
  return <SoundProvider>{children}</SoundProvider>;
}

Correct (toggle available):

function App() {
  const { soundEnabled } = usePreferences();
  return (
    <SoundProvider enabled={soundEnabled}>
      {children}
    </SoundProvider>
  );
}

5.3 Respect prefers-reduced-motion for Sound

Respect prefers-reduced-motion as proxy for sound sensitivity.

Incorrect (ignores preference):

function playSound(name: string) {
  audio.play();
}

Correct (checks preference):

function playSound(name: string) {
  const prefersReducedMotion = window.matchMedia(
    "(prefers-reduced-motion: reduce)"
  ).matches;

  if (prefersReducedMotion) return;
  audio.play();
}

5.4 Independent Volume Control

Allow volume adjustment independent of system volume.

Incorrect (always full volume):

function playSound() {
  audio.volume = 1;
  audio.play();
}

Correct (user-controlled volume):

function playSound() {
  const { volume } = usePreferences();
  audio.volume = volume;
  audio.play();
}

5.5 No Sound on High-Frequency Interactions

Do not add sound to high-frequency interactions (typing, keyboard navigation).

Incorrect (sound on every keystroke):

function Input({ onChange }) {
  const handleChange = (e) => {
    playSound("keystroke");
    onChange(e);
  };
}

Correct (no sound on typing):

function Input({ onChange }) {
  return <input onChange={onChange} />;
}

5.6 Sound for Confirmations

Sound is appropriate for confirmations: payments, uploads, form submissions.

Correct:

async function handlePayment() {
  await processPayment();
  playSound("success");
  showConfirmation();
}

5.7 Sound for Errors and Warnings

Sound is appropriate for errors and warnings that can't be overlooked.

Correct:

function handleError(error: Error) {
  playSound("error");
  showErrorToast(error.message);
}

5.8 No Decorative Sound

Do not add sound to decorative moments with no informational value.

Incorrect (hover sound):

function Card({ onHover }) {
  return (
    <div onMouseEnter={() => playSound("hover")}>
      {children}
    </div>
  );
}

5.9 Informative Not Punishing Sound

Sound should inform, not punish; avoid harsh sounds for user mistakes.

Incorrect (harsh buzzer):

function ValidationError() {
  playSound("loud-buzzer");
  return <span>Invalid input</span>;
}

Correct (gentle alert):

function ValidationError() {
  playSound("gentle-alert");
  return <span>Invalid input</span>;
}

5.10 Preload Audio Files

Preload audio files to avoid playback delay.

Incorrect (loads on demand):

function playSound(name: string) {
  const audio = new Audio(`/sounds/${name}.mp3`);
  audio.play();
}

Correct (preloaded):

const sounds = {
  success: new Audio("/sounds/success.mp3"),
  error: new Audio("/sounds/error.mp3"),
};

Object.values(sounds).forEach(audio => audio.load());

function playSound(name: keyof typeof sounds) {
  sounds[name].currentTime = 0;
  sounds[name].play();
}

5.11 Subtle Default Volume

Default volume should be subtle, not loud.

Incorrect (too loud):

const DEFAULT_VOLUME = 1.0;

Correct (subtle):

const DEFAULT_VOLUME = 0.3;

5.12 Reset currentTime Before Replay

Reset audio currentTime before replay to allow rapid triggering.

Incorrect (won't replay if playing):

function playSound() {
  audio.play();
}

Correct (reset before play):

function playSound() {
  audio.currentTime = 0;
  audio.play();
}

5.13 Match Sound Weight to Action

Sound weight should match action importance.

Incorrect (fanfare for toggle):

function handleToggle() {
  playSound("triumphant-fanfare");
  setEnabled(!enabled);
}

Correct (weight matches action):

function handleToggle() {
  playSound("soft-click");
  setEnabled(!enabled);
}

function handlePurchase() {
  playSound("success-chime");
  completePurchase();
}

5.14 Sound Duration Matches Action Duration

Sound duration should match action duration.

Incorrect (long sound for instant action):

function handleClick() {
  playSound("long-whoosh"); // 2000ms
}

Correct (matched duration):

function handleClick() {
  playSound("click"); // 50ms
}

function handleUpload() {
  playSound("upload-progress"); // Matches upload duration
}

Sound appropriateness matrix:

InteractionSound?Reason
Payment successYesSignificant confirmation
Form submissionYesUser needs assurance
Error stateYesCan't be overlooked
NotificationYesMay not be looking at screen
Button clickMaybeOnly for significant buttons
TypingNoToo frequent
HoverNoDecorative only
ScrollNoToo frequent
NavigationNoKeyboard nav would be noisy

Reference: Web Audio API Documentation, prefers-reduced-motion


6. Sound Synthesis

Impact: MEDIUM — Web Audio API best practices for procedural sound generation.

6.1 Reuse Single AudioContext

Reuse a single AudioContext instance; do not create new ones per sound.

Incorrect (new context per call):

function playSound() {
  const ctx = new AudioContext();
}

Correct (singleton):

let audioContext: AudioContext | null = null;

function getAudioContext(): AudioContext {
  if (!audioContext) {
    audioContext = new AudioContext();
  }
  return audioContext;
}

6.2 Resume Suspended AudioContext

Check and resume suspended AudioContext before playing.

Incorrect (plays without checking):

function playSound() {
  const ctx = getAudioContext();
}

Correct (resumes if suspended):

function playSound() {
  const ctx = getAudioContext();
  if (ctx.state === "suspended") {
    ctx.resume();
  }
}

6.3 Clean Up Audio Nodes After Playback

Disconnect and clean up audio nodes after playback.

Incorrect (nodes remain connected):

source.start();

Correct (cleaned up on end):

source.start();
source.onended = () => {
  source.disconnect();
  gain.disconnect();
};

6.4 Exponential Decay for Natural Sound

Use exponential ramps for natural decay, not linear.

Incorrect (linear ramp):

gain.gain.linearRampToValueAtTime(0, t + 0.05);

Correct (exponential ramp):

gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);

6.5 No Zero Target for Exponential Ramps

Exponential ramps cannot target 0; use 0.001 or similar small value.

Incorrect (targets zero):

gain.gain.exponentialRampToValueAtTime(0, t + 0.05);

Correct (targets near-zero):

gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);

6.6 Set Initial Value Before Ramp

Set initial value before ramping to avoid glitches.

Incorrect (no initial value):

gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);

Correct (initial value set):

gain.gain.setValueAtTime(0.3, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);

6.7 Noise for Percussive Sounds

Use filtered noise for clicks/taps, not oscillators.

Incorrect (oscillator for click):

const osc = ctx.createOscillator();
osc.type = "sine";

Correct (noise burst for click):

const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.008, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
  data[i] = (Math.random() * 2 - 1) * Math.exp(-i / 50);
}

6.8 Oscillators for Tonal Sounds

Use oscillators with pitch movement for tonal sounds (pops, confirmations).

Incorrect (static frequency):

osc.frequency.value = 400;

Correct (pitch sweep):

osc.frequency.setValueAtTime(400, t);
osc.frequency.exponentialRampToValueAtTime(600, t + 0.04);

6.9 Bandpass Filter for Sound Character

Apply bandpass filter to shape percussive sounds.

Incorrect (raw noise):

source.connect(gain).connect(ctx.destination);

Correct (filtered noise):

const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = 4000;
filter.Q.value = 3;
source.connect(filter).connect(gain).connect(ctx.destination);

6.10 Click Duration 5-15ms

Click/tap sounds should be 5-15ms duration.

Incorrect (too long):

const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.1, ctx.sampleRate);

Correct (appropriate duration):

const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.008, ctx.sampleRate);

6.11 Click Filter 3000-6000Hz

Bandpass filter for clicks should be 3000-6000Hz.

Incorrect (too low):

filter.frequency.value = 500;

Correct (crisp range):

filter.frequency.value = 4000;

6.12 Gain Under 1.0

Gain values should not exceed 1.0 to prevent clipping.

Incorrect (clipping):

gain.gain.setValueAtTime(1.5, t);

Correct (safe gain):

gain.gain.setValueAtTime(0.3, t);

6.13 Filter Q Value 2-5

Filter Q for clicks should be 2-5 for focused but not harsh sound.

Incorrect (too resonant):

filter.Q.value = 15;

Correct (balanced Q):

filter.Q.value = 3;

Parameter translation table:

User SaysParameter Change
"too harsh"Lower filter frequency, reduce Q
"too muffled"Higher filter frequency
"too long"Shorter duration, faster decay
"cuts off abruptly"Use exponential decay
"more mechanical"Higher Q, faster decay
"softer"Lower gain, triangle wave

Reference: Web Audio API - MDN


7. Morphing Icons

Impact: LOW — Building icon components that morph between any two icons through SVG line transformation.

Core concept: Every icon is composed of exactly three SVG lines. Icons that need fewer lines collapse the extras to invisible center points. This constraint enables seamless morphing between any two icons.

Architecture:

interface IconLine {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
  opacity?: number;
}

interface IconDefinition {
  lines: [IconLine, IconLine, IconLine];
  rotation?: number;
  group?: string;
}

const CENTER = 7;
const collapsed: IconLine = {
  x1: CENTER, y1: CENTER, x2: CENTER, y2: CENTER, opacity: 0,
};

7.1 Icons Must Use Exactly Three Lines

Every icon MUST use exactly 3 lines. No more, no fewer.

Incorrect (only 2 lines):

const checkIcon = {
  lines: [
    { x1: 2, y1: 7.5, x2: 5.5, y2: 11 },
    { x1: 5.5, y1: 11, x2: 12, y2: 3 },
  ],
};

Correct (3 lines with collapsed):

const checkIcon = {
  lines: [
    { x1: 2, y1: 7.5, x2: 5.5, y2: 11 },
    { x1: 5.5, y1: 11, x2: 12, y2: 3 },
    collapsed,
  ],
};

7.2 Use Collapsed Constant for Unused Lines

Unused lines must use the collapsed constant, not omission or null.

Incorrect (null for unused):

const minusIcon = {
  lines: [
    { x1: 2, y1: 7, x2: 12, y2: 7 },
    null,
    null,
  ],
};

Correct (collapsed constant):

const minusIcon = {
  lines: [
    { x1: 2, y1: 7, x2: 12, y2: 7 },
    collapsed,
    collapsed,
  ],
};

7.3 Consistent ViewBox Size

All icons must use the same viewBox (14x14 recommended).

Incorrect (mixed scales):

const icon1 = { lines: [{ x1: 2, y1: 7, x2: 12, y2: 7 }, ...] }; // 14x14
const icon2 = { lines: [{ x1: 4, y1: 14, x2: 24, y2: 14 }, ...] }; // 28x28

Correct (consistent scale):

const VIEWBOX_SIZE = 14;
const CENTER = 7;

7.4 Shared Group for Rotational Variants

Icons that are rotational variants MUST share the same group and base lines.

Incorrect (different line definitions):

const arrowRight = { lines: [{ x1: 2, y1: 7, x2: 12, y2: 7 }, ...] };
const arrowDown = { lines: [{ x1: 7, y1: 2, x2: 7, y2: 12 }, ...] };

Correct (shared base lines):

const arrowLines: [IconLine, IconLine, IconLine] = [
  { x1: 2, y1: 7, x2: 12, y2: 7 },
  { x1: 7.5, y1: 2.5, x2: 12, y2: 7 },
  { x1: 7.5, y1: 11.5, x2: 12, y2: 7 },
];

const icons = {
  "arrow-right": { lines: arrowLines, rotation: 0, group: "arrow" },
  "arrow-down": { lines: arrowLines, rotation: 90, group: "arrow" },
  "arrow-left": { lines: arrowLines, rotation: 180, group: "arrow" },
  "arrow-up": { lines: arrowLines, rotation: -90, group: "arrow" },
};

7.5 Spring Physics for Rotation

Rotation between grouped icons should use spring physics for natural motion.

Incorrect (duration-based rotation):

<motion.g animate={{ rotate: rotation }} transition={{ duration: 0.3 }} />

Correct (spring rotation):

const rotation = useSpring(definition.rotation ?? 0, activeTransition);

<motion.g style={{ rotate: rotation, transformOrigin: "center" }} />

7.6 Reduced Motion Support for Icons

Respect prefers-reduced-motion by disabling animations.

Incorrect (always animates):

function MorphingIcon({ icon }: Props) {
  return <motion.line animate={...} transition={{ duration: 0.4 }} />;
}

Correct (respects preference):

function MorphingIcon({ icon }: Props) {
  const reducedMotion = useReducedMotion() ?? false;
  const activeTransition = reducedMotion ? { duration: 0 } : transition;

  return <motion.line animate={...} transition={activeTransition} />;
}

7.7 Instant Jump for Non-Grouped Icons

When transitioning between icons NOT in the same group, rotation should jump instantly.

Incorrect (always animates rotation):

useEffect(() => {
  rotation.set(definition.rotation ?? 0);
}, [definition]);

Correct (jumps when not grouped):

useEffect(() => {
  if (shouldRotate) {
    rotation.set(definition.rotation ?? 0);
  } else {
    rotation.jump(definition.rotation ?? 0);
  }
}, [definition, shouldRotate]);

7.8 Round Stroke Line Caps

Lines should use strokeLinecap="round" for polished endpoints.

Incorrect (butt caps):

<motion.line strokeLinecap="butt" />

Correct (round caps):

<motion.line strokeLinecap="round" />

7.9 Aria Hidden on Icon SVGs

Icon SVGs should be aria-hidden since they're decorative.

Incorrect (no aria attribute):

<svg width={size} height={size}>...</svg>

Correct (aria-hidden):

<svg width={size} height={size} aria-hidden="true">...</svg>

Common icon patterns:

// Two-line icons (check, minus, chevron) — one collapsed line
const check = {
  lines: [
    { x1: 2, y1: 7.5, x2: 5.5, y2: 11 },
    { x1: 5.5, y1: 11, x2: 12, y2: 3 },
    collapsed,
  ],
};

// Three-line icons (menu, asterisk) — all lines used
const menu = {
  lines: [
    { x1: 2, y1: 3.5, x2: 12, y2: 3.5 },
    { x1: 2, y1: 7, x2: 12, y2: 7 },
    { x1: 2, y1: 10.5, x2: 12, y2: 10.5 },
  ],
};

// Point icons (more, grip) — zero-length lines as dots
const more = {
  lines: [
    { x1: 3, y1: 7, x2: 3, y2: 7 },
    { x1: 7, y1: 7, x2: 7, y2: 7 },
    { x1: 11, y1: 7, x2: 11, y2: 7 },
  ],
};

Recommended transition:

const defaultTransition: Transition = {
  ease: [0.19, 1, 0.22, 1],
  duration: 0.4,
};

Reference: Motion useSpring, SVG Line Element


8. Container Animation

Impact: MEDIUM — Animating container width and height using a measure-and-animate pattern with ResizeObserver and Motion.

8.1 Two-Div Pattern for Animated Bounds

Use an outer animated div and an inner measured div. Never measure and animate the same element — it creates a feedback loop.

Incorrect (measure and animate same element):

function AnimatedContainer({ children }) {
  const [ref, bounds] = useMeasure();
  return (
    <motion.div ref={ref} animate={{ height: bounds.height }}>
      {children}
    </motion.div>
  );
}

Correct (separate measure and animate targets):

function AnimatedContainer({ children }) {
  const [ref, bounds] = useMeasure();
  return (
    <motion.div animate={{ height: bounds.height }}>
      <div ref={ref}>{children}</div>
    </motion.div>
  );
}

8.2 Guard Against Zero on Initial Render

On initial render, measured bounds are 0. Guard against this to prevent animating from 0 to actual size.

Incorrect (animates from 0 on mount):

<motion.div animate={{ width: bounds.width }}>
  <div ref={ref}>{children}</div>
</motion.div>

Correct (falls back to auto on first frame):

<motion.div animate={{ width: bounds.width > 0 ? bounds.width : "auto" }}>
  <div ref={ref}>{children}</div>
</motion.div>

8.3 Use ResizeObserver for Measurement

Use ResizeObserver to track element dimensions. It fires on resize without causing layout thrashing.

Incorrect (measuring on every render):

function useMeasure(ref) {
  const [bounds, setBounds] = useState({ width: 0, height: 0 });
  useEffect(() => {
    if (ref.current) {
      const rect = ref.current.getBoundingClientRect();
      setBounds({ width: rect.width, height: rect.height });
    }
  });
  return bounds;
}

Correct (ResizeObserver):

function useMeasure() {
  const [element, setElement] = useState(null);
  const [bounds, setBounds] = useState({ width: 0, height: 0 });
  const ref = useCallback((node) => setElement(node), []);

  useEffect(() => {
    if (!element) return;
    const observer = new ResizeObserver(([entry]) => {
      setBounds({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [element]);

  return [ref, bounds];
}

8.4 Overflow Hidden on Animated Container

Set overflow: hidden on the animated outer container to clip content during size transitions.

Incorrect (content overflows during animation):

<motion.div animate={{ height: bounds.height }}>
  <div ref={ref}>{children}</div>
</motion.div>

Correct (clipped during transition):

<motion.div animate={{ height: bounds.height }} style={{ overflow: "hidden" }}>
  <div ref={ref}>{children}</div>
</motion.div>

8.5 Use Animated Bounds Sparingly

Animated bounds is a subtle effect. Reserve it for interactive elements where size changes are meaningful.

Good use cases: loading state buttons, expandable sections, accordions, FAQs, content reveals.

Bad use cases: every container on the page, static layouts, elements that don't change size.

8.6 Use Callback Ref for Measurement

Use a callback ref (not useRef) for measurement hooks so the observer attaches when the DOM node is ready.

Incorrect (useRef may be null on first effect):

const ref = useRef(null);
useEffect(() => {
  if (!ref.current) return;
  observer.observe(ref.current);
}, []);

Correct (callback ref guarantees node):

const [element, setElement] = useState(null);
const ref = useCallback((node) => setElement(node), []);
useEffect(() => {
  if (!element) return;
  observer.observe(element);
  return () => observer.disconnect();
}, [element]);

8.7 Add Delay for Natural Container Transitions

Add a small delay so the transition feels like it's catching up to the content.

Correct:

<motion.div
  animate={{ height: bounds.height }}
  transition={{ duration: 0.2, delay: 0.05 }}
  style={{ overflow: "hidden" }}
>
  <div ref={ref}>{children}</div>
</motion.div>

Reference: ResizeObserver - MDN, Motion Documentation


9. Laws of UX

Impact: HIGH — Psychological principles behind interfaces that feel right. Violating these creates friction users can't articulate.

9.1 Size Interactive Targets for Easy Clicking

The bigger something is, the easier it is to click (Fitts's Law). Make interactive elements at least 32px.

Incorrect (tiny click target):

.icon-button {
  width: 16px;
  height: 16px;
  padding: 0;
}

Correct (comfortable target):

.icon-button {
  width: 32px;
  height: 32px;
  padding: 8px;
}

9.2 Expand Hit Areas with Invisible Padding

Use pseudo-elements or invisible padding to expand clickable areas beyond visible bounds.

Incorrect (visible size equals hit area):

.link {
  font-size: 14px;
}

Correct (expanded invisible hit area):

.link {
  position: relative;
}

.link::before {
  content: "";
  position: absolute;
  inset: -8px -12px;
}

9.3 Minimize Choices to Reduce Decision Time

Decision time increases logarithmically with the number of choices (Hick's Law). Use progressive disclosure.

Incorrect (all options at once):

function Settings() {
  return (
    <div>
      {allSettings.map(setting => (
        <SettingRow key={setting.id} {...setting} />
      ))}
    </div>
  );
}

Correct (progressive disclosure):

function Settings() {
  return (
    <div>
      {commonSettings.map(setting => (
        <SettingRow key={setting.id} {...setting} />
      ))}
      <details>
        <summary>Advanced</summary>
        {advancedSettings.map(setting => (
          <SettingRow key={setting.id} {...setting} />
        ))}
      </details>
    </div>
  );
}

9.4 Chunk Data into Groups of 5-9

Working memory holds about 7 items (Miller's Law). Group and chunk large data sets for scannability.

Incorrect (raw unformatted data):

<span>4532015112830366</span>

Correct (chunked for readability):

<span>4532 0151 1283 0366</span>

9.5 Respond Within 400ms

Interactions must respond within 400ms to feel instant (Doherty Threshold). Above this, users notice delay.

Incorrect (no feedback during loading):

async function handleClick() {
  const data = await fetchData();
  setResult(data);
}

Correct (immediate optimistic feedback):

async function handleClick() {
  setResult(optimisticData);
  const data = await fetchData();
  setResult(data);
}

9.6 Fake Speed When Actual Speed Isn't Possible

If you can't make something fast, make it feel fast with optimistic UI, skeletons, or progress indicators.

Incorrect (blank screen during load):

function Page() {
  const { data, isLoading } = useFetch("/api/data");
  if (isLoading) return null;
  return <Content data={data} />;
}

Correct (skeleton during load):

function Page() {
  const { data, isLoading } = useFetch("/api/data");
  if (isLoading) return <Skeleton />;
  return <Content data={data} />;
}

9.7 Accept Messy Input, Output Clean Data

Inputs should accept messy human data and normalize it (Postel's Law). Validate generously, format strictly.

Incorrect (rigid format required):

function DateInput({ onChange }) {
  return (
    <input
      type="text"
      placeholder="YYYY-MM-DD"
      pattern="\d{4}-\d{2}-\d{2}"
      onChange={onChange}
    />
  );
}

Correct (accepts multiple formats):

function DateInput({ onChange }) {
  function handleChange(e) {
    const parsed = parseFlexibleDate(e.target.value);
    if (parsed) onChange(parsed);
  }

  return (
    <input
      type="text"
      placeholder="Any date format"
      onChange={handleChange}
    />
  );
}

9.8 Show What Matters Now, Reveal Complexity Later

Don't overwhelm users with everything at once. Reveal complexity incrementally as needed.

Incorrect (all controls visible):

function Editor() {
  return (
    <div>
      <BasicTools />
      <AdvancedTools />
      <ExpertTools />
      <DebugTools />
    </div>
  );
}

Correct (progressive disclosure):

function Editor() {
  const [showAdvanced, setShowAdvanced] = useState(false);
  return (
    <div>
      <BasicTools />
      {showAdvanced && <AdvancedTools />}
      <button onClick={() => setShowAdvanced(!showAdvanced)}>
        Toggle
      </button>
    </div>
  );
}

9.9 Use Familiar UI Patterns

Users spend most of their time on other sites. They expect yours to work the same way (Jakob's Law).

Incorrect (custom unconventional navigation):

function Nav() {
  return (
    <nav>
      <button onClick={() => navigate("/")}>⬡</button>
      <button onClick={() => navigate("/search")}>⬢</button>
    </nav>
  );
}

Correct (standard recognizable patterns):

function Nav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/search">Search</Link>
    </nav>
  );
}

9.10 Visual Polish Increases Perceived Usability

Users perceive aesthetically pleasing design as more usable. Small visual details compound into trust.

Incorrect (unstyled, raw elements):

.card {
  border: 1px solid black;
  padding: 10px;
}

Correct (considered visual treatment):

.card {
  padding: 16px;
  background: var(--gray-2);
  border: 1px solid var(--gray-a4);
  border-radius: 12px;
  box-shadow: var(--shadow-1);
}

9.11 Group Related Elements Spatially

Elements near each other are perceived as related (Law of Proximity). Use spacing to create visual groups.

Incorrect (uniform spacing between unrelated items):

.form label,
.form input,
.form .hint,
.form .divider {
  margin-bottom: 16px;
}

Correct (tighter spacing within groups, larger between):

.form label {
  margin-bottom: 4px;
}

.form input {
  margin-bottom: 2px;
}

.form .hint {
  margin-bottom: 24px;
}

9.12 Similar Elements Should Look Alike

Elements that function the same should look the same (Law of Similarity). Visual consistency signals functional consistency.

Incorrect (same function, different appearance):

.save-button {
  background: blue;
  border-radius: 8px;
}

.submit-button {
  background: green;
  border-radius: 0;
}

Correct (same function, same appearance):

.primary-action {
  background: var(--gray-12);
  color: var(--gray-1);
  border-radius: 8px;
}

9.13 Use Boundaries to Group Related Content

Elements sharing a clearly defined boundary are perceived as a group (Law of Common Region).

Incorrect (flat list with no visual grouping):

function Settings() {
  return (
    <div>
      <Toggle label="Dark mode" />
      <Toggle label="Notifications" />
      <Input label="Email" />
      <Input label="Password" />
    </div>
  );
}

Correct (bounded sections):

function Settings() {
  return (
    <div>
      <section className={styles.group}>
        <h3>Appearance</h3>
        <Toggle label="Dark mode" />
      </section>
      <section className={styles.group}>
        <h3>Account</h3>
        <Input label="Email" />
        <Input label="Password" />
      </section>
    </div>
  );
}

9.14 Make Important Elements Visually Distinct

When multiple similar elements are present, the one that differs is most likely to be remembered (Von Restorff Effect).

Incorrect (primary action blends in):

<div className={styles.actions}>
  <button className={styles.button}>Cancel</button>
  <button className={styles.button}>Delete Account</button>
</div>

Correct (destructive action stands out):

<div className={styles.actions}>
  <button className={styles["button-secondary"]}>Cancel</button>
  <button className={styles["button-danger"]}>Delete Account</button>
</div>

9.15 Place Key Items First or Last

Users best remember the first and last items in a sequence (Serial Position Effect).

Incorrect (important action buried in middle):

<nav>
  <Link href="/settings">Settings</Link>
  <Link href="/">Home</Link>
  <Link href="/about">About</Link>
</nav>

Correct (key items at edges):

<nav>
  <Link href="/">Home</Link>
  <Link href="/about">About</Link>
  <Link href="/settings">Settings</Link>
</nav>

9.16 End Experiences with Clear Success States

People judge experiences by their peak moment and their end (Peak-End Rule). Invest in completion states.

Incorrect (abrupt end after action):

async function handleSubmit() {
  await submitForm(data);
  router.push("/");
}

Correct (satisfying completion state):

async function handleSubmit() {
  await submitForm(data);
  setStatus("success");
}

return status === "success" ? (
  <SuccessScreen message="You're all set." />
) : (
  <Form onSubmit={handleSubmit} />
);

9.17 Move Complexity to the System

Every system has irreducible complexity (Tesler's Law). The question is who handles it — the user or the system.

Incorrect (complexity pushed to user):

<input
  type="text"
  placeholder="Enter date as YYYY-MM-DDTHH:mm:ss.sssZ"
/>

Correct (system absorbs complexity):

<DatePicker
  onChange={(date) => setDate(date.toISOString())}
/>

9.18 Show Progress Toward Completion

People accelerate behavior as they approach a goal (Goal-Gradient Effect). Show how close they are.

Incorrect (no sense of progress):

function Onboarding({ step }) {
  return <OnboardingStep step={step} />;
}

Correct (progress visible):

function Onboarding({ step, totalSteps }) {
  return (
    <div>
      <ProgressBar value={step} max={totalSteps} />
      <span>Step {step} of {totalSteps}</span>
      <OnboardingStep step={step} />
    </div>
  );
}

9.19 Show Incomplete State to Drive Completion

People remember incomplete tasks better than completed ones (Zeigarnik Effect).

Incorrect (no indication of incomplete profile):

function Dashboard() {
  return <DashboardContent />;
}

Correct (incomplete state visible):

function Dashboard({ profile }) {
  return (
    <div>
      {!profile.isComplete && (
        <Banner>
          Complete your profile — {profile.completionPercent}% done
        </Banner>
      )}
      <DashboardContent />
    </div>
  );
}

9.20 Simplify Complex Visuals into Clear Forms

People interpret complex visuals as the simplest form possible (Law of Pragnanz). Reduce visual noise.

Incorrect (visually noisy layout):

.card {
  border: 2px dashed red;
  background: linear-gradient(45deg, #f0f, #0ff);
  box-shadow: 5px 5px 0 black, 10px 10px 0 gray;
  outline: 3px dotted blue;
}

Correct (clear, simple form):

.card {
  background: var(--gray-2);
  border: 1px solid var(--gray-a4);
  border-radius: 12px;
  box-shadow: var(--shadow-1);
}

9.21 Prioritize the Critical 20% of Features

80% of users use 20% of features (Pareto Principle). Optimize the critical path first.

Incorrect (all features equally prominent):

function Toolbar() {
  return (
    <div>
      {allFeatures.map(f => <Button key={f.id}>{f.label}</Button>)}
    </div>
  );
}

Correct (critical features prominent, rest accessible):

function Toolbar() {
  return (
    <div>
      {criticalFeatures.map(f => <Button key={f.id}>{f.label}</Button>)}
      <MoreMenu features={secondaryFeatures} />
    </div>
  );
}

9.22 Minimize Extraneous Cognitive Load

Remove anything that doesn't help the user complete their task. Decoration, redundant labels, and unnecessary options all add load.

Incorrect (extraneous elements):

function DeleteDialog() {
  return (
    <dialog>
      <Icon name="warning" size={64} />
      <h2>Warning!</h2>
      <p>Are you absolutely sure you want to delete?</p>
      <p>This action is permanent and cannot be undone.</p>
      <p>All associated data will be lost forever.</p>
      <div>
        <button>Cancel</button>
        <button>Delete</button>
        <button>Learn More</button>
      </div>
    </dialog>
  );
}

Correct (essential information only):

function DeleteDialog() {
  return (
    <dialog>
      <h2>Delete this item?</h2>
      <p>This can't be undone.</p>
      <div>
        <button>Cancel</button>
        <button>Delete</button>
      </div>
    </dialog>
  );
}

9.23 Visually Connect Related Elements

Elements that are visually connected (by lines, color, or frames) are perceived as more related (Law of Uniform Connectedness).

Incorrect (steps with no visual connection):

function Steps({ current }) {
  return (
    <div>
      <span>Step 1</span>
      <span>Step 2</span>
      <span>Step 3</span>
    </div>
  );
}

Correct (connected with a visual line):

function Steps({ current }) {
  return (
    <div className={styles.steps}>
      {steps.map((step, i) => (
        <div key={step.id} className={styles.step} data-active={i <= current}>
          <div className={styles.dot} />
          {i < steps.length - 1 && <div className={styles.connector} />}
          <span>{step.label}</span>
        </div>
      ))}
    </div>
  );
}

Reference: Laws of UX by Jon Yablonski


10. Predictive Prefetching

Impact: MEDIUM — Loading content before the user clicks by analyzing cursor trajectory, reducing perceived latency by 100-200ms.

10.1 Trajectory Prediction Over Hover Prefetching

Hover prefetching starts too late. Trajectory prediction fires while the cursor is still in motion, reclaiming 100-200ms.

Incorrect (waits for hover):

<Link
  href="/about"
  onMouseEnter={() => router.prefetch("/about")}
>
  About
</Link>

Correct (trajectory-based):

const { elementRef } = useForesight({
  callback: () => router.prefetch("/about"),
  hitSlop: 20,
  name: "about-link",
});

<Link ref={elementRef} href="/about">About</Link>

10.2 Prefetch by Intent, Not Viewport

Don't prefetch everything visible in the viewport. Prefetch based on user intent to avoid wasted bandwidth.

Incorrect (prefetch all visible links):

<Link href="/page" prefetch={true}>Page</Link>

Correct (intent-based prefetching):

<Link href="/page" prefetch={false}>Page</Link>

10.3 Use hitSlop to Trigger Predictions Earlier

Expand the invisible prediction area around elements with hitSlop to start loading sooner.

Incorrect (tight prediction area):

const { elementRef } = useForesight({
  callback: () => prefetch(),
  hitSlop: 0,
});

Correct (expanded prediction area):

const { elementRef } = useForesight({
  callback: () => prefetch(),
  hitSlop: 20,
});

10.4 Fall Back Gracefully on Touch Devices

Touch devices have no cursor. Fall back to viewport or touch-start strategies automatically.

Incorrect (assumes cursor exists):

function PrefetchLink({ href, children }) {
  return (
    <Link
      href={href}
      onMouseMove={() => prefetch(href)}
    >
      {children}
    </Link>
  );
}

Correct (device-aware strategy):

const { elementRef } = useForesight({
  callback: () => router.prefetch(href),
  hitSlop: 20,
});

10.5 Prefetch on Keyboard Navigation

Monitor focus changes and prefetch when the user is a few tab stops away from a registered element.

Correct (tab-aware prefetching):

const { elementRef } = useForesight({
  callback: () => router.prefetch("/settings"),
  name: "settings-link",
});

10.6 Use Predictive Prefetching Selectively

Predictive prefetching doesn't belong in every project. Use it where navigation latency is noticeable.

Good use cases: data-heavy dashboards, multi-page apps with slow API responses, e-commerce product pages.

Bad use cases: static sites with instant navigation, single-page apps with all data preloaded.

Reference: ForesightJS, Next.js Prefetching Docs


11. Typography

Impact: MEDIUM — CSS font and text properties most developers overlook. The difference between typographically considered and not.

11.1 Tabular Numbers for Data Display

Use tabular-nums for any numeric data that should align in columns.

Incorrect (proportional numbers misalign):

.price { font-variant-numeric: proportional-nums; }

Correct (tabular numbers align):

.price { font-variant-numeric: tabular-nums; }

11.2 Oldstyle Numbers for Body Text

Use oldstyle-nums in body text so numbers blend with lowercase letters. Use lining-nums in tables and headings.

Correct (prose):

.body-text { font-variant-numeric: oldstyle-nums; }

Correct (data):

.data-table { font-variant-numeric: lining-nums tabular-nums; }

11.3 Slashed Zero for Disambiguation

Enable slashed zero in code-adjacent UIs so users never confuse 0 with O.

Correct:

.code { font-variant-numeric: slashed-zero; }

11.4 Enable Contextual Alternates

Keep contextual alternates (calt) enabled. They adjust punctuation and glyph shapes based on surrounding characters.

Correct (usually on by default — don't disable):

body { font-feature-settings: "calt" 1; }

11.5 Use Disambiguation Stylistic Set for UI

Enable ss02 (or your font's disambiguation set) in code-facing UIs to distinguish I, l, 1 and 0, O.

Correct:

.code-ui { font-feature-settings: "ss02"; }

11.6 Keep Optical Sizing Auto

Leave font-optical-sizing at auto. The font adjusts glyph shapes for the current size — thicker strokes at small sizes, finer details at large sizes.

Incorrect (forced off):

body { font-optical-sizing: none; }

Correct (automatic adjustment):

body { font-optical-sizing: auto; }

11.7 Use Antialiased Font Smoothing

Set -webkit-font-smoothing: antialiased on retina displays. Default subpixel rendering looks thicker and fuzzier.

Correct:

body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

11.8 Balance Headings with text-wrap

Use text-wrap: balance on headings to make lines roughly equal length instead of one long line and a short orphan.

Incorrect (unbalanced heading):

h1 { /* default text-wrap */ }

Correct (balanced):

h1 { text-wrap: balance; }

11.9 Offset Underlines from Descenders

Use text-underline-offset to push underlines below descenders so they look intentional.

Incorrect (underline collides with descenders):

a { text-decoration: underline; }

Correct (offset underline):

a {
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-skip-ink: auto;
}

11.10 Disable Font Synthesis for Missing Styles

Set font-synthesis: none to prevent the browser from faking bold or italic. Browser-generated faux styles look terrible.

Correct:

.icon-font,
.display-font {
  font-synthesis: none;
}

Typography quick reference:

PropertyUse CaseValue
font-variant-numeric: tabular-numsData tables, pricingFixed-width digits
font-variant-numeric: oldstyle-numsBody textBlends with lowercase
font-variant-numeric: slashed-zeroCode UIsDistinguishes 0 from O
font-feature-settings: "ss02"Code UIsDisambiguates I/l/1
font-optical-sizing: autoEverywhereSize-adaptive glyphs
-webkit-font-smoothing: antialiasedRetina displaysThinner, cleaner text
text-wrap: balanceHeadingsEven line lengths
text-underline-offset: 3pxLinksClear descender space
font-synthesis: noneDisplay/icon fontsPrevents faux styles

11.11 Use font-display swap

Set font-display: swap so text renders immediately with a fallback while the custom font loads.

Correct:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter.woff2") format("woff2");
  font-display: swap;
}

11.12 Continuous Weight Values with Variable Fonts

Variable fonts accept any integer from 100-900, not just standard stops.

Correct (precise weight):

.medium { font-weight: 450; }
.semibold { font-weight: 550; }

11.13 text-wrap pretty for Body Text

Use text-wrap: pretty for body text to reduce orphans. Use balance for headings.

Correct:

p { text-wrap: pretty; }
h1, h2, h3 { text-wrap: balance; }

11.14 Pair Justified Text with Hyphens

Justified text without hyphens creates rivers of whitespace.

Incorrect (rivers):

.article { text-align: justify; }

Correct (hyphenation prevents rivers):

.article {
  text-align: justify;
  hyphens: auto;
}

11.15 Add Letter Spacing to Uppercase Text

Uppercase and small-caps text needs positive letter-spacing to feel open and readable.

Incorrect (tight uppercase):

.label {
  text-transform: uppercase;
  font-size: 12px;
}

Correct (opened up):

.label {
  text-transform: uppercase;
  font-size: 12px;
  letter-spacing: 0.05em;
}

11.16 Use Typographic Fractions

Enable diagonal-fractions to convert 1/2, 1/3 into proper typographic fractions.

Correct:

.recipe { font-variant-numeric: diagonal-fractions; }

Reference: Inter Typeface, MDN font-feature-settings, MDN font-variant-numeric


12. Visual Design

Impact: HIGH — CSS design fundamentals that compound into visual polish. Small details that separate considered interfaces from default ones.

12.1 Concentric Border Radius for Nested Elements

When nesting rounded elements, inner radius must equal outer radius minus the gap. Same radius on both creates uneven curves.

Incorrect (same radius on both):

.outer {
  border-radius: 16px;
  padding: 8px;
}

.inner {
  border-radius: 16px;
}

Correct (concentric radius):

.outer {
  --padding: 8px;
  --inner-radius: 8px;

  border-radius: calc(var(--inner-radius) + var(--padding));
  padding: var(--padding);
}

.inner {
  border-radius: var(--inner-radius);
}

12.2 Layer Multiple Shadows for Realistic Depth

A single box-shadow looks flat. Layer multiple shadows with increasing blur and decreasing opacity to mimic real light.

Incorrect (single flat shadow):

.card {
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}

Correct (layered shadows):

.card {
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.06),
    0 4px 8px rgba(0, 0, 0, 0.04),
    0 12px 24px rgba(0, 0, 0, 0.03);
}

12.3 Consistent Shadow Direction Across UI

All shadows must share the same offset direction to imply a single light source. Mixed directions feel broken.

Incorrect (conflicting light sources):

.card { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); }
.modal { box-shadow: 4px 0 8px rgba(0, 0, 0, 0.1); }
.tooltip { box-shadow: 0 -4px 8px rgba(0, 0, 0, 0.1); }

Correct (consistent top-down light):

.card { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); }
.modal { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
.tooltip { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); }

12.4 Use Neutral Colors for Shadows

Pure black shadows look harsh. Use deep neutrals or semi-transparent dark colors.

Incorrect (pure black):

.card {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}

Correct (neutral shadow):

.card {
  box-shadow: 0 4px 12px rgba(17, 24, 39, 0.08);
}

12.5 Shadow Size Indicates Elevation

Larger blur and offset means higher elevation. Use a consistent shadow scale.

Correct (elevation scale):

:root {
  --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-2: 0 2px 8px rgba(0, 0, 0, 0.08);
  --shadow-3: 0 8px 24px rgba(0, 0, 0, 0.12);
}

.card { box-shadow: var(--shadow-1); }
.dropdown { box-shadow: var(--shadow-2); }
.modal { box-shadow: var(--shadow-3); }

12.6 Animate Shadows via Pseudo-Element Opacity

Transitioning box-shadow directly forces expensive repaints. Animate opacity on a pseudo-element instead.

Incorrect (animating box-shadow):

.card {
  box-shadow: var(--shadow-1);
  transition: box-shadow 0.2s ease;
}
.card:hover {
  box-shadow: var(--shadow-3);
}

Correct (pseudo-element opacity):

.card {
  position: relative;
  box-shadow: var(--shadow-1);
}
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: var(--shadow-3);
  opacity: 0;
  transition: opacity 0.2s ease;
  pointer-events: none;
  z-index: -1;
}
.card:hover::after {
  opacity: 1;
}

12.7 Use a Consistent Spacing Scale

Don't use arbitrary pixel values. Define a scale and use it throughout.

Incorrect (arbitrary values):

.header { padding: 17px; }
.card { margin-bottom: 13px; }
.section { gap: 22px; }

Correct (consistent scale):

:root {
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
}

.header { padding: var(--space-4); }
.card { margin-bottom: var(--space-3); }
.section { gap: var(--space-5); }

12.8 Use Semi-Transparent Borders

Semi-transparent borders adapt to any background color and create subtle, non-jarring separation.

Incorrect (hardcoded border color):

.card {
  border: 1px solid #e5e5e5;
}

Correct (alpha border):

.card {
  border: 1px solid var(--gray-a4);
}

12.9 Full Shadow Anatomy on Buttons

A polished button uses six layered techniques, not just a single box-shadow:

  1. Outer cut shadow — 0.5px dark box-shadow to "cut" the button into the surface
  2. Inner ambient highlight — 1px inset box-shadow on all sides for environmental light reflections
  3. Inner top highlight — 1px inset top highlight for the primary light source from above
  4. Layered depth shadows — At least 3 external shadows for natural lighting
  5. Text drop-shadow — Drop-shadow on text/icons for better contrast against the button background
  6. Subtle gradient background — If you can tell there's a gradient, it's too much

Incorrect (flat button):

.button {
  background: var(--gray-12);
  color: var(--gray-1);
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

Correct (full shadow anatomy):

.button {
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--gray-12) 100%, white 4%),
    var(--gray-12)
  );
  color: var(--gray-1);
  box-shadow:
    0 0 0 0.5px rgba(0, 0, 0, 0.3),
    inset 0 0 0 1px rgba(255, 255, 255, 0.04),
    inset 0 1px 0 rgba(255, 255, 255, 0.07),
    0 1px 2px rgba(0, 0, 0, 0.1),
    0 2px 4px rgba(0, 0, 0, 0.06),
    0 4px 8px rgba(0, 0, 0, 0.03);
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
}

Reference: Designing Beautiful Shadows in CSS, Concentric Border Radius, @PixelJanitor


Output Format

When reviewing files, output findings as:

file:line - [rule-id] description of issue

Example:
components/modal/index.tsx:45 - [timing-under-300ms] Exit animation 400ms exceeds 300ms limit
components/button/styles.module.css:12 - [physics-active-state] Missing :active transform
components/drawer/index.tsx:23 - [spring-for-gestures] Drag interaction using easing instead of spring

Summary Table

After findings, output a summary:

RuleCountSeverity
timing-under-300ms2HIGH
physics-active-state3MEDIUM
exit-requires-wrapper1HIGH

References

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.