web-ui
Skill4 starsChanged 54 days agoMITView on GitHub
Principles for building great web app interfaces — performance, layout, interactions, forms, accessibility, animation, and motion craft. Apply when building or reviewing web UI, including transitions, gestures, and component polish.
4 other projects use a version of this.
Adopted by
Most recently taken up by
- dimastriann/odoo-erp-mcptheir own version, first seen Nov 8, 2026
- emergent-wisdom/understanding-graphtheir own version, first seen Nov 1, 2026
- darknecrocities/SkillBuildertheir own version, first seen Nov 1, 2026
- MKS-01/appscreenmaptheir own version, first seen Oct 17, 2026
Other versions4 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 4
- darknecrocities/SkillBuilder★ 13same name
- dimastriann/odoo-erp-mcp★ 2same name
- MKS-01/appscreenmap★ 2same name
- emergent-wisdom/understanding-graph★ 1same name
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.
In this repository18 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- agent-browserSkill
- ai-writing-detectorSkill
- animate-svgSkill
- browserbaseSkill
- clickhouseSkill
- commitSkill
- geist-designSkill
- generate-faviconSkill
- generate-imageSkill
- google-indexingSkill
- google-search-consoleSkill
- google-searchSkill
- humanizerSkill
- keyword-researchSkill
- parallelSkill
- remove-backgroundSkill
- reviewSkill
The file
We split this skill into the rules it gives an agent
10 rules. 0 are common, 7 are distinctive, and 3 are very specific to this project.
0
Common
Most of the 4 edited versions we read keep these.
7
Distinctive
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 30
No layout reads (getBoundingClientRect, offsetHeight, getComputedStyle) in the render path.
- Very specific: line 33
Every <img> has explicit width/height (CLS).
- Distinctive: line 42
Never leave a button ambiguous after click.
- Distinctive: line 122
Don't hand-craft curves — use easing.dev or easings.co.
- Very specific: line 140
Never transition: all — list properties explicitly: transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out)
- Distinctive: line 158
Use transitions for any rapidly-triggered UI (toasts, list reorder, drag state)
- Distinctive: line 163
Use for: drag with momentum, gestures interruptible mid-motion, decorative mouse-tracking, "alive" elements (Apple Dynamic Island)
- Distinctive: line 165
Keep bounce subtle (0.1–0.3).
- Distinctive: line 209
Test gestures on real devices (USB-tether iPhone via Safari remote devtools).
- Distinctive: line 291
When authoring shared components or library-grade primitives, the bar is higher.
“Common” is measured: kept by at least 6 in 10 of the 4 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Taste is trained, not innate — develop it by studying interfaces that feel right and reverse-engineering why. Most of the details users will love are details they'll never consciously notice; the aggregate of invisible correctness is what makes software feel great. Beauty is leverage — good defaults and good motion are real differentiators.
Apply these principles when generating or modifying UI code. When reviewing existing UI, flag violations with the section name (e.g. "Fundamentals › Feedback: button left in ambiguous state after click"). For motion/animation/transition issues, use a markdown table with | Before | After | Why | columns — one row per issue. Not every rule applies to every component — use judgement, but default to following the principle unless there's a clear reason not to.
Example motion-review table:
| Before | After | Why |
transition: all 300ms | transition: transform 200ms ease-out | Specify exact properties; avoid all |
transform: scale(0) on enter | transform: scale(0.95); opacity: 0 | Nothing in the real world appears from nothing |
ease-in on dropdown | ease-out with custom curve | ease-in delays the moment the user is watching most closely |
Scope note: the typographic rules below (curly quotes, …, Title Case, non-breaking spaces) govern UI chrome — labels, buttons, headings, empty states. They do not govern long-form prose rendered from markdown or a CMS.
1. Fundamentals — checklist
The specific, easy-to-forget half of the web interface guidelines. The rest (semantic HTML, sensible contrast, keyboard reachability) you do by default; these are the ones that get missed.
Performance & rendering
- Show the loading state before the response, not after; skeletons match the shape of incoming content and stagger their resolve
- Optimistic UI by default — assume success, roll back with a clear undo path
content-visibility: autoon long lists; virtualise at >50 items- No layout reads (
getBoundingClientRect,offsetHeight,getComputedStyle) in the render path; batch all reads, then all writes - Uncontrolled inputs (
defaultValue+ ref) for high-frequency typing preconnectthird-party origins;preloadcritical fonts withfont-display: swap- Every
<img>has explicitwidth/height(CLS);loading="lazy"below the fold,fetchpriority="high"for the hero - Hydration: inputs with
valueneedonChange; guard date rendering against server-vs-client timezone;suppressHydrationWarningonly for genuinely unfixable timestamps, never to silence a real mismatch
URL & navigation
- Serialise meaningful state to the URL (filters, sort, tab, pagination, query, open panels) and hydrate from it on mount, not from component state
replaceStatefor ephemeral changes,pushStatefor real navigation; encode which item is selected, not "modal is open"Escapecloses everything; back/forward work everywhere; restore scroll position on back;Cmd/Ctrl+Kopens a command palette
Interaction & feedback
- Never leave a button ambiguous after click; never disable one without explaining why
- States escalate contrast: hover > rest, active > hover, focus > rest
- Gate hover behind
@media (hover: hover) and (pointer: fine)— touch fires hover on tap - Errors prescribe the fix ("Password must be at least 8 characters") and sit inline beside the field, never only at the top
- Destructive actions need friction, not a generic modal — inline confirm with a short timeout usually beats one
Hierarchy & type
- ≤3 primary colours, shades built in HSL; never signal state with colour alone
- Bold + normal colour beats large + grey; grey text on a coloured background breaks hierarchy — use a desaturated tint of that background
- Fixed type scale (8–12 sizes);
chunits for a 65–75ch measure; don't centre body text font-variant-numeric: tabular-numson number columns;text-wrap: balanceon headings,prettyon body…not...; curly quotes; in units and shortcuts (10 MB,⌘ K)- Fewer borders — whitespace, background contrast or a subtle shadow instead
Layout
- One spacing base (4 or 8px), adjacent steps ≥25% apart; flex/grid over JS measurement
env(safe-area-inset-*)on full-bleed layouts- Flex children need
min-width: 0or truncation silently fails - Plan for short, average and very long content in every text container
- Empty states are onboarding — say what goes here and offer an action
Forms
- Validate on blur; focus the first error field on submit; auto-advance on completion (OTP)
- Correct
typeandinputmode(decimal,numeric) — they control the mobile keyboard - Meaningful
name+autocomplete;spellcheck={false}on emails, codes, usernames, URLs - Never block paste — no
preventDefaulton paste, especially password and confirmation fields - Visible, persistent, clickable label on every input (
htmlForor wrapping<label>); a placeholder is not a label - Submit stays enabled until the request starts, then spins; never clear a form on failed submission; warn on unsaved-change navigation
- 44×44px minimum touch target; shared hit target across checkbox/radio + label;
autoFocusdesktop-only and once per page
Accessibility
- Skip link to main content as the first focusable element
:focus-visibleover:focus; neveroutline: nonewithout a replacement (≥2px, 3:1);:focus-withinfor compound controls- Focus trap in modals; on close return focus to the trigger — never strand focus
aria-labelon icon-only buttons,aria-hiddenon decorative icons,aria-live="polite"for async statusscroll-margin-topon heading anchors so sticky headers don't cover themprefers-reduced-motion: reduced ≠ none — keep opacity and colour, drop movement
Copy & platform
- ≤7 words per label; button text is an outcome ("Save changes", not "Submit"); Title Case headings and buttons, sentence case body
Intl.DateTimeFormat/Intl.NumberFormat— never hardcode formats; detect language fromAccept-Language, not IPcolor-schemeon<html>+ matching<meta name="theme-color">; native<select>needs explicitbackground-color/color(Windows dark mode)touch-action: manipulationon interactive elements;overscroll-behavior: containin modals and drawers
2. Animation
Motion clarifies, never decorates. Every animation answers two questions: should this animate at all, and what is the purpose.
Decision framework — frequency dictates everything
| User sees animation | Decision |
| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. |
| Tens of times/day (hover, list nav) | Remove or drastically reduce |
| Occasional (modals, drawers, toasts) | Standard animation |
| Rare/first-time (onboarding, celebrations) | Can add delight |
Never animate keyboard-initiated actions — Cmd+K, hotkeys, etc. Animation makes repeated actions feel sluggish. Raycast has no open/close animation; that's optimal for something used hundreds of times a day.
Purpose — every animation must justify itself: spatial consistency (toast enters/exits same direction so swipe-to-dismiss feels right), state indication (morphing button), feedback (button scales on press), preventing jarring change (fade between states). "It looks cool" + frequent usage = no animation.
Easing
- Enter/exit →
ease-out(instant feedback, fast start) - On-screen movement / morphing →
ease-in-out - Hover, color →
ease - Constant motion (marquee, progress bar) →
linear - Never
ease-infor UI — it delays the moment the user is watching most closely; a 180msease-outfeels faster than a 180msease-in
Built-in CSS easings are weak. Define custom curves once and reuse:
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like */
}Don't hand-craft curves — use easing.dev or easings.co.
Duration
| Element | Duration |
| Button press feedback | 100–160ms |
| Tooltip, small popover | 125–200ms |
| Dropdown, select | 150–250ms |
| Modal, drawer | 200–500ms |
| Marketing / explanatory | Can be longer |
UI animations stay under 300ms. A 180ms dropdown feels more responsive than a 400ms one.
Perceived performance — speed is partly perception. A faster spinner makes the same load time feel quicker. After the first tooltip is open, subsequent tooltips on the same toolbar should appear instantly (no delay, no animation) — the toolbar feels faster.
Hardware acceleration
- Animate
transformandopacityonly — they skip layout and paint and run on the compositor.padding,margin,height,width,top/lefttrigger all three rendering steps. - Never
transition: all— list properties explicitly:transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out) - Set
transform-originto match the element's visual anchor — defaultcenteris wrong for almost every popover (see Component Polish) - SVG transforms: apply on a
<g>wrapper withtransform-box: fill-box
Under load: CSS beats JS
- CSS animations run off the main thread. Framer Motion
x/yshorthand usesrequestAnimationFrameand drops frames when the browser is busy loading/painting. - For hardware-accelerated Framer Motion, use the full transform string:
<motion.div animate={{ transform: "translateX(100px)" }} />— notanimate={{ x: 100 }} - For programmatic control with CSS performance, use the Web Animations API:
element.animate(
[{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }],
{ duration: 1000, fill: 'forwards', easing: 'cubic-bezier(0.77, 0, 0.175, 1)' }
);Transitions vs keyframes
- CSS transitions can be interrupted and retargeted mid-animation — keyframes restart from zero
- Use transitions for any rapidly-triggered UI (toasts, list reorder, drag state)
- Reserve keyframes for one-shot decoration (loading spinner, marketing reveal)
- Animations must be interruptible — respond to new user input mid-animation
Springs
- Use for: drag with momentum, gestures interruptible mid-motion, decorative mouse-tracking, "alive" elements (Apple Dynamic Island)
- Apple's parameterisation is easier to reason about:
{ type: "spring", duration: 0.5, bounce: 0.2 } - Keep bounce subtle (0.1–0.3); avoid bounce in most professional UI
- Springs maintain velocity when interrupted — ideal for gestures users may reverse
@starting-style — modern CSS entry animation, no useEffect(setMounted, true) needed:
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms var(--ease-out), transform 400ms var(--ease-out);
@starting-style {
opacity: 0;
transform: translateY(100%);
}
}Fall back to a data-mounted attribute pattern where browser support isn't there yet.
CSS variable inheritance footgun — changing a CSS var on a parent recalculates styles for every descendant. In a drawer with many items, updating --swipe-amount on the container is expensive. Set transform directly on the dragged element:
// Bad: recalcs all children
container.style.setProperty('--swipe-amount', `${distance}px`);
// Good: only this element
element.style.transform = `translateY(${distance}px)`;Asymmetric enter/exit — slow when the user is deciding, snappy when the system responds. Hold-to-delete: 2s linear while pressed; 200ms ease-out snap-back on release.
Stagger — when multiple elements enter together, delay each by 30–80ms after the previous. Long stagger delays make the interface feel slow. Stagger is decorative — never block interaction while it plays.
clip-path is an animation tool
clip-path: inset(top right bottom left)defines a rectangular clip; each value eats from that side.inset(0 100% 0 0)is fully hidden from the right- Tabs with perfect color transitions: render the tab list twice (one default, one styled active), clip the active copy so only the active tab shows, animate the clip on change
- Hold-to-delete: clip-path overlay from
inset(0 100% 0 0)toinset(0 0 0 0)over 2s linear on:active, snap back 200msease-outon release - Image reveals on scroll:
inset(0 0 100% 0)→inset(0 0 0 0)triggered byIntersectionObserver - Comparison sliders: overlay two images, clip the top one with
inset(0 50% 0 0), drive the right inset from drag position — no extra DOM, fully GPU
Debugging
- Slow-mo: temporarily 2–5x duration to reveal timing issues invisible at full speed
- Chrome DevTools → Animations panel for frame-stepping coordinated properties
- Test gestures on real devices (USB-tether iPhone via Safari remote devtools); simulators miss inertia and touch latency
- Review animations the next day with fresh eyes — issues you missed during build will jump out
3. Component Polish
Small details compound. These rules apply everywhere a user can interact, hover, or wait for a popover.
Buttons feel responsive
.button {
transition: transform 160ms var(--ease-out);
}
.button:active {
transform: scale(0.97);
}Subtle (0.95–0.98). Applies to any pressable element. scale() shrinks children too — icons and text shrink in proportion, which is correct.
Never animate from scale(0) — nothing in the real world appears from nothing. Start from scale(0.95) (or higher) combined with opacity: 0. A barely-visible initial scale makes entrances feel natural.
Origin-aware popovers — popovers should scale from their trigger, not from center. Default transform-origin: center is wrong for almost every popover. Modals are exempt — they appear centered in the viewport, not anchored to a trigger.
/* Radix UI */
.popover-content { transform-origin: var(--radix-popover-content-transform-origin); }
/* Base UI */
.popover-content { transform-origin: var(--transform-origin); }Tooltips — skip delay after the first — first tooltip uses delay (prevents accidental activation); once one is open, hovering adjacent tooltips opens them instantly. Feels faster without defeating the initial protection.
.tooltip {
transition: opacity 125ms var(--ease-out), transform 125ms var(--ease-out);
transform-origin: var(--transform-origin);
}
.tooltip[data-starting-style],
.tooltip[data-ending-style] { opacity: 0; transform: scale(0.97); }
.tooltip[data-instant] { transition-duration: 0ms; }Blur to mask imperfect crossfades — when two states cross-fade and you can see both at once, add filter: blur(2px) during the transition. Blur blends the two states into one perceived transformation. Keep blur < 20px (Safari paints heavy blur expensively).
Combine press feedback patterns — scale(0.97) on :active + brief filter: blur(2px) on the content during state change = polished button transition.
4. Gestures & Drag
Drag interactions feel right when they obey real-world physics.
Momentum-based dismissal — don't require dragging past a threshold; calculate velocity and dismiss on a flick:
const elapsed = Date.now() - dragStart;
const velocity = Math.abs(distance) / elapsed;
if (Math.abs(distance) >= SWIPE_THRESHOLD || velocity > 0.11) dismiss();Damping at boundaries — when a user drags past the natural limit, apply increasing friction instead of a hard stop. The further they drag, the less the element moves. Real things slow to a stop, they don't snap to walls.
Pointer capture — once drag starts, call setPointerCapture so the gesture continues even if the pointer leaves element bounds.
Multi-touch protection — ignore additional touch points after the initial drag begins; otherwise switching fingers mid-drag teleports the element.
function onPress(e) {
if (isDragging) return;
// start drag…
}Drag hygiene — user-select: none on the dragged element (prevents text selection mid-drag); mark transient siblings with inert so they can't receive input during drag.
5. Building Loved Components
When authoring shared components or library-grade primitives, the bar is higher. Distillation of what makes Sonner-class components stick:
- Zero-config DX — single mount, no hooks/context required to use. Insert
<Toaster />once, calltoast()from anywhere. Friction kills adoption. - Good defaults beat many options — most users never customise; ship beautiful out of the box.
- Handle edge cases invisibly — pause toast timers when the tab is hidden; fill gaps between stacked toasts with pseudo-elements so hover states stay live; capture pointer during drag. Users never notice; that's the goal.
- Transitions over keyframes for dynamic UI — toasts/lists are added rapidly; transitions retarget, keyframes restart.
- Cohesion — match motion personality to the component. A playful component can be bouncier; a professional dashboard should be crisp and fast. A toast library that uses
ease(notease-out) at slightly slower than typical durations feels elegant — design the easing to match the vibe. - Review the next day — bugs invisible at full speed surface with fresh eyes and slow-mo.
- Documentation as product — interactive examples + ready-to-paste snippets. Let people touch the component before they commit.
Anti-Patterns
Flag these in code review — they are common and always wrong.
<div onClick>instead of<button>or<a>outline: none/outline: 0without a replacement focus styletransition: all— always list specific properties- Placeholder text as the only label on an input
event.preventDefault()on paste events- Missing
width/heighton<img>elements - Layout reads (
offsetHeight,getBoundingClientRect) interleaved with DOM writes autoFocuson mobile or on more than one element- Colour as the sole indicator of state (no icon or text backup)
- Generic "An error occurred" with no recovery guidance
- Disabled submit button with no explanation of what's missing
console.logleft in production event handlers- Hardcoded date/number formats instead of
Intl.* suppressHydrationWarningused to hide real mismatches- Missing
aria-labelon icon-only buttons - Entry animation from
scale(0)— start atscale(0.95)withopacity: 0 ease-inon UI elements — useease-outor a custom curvetransform-origin: centeron popovers (modals are exempt)- Animation on keyboard-initiated actions (Cmd+K, hotkeys)
- UI animation longer than 300ms
- Hover effect without
@media (hover: hover) and (pointer: fine)gate - Keyframes on rapidly-triggered elements (toasts, list reorder, drag state)
- Framer Motion
x/yprops on hot animations under main-thread load — usetransform: "translateX()" - Symmetric enter/exit timing on press-and-hold patterns — release should always be snappy
- Updating CSS variables on a parent for per-item drag positions — set
transformdirectly on the element
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
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.

