
Userinterface Wiki
- 3.5k installs
- 862 repo stars
- Updated July 29, 2026
- raphaelsalaja/userinterface-wiki
A systematic ruleset for building polished, performant web interfaces. Rules are grounded in cognitive psychology (Fitts's Law, Hick's Law, Zeigarnik Effect), physics simulation (spring mathematics, exponential decay), a
About
User Interface Wiki v3.0 is a systematic guide to 152 UI/UX rules prioritized by impact, spanning animation principles (critical), timing functions (high), exit animations, CSS pseudo-elements, audio feedback, sound synthesis, morphing icons, container animation, UX laws, predictive prefetching, typography, and visual design (medium/low). Each rule includes detailed explanations and paired incorrect/correct code examples optimized for AI-assisted workflows. Rules address practical constraints: user animations under 300ms, spring physics for gesture-driven motion, AnimatePresence wrappers for exit animations, concentric border radius for nested elements, layered shadows for depth, and typographic features like tabular numbers and font-variant-numeric. Grounded in Disney animation principles, Apple WWDC guidance, and UX psychology research.
- 152 rules across 12 categories prioritized by implementation impact
- Paired incorrect/correct code examples for every rule
- Critical timing guidance: 300ms user animations, 120-180ms hover, springs for gestures
- AnimatePresence patterns: exit animations, popLayout, nested coordination
- Visual polish anatomy: concentric border radius, layered shadows, typographic precision
Userinterface Wiki by the numbers
- 3,542 all-time installs (skills.sh)
- +85 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #143 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/raphaelsalaja/userinterface-wiki --skill userinterface-wikiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.5k |
|---|---|
| repo stars | ★ 862 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 29, 2026 |
| Repository | raphaelsalaja/userinterface-wiki ↗ |
What it does
Comprehensive UI/UX best practices guide with 152 rules for animation, timing, exit animations, typography, and visual design. For AI agents and developers reviewing/generating/refactoring web
Who is it for?
Frontend engineers building web apps, UI component libraries, design systems; AI code agents learning to generate production-grade interfaces; teams standardizing UI practices across products.
Skip if: Mobile-native development (iOS/Android native APIs differ); backend services; static HTML sites; teams without performance budgets for animation.
When should I use this skill?
Reviewing animation code, refactoring motion libraries, generating new UI components, training AI models on UI best practices, establishing design system standards, debugging 'feels laggy' complaints.
What you get
Teams using this guide produce interfaces that feel intentional, responsive within 400ms, visually polished, and accessible. AI agents trained on these rules generate code that requires minimal human refinement.
- standards-compliant UI code
- review findings against 152 rules
By the numbers
- Version 3.0.0 with 152 rules across 12 categories
- Published March 2026 by raphael-salaja
Files
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 Pro
{
"version": "3.0.0",
"organization": "User Interface Wiki",
"author": "raphael-salaja",
"date": "March 2026",
"abstract": "Comprehensive UI/UX best practices guide for web interfaces. Contains 152 rules across 12 categories covering animation principles, timing functions, exit animations, CSS pseudo-elements, audio feedback, sound synthesis, morphing icons, container animation, laws of UX, predictive prefetching, typography, and visual design. Each rule includes detailed explanations and code examples comparing incorrect vs. correct implementations.",
"references": [
"https://developer.apple.com/videos/play/wwdc2023/10158",
"https://motion.dev",
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API",
"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-elements",
"https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API",
"https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion",
"https://developer.mozilla.org/en-US/docs/Web/SVG/Element/line",
"https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver",
"https://lawsofux.com",
"https://foresightjs.com",
"https://nextjs.org/docs/app/guides/prefetching",
"https://rsms.me/inter",
"https://developer.mozilla.org/en-US/docs/Web/CSS/font-feature-settings",
"https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-numeric",
"https://www.joshwcomeau.com/css/designing-shadows",
"https://jakub.kr/work/concentric-border-radius",
"https://developer.mozilla.org/en-US/docs/Web/CSS/text-wrap"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Animation Principles (timing, physics, staging)
Impact: CRITICAL Description: Disney's 12 principles adapted for web. Timing, physics, and staging rules ensure animations feel natural and purposeful. Violations here produce the most noticeable quality issues.
2. Timing Functions (spring, easing, duration, none)
Impact: HIGH Description: Choosing the right timing function—spring, easing curve, or no animation—based on whether motion is user-driven, system-driven, or high-frequency.
3. Exit Animations (exit, presence, mode, nested)
Impact: HIGH Description: AnimatePresence and exit animation patterns in Motion/Framer Motion. Correct usage prevents layout shifts, stale interactions, and orphaned elements.
4. CSS Pseudo Elements (pseudo, transition, native)
Impact: MEDIUM Description: Leveraging ::before, ::after, View Transitions API, and native pseudo-elements (::backdrop, ::placeholder, ::selection) to reduce DOM nodes and improve transitions.
5. Audio Feedback (a11y, appropriate, impl, weight)
Impact: MEDIUM Description: When and how to use sound in UI. Covers accessibility, appropriateness heuristics, implementation patterns, and matching sound weight to action importance.
6. Sound Synthesis (context, envelope, design, param)
Impact: MEDIUM Description: Web Audio API best practices for procedural sound generation. Covers AudioContext management, envelope shaping, sound design patterns, and parameter ranges.
7. Morphing Icons (morphing)
Impact: LOW Description: Building icon components that morph between any two icons through SVG line transformation. All icons share a 3-line structure enabling seamless transitions.
8. Container Animation (container)
Impact: MEDIUM Description: Animating container width and height using a measure-and-animate pattern with ResizeObserver and Motion. The two-div pattern separates measurement from animation to avoid feedback loops.
9. Laws of UX (ux)
Impact: HIGH Description: Psychological principles behind interfaces that feel right. Covers Fitts's Law (target sizing), Hick's Law (choice reduction), Miller's Law (chunking), Doherty Threshold (response time), and Postel's Law (input tolerance).
10. Predictive Prefetching (prefetch)
Impact: MEDIUM Description: Loading content before the user clicks by analyzing cursor trajectory, reducing perceived latency by 100-200ms. Covers trajectory vs hover vs click strategies and device-aware fallbacks.
11. Typography (type)
Impact: MEDIUM Description: CSS font and text properties most developers overlook. OpenType features, numeric variants, variable font axes, text rendering, wrapping, and decoration controls that make typography feel considered.
12. Visual Design (visual)
Impact: HIGH Description: CSS design fundamentals that compound into visual polish. Concentric border radius, layered shadows, consistent spacing scales, and alpha borders. Small details that separate considered interfaces from default ones.
Rule Title Here
Brief explanation of the rule and why it matters.
Incorrect (description of what's wrong):
// Bad code example hereCorrect (description of what's right):
// Good code example hereReference: Link to documentation or resource
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();
}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>
);
}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>;
}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();
}Sound for Confirmations
Sound is appropriate for confirmations: payments, uploads, form submissions.
Correct:
async function handlePayment() {
await processPayment();
playSound("success");
showConfirmation();
}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);
}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>
);
}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} />;
}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>;
}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]);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>Use Animated Bounds Sparingly
Animated bounds is a subtle effect. Use it for buttons, accordions, and interactive elements — not everywhere.
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.
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>Add Delay for Natural Container Transitions
Add a small delay to container size animations 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>Two-Div Pattern for Animated Bounds
Use an outer animated div and an inner measured div. Never measure and animate the same element.
Incorrect (measure and animate same element — creates feedback loop):
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>
);
}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];
}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();
};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();
}
}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;
}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);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);
}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);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 }} />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; }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; }Small State Changes 180-260ms
Small state changes should use 180-260ms duration.
Correct:
.toggle { transition: transform 200ms ease; }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; }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; }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" }}
/>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; }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);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; }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; }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);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);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);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>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 }}
/>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>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>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;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();
}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();
}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>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>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>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>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 }, ...] }; // 28x28Correct (consistent scale):
const VIEWBOX_SIZE = 14;
const CENTER = 7;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" },
};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]);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} />;
}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" }} />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" />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,
],
};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,
],
};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);
}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;
}Use ::selection for Text Styling
Use ::selection for text selection styling.
Correct:
::selection {
background: var(--blue-a5);
color: var(--gray-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>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>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 }} />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} />;
}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} />
));
}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);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;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;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);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); }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 }}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" }} />
// When element should bounce/settleCorrect (spring physics):
<motion.div transition={{ type: "spring", stiffness: 500, damping: 30 }} />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 }} />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,
});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",
// Tab prediction fires when focus approaches
});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>
// Let trajectory/hover prediction handle itFall 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,
});
// Automatically falls back to touch-start on mobileTrajectory 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>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.
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>
);
}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} />;
}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]);
}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);
}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 of this paragraph</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);
}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;
}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;
}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 */
}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;
}Correct (parent positioned):
.button {
position: relative;
}
.button::before {
content: "";
position: absolute;
inset: 0;
}Z-Index Layering for Pseudo-Elements
Pseudo-elements need z-index to layer correctly with content.
Incorrect (covers 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;
}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 }}
/>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 }}
/>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,
}}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,
});
}}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); }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 }} />Correct (single focal point):
<motion.div animate={{ scale: 1.1 }} />
<motion.div animate={{ scale: 1 }} />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; }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; }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 }} />Related skills
How it compares
Use userinterface-wiki for agent-enforceable web UI rules; use a retention case-study skill when the problem is engagement psychology rather than component behavior.
FAQ
Why springs instead of easing for drag interactions?
Springs preserve input velocity and survive interruption. If the user drags and changes direction mid-motion, easing-based animations feel wrong (momentum is lost). Springs handle this naturally.
What's the difference between popLayout and sync mode?
mode='sync' animates exit and entrance simultaneously, causing layout thrashing as elements compete for space. popLayout removes exiting elements from layout flow first, preventing conflicts.
Why prefers-reduced-motion for sound if it's audio-only?
prefers-reduced-motion signals motion sensitivity broadly, including sensory sensitivity to rapid changes. Sound paired with rapid animations compounds the issue. Respect the preference as a proxy.
Is Userinterface Wiki safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.