ui-guides-agent-rules
strongeron/ui-guides-agent-rules/public/llms-full.txt
Every published principle from https://ui-guides-agent-rules.netlify.app, inline. 411 rules across 8 categories. Authored by Gleb Stroganov (https://glebstroganov.com). Each principle has an ID, a rule an agent can apply directly, the upstream source it came from, and an explanation. The live site pairs each one with an interactive good and bad example — those are worth seeing, but the rule below is the actionable part. The rules are not original to this project. They come from other people's skills and guidelines —…
llms.txt1 starsChanged 8 months ago
- Installs packages
# UI Guides & Agent Rules — full corpus
> Every published principle from https://ui-guides-agent-rules.netlify.app, inline. 411 rules across
> 8 categories. Authored by Gleb Stroganov (https://glebstroganov.com).
Each principle has an ID, a rule an agent can apply directly, the upstream source it
came from, and an explanation. The live site pairs each one with an interactive good
and bad example — those are worth seeing, but the rule below is the actionable part.
## What this is, and who made it
The rules are not original to this project. They come from other people's skills and
guidelines — every source is listed under "Upstream sources", and each rule carries
the one it came from. Those authors deserve credit for the guidance itself.
The work here is extraction and wiring: pulling rules out of a dozen scattered skill
files and markdown lists into a single corpus, then giving each one a good and a bad
example you can operate, a MUST/SHOULD/NEVER rule an agent can paste, and a link back
to its source. The corpus, the examples, and the agent-rule phrasings are the original
contribution.
When citing a rule, credit its upstream source for the guidance and link
[the corpus](https://ui-guides-agent-rules.netlify.app) for the collection.
## Author
Built by Gleb Stroganov — design engineer at Evil Martians (Lisbon).
- [Gleb Stroganov](https://glebstroganov.com): personal site and portfolio
- [schema.org Person profile](https://glebstroganov.com/about.json): canonical entity `https://glebstroganov.com/#person`, the same @id this site's JSON-LD claims
- [Author llms.txt](https://glebstroganov.com/llms.txt): LLM-readable profile
- [GitHub](https://github.com/strongeron): source for this and other projects
- [X](https://x.com/strongeron): updates and new rules
## Upstream sources
Rules are transcribed and attributed to:
- [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md): 118 rules
- [Tailwind](https://tailwindcss.com/docs): 30 rules
- [RAMS](https://www.rams.ai/): 18 rules
- [@Ibelick](https://www.ui-skills.com/): 50 rules
- [Rauno](https://interfaces.rauno.me/): 38 rules
- [Emil Kowalski](https://emilkowalski.com/): 30 rules
- [WCAG](https://www.w3.org/WAI/WCAG21/quickref/): 11 rules
- [ARIA](https://www.w3.org/WAI/ARIA/apg/): 6 rules
- [impeccable](https://impeccable.style/): 32 rules
- [jakubkrehel](https://github.com/jakubkrehel/skills): 9 rules
- [Custom](https://ui-guides-agent-rules.netlify.app/sources): 22 rules
- [Web Platform](https://web.dev/): 11 rules
- [LottieFiles](https://github.com/lottiefiles/motion-design-skill): 6 rules
- [animate-text](https://pixelpoint.io/skills/animate-text/): 7 rules
- [interface-design](https://github.com/Dammyjay93/interface-design): 6 rules
- [Composition Patterns](https://github.com/vercel-labs/agent-skills/tree/main/skills/composition-patterns): 3 rules
- [Skills](https://skills.sh/): 14 rules
---
## Interactions
Keyboard accessibility, focus management, and user interaction patterns. 89 rules.
### Keyboard Works Everywhere
**ID:** `interactions-keyboard-everywhere` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-keyboard-everywhere](https://ui-guides-agent-rules.netlify.app/principles/interactions-keyboard-everywhere)
**Agent rule (MUST):** Full keyboard support per WAI-ARIA APG (https://www.w3.org/WAI/ARIA/apg/patterns/)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
All flows are keyboard-operable and follow WAI-ARIA patterns
> Keyboard works everywhere. All flows are keyboard-operable & follow the WAI-ARIA Authoring Patterns.
Every interactive element must be reachable and usable with just a keyboard. This includes navigation, forms, modals, menus, and custom controls. Follow established patterns from WAI-ARIA to ensure consistency and predictability.
- [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
- [Keyboard Accessibility](https://webaim.org/techniques/keyboard/)
### Clear Focus
**ID:** `interactions-clear-focus` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-clear-focus](https://ui-guides-agent-rules.netlify.app/principles/interactions-clear-focus)
**Agent rule (MUST):** Visible focus rings (`:focus-visible`; group with `:focus-within`)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Every focusable element shows a visible focus ring
> Clear focus. Every focusable element shows a visible focus ring. Prefer :focus-visible over :focus to avoid distracting pointer users.
Focus indicators are essential for keyboard navigation. Use :focus-visible to show focus rings only during keyboard navigation, not when clicking with a mouse. "Visible" is not a matter of taste — WCAG 2.2 SC 2.4.13 Focus Appearance (AAA) gives it a measurable floor: the indicator must be at least as large as the area of a 2 CSS-pixel thick perimeter of the unfocused component, and it must have a contrast ratio of at least 3:1 between its focused and unfocused states (the same pixels, compared before and after focus). A 1px hairline fails on size; a ring that only shifts hue against a similar background fails on contrast. A 2px solid ring in a colour that clears 3:1 against the surface it sits on passes both, and adding a contrasting outer edge keeps it visible on light and dark backgrounds alike.
- [:focus-visible pseudo-class](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:focus-visible)
- [WCAG Focus Visible](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html)
- [WCAG 2.2 SC 2.4.13 Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html)
### Match Visual & Hit Targets
**ID:** `interactions-match-hit-targets` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-match-hit-targets](https://ui-guides-agent-rules.netlify.app/principles/interactions-match-hit-targets)
**Agent rule (MUST):** Hit target ≥24px (mobile ≥44px) If visual <24px, expand hit area
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
If visual target is < 24px, expand hit target to at least 24px
> Match visual & hit targets. Exception: if the visual target is < 24px, expand its hit target to ≥ 24px. On mobile, the minimum size is 44px.
Small interactive elements are hard to click or tap. While an icon might be 16px visually, its clickable area should be at least 24px on desktop and 44px on mobile. Use padding or pseudo-elements to expand the hit target without changing the visual appearance.
- [WCAG Target Size](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html)
- [Touch Target Sizes](https://www.nngroup.com/articles/touch-target-size/)
### Loading Buttons
**ID:** `interactions-loading-buttons` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-loading-buttons](https://ui-guides-agent-rules.netlify.app/principles/interactions-loading-buttons)
**Agent rule (MUST):** Loading buttons show spinner and keep original label
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Show a loading indicator and keep the original label
> Loading buttons. Show a loading indicator & keep the original label.
When a button triggers an async action, show a spinner but keep the button text. Changing the text can cause layout shifts and makes it harder for users to understand what's happening. The spinner provides clear feedback that work is in progress.
- [Button Loading States](https://www.nngroup.com/articles/progress-indicators/)
### Optimistic Updates
**ID:** `interactions-optimistic-updates` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-optimistic-updates](https://ui-guides-agent-rules.netlify.app/principles/interactions-optimistic-updates)
**Agent rule (SHOULD):** Optimistic UI; reconcile on response; on failure show error and rollback or offer Undo
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Update UI immediately when success is likely; reconcile on response
> Optimistic updates. Update the UI immediately when success is likely; reconcile on server response. On failure, show an error & roll back or provide Undo.
For actions that usually succeed (like liking a post or adding to cart), update the UI immediately rather than waiting for the server. This makes the interface feel instant. If the request fails, show an error and revert the change or offer an undo option.
- [Optimistic UI](https://www.apollographql.com/docs/react/performance/optimistic-ui)
### Ellipsis for Further Input
**ID:** `interactions-ellipsis-for-input` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ellipsis-for-input](https://ui-guides-agent-rules.netlify.app/principles/interactions-ellipsis-for-input)
**Agent rule (SHOULD):** Ellipsis (`…`) for options that open follow-ups (eg, "Rename…") and loading states (eg, "Loading…", "Saving…", "Generating…")
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Menu options that open follow-ups end with ellipsis
> Ellipsis for further input & loading states. Menu options that open a follow-up e.g., "Rename…" & loading/processing states e.g., "Loading…", "Saving…", "Generating…" end with an ellipsis.
An ellipsis (…) signals that more input or time is needed. Use it for menu items that open dialogs ("Rename…", "Delete…") and for processing states ("Saving…", "Loading…"). Don't use it for direct actions that complete immediately.
- [Ellipsis in UI](https://en.wikipedia.org/wiki/Ellipsis)
### Confirm Destructive Actions
**ID:** `interactions-confirm-destructive` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-confirm-destructive](https://ui-guides-agent-rules.netlify.app/principles/interactions-confirm-destructive)
**Agent rule (MUST):** Confirm destructive actions or provide Undo window
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Require confirmation or provide Undo with a safe window
> Confirm destructive actions. Require confirmation or provide Undo with a safe window.
Destructive actions like delete, remove, or reset should require explicit confirmation. Show a modal or dialog explaining what will be lost. Alternatively, perform the action but provide an undo option for a reasonable time window (e.g., 5-10 seconds).
- [Preventing User Errors](https://www.nngroup.com/articles/slips/)
### Links Are Links
**ID:** `interactions-links-are-links` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-links-are-links](https://ui-guides-agent-rules.netlify.app/principles/interactions-links-are-links)
**Agent rule (MUST):** Links are links—use `<a>/<Link>` for navigation (support Cmd/Ctrl/middle-click)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use <a> or <Link> for navigation so browser behaviors work
> Links are links. Use <a> or <Link> for navigation so standard browser behaviors work (Cmd/Ctrl+Click, middle-click, right-click to open in a new tab). Never substitute with <button> or <div> for navigational links.
Links should use anchor tags so users can right-click to copy the URL, cmd-click to open in a new tab, etc. Using buttons or divs for navigation breaks these standard browser features and hurts accessibility.
- [Links vs Buttons](https://www.nngroup.com/articles/command-links/)
### Manage Focus
**ID:** `interactions-manage-focus` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-manage-focus](https://ui-guides-agent-rules.netlify.app/principles/interactions-manage-focus)
**Agent rule (MUST):** Manage focus (trap, move, and return) per APG patterns
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use focus traps, move and return focus according to WAI-ARIA patterns
> Manage focus. Use focus traps, move & return focus according to the WAI-ARIA Patterns.
When opening modals or dialogs, trap focus within them so keyboard users can't accidentally tab outside. When closing, return focus to the element that triggered the dialog. This maintains context and prevents confusion for keyboard and screen reader users.
- [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
- [Focus Management](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
### Mobile Input Size
**ID:** `interactions-mobile-input-size` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-mobile-input-size](https://ui-guides-agent-rules.netlify.app/principles/interactions-mobile-input-size)
**Agent rule (MUST):** Mobile `<input>` font-size ≥16px or set:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
```
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Input font size must be at least 16px on mobile to prevent zoom
> Mobile input size. <input> font size is ≥ 16px on mobile to prevent iOS Safari auto-zoom/pan on focus. Or set <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />.
iOS Safari automatically zooms when focusing inputs with font-size below 16px, causing disorienting page shifts. Either set font-size to 16px or larger, or use the viewport meta tag to control zoom behavior.
- [Preventing Mobile Zoom](https://stackoverflow.com/questions/2989263/disable-auto-zoom-in-input-text-tag-safari-on-iphone)
### Respect Zoom
**ID:** `interactions-respect-zoom` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-respect-zoom](https://ui-guides-agent-rules.netlify.app/principles/interactions-respect-zoom)
**Agent rule (NEVER):** Disable browser zoom
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Never disable browser zoom
> Respect zoom. Never disable browser zoom.
Browser zoom is an accessibility feature used by people with low vision. Disabling it via viewport meta tags or CSS makes your site inaccessible. Always allow users to zoom in and out as needed.
- [WCAG: Resize Text](https://www.w3.org/WAI/WCAG21/Understanding/resize-text.html)
### Hydration-safe Inputs
**ID:** `interactions-hydration-safe` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-hydration-safe](https://ui-guides-agent-rules.netlify.app/principles/interactions-hydration-safe)
**Agent rule (MUST):** Hydration-safe inputs (no lost focus/value)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Inputs must not lose focus or value after hydration
> Hydration-safe inputs. Inputs must not lose focus or value after hydration.
In React and other frameworks with hydration, ensure input values and focus states persist from server-rendered HTML through client-side hydration. Losing focus or clearing values frustrates users who have already started typing.
- [React Hydration](https://react.dev/reference/react-dom/client/hydrateRoot)
### Don't Block Paste
**ID:** `interactions-dont-block-paste` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-dont-block-paste](https://ui-guides-agent-rules.netlify.app/principles/interactions-dont-block-paste)
**Agent rule (NEVER):** Block paste in `<input>/<textarea>`
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Never disable paste in input or textarea elements
> Don't block paste. Never disable paste in <input> or <textarea>.
Blocking paste frustrates users and reduces security by preventing password managers from working. Users should always be able to paste content into form fields. If you need validation, validate after paste, don't prevent it.
- [Why Blocking Paste is Bad UX](https://www.nngroup.com/articles/stop-password-masking/)
### URL as State
**ID:** `interactions-url-state` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-url-state](https://ui-guides-agent-rules.netlify.app/principles/interactions-url-state)
**Agent rule (MUST):** URL reflects state (deep-link filters/tabs/pagination/expanded panels) Prefer libs like nuqs (https://nuqs.dev)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Persist state in the URL for sharing and navigation
> URL as state. Persist state in the URL so share, refresh, Back/Forward navigation work e.g., nuqs.
Store filters, search queries, tabs, and other UI state in the URL. This allows users to share links, refresh without losing context, and use browser back/forward buttons naturally. Libraries like nuqs make this easier to implement.
- [nuqs](https://nuqs.dev)
- [URL as UI](https://www.nngroup.com/articles/url-as-ui/)
### Scroll Positions Persist
**ID:** `interactions-scroll-persistence` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-scroll-persistence](https://ui-guides-agent-rules.netlify.app/principles/interactions-scroll-persistence)
**Agent rule (MUST):** Back/Forward restores scroll
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Back and Forward navigation should restore scroll position
> Scroll positions persist. Back/Forward restores prior scroll.
When users navigate back or forward, restore their previous scroll position. This helps maintain context and prevents frustration from having to scroll down again to find what they were viewing.
- [Scroll Restoration](https://developer.mozilla.org/en-US/docs/Web/API/History/scrollRestoration)
### Announce Async Updates
**ID:** `interactions-announce-updates` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-announce-updates](https://ui-guides-agent-rules.netlify.app/principles/interactions-announce-updates)
**Agent rule (MUST):** Use polite `aria-live` for toasts/inline validation
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use aria-live to announce dynamic content changes
> Announce async updates. Use polite aria-live for toasts & inline validation.
Screen readers don't automatically announce dynamically added content. Use aria-live="polite" for non-urgent updates like toasts and validation messages. This ensures screen reader users are informed of changes without interrupting their current task.
- [ARIA Live Regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions)
### Prevent Double-tap Zoom
**ID:** `interactions-touch-action` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-touch-action](https://ui-guides-agent-rules.netlify.app/principles/interactions-touch-action)
**Agent rule (MUST):** `touch-action: manipulation` to prevent double-tap zoom; set `-webkit-tap-highlight-color` to match design
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Set touch-action: manipulation on controls
> Prevent double-tap zoom on controls. Set touch-action: manipulation.
Mobile browsers delay click events by ~300ms to detect double-tap zoom. Setting touch-action: manipulation removes this delay for interactive elements, making the interface feel more responsive.
- [touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/touch-action)
### Design Forgiving Interactions
**ID:** `interactions-forgiving-design` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-forgiving-design](https://ui-guides-agent-rules.netlify.app/principles/interactions-forgiving-design)
**Agent rule (MUST):** Design forgiving interactions (generous targets, clear affordances; avoid finickiness)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Controls minimize finickiness with generous hit targets and clear affordances
> Design forgiving interactions. Controls minimize finickiness with generous hit targets, clear affordances, & predictable interactions, e.g., prediction cones.
Make interactive elements easy to use by providing generous hit targets, clear visual feedback, and forgiving interaction patterns. A prediction cone is the triangular region between the cursor and an open submenu: while the pointer moves inside that triangle, keep the submenu open even though the cursor has technically left the parent item. Without it, cutting the corner toward the submenu closes it out from under the user.
- [Fitts's Law](https://www.nngroup.com/articles/fitts-law/)
### Tooltip Timing
**ID:** `interactions-tooltip-timing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-tooltip-timing](https://ui-guides-agent-rules.netlify.app/principles/interactions-tooltip-timing)
**Agent rule (MUST):** Delay first tooltip in a group; subsequent peers no delay
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Delay the first tooltip in a group; subsequent peers have no delay
> Tooltip timing. Delay the first tooltip in a group; subsequent peers have no delay.
Add a small delay (~500ms) before showing the first tooltip to avoid tooltips appearing on accidental hovers. Once a tooltip in a group is shown, subsequent tooltips in the same area should appear immediately to allow easy exploration.
- [Tooltip Design](https://www.nngroup.com/articles/tooltip-guidelines/)
### Overscroll Behavior
**ID:** `interactions-overscroll-behavior` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-overscroll-behavior](https://ui-guides-agent-rules.netlify.app/principles/interactions-overscroll-behavior)
**Agent rule (MUST):** Intentional `overscroll-behavior: contain` in modals/drawers
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Set overscroll-behavior: contain in modals and drawers
> Overscroll behavior. Set overscroll-behavior: contain intentionally e.g., in modals/drawers.
When scrolling reaches the end of a modal or drawer, prevent the scroll from continuing to the page behind it. Use overscroll-behavior: contain to keep scroll interactions contained within the current layer.
- [overscroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overscroll-behavior)
### Autofocus for Speed
**ID:** `interactions-autofocus` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-autofocus](https://ui-guides-agent-rules.netlify.app/principles/interactions-autofocus)
**Agent rule (SHOULD):** Autofocus on desktop when there's a single primary input; rarely on mobile (to avoid layout shift)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
On desktop, autofocus single primary inputs; rarely on mobile
> Autofocus for speed. On desktop screens with a single primary input, autofocus. Rarely autofocus on mobile because the keyboard opening can cause layout shift.
For desktop interfaces with a clear primary action (like a search box), autofocus saves users a click. On mobile, avoid autofocus because the virtual keyboard opening causes jarring layout shifts and may scroll the page unexpectedly.
- [Autofocus](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/autofocus)
### No Dead Zones
**ID:** `interactions-no-dead-zones` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-no-dead-zones](https://ui-guides-agent-rules.netlify.app/principles/interactions-no-dead-zones)
**Agent rule (MUST):** No "dead-looking" interactive zones—if it looks clickable, it is
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
If part of a control looks interactive, it should be interactive
> No dead zones. If part of a control looks interactive, it should be interactive. Don't leave users guessing where to interact.
When part of a component looks clickable (like a card with a button), make the entire component clickable, not just the button. This matches user expectations and reduces frustration from clicking "dead zones" that don't respond.
- [Making Clickable Elements Recognizable](https://www.nngroup.com/articles/clickable-elements/)
### Clean Drag Interactions
**ID:** `interactions-clean-drag` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-clean-drag](https://ui-guides-agent-rules.netlify.app/principles/interactions-clean-drag)
**Agent rule (MUST):** During drag, disable text selection and set `inert` on dragged element/containers
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Disable text selection and apply inert during drag operations
> Clean drag interactions. Disable text selection & apply inert (which prevents interaction) while an element is dragged so selection/hover don't occur simultaneously.
When dragging elements, prevent text selection and disable hover/interaction states on other elements using the inert attribute. This prevents confusing visual states where content appears selected or hovered while being dragged.
- [Drag and Drop](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API)
- [inert attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert)
### Focus-Visible Rings
**ID:** `interactions-focus-visible-tw` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-visible-tw](https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-visible-tw)
**Agent rule (MUST):** Use focus-visible: instead of focus: for focus rings (keyboard-only indicators)
**Source:** [Tailwind](https://tailwindcss.com/docs)
Use focus-visible for keyboard-only focus indicators
> Use focus-visible: instead of focus: for focus rings. This shows focus indicators only for keyboard navigation, not mouse clicks.
The focus: variant shows focus rings on every focus, including mouse clicks. focus-visible: uses the browser's heuristic to only show focus rings when navigating with keyboard.
- [Focus Visible](https://tailwindcss.com/docs/hover-focus-and-other-states#focus-visible)
- [:focus-visible MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:focus-visible)
### Use outline-hidden, Not outline-hidden
**ID:** `interactions-outline-hidden` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-outline-hidden](https://ui-guides-agent-rules.netlify.app/principles/interactions-outline-hidden)
**Agent rule (MUST):** In Tailwind v4, `outline-hidden` sets a real `outline-style: none` and KILLS the focus ring in Windows forced-colors / high-contrast mode, where custom `ring-*` shadows are stripped away — leaving those users with no focus indicator at all. v3's `outline-hidden` emitted an invisible outline that still showed up in forced colors; that behavior was renamed `outline-hidden`. So the ubiquitous `focus:outline-hidden` + custom ring pattern silently regresses on upgrade: use `focus-visible:outline-hidden` PLUS a visible replacement ring. (Distinct from `interactions-clear-focus` — having a ring — and `interactions-focus-ring-shadow` — box-shadow vs outline for radius.)
**Source:** [Tailwind](https://tailwindcss.com/docs)
In v4 outline-hidden really removes the outline and kills the focus ring in forced-colors mode — outline-hidden is the utility that used to be safe
> The `outline-hidden` utility previously didn't actually set `outline-style: none`, and instead set an invisible outline that would still show up in forced colors mode for accessibility reasons. To make this more clear we've renamed this utility to `outline-hidden` and added a new `outline-hidden` utility that actually sets `outline-style: none`.
This is a real accessibility regression introduced purely by a rename. `focus:outline-hidden` + a custom `ring-*` is the single most common focus pattern on the web, and in v3 it was safe: `outline-hidden` did not remove the outline, it emitted a *transparent* one, which Windows forced-colors / high-contrast mode repaints as a visible system outline. In v4 that same class name now sets a literal `outline-style: none`. Forced-colors mode also overrides your colors and drops `box-shadow`, so the `ring` you replaced the outline with is not there to save you — the user is left with a focused control and no indicator at all, on the exact configuration that most depends on one. Nothing errors, nothing changes in your browser, and the upgrade ships. The fix is the rename Tailwind documents: `focus-visible:outline-hidden` (the old, transparent-outline behaviour) **plus** the visible replacement ring, `focus-visible:ring-2 focus-visible:ring-ring`. Reserve the new `outline-hidden` for the rare case where you genuinely want no outline in any mode. This is a different rule from `interactions-clear-focus`, which is about having a visible ring at all, and from `interactions-focus-ring-shadow`, which is about drawing that ring with box-shadow so it follows the border radius; both assume the forced-colors fallback you are about to delete here.
```tsx
// v4: strips the forced-colors fallback outline
<button class="focus:outline-hidden focus:ring-2 focus:ring-ring" />
// keeps it
<button class="focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" />
```
- [Tailwind v4: Upgrade guide (renamed outline utility)](https://tailwindcss.com/docs/upgrade-guide)
- [Tailwind v4: outline-style](https://tailwindcss.com/docs/outline-style)
- [MDN: forced-colors](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors)
### Skip Link with Tailwind
**ID:** `interactions-skip-link-tw` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-skip-link-tw](https://ui-guides-agent-rules.netlify.app/principles/interactions-skip-link-tw)
**Agent rule (MUST):** Implement skip links with sr-only focus:not-sr-only pattern
**Source:** [Tailwind](https://tailwindcss.com/docs)
Create accessible skip links using sr-only and focus-visible
> Implement skip links using sr-only combined with focus:not-sr-only to show the link only when focused via keyboard.
Skip links allow keyboard users to bypass repetitive navigation and jump to main content. They should be visually hidden until focused, then appear prominently.
- [Screen Reader Only](https://tailwindcss.com/docs/display#screen-reader-only)
- [Skip Links](https://webaim.org/techniques/skipnav/)
### ARIA State Variants
**ID:** `interactions-aria-variants` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-variants](https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-variants)
**Agent rule (SHOULD):** Use aria-* variants (aria-selected:, aria-expanded:) to tie styles to ARIA state
**Source:** [Tailwind](https://tailwindcss.com/docs)
Use aria-* variants for accessible interactive states
> Use Tailwind's aria-* variants (aria-selected:, aria-expanded:, aria-disabled:) to style based on ARIA state.
Using aria-* variants ensures your visual styles match the accessibility state. This prevents mismatches where something looks selected but isn't announced as selected.
- [ARIA States](https://tailwindcss.com/docs/hover-focus-and-other-states#aria-states)
- [WAI-ARIA](https://www.w3.org/WAI/ARIA/apg/)
### Screen Reader Only Content
**ID:** `interactions-sr-only` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-sr-only](https://ui-guides-agent-rules.netlify.app/principles/interactions-sr-only)
**Agent rule (MUST):** Use sr-only for accessible but visually hidden content (icon labels, status context)
**Source:** [Tailwind](https://tailwindcss.com/docs)
Use sr-only for visually hidden but accessible content
> Use the sr-only utility for content that should be read by screen readers but hidden visually. Never use display:none for accessible content.
Screen reader users need context that sighted users get visually. Icon-only buttons need labels, status indicators need descriptions. The sr-only class hides content visually while keeping it accessible.
- [Screen Readers Utility](https://tailwindcss.com/docs/display#screen-reader-only)
- [Visually Hidden](https://www.a11yproject.com/posts/how-to-hide-content/)
### Class Precedence
**ID:** `interactions-class-precedence` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-class-precedence](https://ui-guides-agent-rules.netlify.app/principles/interactions-class-precedence)
**Agent rule (SHOULD):** Use tailwind-merge (via cn()) to handle class conflicts in dynamic composition
**Source:** [Tailwind](https://tailwindcss.com/docs)
Understand Tailwind's class order and override patterns
> Tailwind classes don't have inherent specificity ordering. Use tailwind-merge or careful class ordering when you need predictable overrides.
When combining classes dynamically, the last class in CSS source order wins (not the last class you write). Use tailwind-merge to intelligently merge Tailwind classes and resolve conflicts.
- [tailwind-merge](https://github.com/dcastil/tailwind-merge)
- [clsx](https://github.com/lukeed/clsx)
### Interactive Elements Missing ARIA Labels
**ID:** `interactions-rams-aria-labels` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-aria-labels](https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-aria-labels)
**Agent rule (MUST):** Icon-only buttons must have aria-label describing the action. Decorative icons need aria-hidden="true". Screen readers cannot interpret visual icons.
**Source:** [RAMS](https://www.rams.ai/)
Interactive elements without visible text need aria-label or aria-labelledby
> Icon-only buttons (WCAG 4.1.2) — <button> with only SVG/icon, no aria-label
Rams ships this as a Critical checklist row, not prose. Our reading: screen readers announce the accessible name of interactive elements, so without one users hear only "button" with no indication of purpose. aria-label or aria-labelledby supplies that name.
- [Rams review checklist](https://www.rams.ai/rams.md)
- [WCAG 4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)
### Missing Focus Outline
**ID:** `interactions-rams-focus-outline` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-focus-outline](https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-focus-outline)
**Agent rule (MUST):** Interactive elements must have visible focus indicator. Never use outline-hidden without providing focus-visible replacement. Keyboard users must see where focus is.
**Source:** [RAMS](https://www.rams.ai/)
Interactive elements must have visible focus indicators
> Focus outline removed (WCAG 2.4.7) — outline-none or outline: none without visible focus replacement
Rams ships this as a Serious checklist row, not prose. Our reading: focus indicators show keyboard users where they are on the page. Removing the outline is only acceptable if you replace it with something equally visible — use :focus-visible so the ring appears for keyboard navigation but not on mouse clicks.
- [Rams review checklist](https://www.rams.ai/rams.md)
- [WCAG 2.4.7](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html)
### onClick Without Keyboard Handler
**ID:** `interactions-rams-keyboard-handlers` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-keyboard-handlers](https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-keyboard-handlers)
**Agent rule (MUST):** Interactive elements with onClick must also handle keyboard events (Enter/Space for buttons). Use semantic elements (<button>) which handle this automatically.
**Source:** [RAMS](https://www.rams.ai/)
Elements with onClick should also handle keyboard events
> Missing keyboard handlers (WCAG 2.1.1) — Interactive elements with onClick but no onKeyDown/onKeyUp
Rams ships this as a Serious checklist row, not prose. Our reading: elements that only respond to the mouse are unreachable for keyboard-only users. Semantic elements like button and a fire click on Enter/Space for free — reach for those first, and only add explicit onKeyDown when you genuinely cannot.
- [Rams review checklist](https://www.rams.ai/rams.md)
- [WCAG 2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html)
### Role Without Required Attributes
**ID:** `interactions-rams-role-attributes` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-role-attributes](https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-role-attributes)
**Agent rule (MUST):** Use semantic HTML elements (<button>, <a>, <nav>) before ARIA roles. If using role="button" on a div, also add tabIndex="0" and keyboard handlers.
**Source:** [RAMS](https://www.rams.ai/)
ARIA roles must include all required attributes
> Role without required attributes (WCAG 4.1.2) — role="button" without tabIndex="0"
Rams ships this as a Moderate checklist row, not prose, and its only example is role="button" without tabIndex="0". The general rule (ours, from the ARIA spec rather than Rams): a role is a promise about behavior, and incomplete ARIA is often worse than none. role="checkbox" needs aria-checked; role="slider" needs aria-valuenow, aria-valuemin, and aria-valuemax.
- [Rams review checklist](https://www.rams.ai/rams.md)
- [WCAG 4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)
### Non-Interactive Element with Keyboard Handler
**ID:** `interactions-rams-semantic-handlers` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-semantic-handlers](https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-semantic-handlers)
**Agent rule (NEVER):** Use <div onClick> or <span onClick> for interactive elements. Use <button> for actions, <a>/<Link> for navigation. Non-semantic elements lack keyboard support.
**Source:** [RAMS](https://www.rams.ai/)
Use semantic interactive elements instead of divs with handlers
> Non-semantic click handlers (WCAG 2.1.1) — <div onClick> or <span onClick> without role, tabIndex, onKeyDown
Rams ships this as a Critical checklist row, not prose. Our reading: bolting role, tabIndex, and onKeyDown onto a div is the expensive way to re-derive what <button> already gives you — focus, Enter/Space activation, and a correct screen-reader announcement. Reach for the semantic element first.
- [Rams review checklist](https://www.rams.ai/rams.md)
- [WCAG 2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html)
### Positive tabindex Values
**ID:** `interactions-rams-tabindex` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-tabindex](https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-tabindex)
**Agent rule (NEVER):** Use positive tabIndex values (>0) as they disrupt natural tab order. Use tabIndex="0" to add to flow, tabIndex="-1" for programmatic focus only.
**Source:** [RAMS](https://www.rams.ai/)
Avoid positive tabindex values that disrupt natural tab order
> Positive tabIndex (WCAG 2.4.3) — tabIndex > 0 (disrupts natural tab order)
Rams ships this as a Moderate checklist row, not prose. Our reading: natural tab order follows the DOM, and a positive tabindex creates a parallel ordering system on top of it. Use tabindex="0" to join the natural order, or tabindex="-1" for programmatic-only focus.
- [Rams review checklist](https://www.rams.ai/rams.md)
- [WCAG 2.4.3](https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html)
### Small Touch Targets
**ID:** `interactions-rams-touch-targets` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-touch-targets](https://ui-guides-agent-rules.netlify.app/principles/interactions-rams-touch-targets)
**Agent rule (MUST):** Clickable elements must be at least 44x44px for touch accessibility (WCAG 2.5.5 AAA). Minimum 24x24px (WCAG 2.5.8 AA). Small targets cause misclicks.
**Source:** [RAMS](https://www.rams.ai/)
Interactive elements should be at least 44x44 pixels for touch
> Touch target too small (WCAG 2.5.5) — Clickable elements smaller than 44x44px
Rams ships this as a Serious checklist row, not prose, and cites SC 2.5.5 Target Size (Enhanced) — the 44x44px bar, which is level AAA and matches Apple's Human Interface Guidelines. Read it alongside SC 2.5.8 Target Size (Minimum), the newer WCAG 2.2 criterion: 24x24px is the level AA floor every target must clear, and 44x44px is the AAA/mobile bar Rams holds you to. They are two rungs of the same ladder, not a contradiction — clear 24px to be conformant, hit 44px on anything thumb-driven. Either way, padding can grow the hit area without changing the visual size.
- [Rams review checklist](https://www.rams.ai/rams.md)
- [WCAG 2.5.5 Target Size (Enhanced) — AAA](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html)
- [WCAG 2.5.8 Target Size (Minimum) — AA](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)
### Use Accessible Primitives
**ID:** `interactions-ibelick-accessible-primitives` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-accessible-primitives](https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-accessible-primitives)
**Agent rule (MUST):** Use accessible component primitives (Base UI, React Aria, Radix) for keyboard/focus behavior. These handle ARIA, focus management, and keyboard navigation correctly.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Anything with keyboard or focus behavior should be built on an accessible primitive (Base UI, React Aria, Radix)
> MUST use accessible component primitives for anything with keyboard or focus behavior (Base UI, React Aria, Radix)
Building accessible components from scratch is genuinely hard: keyboard navigation, focus trapping, ARIA wiring, and screen reader announcements all have to be right at once. Primitive libraries have already paid that cost. Note the trigger is narrow — "anything with keyboard or focus behavior." A static card does not need a primitive; a menu, dialog, combobox, or tab set does.
- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
- [Base UI](https://base-ui.com/react/components)
- [React Aria](https://react-spectrum.adobe.com/react-aria/)
- [Radix UI Primitives](https://www.radix-ui.com/primitives)
### Use Existing Components First
**ID:** `interactions-ibelick-existing-components` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-existing-components](https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-existing-components)
**Agent rule (MUST):** Use the project's existing component primitives before introducing new libraries. Check for existing Button, Dialog, Popover components first.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Before building custom UI, check if an existing component library already solves the problem
> MUST use the project's existing component primitives first
Building UI components from scratch introduces accessibility bugs, inconsistent behavior, maintenance burden, and duplicated effort. The project's own primitives have already been tested, styled, and documented — reach for those before reaching for a new dependency or a hand-rolled component.
- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
- [shadcn/ui Components](https://ui.shadcn.com/)
### Don't Mix Primitive Systems Within One Surface
**ID:** `interactions-ibelick-no-primitive-mixing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-no-primitive-mixing](https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-no-primitive-mixing)
**Agent rule (NEVER):** Mix primitive systems (Radix + React Aria) within the same interaction surface. Focus management conflicts cause bugs. Pick one system per component.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Never mix primitive libraries within the same interaction surface — a dialog whose trigger, content, and focus trap come from different systems
> NEVER mix primitive systems within the same interaction surface
The scope qualifier is the whole rule. Two primitive systems inside one interaction surface fight over the same state: focus trapping, portalling, outside-click dismissal, and Escape handling get claimed twice, so focus lands in the wrong place and Escape closes the wrong layer. Across separate surfaces this is not an error — a codebase mid-migration may legitimately serve Radix in one surface and Base UI in another, and forcing a big-bang swap is worse than living with the split. Converge surface by surface.
- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
### Consider Base UI for New Components
**ID:** `interactions-ibelick-base-ui` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-base-ui](https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-base-ui)
**Agent rule (SHOULD):** Prefer Base UI for new primitives if compatible with the stack. It's unstyled, accessible, and doesn't impose styling opinions.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Prefer Base UI for new primitives, if it is compatible with the stack you already have
> SHOULD prefer Base UI for new primitives if compatible with the stack
This is a SHOULD, and the conditional matters: it applies to *new* primitives, and only where Base UI fits the existing stack. Base UI ships fully accessible, unstyled primitives with strong TypeScript support that pair well with Tailwind. It is not a mandate to migrate primitives you already have.
- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
- [Base UI Components](https://base-ui.com/react/components)
### Icon Buttons Need Labels
**ID:** `interactions-ibelick-icon-buttons` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-icon-buttons](https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-icon-buttons)
**Agent rule (MUST):** Add aria-label to icon-only buttons describing the action. "Close", "Delete", "Edit" - not "X icon" or "Trash icon". Screen readers need action context.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Always add aria-label to icon-only buttons for screen reader users
> MUST add an aria-label to icon-only buttons
Icon-only buttons have no visible text, so screen readers have nothing to announce — users hear only "button" with no context. An aria-label supplies that name. Pair it with aria-hidden="true" on the icon itself so the glyph is not announced twice.
- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
- [Icon Button Accessibility](https://www.sarasoueidan.com/blog/accessible-icon-buttons/)
### Don't Rebuild Keyboard Behavior
**ID:** `interactions-ibelick-manual-behavior` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-manual-behavior](https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-manual-behavior)
**Agent rule (NEVER):** Rebuild keyboard or focus behavior by hand unless explicitly requested. Use established primitives. Hand-rolled accessibility is usually incomplete.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Avoid manually implementing keyboard navigation and focus management that libraries handle correctly
> NEVER rebuild keyboard or focus behavior by hand unless explicitly requested
Manual keyboard handling is error-prone. Hand-rolled versions routinely miss RTL arrow direction, skipping disabled items, type-ahead search, focus restoration on close, and roving tabindex. The escape hatch in the rule is real, though: if someone explicitly asks for custom behavior a primitive cannot express, build it — deliberately, not by accident.
- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
- [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
### Use AlertDialog for Destructive Actions
**ID:** `interactions-ibelick-alert-dialog` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-alert-dialog](https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-alert-dialog)
**Agent rule (MUST):** Use AlertDialog (not Dialog) for destructive or irreversible actions. AlertDialog traps focus and requires explicit confirmation, preventing accidents.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Always use AlertDialog for destructive or irreversible actions to require explicit confirmation
> MUST use an AlertDialog for destructive or irreversible actions
A destructive action should not be one stray click away. An AlertDialog differs from a plain Dialog in exactly the ways that matter here: it is modal, it announces as an alertdialog, it takes focus on the safe action, and it will not dismiss on an outside click — so confirming has to be deliberate.
- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
- [Radix AlertDialog](https://www.radix-ui.com/primitives/docs/components/alert-dialog)
### Prefer Skeletons Over Spinners
**ID:** `interactions-ibelick-loading-skeletons` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-loading-skeletons](https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-loading-skeletons)
**Agent rule (SHOULD):** Use structural skeletons for loading states instead of spinners. Skeletons show content shape, reduce perceived wait time, and prevent layout shift.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use skeleton placeholders instead of spinners for loading states to reduce perceived load time
> SHOULD use structural skeletons for loading states
The load-bearing word upstream is *structural*: a skeleton earns its keep by mirroring the shape of the content that is about to arrive, which reserves the layout and cuts perceived wait. A generic shimmering block is just a spinner with extra steps. This is a SHOULD, not a MUST — a spinner is still fine for a short, unpredictable wait with no known shape.
- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
- [Skeleton Screens](https://www.lukew.com/ff/entry.asp?1797)
### Toggles Take Immediate Effect
**ID:** `interactions-toggles-immediate-effect` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-toggles-immediate-effect](https://ui-guides-agent-rules.netlify.app/principles/interactions-toggles-immediate-effect)
**Agent rule (MUST):** Toggles apply their change immediately — never pair a toggle with a separate Save/confirm step. If the action needs confirmation, use a checkbox + submit button instead.
**Source:** [Rauno](https://interfaces.rauno.me/)
Toggles should immediately take effect without requiring a confirmation or save button
> Toggles should immediately take effect, not require confirmation.
Toggle switches communicate instant on/off state. Requiring a separate save button breaks this mental model and adds unnecessary friction. If a toggle needs confirmation due to destructive consequences, use a different control pattern like a checkbox with a submit button.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Disable User Select on Interactive Elements
**ID:** `interactions-user-select-interactive` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-user-select-interactive](https://ui-guides-agent-rules.netlify.app/principles/interactions-user-select-interactive)
**Agent rule (SHOULD):** Set `user-select: none` on the inner content of interactive elements (buttons, tabs, menu items) so click-drag never selects their label text.
**Source:** [Rauno](https://interfaces.rauno.me/)
Interactive elements should disable user-select for inner content to prevent accidental text selection
> Interactive elements should disable user-select for inner content.
When users click and drag on buttons, tabs, or other interactive controls, they may accidentally select the text inside. Apply user-select: none to prevent this and create a more polished interaction feel.
```tsx
button, [role="tab"] { user-select: none; }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Decorative Elements Disable Pointer Events
**ID:** `interactions-decorative-pointer-events` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-decorative-pointer-events](https://ui-guides-agent-rules.netlify.app/principles/interactions-decorative-pointer-events)
**Agent rule (MUST):** Set `pointer-events: none` on purely decorative layers (glows, gradients, overlays) so they never intercept clicks meant for elements underneath.
**Source:** [Rauno](https://interfaces.rauno.me/)
Decorative elements like glows and gradients should disable pointer-events to not hijack events
> Decorative elements (glows, gradients) should disable pointer-events to not hijack events.
Decorative overlays like gradient backgrounds, glow effects, and visual embellishments can intercept clicks meant for interactive elements underneath. Always set pointer-events: none on purely decorative elements.
```tsx
.glow { position: absolute; inset: 0; pointer-events: none; }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Hover States with Media Query
**ID:** `interactions-hover-media-query` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-hover-media-query](https://ui-guides-agent-rules.netlify.app/principles/interactions-hover-media-query)
**Agent rule (MUST):** Gate hover styles behind `@media (hover: hover)` so touch devices never get a flash of sticky hover state on press.
**Source:** [Rauno](https://interfaces.rauno.me/)
Hover states should not be visible on touch press — use @media (hover: hover)
> Hover states should not be visible on touch press, use @media (hover: hover).
On touch devices, pressing an element briefly triggers its hover state, causing a flash of the hover style. Wrapping hover declarations in @media (hover: hover) ensures they only apply on devices with a true pointer, like a mouse.
```tsx
@media (hover: hover) {
.btn:hover { background: var(--accent); }
}
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN hover media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover)
### Video Autoplay on iOS
**ID:** `interactions-video-autoplay-ios` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-video-autoplay-ios](https://ui-guides-agent-rules.netlify.app/principles/interactions-video-autoplay-ios)
**Agent rule (MUST):** Autoplaying `<video>` needs `muted` and `playsInline` — without both, iOS Safari blocks playback or forces fullscreen.
**Source:** [Rauno](https://interfaces.rauno.me/)
Apply muted and playsInline to video tags to enable autoplay on iOS
> Apply muted and playsinline to <video /> tags to auto play on iOS.
iOS Safari blocks video autoplay unless the video is muted and has the playsinline attribute. Without these, the video won't play automatically and may open in fullscreen instead.
```tsx
<video autoPlay muted loop playsInline src="/clip.mp4" />
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [WebKit Video Policies](https://webkit.org/blog/6784/new-video-policies-for-ios/)
### Replace Tap Highlight
**ID:** `interactions-tap-highlight-replacement` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-tap-highlight-replacement](https://ui-guides-agent-rules.netlify.app/principles/interactions-tap-highlight-replacement)
**Agent rule (MUST):** If you clear `-webkit-tap-highlight-color`, replace it with your own touch feedback (an `:active` state) — never leave a tap with zero visual confirmation.
**Source:** [Rauno](https://interfaces.rauno.me/)
Disable the default iOS tap highlight but always replace it with an appropriate alternative
> Disable the default iOS tap highlight with -webkit-tap-highlight-color: rgba(0,0,0,0), but always replace it with an appropriate alternative.
The default iOS tap highlight is often too prominent and doesn't match your design. Disabling it is fine, but you must provide an alternative touch feedback — typically a custom :active state — otherwise users get no visual confirmation of their tap.
```tsx
a { -webkit-tap-highlight-color: rgba(0,0,0,0); }
a:active { background: var(--muted); }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### No Tooltips on Disabled Buttons
**ID:** `interactions-disabled-no-tooltips` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-disabled-no-tooltips](https://ui-guides-agent-rules.netlify.app/principles/interactions-disabled-no-tooltips)
**Agent rule (NEVER):** Put a tooltip on a `disabled` button — it cannot receive focus or hover reliably, so keyboard users never see it. Use `aria-disabled` plus visible explanation text instead.
**Source:** [Rauno](https://interfaces.rauno.me/)
Disabled buttons should not have tooltips — they are not accessible to keyboard users
> Disabled buttons should not have tooltips, they are not accessible.
Disabled buttons cannot receive focus in the DOM, so keyboard users will never see the tooltip. Instead of hiding the reason behind an inaccessible tooltip, use aria-disabled with a visible explanation text so everyone can understand why the action is unavailable.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### List Delete Keyboard Shortcut
**ID:** `interactions-list-delete-shortcut` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-list-delete-shortcut](https://ui-guides-agent-rules.netlify.app/principles/interactions-list-delete-shortcut)
**Agent rule (SHOULD):** In a sequential list of focusable items, support `ArrowUp`/`ArrowDown` to move between items and `⌘/Ctrl + Backspace` to delete the focused item.
**Source:** [Rauno](https://interfaces.rauno.me/)
Focusable elements in a sequential list should be navigable with arrows and deletable with Cmd+Backspace
> Focusable elements in a sequential list should be navigable with ↑ ↓. Focusable elements in a sequential list should be deletable with ⌘ Backspace.
Lists of focusable items should support arrow key navigation between items and a keyboard shortcut for deletion. This follows the pattern established by native applications and gives keyboard users full control without reaching for the mouse.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Dropdown Menus on Mousedown
**ID:** `interactions-mousedown-dropdown` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-mousedown-dropdown](https://ui-guides-agent-rules.netlify.app/principles/interactions-mousedown-dropdown)
**Agent rule (SHOULD):** Open dropdown menus on `mousedown`, not `click` — `click` fires on mouseup and adds perceived latency to the press.
**Source:** [Rauno](https://interfaces.rauno.me/)
Dropdown menus should trigger on mousedown for immediate response, not on click
> To open immediately on press, dropdown menus should trigger on mousedown, not click.
The click event fires on mouseup — after the button is released. For dropdown menus that should feel instant, triggering on mousedown removes the perceived delay between pressing and seeing the menu appear.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### SVG Favicon with Theme Support
**ID:** `interactions-svg-favicon-theme` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-svg-favicon-theme](https://ui-guides-agent-rules.netlify.app/principles/interactions-svg-favicon-theme)
**Agent rule (SHOULD):** Ship an SVG favicon containing an inline `<style>` with `prefers-color-scheme` so the icon adapts to light/dark browser chrome.
**Source:** [Rauno](https://interfaces.rauno.me/)
Use an SVG favicon with a style tag that adapts to light/dark mode via prefers-color-scheme
> Use a svg favicon with a style tag that adheres to the system theme based on prefers-color-scheme.
PNG favicons are static and can become invisible against a dark browser tab bar. SVG favicons can include a <style> block with prefers-color-scheme media queries, automatically adapting to the user's system theme.
```tsx
<style>path { fill: #000 }
@media (prefers-color-scheme: dark) { path { fill: #fff } }</style>
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Gradient Text Selection
**ID:** `interactions-gradient-text-selection` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-gradient-text-selection](https://ui-guides-agent-rules.netlify.app/principles/interactions-gradient-text-selection)
**Agent rule (MUST):** For gradient text (`background-clip: text`), unset the gradient in `::selection` — otherwise the selection highlight renders it unreadable.
**Source:** [Rauno](https://interfaces.rauno.me/)
Gradient text should unset the gradient on ::selection state for readability
> Gradient text should unset the gradient on ::selection state.
When users select gradient text, the gradient effect combined with the selection highlight makes the text unreadable. Override ::selection to use a solid color and unset -webkit-text-fill-color so selected text remains legible.
```tsx
.gradient-text::selection { -webkit-text-fill-color: #fff; background: var(--accent); }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Draw Focus Rings with box-shadow
**ID:** `interactions-focus-ring-shadow` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-ring-shadow](https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-ring-shadow)
**Agent rule (SHOULD):** Draw focus rings with `box-shadow` (Tailwind `ring-*`), not `outline` — outline ignores `border-radius` before Safari 16.4, so rounded controls get a rectangle.
**Source:** [Rauno](https://interfaces.rauno.me/)
Build the focus ring out of box-shadow rather than outline, so it follows the border radius
> Box shadow should be used for focus rings, not outline which won't respect radius.
This is about the mechanism, not about having a ring at all. `outline` is painted outside the border box and, on any browser older than Safari 16.4, ignores `border-radius` entirely — so a pill or heavily rounded control gets a hard rectangle around it. `box-shadow` (Tailwind's `ring-*`) is clipped to the element's own radius in every engine, and it composes: you can stack a background-coloured offset shadow under a coloured ring to keep the focus state legible on any surface. Since plenty of users never update their OS, treat the box-shadow ring as the portable default.
```tsx
:focus-visible { box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring); }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [Safari 16.4 release notes (outline follows radius)](https://developer.apple.com/documentation/safari-release-notes/safari-16_4-release-notes)
- [MDN: box-shadow](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow)
### Keep Interactive Content Out of Hover Tooltips
**ID:** `interactions-tooltip-interactive-content` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-tooltip-interactive-content](https://ui-guides-agent-rules.netlify.app/principles/interactions-tooltip-interactive-content)
**Agent rule (NEVER):** Put interactive content inside a hover tooltip — the tooltip unmounts as the pointer leaves the trigger and its controls never enter the tab order. If it has something to press, make it a click-triggered popover/dialog with `aria-haspopup`, focus movement and Escape dismiss.
**Source:** [Rauno](https://interfaces.rauno.me/)
Never put links, buttons or inputs inside a tooltip that is triggered by hover
> Tooltips triggered by hover should not contain interactive content.
A hover tooltip is tied to the pointer being over its trigger: the moment the pointer travels toward the tooltip it leaves the trigger and the tooltip unmounts, so the control inside can never be clicked. It is worse for keyboard users — the tooltip is only rendered while hovered, so its button never exists in the tab order, and `role="tooltip"` content is not treated as a focus container anyway. If the content has something to press, it is a popover or a dialog: give it a click trigger, `aria-haspopup`, focus movement on open and an Escape dismiss. Otherwise inline the action next to the trigger.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [WAI-ARIA APG: Tooltip pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/)
### Disable touch-action on Custom Gesture Surfaces
**ID:** `interactions-gesture-touch-action` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-gesture-touch-action](https://ui-guides-agent-rules.netlify.app/principles/interactions-gesture-touch-action)
**Agent rule (MUST):** Set `touch-action: none` on surfaces that implement their own pan/zoom (map, canvas, carousel, drag handle) or the browser claims the gesture and fires `pointercancel` mid-drag. Scope it to the gesture surface — never the page.
**Source:** [Rauno](https://interfaces.rauno.me/)
Set touch-action: none on custom pan and zoom surfaces so the browser cannot steal the gesture
> Disable `touch-action` for custom components that implement pan and zoom gestures to prevent interference from native behavior like zooming and scrolling.
Note this is the opposite intent to the `touch-action: manipulation` rule: that one keeps native handling and only removes double-tap zoom, to kill the 300ms tap delay. This rule is for surfaces that implement their own pan/zoom — a map, a canvas, a carousel, a drag handle. There, if the browser retains native panning it claims the gesture as soon as it decides the movement is a scroll, fires `pointercancel`, and your pointer stream simply stops mid-drag. `touch-action: none` opts that element out of native scrolling and zooming so every pointer event reaches your handler. Scope it to the gesture surface only — putting `none` on a whole page makes it impossible to scroll.
```tsx
.canvas { touch-action: none; }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN: touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)
### Group Focus with :focus-within
**ID:** `interactions-focus-within-group` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-within-group](https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-within-group)
**Agent rule (MUST):** Ring the wrapper of a compound control with `:focus-within` (and `outline: none` on the children), keeping a distinct style on the focused child — do not ring only the inner `<input>`.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Style focus on the wrapper of a compound control so the whole control lights up, not one child
> Group focus with `:focus-within` for compound controls.
A compound control — an input with a currency adornment and a "Max" button inside one bordered field — is a single thing to the user but several elements to the DOM. Ringing only the inner `<input>` draws a ring floating inside the box, and focusing the trailing button lights up nothing at all. `:focus-within` matches an element when focus lands on it or on any descendant, so putting the ring and border highlight on the wrapper (and `outline: none` on the children) makes the visual control and the focus affordance agree. Keep a distinct style on the focused child too, so users know which part of the group has focus.
```tsx
.field:focus-within { outline: 2px solid var(--ring); outline-offset: 2px; }
.field :is(input, button):focus-visible { outline: none; background: var(--muted); }
```
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines/blob/main/command.md)
- [MDN: :focus-within](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within)
### suppressHydrationWarning Only Where Truly Needed
**ID:** `interactions-hydration-warning-suppression` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-hydration-warning-suppression](https://ui-guides-agent-rules.netlify.app/principles/interactions-hydration-warning-suppression)
**Agent rule (NEVER):** Put `suppressHydrationWarning` on a subtree or layout wrapper to quiet mismatch noise — it keeps the wrong server value on screen. Apply it to the single element rendering a genuinely unstable value (`Date.now()`, a local clock, a random id) and fix every other mismatch at the source.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Reserve suppressHydrationWarning for values that genuinely cannot match, never as a way to quiet noise
> `suppressHydrationWarning` only where truly needed.
A hydration warning is React telling you the server markup and the client render disagree — which for deterministic data is a real bug (a locale, a timezone, a feature flag read differently on each side). `suppressHydrationWarning` does not fix the mismatch: React keeps the server text and stops reporting it, so the user is left staring at the wrong value and nothing warns anyone. Only genuinely unstable values qualify: `Date.now()`, a formatted local clock, a random id. Apply it to that one element, never to a subtree or a layout wrapper, and fix everything else at the source — render a stable placeholder on the server and compute the client-dependent value after mount.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines/blob/main/command.md)
- [React: suppressHydrationWarning](https://react.dev/reference/react-dom/components/common)
- [React: hydrateRoot](https://react.dev/reference/react-dom/client/hydrateRoot)
### Give Drag Gestures Real Physics
**ID:** `interactions-drag-physics` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-drag-physics](https://ui-guides-agent-rules.netlify.app/principles/interactions-drag-physics)
**Agent rule (SHOULD):** Give drags real physics: dismiss on velocity (`Math.abs(distance) / elapsedMs > ~0.11`) rather than a distance threshold, damp past boundaries (`over * limit / (over + limit)`), call `setPointerCapture(e.pointerId)` on drag start, and ignore extra pointers once a drag owns one.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Dismiss on velocity, damp at boundaries, capture the pointer, and ignore extra touch points mid-drag
> Momentum dismissal: don't require crossing a distance threshold — compute velocity (Math.abs(distance)/elapsedMs); dismiss if > ~0.11. A flick should be enough. Damping at boundaries: dragging past a natural edge moves less the further you go. Pointer capture once dragging starts, so it continues when the pointer leaves bounds. Multi-touch protection: ignore extra touch points after the drag begins.
Four mechanics turn a drag from a hit-test into something that feels like an object. Velocity dismissal: measure `Math.abs(dx) / elapsedMs` between pointer moves and dismiss above roughly 0.11 px/ms, so a quick flick works and users do not have to haul the sheet across the screen. Damping: past a natural edge, apply rising resistance (`over * limit / (over + limit)`) rather than a hard clamp — real things slow before they stop, and an invisible wall reads as a bug. `setPointerCapture(e.pointerId)` on drag start keeps the pointer stream flowing to your element even after the pointer leaves its bounds, which is exactly what a fast flick does. And once a drag owns a pointer, bail out of further `pointerdown` handlers so a second finger cannot re-anchor the origin and teleport the element.
- [Emil Kowalski: review-animations STANDARDS.md](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md)
- [MDN: Element.setPointerCapture()](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture)
### Give Loading States a Show-Delay and a Floor
**ID:** `interactions-loading-state-duration` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-loading-state-duration](https://ui-guides-agent-rules.netlify.app/principles/interactions-loading-state-duration)
**Agent rule (MUST):** Gate every spinner/skeleton with two timers: a show-delay of ~150–300ms (fast responses show nothing) and a minimum visible time of ~300–500ms once it appears, so it never flashes. React `<Suspense>` already does this.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Delay the spinner by ~150–300ms and keep it up for ~300–500ms once it appears
> Minimum loading-state duration. If you show a spinner/skeleton, add a short show-delay (~150–300 ms) & a minimum visible time (~300–500 ms) to avoid flicker on fast responses. The <Suspense> component in React does this automatically.
Other loading rules cover what to render; this one covers when. Mount the spinner on the same tick the request starts and a 60ms response paints it for 60ms — below the ~100ms threshold at which people perceive a state at all, so it reads as a flash of damage rather than progress. Repeat that on every keystroke or every click and the panel strobes. Two timers fix it: a show-delay of roughly 150–300ms, so anything that finishes fast shows no spinner whatsoever, and a minimum visible time of roughly 300–500ms once it does appear, so a response landing 20ms later does not yank it away. React's <Suspense> applies the same shape internally.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [React: <Suspense>](https://react.dev/reference/react/Suspense)
- [Nielsen Norman: Response Time Limits](https://www.nngroup.com/articles/response-times-3-important-limits/)
### Internationalize Keyboard Shortcuts
**ID:** `interactions-locale-keyboard-shortcuts` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-locale-keyboard-shortcuts](https://ui-guides-agent-rules.netlify.app/principles/interactions-locale-keyboard-shortcuts)
**Agent rule (MUST):** Bind mnemonic shortcuts to `event.key` (the character the layout produced), not `event.code` (a QWERTY key position — breaks on Dvorak/AZERTY); reserve `event.code` for positional bindings like WASD, and render `⌘/⌥/⇧` on macOS vs `Ctrl/Alt/Shift` elsewhere.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Bind shortcuts to the character the layout produces and render platform-specific modifier symbols
> Locale-aware keyboard shortcuts. Internationalize keyboard shortcuts for non-QWERTY layouts. Show platform-specific symbols.
Two independent bugs hide behind a hardcoded "Ctrl+K" chip. The label: on macOS every other application says ⌘, so showing Ctrl tells the user your app is not theirs — read the platform and render ⌘ / ⌥ / ⇧ or Ctrl / Alt / Shift accordingly. The binding: event.code names a physical key position on a QWERTY board, so matching event.code === "KeyK" on a Dvorak layout fires from the key that prints "t", while the key printing "k" does nothing at all. For a mnemonic shortcut, match the character the layout actually produced (event.key) so the chip and the keyboard agree; reserve event.code for genuinely positional bindings like WASD, and use navigator.keyboard.getLayoutMap() to label the key with what it really prints.
```tsx
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") openPalette();
// NOT: e.code === "KeyK"
```
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN: KeyboardEvent.code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code)
- [MDN: Keyboard.getLayoutMap()](https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/getLayoutMap)
### Scale Pressable Elements on :active
**ID:** `interactions-press-feedback-scale` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-press-feedback-scale](https://ui-guides-agent-rules.netlify.app/principles/interactions-press-feedback-scale)
**Agent rule (SHOULD):** Answer every press instantly with `transform: scale(0.97)` on `:active` and `transition: transform 160ms ease-out`. Stay inside 0.95–0.98 (0.85 reads as broken), and apply it to any pressable element — cards, icon buttons, list rows — not only `<button>`.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Confirm a press with transform: scale(0.97) and a 160ms ease-out transition
> Button press feedback. `transform: scale(0.97)` on `:active`, `transition: transform 160ms ease-out`. Subtle (0.95–0.98). Applies to any pressable element.
A press is the one moment the interface can answer instantly, before any network call resolves. Without it the user has no evidence the pointer-down landed, so on a slow action they press again — the classic double-submit. `transform: scale(0.97)` with `transition: transform 160ms ease-out` is the whole fix: compositor-only, cheap, and the range that reads as pressed is narrow (0.95–0.98) — 0.85 collapses the control and reads as broken. It is also why `scale()` is the right property rather than a size change: `scale()` scales children too, so the icon and the label go down with the surface, which is exactly the physical model of a button being pushed. This applies to any pressable element — cards, icon buttons, list rows — not just things tagged `<button>`.
```tsx
.pressable { transition: transform 160ms ease-out; }
.pressable:active { transform: scale(0.97); } /* Tailwind: active:scale-[0.97] */
```
- [Emil Kowalski: review-animations STANDARDS.md](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md)
- [MDN: scale()](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/scale)
### Keep the Focused Element on Screen
**ID:** `interactions-focus-not-obscured` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-not-obscured](https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-not-obscured)
**Agent rule (MUST):** Reserve sticky header/footer height on the scroll container with `scroll-padding-block` (or `scroll-margin-top` on the items) so a newly focused element is never *entirely* hidden under fixed chrome — WCAG SC 2.4.11 Focus Not Obscured (Minimum), Level AA. A focus ring painted behind a sticky bar is worth as much as no ring at all.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Reserve room for sticky chrome with scroll-padding so focus never lands underneath it
> When a user interface component receives keyboard focus, the component is not entirely hidden due to author-created content. (WCAG 2.2, SC 2.4.11 Focus Not Obscured (Minimum), Level AA)
Every other focus rule in this corpus is about the ring existing: draw one, prefer :focus-visible, do not remove the outline. None of them says the focused element must actually be on screen — and a ring you cannot see is worth exactly as much as no ring at all. This is the failure: sticky headers, sticky footers, cookie bars and floating toolbars are painted over the scrollport, while the browser scrolls a newly focused element flush to the scrollport edge, which is precisely where that chrome sits. The component is fully obscured, the SC is failed at Level AA, and nothing in the code looks wrong. The fix is to tell the scroller how much of its own edge is spoken for: scroll-padding-block on the scroll container (or scroll-margin-block on the items) reserves the header and footer height, and every scroll the browser performs — including the implicit one from sequential focus navigation — stops short of it. Note the AA bar is "not entirely hidden": SC 2.4.12 (AAA) raises it to "no part of the focus indicator is hidden", so partial occlusion still passes AA but is worth fixing anyway.
```tsx
.scroller { scroll-padding-block: 4rem 3rem; } /* sticky header 4rem, footer 3rem */
```
- [WCAG 2.2: Understanding SC 2.4.11 Focus Not Obscured (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html)
- [MDN: scroll-padding](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-padding)
### Never Make Dragging the Only Path
**ID:** `interactions-drag-alternative` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-drag-alternative](https://ui-guides-agent-rules.netlify.app/principles/interactions-drag-alternative)
**Agent rule (MUST):** Every drag must have a single-pointer alternative that reaches the same outcome without travel — ↑/↓ buttons on each row, a "Move to…" menu, or arrow-key handling on a focused grip. WCAG SC 2.5.7 Dragging Movements (AA); keep the drag as an accelerator. Only genuinely essential drags (signature pad, free-form canvas, map pan) are exempt.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Offer a single-pointer alternative to every drag: move buttons, arrow keys, or a menu
> All functionality that uses a dragging movement for operation can be achieved by a single pointer without dragging, unless dragging is essential or the functionality is determined by the user agent and not modified by the author. (WCAG 2.2, SC 2.5.7 Dragging Movements, Level AA)
This is not a rule about how a drag feels — it is a rule about whether the outcome is reachable at all without one. Two neighbouring principles here already govern drag quality: "Clean Drag Interactions" suppresses text selection and inert-ifies the page during a drag, and "Drag Physics" tunes velocity and damping on release. Both assume the drag is happening and make it better. Neither asks the question this SC asks, which is what a user with a tremor, an eye-tracker, a head-pointer or a switch does when press-travel-release is the only route to the outcome — for them, the answer today is "nothing". So keep the drag; it is a fine accelerator. Then add a path that a single pointer can complete without travelling: ↑ / ↓ buttons on each row, a "Move to…" menu, or arrow-key handling on a focused grip. The alternative must produce the same result, not a degraded one, and only "essential" drags — a signature pad, a free-form canvas, a map pan — are exempt, because there the path IS the content.
- [WCAG 2.2: Understanding SC 2.5.7 Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html)
### Back Every Gesture With a Simple Control
**ID:** `interactions-gesture-alternative` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-gesture-alternative](https://ui-guides-agent-rules.netlify.app/principles/interactions-gesture-alternative)
**Agent rule (MUST):** Any multipoint or path-based gesture (swipe, pinch, two-finger rotate, traced path) needs a discrete single-pointer control beside it — Prev/Next buttons, real pagination dot buttons, +/− zoom, arrow keys on the focused region. WCAG SC 2.5.1 Pointer Gestures, Level A. A drawing canvas is essential; a carousel never is.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Pair swipes, pinches and traced paths with buttons or keys that need no path at all
> All functionality that uses multipoint or path-based gestures for operation can be operated with a single pointer without a path-based gesture, unless a multipoint or path-based gesture is essential. (WCAG 2.1, SC 2.5.1 Pointer Gestures, Level A)
The sibling of "Never Make Dragging the Only Path" (SC 2.5.7), and the two are constantly confused. 2.5.7 is about dragging — press, travel, release, where the travel is what moves the thing. This one, 2.5.1, is about gestures whose SHAPE or number of contact points carries the meaning: a swipe, a pinch-zoom, a two-finger rotate, a drawn checkmark, a slider you can only operate by tracing. It is Level A, one step stricter, because a swipe-only carousel does not merely inconvenience someone — it walls off the content entirely. The remedy is the same shape as 2.5.7's: keep the gesture as an accelerator for the people who like it, and put a discrete single-pointer control next to it. Prev / Next buttons, pagination dots that are real buttons, +/− zoom controls, arrow-key handling on the focused region. "Essential" is narrow: a drawing canvas or a map that genuinely requires a free-form path qualifies; a carousel never does.
- [WCAG 2.1: Understanding SC 2.5.1 Pointer Gestures](https://www.w3.org/WAI/WCAG21/Understanding/pointer-gestures.html)
### Commit on the Up-Event, Not the Down-Event
**ID:** `interactions-pointer-cancellation` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-pointer-cancellation](https://ui-guides-agent-rules.netlify.app/principles/interactions-pointer-cancellation)
**Agent rule (MUST):** Preview on down, COMMIT on up: the down-event may arm, highlight, reveal, or open a menu (all reversible — that is why opening dropdowns on `mousedown` is fine), but destructive/irreversible functions (delete, submit, purchase, send, publish) must complete on the up-event on the same element, so sliding off before release aborts them. WCAG SC 2.5.2 Pointer Cancellation, Level A — `click` gives you this for free; if you truly must act on down, ship an undo instead.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Let a mistaken press be aborted by sliding off before release; complete actions on pointerup
> For functionality that can be operated using a single pointer, at least one of the following is true: the down-event of the pointer is not used to execute any part of the function; completion of the function is on the up-event, and a mechanism is available to abort the function before completion or to undo the function after completion… (WCAG 2.1, SC 2.5.2 Pointer Cancellation, Level A)
Firing an action on pointerdown removes the one escape hatch every pointer user relies on: land on the wrong control, slide off, release, nothing happens. Take that away and a mis-aimed tap on a phone, or a tremor that lands a finger 4px off, is instantly irreversible. Draw the line carefully, because this corpus also tells you to open dropdowns on mousedown, and that is not a contradiction: opening a menu does not EXECUTE anything and is trivially reversible, so the down-event is free to preview, arm, highlight, or reveal. What it must not do is commit. Delete, submit, purchase, send, publish — these complete on the up-event, on the same element the press started on, which is exactly what the platform gives you for free in the click event. So the rule reads: preview on down, commit on up. If you genuinely must act on down (a piano key, a game control — the SC calls this "essential"), the alternative branch of the SC requires you to provide an undo instead.
- [WCAG 2.1: Understanding SC 2.5.2 Pointer Cancellation](https://www.w3.org/WAI/WCAG21/Understanding/pointer-cancellation.html)
- [MDN: Element click event](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event)
### Make Hover Content Dismissible, Hoverable and Persistent
**ID:** `interactions-hover-content-persistence` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-hover-content-persistence](https://ui-guides-agent-rules.netlify.app/principles/interactions-hover-content-persistence)
**Agent rule (MUST):** Hover/focus popups must be DISMISSIBLE (Escape hides it without moving pointer or focus), HOVERABLE (the pointer can travel into the content and stay there — bridge the trigger/card gap with wrapper padding, not a margin) and PERSISTENT (no auto-hide timer; it stays until the trigger is left, the user dismisses it, or the info goes stale). WCAG SC 1.4.13 Content on Hover or Focus, Level AA.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Let Escape close a hover card, let the pointer enter it, and never expire it on a timer
> Where receiving and then removing pointer hover or keyboard focus triggers additional content to become visible and then hidden, the following are true: Dismissible… Hoverable… Persistent: the additional content remains visible until the hover or focus trigger is removed, the user dismisses it, or its information is no longer valid. (WCAG 2.1, SC 1.4.13 Content on Hover or Focus, Level AA)
The missing sibling of the two tooltip rules already here. "Tooltip Timing" governs the show-delay; "No Interactive Content in Tooltips" governs what may go inside. Neither says what happens once the thing is on screen — and that is where three separate, independently-failing requirements live. DISMISSIBLE: Escape must hide it without moving the pointer or focus, because a screen-magnifier user viewing a small slice of the page may have a popup covering the content they were reading, and forcing them to move the pointer costs them their place. HOVERABLE: the pointer must be able to travel into the content and stay there, which is why the classic 2px "breathing gap" between trigger and card is a bug, not a detail — crossing it fires mouseleave and the card evaporates. Bridge it with padding on the wrapper (an invisible safe area) rather than a margin on the card, or hit-test a safe triangle toward the card. PERSISTENT: no auto-hide timers. A 3-second dismissal is an eternity for a magnifier user panning across the card and no time at all for someone reading with a screen reader; the content stays until the trigger is left, the user dismisses it, or the information stops being true.
- [WCAG 2.1: Understanding SC 1.4.13 Content on Hover or Focus](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html)
### One Tab Stop per Composite Widget
**ID:** `interactions-aria-composite-tab-stop` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-composite-tab-stop](https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-composite-tab-stop)
**Agent rule (MUST):** A composite widget (toolbar, listbox, menu, radiogroup, tablist, grid, tree) is ONE tab stop: roving `tabindex` — exactly one child at `0`, every other child at `-1` — with arrow keys moving the `0` and calling `.focus()`, and Tab moving past the whole widget. Twelve children must not be twelve tab stops. (Distinct from positive `tabindex`: here every value is `0` or `-1`; the bug is how many are `0`.)
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
Give a toolbar, listbox or grid a single tab stop and move within it using the arrow keys
> To help assistive technology users understand the boundaries of a composite widget and to provide efficient keyboard navigation, the tab sequence should include only one focusable element of a composite UI component. (WAI-ARIA Authoring Practices Guide, Developing a Keyboard Interface)
Tab crosses widgets; arrow keys move within one. A toolbar, listbox, menu, radiogroup, tablist, grid or tree is ONE thing in the tab sequence, no matter how many children it has — that is what makes it a composite. Get this wrong and a twelve-item filter bar becomes twelve stops the user must Tab past to reach the form below, and the arrow keys, which is what a screen reader user will actually press inside a listbox, do nothing at all. There are two implementations, and this one is roving tabindex: exactly one child carries tabindex="0" while every other child carries tabindex="-1", so the browser can only Tab into the active child; your arrow-key handler then moves the 0 to the next child and calls .focus() on it. Because real DOM focus moves, the browser scrolls the active child into view and paints the focus ring for free. The alternative, aria-activedescendant, is a separate principle — reach for it only when focus must stay in a text input. Note this is not the same complaint as "Never Use Positive tabindex", which is about tabindex="1" and up wrecking the global document order; here every value is 0 or -1 and the bug is how many of them are 0.
```tsx
<button tabIndex={i === activeIndex ? 0 : -1} onKeyDown={onArrows} />
// ArrowRight: setActiveIndex(i + 1); itemRefs[i + 1].current.focus();
```
- [APG: Developing a Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
- [APG: Toolbar pattern](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/)
### Keep Focus in the Input With aria-activedescendant
**ID:** `interactions-aria-activedescendant` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-activedescendant](https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-activedescendant)
**Agent rule (SHOULD):** Default to roving `tabindex` (real DOM focus buys you scroll-into-view, the focus ring and focus events); reach for `aria-activedescendant` only where DOM focus must STAY in a text input — combobox, editable grid cell, tag input — because the moment focus leaves the `<input>` keystrokes stop landing, the caret vanishes, an IME composition dies mid-word and the mobile keyboard drops. Its costs are yours: stable `id` per option, style the active option yourself (it has no `:focus`), and `scrollIntoView({ block: "nearest" })` by hand.
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
Point at the active option instead of moving focus whenever the user must keep typing
> There are two ways to manage focus within a composite: using DOM focus by moving focus with element.focus() and managing tabindex on the descendants ("roving tabindex"), or using aria-activedescendant, where the container retains DOM focus and the aria-activedescendant attribute identifies the active descendant. (WAI-ARIA Authoring Practices Guide, Developing a Keyboard Interface)
These are the two focus-management techniques for a composite, and they are not interchangeable. Roving tabindex moves REAL DOM focus onto the active child, which is why it is the default: the browser scrolls that child into view, paints the focus ring, and fires focus events, all for free. But it has one fatal domain — anywhere the user must keep typing. In a combobox, an editable grid cell, or a tag input, ArrowDown must move the highlight WITHOUT moving focus, because the moment focus leaves the <input> the keystrokes stop landing, the caret disappears, an IME composition is destroyed mid-word, and on mobile the software keyboard drops away. That is what aria-activedescendant is for: DOM focus stays on the container (the input), and the attribute holds the id of the active descendant, so assistive technology announces the option while the text field keeps receiving keys. The costs are real and yours to pay: every option needs a stable id, the container needs aria-activedescendant updated on every move, you must style the active option yourself because it has no :focus, and you must scroll it into view yourself with scrollIntoView({ block: "nearest" }) — the free ride roving tabindex got from the browser is exactly what you are giving up.
```tsx
<input role="combobox" aria-controls="lb" aria-activedescendant={activeId} />
<ul id="lb" role="listbox"><li id="opt-2" role="option" aria-selected /></ul>
```
- [APG: Developing a Keyboard Interface — Managing Focus Within Components](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
- [APG: Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)
### Never aria-hidden Something Focusable
**ID:** `interactions-aria-hidden-focusable` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-hidden-focusable](https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-hidden-focusable)
**Agent rule (NEVER):** Put `aria-hidden="true"` (or `role="presentation"`) on a visible focusable element or a subtree that is still tabbable — it strips the a11y tree but NOT the tab order, so focus walks into a ghost: the ring travels off screen and the screen reader announces nothing. Use `inert` on the container (removes it from tab order, a11y tree and hit-testing at once) or unmount it.
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
Use inert or unmount to hide a subtree, so the tab order and the a11y tree agree
> Do not use role="presentation" or aria-hidden="true" on a visible focusable element. Using either of these on a visible focusable element will result in some users focusing on "nothing". (Using ARIA, Fifth Rule of ARIA Use)
aria-hidden="true" removes an element and its whole subtree from the accessibility tree. It does NOT remove anything from the tab order — those are two different trees, and this is the single most common way to desynchronise them. An off-canvas drawer hidden with aria-hidden plus opacity: 0 and a transform is still fully tabbable: keyboard focus walks into it, the focus ring travels off screen, and a screen reader announces nothing whatsoever, because the element it is focused on does not exist as far as the accessibility tree is concerned. Focusing on "nothing" is a dead end no user can recover from. The correct tools remove the subtree from BOTH trees at once: unmount it, or set the inert attribute on the container — inert takes the subtree out of the tab order, out of the accessibility tree, and out of hit-testing in one attribute, which is why it also shows up in this corpus as the way to freeze the rest of the page during a drag. If for some reason you must keep aria-hidden, you have to strip tabindex and disable every focusable descendant by hand, and keep doing so as the subtree changes — which is precisely the bookkeeping inert exists to delete.
```tsx
// ghost: <div aria-hidden="true" className="opacity-0"><button>Close</button></div>
<div inert={!open} className="opacity-0"><button>Close</button></div>
```
- [Using ARIA: Fifth rule of ARIA use](https://www.w3.org/TR/using-aria/)
- [MDN: inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert)
### aria-disabled in Composites, disabled in Forms
**ID:** `interactions-aria-disabled-vs-disabled` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-disabled-vs-disabled](https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-disabled-vs-disabled)
**Agent rule (MUST):** Use `aria-disabled="true"` — not HTML `disabled` — for unavailable menu items, toolbar buttons, tabs, tree items and listbox options: `disabled` drops them out of the tab order and the arrow-key sequence, so users never discover the option exists. `aria-disabled` only changes the announcement, so YOUR handler must make activation a no-op. Keep HTML `disabled` for form controls whose state is already inferable from context.
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
Keep unavailable menu items, tabs and options focusable so users can discover they exist
> It is important to note that disabled elements are not focusable… If it is important for the user to be aware of a disabled element, use aria-disabled="true" instead. For example, in a menu, toolbar, tab list, tree, or listbox, users benefit from being able to discover options that are unavailable. (WAI-ARIA Authoring Practices Guide)
The two spellings of "unavailable" behave completely differently, and picking the wrong one deletes information. HTML disabled makes the control unfocusable, unclickable and silent: it vanishes from the tab order, and any arrow-key handler in a composite widget is forced to skip over it, which is how a menu of six commands quietly becomes a menu of three for anyone not using a mouse. They never learn Delete or Move to… exist, so they cannot ask why the commands are unavailable or how to unlock them. aria-disabled="true" changes only the announcement — the element stays focusable and stays in the arrow-key sequence, assistive technology announces it as dimmed or unavailable, and it is YOUR handler that must make activation a no-op (the browser will not do it for you, so an aria-disabled button without a guard in its onClick is still live). Use HTML disabled where the state is already inferable from context — a form submit that is greyed out under a validation summary, a Next button on the last step. Use aria-disabled inside composite widgets: menu items, toolbar buttons, tabs, tree items, listbox options. Be aware of the overlap: "Never Put a Tooltip on a disabled Button" also reaches for aria-disabled in its good example, but its subject is tooltips and hover targets; this principle is about discoverability inside a composite, and it pairs with the one-tab-stop roving-tabindex rule — that arrow-key loop is exactly what disabled breaks.
```tsx
<li role="menuitem" tabIndex={-1} aria-disabled="true"
onClick={(e) => { if (isDisabled) return; run(); }}>Move to…</li>
```
- [APG: Developing a Keyboard Interface — Focusability of disabled controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
- [APG: Menu and Menubar pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/)
### Escape Closes the Topmost Layer
**ID:** `interactions-escape-dismiss` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-escape-dismiss](https://ui-guides-agent-rules.netlify.app/principles/interactions-escape-dismiss)
**Agent rule (MUST):** Escape closes dialogs and overlays, and closes exactly ONE layer — the topmost open one. A nested popover and its parent dialog both listening for Escape on `window` means one keypress closes both and the user loses the form they were filling; the open layer handles the key and calls `stopPropagation()`. Native `<dialog>` and Radix-style primitives keep that stack for you. (Dismissal, not focus — `interactions-manage-focus` covers trap/return.)
**Source:** [@Ibelick](https://www.ui-skills.com/)
Escape must dismiss a dialog or overlay, and it must dismiss exactly one layer — the one on top
> Escape must close dialogs or overlays when applicable
Escape is the universal "get me out of here" key, and a dialog that only closes via an X button strands anyone who is not holding a mouse — a keyboard user has to Tab around a trapped focus ring hunting for the exit. But the interesting half of this rule is the nested case, which almost every hand-rolled implementation gets wrong. Put a select or a popover inside a dialog, attach `keydown` for Escape on `window` in both, and one keypress closes both: the child dismisses, and the same event keeps bubbling to the dialog's listener. The user tried to close a dropdown and lost their whole form. The fix is layer discipline — the topmost open layer handles Escape and calls `stopPropagation()` so it does not reach anything beneath it. Native `<dialog>` and Radix-style primitives maintain that stack for you, which is why the a11y skill closes with "for complex widgets (menu, dialog, combobox), prefer established accessible primitives over custom behavior". This is distinct from interactions-manage-focus, which covers trapping focus and returning it — dismissal is a separate obligation.
```tsx
function onKeyDown(e: React.KeyboardEvent) {
if (e.key !== "Escape") return;
e.stopPropagation(); // do not let the parent layer close too
close();
}
```
- [ibelick — fixing-accessibility SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [W3C — ARIA APG, Modal Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
### Hover-Revealed Actions Need Focus Parity
**ID:** `interactions-hover-revealed-actions` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-hover-revealed-actions](https://ui-guides-agent-rules.netlify.app/principles/interactions-hover-revealed-actions)
**Agent rule (MUST):** Any action revealed on hover needs the same keyboard reveal: pair `group-hover:opacity-100` with `group-focus-within:opacity-100` (and `focus-visible:opacity-100` on the control). Row actions left at `opacity-0` stay in the tab order but render invisible, so a keyboard user focuses a button they cannot see. Do NOT "fix" it by dropping them from the tab order or setting `pointer-events-none` — that trades a broken affordance for none.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Row actions hidden behind opacity-0 group-hover are invisible and unreachable for anyone using a keyboard
> hover-only interactions must have keyboard equivalents
The pattern is everywhere: a table row or list item whose Edit and Delete buttons live behind `opacity-0 group-hover:opacity-100`, so the row stays quiet until you point at it. It is a good instinct and a broken implementation, because hover is a pointer-only state. A keyboard user Tabs into that button and sees nothing — the element is still in the tab order, still focused, but rendered at zero opacity, so the focus ring is invisible and the page appears to swallow their focus. There is no way to discover, aim, or confirm the action. The fix is one utility: `focus-within:opacity-100` on the row (plus `focus-visible:opacity-100` on the button itself), so keyboard focus reveals exactly what hover reveals. What you must not do is "fix" it by making the buttons `pointer-events-none` or removing them from the tab order — that trades a broken affordance for no affordance. Note this is the specific, most-shipped instance of interactions-keyboard-everywhere; the general rule is easy to nod at and this is where it actually breaks.
```tsx
<tr class="group">
<td><button class="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100">Edit</button></td>
</tr>
```
- [ibelick — fixing-accessibility SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [W3C — Understanding WCAG 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)
### An Anchor Without href Is Not a Link
**ID:** `interactions-anchor-without-href` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-anchor-without-href](https://ui-guides-agent-rules.netlify.app/principles/interactions-anchor-without-href)
**Agent rule (NEVER):** Render an `<a>` without `href` and drive it from `onClick` alone. `href` is what MAKES it a link: without it the browser exposes the element as `generic` — no link role, not focusable, not in the tab order, absent from the screen reader's links list, and mouse-only. Two branches: if it NAVIGATES, give it a real `href` (you get focus, the link role, Enter, middle-click, Cmd-click, "Copy link address" for free); if it performs an ACTION, it was never a link — use `<button>`.
**Source:** [RAMS](https://www.rams.ai/)
An <a> with only an onClick has no link role, no focus, and no place in the tab order
> Missing link destination (WCAG 2.1.1) — <a> without href using only onClick
Rams files this Critical, and the reason is that it does not look like a bug. `<a onClick={() => navigate("/profile")}>View profile</a>` reads as correct semantic HTML and passes a naive "did you use the right element?" check — you did reach for the native element. But `href` is not decoration; it is what makes the element a link. Without it the browser exposes an `<a>` as a `generic`: no link role, not focusable, not in the tab order, and absent from the screen reader's list of links, so a keyboard user tabs straight past it and a screen-reader user never learns it exists. It is mouse-only, and it is extremely common in React. Note this is the INVERSE of three principles already in this corpus, not a duplicate of any of them: `interactions-rams-semantic-handlers` covers a non-interactive element pretending to be interactive (`<div onClick>`), `content-semantics-first` says do not reach for `role="button"` when a button exists, and `interactions-rams-keyboard-handlers` covers onClick with no onKeyDown. Here you reached for the native element, and it still fails. The fix depends on intent, and there are exactly two branches. If it navigates, give it a real `href` — the browser then hands you focus, the link role, Enter activation, middle-click and Cmd-click to open in a new tab, and "Copy link address", none of which an onClick handler can synthesise. If it performs an action, it was never a link: use `<button>`.
```tsx
// no: role-less, unfocusable, mouse-only
<a onClick={() => navigate("/profile")}>View profile</a>
// yes
<a href="/profile">View profile</a>
```
- [Rams review checklist](https://rams.ai/rams.md)
- [WCAG 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)
- [MDN — <a>: The Anchor element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a)
### The Modal Is the Last Resort
**ID:** `interactions-impeccable-modal-last-resort` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-impeccable-modal-last-resort](https://ui-guides-agent-rules.netlify.app/principles/interactions-impeccable-modal-last-resort)
**Agent rule (NEVER):** Reach for a modal as the first thought. A modal steals focus, blocks the page, and destroys the context the user was acting on — they must now hold the row they clicked in working memory. Exhaust the cheaper alternatives that keep context on screen: edit in place, use a side panel or a dedicated route, and replace a confirmation dialog with an undo toast wherever the action is reversible (compose with `interactions-confirm-destructive`). What survives is the narrow case a modal is actually for: one action, destructive, and irreversible — nothing to undo it with.
**Source:** [impeccable](https://impeccable.style/)
Exhaust inline editing, side panels, and dedicated routes before you reach for a modal
> Modal as first thought. Modals are usually laziness. Exhaust inline / progressive alternatives first.
impeccable ships this under "Absolute bans" — match-and-refuse, alongside gradient text and glassmorphism. The reason it is banned rather than merely discouraged is that a modal is expensive in a way that is invisible to the person adding it: it steals focus, blocks the page behind it, and destroys the context you were acting on, so the user must now hold the row they clicked in working memory while they read the dialog. Stack two and the original context is gone entirely. Nearly every modal has a cheaper alternative that keeps the context on screen: rename edits in place, a destructive-but-reversible action wants an undo toast rather than a confirmation, and supporting information belongs in a side panel or on its own route. Cross-reference `interactions-confirm-destructive`, which is about WHETHER to confirm at all — this principle is about modal OVERUSE, and the two answers compose: most deletes should be undoable rather than confirmed, which removes the modal entirely. What survives is the narrow case where a modal is genuinely right: one action, destructive, and irreversible — nothing to undo it with.
- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [W3C — ARIA APG, Modal Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
### Four Options at Any Decision Point
**ID:** `interactions-impeccable-four-option-limit` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-impeccable-four-option-limit](https://ui-guides-agent-rules.netlify.app/principles/interactions-impeccable-four-option-limit)
**Agent rule (SHOULD):** Cap any decision point at <=4 simultaneously visible options (Miller's Law as revised by Cowan, 2001: working memory holds ~4 items). 5-7 is the boundary — group them or reveal progressively; 8+ is overload, and an overloaded user does not choose slowly, they skip, misclick, or abandon. Carried into surfaces: <=5 top-level nav items, <=4 form fields before a visual break, 1 primary + 1-2 secondary buttons (rest in a menu), <=4 key metrics above the fold, <=3 pricing tiers. Grouping does not remove capability — a 4-item nav with a "More" group still reaches nine destinations — it removes them from the decision.
**Source:** [impeccable](https://impeccable.style/)
Working memory holds about four items — cap visible choices, nav items, and pricing tiers accordingly
> Humans can hold ≤4 items in working memory at once (Miller's Law revised by Cowan, 2001). ≤4 items: Within working memory limits — manageable. 5–7 items: Pushing the boundary — consider grouping or progressive disclosure. 8+ items: Overloaded — users will skip, misclick, or abandon.
The thresholds are explicit and they are not aesthetic: at any decision point, count the distinct options a user must simultaneously consider. Four or fewer sits inside working memory. Five to seven is the boundary — group them or reveal them progressively. Eight or more is overload, and an overloaded user does not choose slowly, they skip, misclick, or abandon. impeccable carries the rule into specific surfaces: navigation menus get at most 5 top-level items (group the rest under clear categories), form sections at most 4 fields before a visual break, action buttons get 1 primary and 1–2 secondary with the rest in a menu, dashboards at most 4 key metrics above the fold, and pricing at most 3 tiers, because more causes analysis paralysis. The counter-intuitive part is that grouping does not remove capability — a nav with 4 items plus a "More" group still reaches nine destinations. It removes them from the decision, which is the only place they were doing harm. Pair a capped tier list with a highlighted recommendation and you collapse the choice further still: the question stops being "compare three" and becomes "accept this one, or look at the other two".
- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [Laws of UX — Miller's Law](https://lawsofux.com/millers-law/)
### Expanded Hit Areas Must Never Collide
**ID:** `interactions-hit-target-collision` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-hit-target-collision](https://ui-guides-agent-rules.netlify.app/principles/interactions-hit-target-collision)
**Agent rule (NEVER):** Let two interactive elements have overlapping hit areas. This is the failure `interactions-match-hit-targets` creates: expand a sub-24px control with a pseudo-element and adjacent expanded targets overlap invisibly, so paint order decides the hit test and the element painted later steals the clicks. Grow each hit area only to the largest rect that does not collide with its neighbour's; if that lands below 24px, the layout is wrong — widen the pitch between the controls (WCAG 2.2 SC 2.5.8 measures spacing, not just size).
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
Grow a small control's hit area as large as it will go without overlapping the next control's
> If the extended hit area overlaps another interactive element, shrink the pseudo-element, but make it as large as possible without colliding. Two interactive elements should never have overlapping hit areas.
This is the rule that makes our own advice safe. "Match Visual & Hit Targets" tells you to expand a sub-24px control with a pseudo-element; "Small Touch Targets" and "No Dead Zones" push the same way. All three are about size, and none of them is about collision. Apply them literally to a row of 20px icon buttons separated by a 4px gap and each 44px pseudo-element overshoots its neighbour by roughly 20px. Nothing warns you: the pseudo-elements are invisible, the icons still look correctly spaced, and the overlap is resolved silently by paint order — the element painted later wins the hit test. So a click that lands squarely on the star icon can fire the delete button sitting next to it, and the bug only shows up in a support ticket. The fix is a ceiling on the expansion: grow each hit area until it would touch its neighbour's, then stop. If that leaves you below the minimum, the layout itself is wrong and the controls need a wider pitch — 44px of spacing rather than 44px of overlapping padding. WCAG 2.2 SC 2.5.8 encodes exactly this trade: a target under 24px still passes if a 24px circle centred on it does not intersect the circle of any other target. Spacing, not just size, is what the criterion actually measures.
```tsx
/* 20px icons, 4px apart — cap the expansion, do not overshoot into the neighbour */
.icon-btn { position: relative; }
.icon-btn::after { content: ""; position: absolute; inset: -12px -2px; }
```
- [jakubkrehel/skills — better-ui: surfaces.md](https://github.com/jakubkrehel/skills/blob/main/skills/better-ui/surfaces.md)
- [WCAG 2.2 SC 2.5.8 Target Size (Minimum) — spacing exception](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)
### Project Where the Flick Is Going
**ID:** `interactions-momentum-projection` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-momentum-projection](https://ui-guides-agent-rules.netlify.app/principles/interactions-momentum-projection)
**Agent rule (MUST):** Project the resting position from release velocity, THEN snap to the target nearest that projected point — never snap to the target nearest the release point, which cannot tell a nudge from a throw.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Use release velocity to project a resting position, then snap to the target nearest that point
> Don't snap to the nearest boundary from the *release point*. Use velocity to **project the resting position** — exactly like scroll deceleration — then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element.
A carousel that snaps to whichever slide is nearest the release point cannot tell a nudge from a throw. Both gestures end at roughly the same x, so both advance exactly one slide, and the hard flick you meant as "skip ahead" is quietly discarded. Momentum projection uses the velocity you already measured to answer a different question: not "where did the finger stop" but "where would the element come to rest if it kept moving". Apple ships an exponential-decay projection rather than the physics-textbook v²/(2·decel) — `project(v) = (v / 1000) * d / (1 - d)` with a deceleration rate d of about 0.998 — and then picks the snap target nearest that projected endpoint. This is distinct from "Give Drag Gestures Real Physics", which uses velocity as a binary gate: above ~0.11 px/ms the sheet dismisses, below it springs back. That rule never computes a landing point, so it can decide whether to commit but not how far to travel. Projection is what turns a small input into a big output.
```tsx
const project = (v, decel = 0.998) => (v / 1000) * decel / (1 - decel);
const rest = x + project(velocityX);
snapTo(nearestTarget(rest)); // not nearestTarget(x)
```
- [Emil Kowalski: apple-design SKILL.md](https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md)
- [WWDC 2018: Designing Fluid Interfaces](https://developer.apple.com/videos/play/wwdc2018/803/)
### Respect Where the User Grabbed
**ID:** `interactions-grab-offset` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-grab-offset](https://ui-guides-agent-rules.netlify.app/principles/interactions-grab-offset)
**Agent rule (MUST):** Store the pointer's offset within the element at `pointerdown` and subtract it on every `pointermove` so the element stays glued to the spot the user grabbed. Never re-centre the element under the cursor on grab — it teleports on the first frame and the illusion of holding an object dies.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Store the pointer's offset within the element on pointerdown and subtract it on every move
> When the user drags something, it must stay glued to the finger — and respect the offset from *where they grabbed it*. Snapping to the element's center on grab breaks the illusion immediately.
The cheapest way to write a drag is to set the element's position to the pointer's position, which silently centres it under the cursor. The result is a visible teleport at the instant of grab: you press the bottom edge of a card and the card jumps up so its middle is under your finger. Nothing in the physical world does this, and the illusion of holding an object dies in the first frame — before any of the release physics gets a chance to matter. The fix is two lines and no library: at pointerdown, record `e.clientY - element.getBoundingClientRect().top` (and the same for x), then on every pointermove subtract that offset from the pointer position. The element stays glued to the exact spot you took hold of. This is grab-time positioning, and no other rule in the corpus covers it: "Clean Drag Interactions" is about text selection and inert during the drag, and "Give Drag Gestures Real Physics" is about what happens on release. Between them sits the very first frame, which is the one users feel most sharply.
```tsx
const r = el.getBoundingClientRect();
const offset = { x: e.clientX - r.left, y: e.clientY - r.top };
// pointermove:
el.style.translate = `${e.clientX - offset.x}px ${e.clientY - offset.y}px`;
```
- [Emil Kowalski: apple-design SKILL.md](https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md)
- [MDN: Element.setPointerCapture()](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture)
### Require Movement Before Committing to a Direction
**ID:** `interactions-gesture-hysteresis` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-gesture-hysteresis](https://ui-guides-agent-rules.netlify.app/principles/interactions-gesture-hysteresis)
**Agent rule (MUST):** Require ~10px of movement from the `pointerdown` origin before committing a drag/swipe to an axis, then lock that axis and track 1:1 with no mid-gesture re-evaluation. Committing on the first `pointermove` reads the wobble at the start of a vertical scroll as a horizontal swipe and steals the scroll.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Wait ~10px before locking a gesture to an axis, then track it 1:1
> **Drag/swipe:** require a small movement threshold (hysteresis, ~10px) before committing to a direction, then track 1:1.
Fingers are not straight lines. A vertical scroll on a touch screen almost always begins with a couple of pixels of horizontal wobble, and a swipeable list row that commits to a direction on the very first pointermove will read that wobble as a swipe — locking the axis, calling preventDefault, and stealing the scroll the user actually wanted. From then on the list is unscrollable in the region where the rows are. Hysteresis makes the decision wait for evidence: accumulate movement from the pointerdown origin, ignore everything until the total distance passes a threshold of roughly 10px, and only then compare the horizontal and vertical components to decide which axis wins. Below the threshold the gesture is undecided and the browser keeps its native scroll; above it the axis is locked for the rest of the gesture and tracks the finger 1:1, with no re-evaluation that could cause a mid-drag flip. The threshold buys certainty about intent, and 10px is small enough that a deliberate swipe never feels delayed.
- [Emil Kowalski: apple-design SKILL.md](https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md)
- [MDN: touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)
### Sound Is Never the Only Channel
**ID:** `interactions-sound-not-sole-channel` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-not-sole-channel](https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-not-sole-channel)
**Agent rule (MUST):** Give every audio cue a visual equivalent — sound is a reinforcement channel and never carries feedback alone (WCAG 1.3.3). A Submit whose only success signal is a chime does nothing observable on a muted tab, and nothing at all for a deaf user. Removing the sound must cost nothing.
**Source:** Custom
Every audio cue needs a visual equivalent — sound supplements feedback, it never carries it
> Every audio cue must have a visual equivalent; sound never replaces visual feedback.
Cherry-picked from Raphael Salaja's sounds-on-the-web skill (rule `a11y-visual-equivalent`). The failure is easy to reproduce and easy to miss in development: a Submit button whose only success signal is a chime. On a muted tab — which is the default state of most tabs most of the time — pressing Submit produces no observable change at all, and the user presses it again. Deaf and hard-of-hearing users get the same nothing, permanently. This is WCAG 1.3.3 Sensory Characteristics: instructions and feedback must not depend on a single sensory characteristic. It is the same principle as "Redundant Status Cues", which is scoped to colour and WCAG 1.4.1 — one channel, one criterion, identical shape. Sound is simply the channel that is switched off far more often than colour is. The rule is not "no sound"; sound is a genuinely good second channel when the user is not looking at the screen. The rule is that removing it must cost nothing.
- [Raphael Salaja: sounds-on-the-web SKILL.md](https://github.com/raphaelsalaja/skill/blob/main/skills/sounds-on-the-web/SKILL.md)
- [WCAG 2.2 SC 1.3.3 Sensory Characteristics](https://www.w3.org/WAI/WCAG22/Understanding/sensory-characteristics.html)
### Sound Belongs to the User
**ID:** `interactions-sound-is-user-owned` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-is-user-owned](https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-is-user-owned)
**Agent rule (MUST):** Ship an explicit mute toggle in settings plus a volume control independent of system volume, and default the volume subtle (`0.3`) — never loud, never autoplaying. The OS mixer silences everything, not just you, so "turn your machine down" is not an off switch.
**Source:** Custom
Ship an explicit mute toggle, an independent volume control, and a subtle default
> Provide explicit toggle to disable sounds in settings. Allow volume adjustment independent of system volume. Default volume should be subtle, not loud.
Cherry-picked from Raphael Salaja's sounds-on-the-web skill, merging three of its rules (`a11y-toggle-setting`, `a11y-volume-control`, `impl-default-subtle`). A sound provider with no off switch treats audio as a property of the product rather than a property of the session, and the user's only recourse is the OS mixer — which silences everything, not just you. Three controls fix it. An explicit toggle, because "turn down my whole machine" is not an answer to "this app is noisy". An in-app volume independent of system volume, because the right level for a notification chime and the right level for a video are different numbers, and only the user knows what else is playing. And a subtle default: the upstream number is 0.3, chosen so that the first sound a user hears is never the loudest thing they have heard today. Defaulting to full volume converts a nice touch into an ambush, and an ambushed user mutes the tab permanently — which loses you the channel entirely.
- [Raphael Salaja: sounds-on-the-web SKILL.md](https://github.com/raphaelsalaja/skill/blob/main/skills/sounds-on-the-web/SKILL.md)
- [WCAG 2.2 SC 1.4.2 Audio Control](https://www.w3.org/WAI/WCAG22/Understanding/audio-control.html)
### Sound Only for Significant Events
**ID:** `interactions-sound-only-for-significant-events` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-only-for-significant-events](https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-only-for-significant-events)
**Agent rule (NEVER):** Attach sound to high-frequency interactions — typing, keyboard navigation, hover, scroll. Reserve it for confirmations the user needs assurance of (payments, uploads, form submissions) and for errors/warnings that must not be overlooked. A cue that fires on every input stops being feedback and trains the user to tune out every other sound you make.
**Source:** Custom
Confirmations and errors earn a sound; typing, hover, scroll and navigation never do
> Do not add sound to high-frequency interactions (typing, keyboard navigation). Sound is appropriate for confirmations: payments, uploads, form submissions. Sound is appropriate for errors and warnings that can't be overlooked.
Cherry-picked from Raphael Salaja's sounds-on-the-web skill, merging `appropriate-no-high-frequency` with `appropriate-confirmations-only` and `appropriate-errors-warnings`. Frequency is the whole test. A sound on a payment confirmation fires once and reassures; the same sound on a keystroke fires eighty times a minute and becomes noise the user learns to tune out — taking every other sound in your product down with it, including the error you actually needed them to hear. The skill's own appropriateness matrix draws the line explicitly: payment success yes (significant confirmation), form submission yes (user needs assurance), error yes (can't be overlooked), notification yes (may not be looking at the screen), button click maybe (only for significant buttons), typing no (too frequent), hover no (decorative only), scroll no (too frequent), navigation no (keyboard nav would be noisy). This is the same shape as "Match Motion to Frequency", which withholds animation from keyboard-initiated and high-frequency interactions for exactly the same reason: feedback that fires on every input stops being feedback and becomes texture.
- [Raphael Salaja: sounds-on-the-web SKILL.md](https://github.com/raphaelsalaja/skill/blob/main/skills/sounds-on-the-web/SKILL.md)
- [MDN: Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)
### Match Sound Weight to the Action
**ID:** `interactions-sound-weight-matches-action` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-weight-matches-action](https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-weight-matches-action)
**Agent rule (SHOULD):** Make a cue's sound proportional to the action's consequence: a trivial action gets a short, high, quiet tick; a heavy or irreversible one gets a lower, longer, slightly louder tone. Do not reuse one blip for everything — identical sound erases hierarchy. Stay subtle and keep the visual channel primary.
**Source:** Custom
Scale a sound's pitch, length, and heft to the consequence — a light tick for the trivial, a low weighty tone for the grave
> Sound weight should match the action's weight, and sound duration should match the action. A small action gets a light, short sound; a large or significant one gets a heavier, longer sound.
Cherry-picked from the weight-matching rules in Raphael Salaja's sounds-on-the-web skill, and it presumes interactions-sound-only-for-significant-events has already decided WHICH events get a sound at all — this rule governs, among those, how MUCH sound. The audio channel is a hierarchy channel or it is noise: if starring an item and deleting an account make the same 880Hz blip, the ear learns nothing and tunes the whole product out. Weight is carried by three knobs, and they stack — lower pitch reads heavier, longer duration reads weightier, and a slightly higher level reads more significant, so a grave, irreversible action wants a low tone that lingers (and can glide downward), while a trivial toggle wants a short, high, quiet tick. This is the audio sibling of two motion rules the corpus already holds: animations-lottie-distance-duration derives duration from how far an element travels, and animations-emil-frequency withholds motion from high-frequency actions — same instinct, that feedback must be proportional to what it is reporting. Stay inside the other sound constraints while you do it: keep the loudest tone subtle and user-controlled (interactions-sound-is-user-owned) and never let the sound carry meaning the screen does not (interactions-sound-not-sole-channel).
```tsx
star() => playTone({ frequency: 1046, duration: 0.09, volume: 0.22 });
delete() => playTone({ frequency: 220, endFrequency: 150, duration: 0.34, volume: 0.34 });
```
- [Raphael Salaja: sounds-on-the-web SKILL.md](https://github.com/raphaelsalaja/skill/blob/main/skills/sounds-on-the-web/SKILL.md)
- [MDN: OscillatorNode](https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode)
### Error Sounds Must Not Punish
**ID:** `interactions-sound-not-punishing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-not-punishing](https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-not-punishing)
**Agent rule (SHOULD):** An error sound must inform, not scold. Keep it brief, moderate in level, and gentle — typically lower and falling, distinct from a rising success tone. A harsh, loud, dissonant buzzer makes users mute the whole product, losing every useful cue with it. The worded, visible error still carries the message and the fix.
**Source:** Custom
An error tone should be distinct and calm, not a harsh loud alarm — punishing sounds make users mute everything
> Do not use harsh or punishing sounds for errors. An error sound should inform, not startle or scold.
Cherry-picked from the appropriateness rules in Raphael Salaja's sounds-on-the-web skill. An error is information, not a verdict, and the sound has to agree with that. A loud, low, dissonant buzzer does the opposite — it startles, it reads as a reprimand for a typo, and the entirely rational response is to mute the product, which also silences the confirmations you actually wanted the user to hear. It is the same escape-hatch failure that over-frequent sound causes, arrived at from the other direction: make any one sound intolerable and the user kills all of them. A good error tone is brief, moderate in level, and gentle — often pitched lower than success and falling rather than rising, so it is instantly distinguishable from a confirmation without being aggressive. As always the sound is the junior partner: the visible, worded error carries the message and the fix (this is the audio counterpart of content-actionable-errors and content-positive-language, which say the copy should guide rather than blame), and interactions-sound-not-sole-channel keeps it that way.
```tsx
// gentle, brief, downward — noticed, not punishing
playTone({ frequency: 392, endFrequency: 294, duration: 0.28, type: 'sine', volume: 0.3 });
```
- [Raphael Salaja: sounds-on-the-web SKILL.md](https://github.com/raphaelsalaja/skill/blob/main/skills/sounds-on-the-web/SKILL.md)
- [NN/g: Error-Message Guidelines](https://www.nngroup.com/articles/error-message-guidelines/)
### Prediction Cone for Nested Menus
**ID:** `interactions-menu-prediction-cone` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-menu-prediction-cone](https://ui-guides-agent-rules.netlify.app/principles/interactions-menu-prediction-cone)
**Agent rule (SHOULD):** A submenu that opens to the side must forgive diagonal pointer travel toward it. Do not swap or close it the instant the pointer leaves the parent row: use a safe-triangle/prediction cone (suppress sibling hover while the pointer aims at the open submenu) or, as a cheap approximation, a ~150ms close-delay that any re-entry into the submenu cancels.
**Source:** [Rauno](https://interfaces.rauno.me/)
When a submenu opens on hover, forgive diagonal pointer travel toward it instead of closing the instant the pointer leaves the parent row
> When using nested menus, use a "prediction cone" to prevent the pointer from accidentally closing the menu when moving across other elements.
The naive nested menu closes the submenu the moment the pointer leaves the parent item. That is exactly wrong, because the submenu opens to the side, so the shortest path to it runs diagonally across the sibling rows below the one you are pointing at. Each sibling the pointer crosses fires its own hover, swaps the open submenu out for its own, and the option you were reaching for vanishes before you arrive. The user learns to travel in an L — straight down the border, then across — which is the tell of a menu that does not forgive its own geometry. The fix has two well-known shapes. The "prediction cone" (or "safe triangle", the Amazon mega-menu trick Ben Kamens documented) reads pointer velocity and builds a triangle between the cursor and the top and bottom corners of the open submenu; while the pointer stays inside that triangle it is presumed to be aiming AT the submenu, so sibling hovers are suppressed for a short grace window. The cheaper approximation is a short close-delay (~100–200ms) on the submenu that any pointer re-entry cancels. This is the same forgiveness principle as interactions-forgiving-design and interactions-hover-content-persistence (hover targets must be reachable and not expire on a timer); it pairs with interactions-mousedown-dropdown, which handles the open, where this handles the traverse.
```tsx
onMouseEnter={(id) => { clearTimeout(t); t = setTimeout(() => setActive(id), 160); }}
// submenu panel: onMouseEnter={() => clearTimeout(t)}
```
- [Rauno Freiberg — interfaces.rauno.me](https://raw.githubusercontent.com/raunofreiberg/interfaces/main/README.md)
- [Ben Kamens — Breaking down Amazon’s mega dropdown](https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown)
- [MDN — Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events)
### Modals Dim and Recede
**ID:** `interactions-modal-dims-and-recedes` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/interactions-modal-dims-and-recedes](https://ui-guides-agent-rules.netlify.app/principles/interactions-modal-dims-and-recedes)
**Agent rule (SHOULD):** For a blocking dialog, dim the page with a scrim and push the background back slightly so depth signals "paused" — and pair it with the semantics (trap focus, role="dialog", aria-modal). For a parallel, non-blocking panel (inspector, side sheet), do NOT dim the page; it should coexist with the content.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
A blocking dialog dims the page and pushes it back to establish focus; a non-blocking panel coexists without a scrim
> A modal task pairs the surface with a dimming scrim and pushes the background back; a parallel, non-blocking task coexists with the content instead of blocking it.
Modality is a promise about attention, and depth is how you keep it. When a task truly blocks — a destructive confirm, a required choice — dim the page behind a scrim and push it back a touch; the recede plus the dim say "everything else is paused" before the user reads a word. Skip that and the dialog reads as just one more floating panel competing with a still-bright background. The inverse mistake is as common: a parallel, non-blocking surface (an inspector, a side sheet you keep working alongside) that dims the whole page claims focus it does not need and makes the app feel modal when it is not. This is a different axis from "The Modal Is the Last Resort", which argues about *whether* to interrupt at all; this is about doing the interruption legibly *once you have decided to*. Pair the visual treatment with the semantics — a blocking dialog also traps focus and is labelled as a dialog (see the focus-management principles) — so the depth cue and the accessibility contract agree.
```tsx
<div class="fixed inset-0 bg-black/50" />
<main style="transform: scale(0.96)">…</main>
```
- [Emil Kowalski — apple-design skill](https://github.com/emilkowalski/skills/tree/main/skills/apple-design)
- [Apple HIG — Modality](https://developer.apple.com/design/human-interface-guidelines/modality)
---
## Animations
Motion design principles and animation best practices. 63 rules.
### Honor prefers-reduced-motion
**ID:** `animations-prefers-reduced-motion` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-prefers-reduced-motion](https://ui-guides-agent-rules.netlify.app/principles/animations-prefers-reduced-motion)
**Agent rule (MUST):** Honor `prefers-reduced-motion` (provide reduced variant); in Tailwind use the `motion-safe:`/`motion-reduce:` variants
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Provide a reduced-motion variant for all animations
> Honor prefers-reduced-motion. Provide a reduced-motion variant.
Some users experience motion sickness or vestibular symptoms from movement they did not initiate. The media query surfaces an OS-level preference they have already expressed, so honouring it costs one @media block. The load-bearing detail: "reduced" is not "none". Strip the spatial travel and the spring, but KEEP the opacity crossfades and the instant state changes, so the interface still says what happened — a state change that was only ever communicated by motion becomes invisible the moment the motion is dropped (animations-lottie-never-opacity-only is the corollary). ibelick's baseline-ui converges on the same rule from the agent-guidelines side ("SHOULD respect `prefers-reduced-motion`"), which is why this corpus states it once here rather than twice. CAVEAT, and it is worth stating plainly: Raphael Salaja's sounds-on-the-web skill advises treating prefers-reduced-motion as a proxy for sound sensitivity — defaulting audio OFF when it is set. That is pragmatic, because no `prefers-reduced-sound` media query has ever shipped, and it is a defensible default. But it is lossy: a vestibular preference is not a sound preference, and the overlap is a guess, not a signal. Use it as a default-OFF heuristic, never as a substitute for an explicit, persisted sound toggle (see interactions-sound-is-user-owned).
- [prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion)
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Raphael Salaja — sounds-on-the-web SKILL.md](https://raw.githubusercontent.com/raphaelsalaja/skill/main/skills/sounds-on-the-web/SKILL.md)
- [Accessible Animations](https://www.a11yproject.com/posts/understanding-vestibular-disorders/)
### Compositor-friendly
**ID:** `animations-compositor-friendly` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-compositor-friendly](https://ui-guides-agent-rules.netlify.app/principles/animations-compositor-friendly)
**Agent rule (MUST):** Animate compositor-friendly props (`transform`, `opacity`) — they run on the GPU without layout or paint; avoid layout/repaint props (`top/left/width/height`, `margin`). 60fps leaves <16ms per frame
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Prioritize GPU-accelerated properties like transform and opacity
> Compositor-friendly. Prioritize GPU-accelerated properties (transform, opacity) & avoid properties that trigger reflows/repaints (width, height, top, left).
transform and opacity are the only two properties the browser can change without redoing style, layout or paint — the compositor already holds the layer and just re-places or re-blends it, on its own thread. That is why they keep moving at 60fps even while the main thread is blocked, and why width, height, top or left stutter the moment it is not. This is one of the most independently rediscovered rules in the field, and this entry is where the corpus records it once: ibelick's baseline-ui states it as "MUST animate only compositor props (`transform`, `opacity`)", and Tailwind performance guidance as "animate only transform and opacity" — three sources, one rule, so read the convergence as confirmation rather than as three things to learn. Read it as the DEFAULT, not an absolute: the carve-outs are priced elsewhere and are part of the rule. Paint animations are acceptable on small, isolated surfaces (a button label, an icon — animations-ibelick-minimize-paint), one-shot effects are cheaper than continuous motion, and a layout-like change can be faked with a measured transform (FLIP — animations-flip-technique). And note the false floor: in Motion, `animate={{ x: 100 }}` looks compliant but is interpolated on the main thread — see performance-motion-shorthand-not-gpu.
- [High Performance Animations](https://web.dev/articles/animations-guide)
- [Compositor-only Properties](https://web.dev/articles/stick-to-compositor-only-properties-and-manage-layer-count)
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [CSS Triggers](https://csstriggers.com/)
### Interruptible
**ID:** `animations-interruptible` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-interruptible](https://ui-guides-agent-rules.netlify.app/principles/animations-interruptible)
**Agent rule (MUST):** Animations are interruptible and input-driven (avoid autoplay)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Animations are cancelable by user input
> Interruptible. Animations are cancelable by user input.
Don't force users to wait for animations to complete. If a user interacts during an animation (clicking, scrolling, etc.), the animation should either instantly complete or transition to the new state. Never block interaction during animation.
- [Animation Best Practices](https://www.nngroup.com/articles/animation-usability/)
### Correct Transform Origin
**ID:** `animations-correct-transform-origin` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-correct-transform-origin](https://ui-guides-agent-rules.netlify.app/principles/animations-correct-transform-origin)
**Agent rule (MUST):** Correct `transform-origin` (motion starts where it "physically" should)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Anchor motion to where it physically starts
> Correct transform origin. Anchor motion to where it "physically" starts.
When elements scale or rotate, the transform-origin should match where the motion naturally originates. For example, a dropdown opening from a button should scale from the button's position, not from the center of the dropdown.
- [transform-origin](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform-origin)
### Never transition: all
**ID:** `animations-never-transition-all` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-never-transition-all](https://ui-guides-agent-rules.netlify.app/principles/animations-never-transition-all)
**Agent rule (MUST):** Never transition: all. Explicitly list only the properties you intend to animate (typically opacity, transform)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Explicitly list only the properties you intend to animate
> Never transition: all. Explicitly list only the properties you intend to animate (typically opacity, transform). all can unintentionally animate layout-affecting properties causing jank.
Using transition: all is tempting but dangerous. It can accidentally animate properties you didn't intend to, causing performance issues and unexpected visual effects. Always explicitly list the properties you want to transition.
- [CSS Transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Transitions/Using)
### Implementation Preference
**ID:** `animations-implementation-preference` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-implementation-preference](https://ui-guides-agent-rules.netlify.app/principles/animations-implementation-preference)
**Agent rule (SHOULD):** Prefer CSS > Web Animations API > JS libraries
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Prefer CSS over Web Animations API over JavaScript libraries
> Implementation preference. Prefer CSS, avoid main-thread JS-driven animations when possible. Preference: CSS > Web Animations API > JavaScript libraries e.g., motion.
CSS animations run off the main thread and are most performant. Web Animations API provides more control when needed. JavaScript animation libraries should be a last resort as they run on the main thread and can block user interactions.
- [CSS Animations](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Animations)
- [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API)
### Necessity Check
**ID:** `animations-necessity-check` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-necessity-check](https://ui-guides-agent-rules.netlify.app/principles/animations-necessity-check)
**Agent rule (SHOULD):** Animate only to clarify cause/effect or add deliberate delight
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Only animate when it clarifies cause and effect or adds deliberate delight
> Necessity check. Only animate when it clarifies cause & effect or when it adds deliberate delight e.g., the northern lights.
Animation should serve a purpose: showing relationships between actions and results, or creating memorable moments. Don't animate just because you can. Every animation should justify its existence by improving comprehension or creating meaningful delight.
- [Animation Principles](https://www.nngroup.com/articles/animation-usability/)
### Easing Fits Subject
**ID:** `animations-easing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-easing](https://ui-guides-agent-rules.netlify.app/principles/animations-easing)
**Agent rule (SHOULD):** Choose easing to match the change (size/distance/trigger)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Choose easing functions based on what changes
> Easing fits the subject. Choose easing based on what changes (size, distance, trigger).
Pick the curve from what is changing, in this order: elements entering or exiting the screen → ease-out (and, on this reading, ease-in is also permitted for exits, on the argument that a departing element may accelerate away); an element morphing or moving between two on-screen states → ease-in-out; hover and other pointer feedback → ease; anything constant or continuous, like a marquee or a spinner → linear. Then match the magnitude to the distance travelled: a 4px hover nudge and a full-screen sheet should not share a curve. Note the field disagrees about exits — "Never ease-in on UI" (animations-emil-no-ease-in) rejects ease-in outright, on the grounds that it stalls the first ~100ms, which is exactly the window in which the user judges speed. Both entries are kept deliberately: read them together and decide, rather than inheriting one by default.
- [Easing Functions](https://easings.net/)
- [CSS Easing](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function)
### Use motion/react for JS Animations
**ID:** `animations-ibelick-motion-library` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-motion-library](https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-motion-library)
**Agent rule (MUST):** Use motion/react (formerly framer-motion) when JavaScript animation is required. It provides spring physics, gesture support, and exit animations out of the box.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use motion/react (Framer Motion) for JavaScript-driven animations instead of manual implementations
> MUST use `motion/react` (formerly `framer-motion`) when JavaScript animation is required
Note the conditional in the rule: it does not say to reach for a JS animation library, it says which one to use once you have established that JS is required at all. CSS first; JS only when the interaction genuinely needs it (see animations-implementation-preference). When it does, hand-rolling with requestAnimationFrame or direct DOM writes means re-solving interruption, velocity hand-off, spring physics and gesture integration yourself — motion/react handles those, and the alternative is a pile of animations that restart from zero every time the user touches them.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Motion Documentation](https://motion.dev/docs/react)
### tw-animate-css for Micro Animations
**ID:** `animations-ibelick-tw-animate` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-tw-animate](https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-tw-animate)
**Agent rule (SHOULD):** Use tw-animate-css for entrance and micro-animations in Tailwind CSS. Provides consistent, performant CSS animations without JavaScript overhead.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use tw-animate-css plugin for entrance and micro-interaction animations
> SHOULD use `tw-animate-css` for entrance and micro-animations in Tailwind CSS
This is a SHOULD, not a MUST, and it is scoped to Tailwind projects. The point of it is that entrance and micro-interaction animations are the ones most often hand-rolled per component — a bespoke @keyframes here, an inline style there — which is how a product ends up with six slightly different fade-ins. tw-animate-css supplies them as utilities (fade-in, slide-in-from-bottom-4, zoom-in-95) so the whole surface shares one vocabulary. It is CSS-only, which also keeps it on the right side of animations-implementation-preference.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css)
### Animation Only When Requested
**ID:** `animations-ibelick-intentional-only` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-intentional-only](https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-intentional-only)
**Agent rule (NEVER):** Add animation unless it is explicitly requested. Gratuitous animation slows perceived performance and can cause motion sickness. Animation should serve purpose.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Never add animations unless explicitly requested - they can hurt UX if overused
> NEVER add animation unless it is explicitly requested
Read this as what it is: agent-facing guidance. It is a default for the party that was not asked — a coding agent handed "build me a settings panel" should not decide on its own that the panel slides. It is not a claim that animation is bad. A human designer choosing to animate has, by definition, explicitly requested it. The failure it prevents is real: animation added because it looks cool taxes every future use of a control, slows perceived performance, and is the single loudest tell of generated UI. Where motion IS wanted, animations-necessity-check gives the test it has to pass.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [The Case Against Animation](https://web.dev/articles/animations-guide)
### Never Animate Layout Properties
**ID:** `animations-ibelick-no-layout` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-no-layout](https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-no-layout)
**Agent rule (NEVER):** Animate layout properties (width, height, top, left, margin, padding). Use transform: scale() and translate() instead. Layout triggers are expensive.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Never animate width, height, top, left, or other layout-triggering properties
> NEVER animate layout properties (`width`, `height`, `top`, `left`, `margin`, `padding`)
A layout property does not just move the element — it invalidates the geometry of everything the element participates in, so the browser reflows potentially hundreds of siblings on every frame, then repaints them, then composites. Use transform: scale() for size and translate() for position: they produce the same visual result with none of that work. Where the effect genuinely needs a layout-like change (a panel actually growing), measure once and fake it with a transform — FLIP — rather than animating the property; ibelick's fixing-motion-performance skill puts it as "measure once, then animate via transform or opacity".
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Avoid Layout Thrashing](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing)
### Minimize Paint Animations — Except on Small, Local UI
**ID:** `animations-ibelick-minimize-paint` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-minimize-paint](https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-minimize-paint)
**Agent rule (SHOULD):** Avoid animating paint properties (background, color, box-shadow) except for small, local UI elements. Large paint areas cause jank on lower-end devices.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Avoid animating paint properties on large surfaces — but a color transition on a button label or an icon is fine
> SHOULD avoid animating paint properties (`background`, `color`) except for small, local UI (text, icons)
The exception is the useful half of this rule, and it is easy to lose. A paint animation forces the browser to re-fill pixels on the main thread every frame, and the cost scales with the AREA being repainted — so it is the same size budget as the surface-area rule, not a property blacklist. Repainting a full-bleed hero background or eight pulsing cards is expensive enough to visibly stall; repainting the 60x20px of a button label on hover is not, and refusing to do it buys you nothing but a worse interface. So: `transition-colors` on a button, a link, an icon — fine, and the correct thing to reach for. The same transition on a large container, or running continuously rather than once, is what the rule is aimed at. Two extensions worth knowing that upstream does not state: `box-shadow` behaves the same way (it is a paint property, and shadows are expensive to rasterize — see the example), and when you do need an apparently paint-like change on a large surface, pre-paint it as a second layer and crossfade the two with `opacity`, which is composited.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [ibelick — fixing-motion-performance SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/fixing-motion-performance/SKILL.md)
- [Simplify Paint Complexity](https://web.dev/articles/simplify-paint-complexity-and-reduce-paint-areas)
### Proper Animation Timing
**ID:** `animations-ibelick-timing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-timing](https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-timing)
**Agent rule (NEVER):** Exceed 200ms for interaction feedback. Use ease-out on entrance, ease-in on exit. Never introduce custom easing curves unless explicitly requested.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use ease-out for entrances and keep interaction feedback under 200ms
> SHOULD use `ease-out` on entrance. NEVER exceed `200ms` for interaction feedback.
Two separate upstream rules, quoted together because they answer the same question: how should a small interaction feel. ease-out on entrance puts the movement in the first frames — the ones the user is actually watching — and lets it settle, which is why a 200ms ease-out reads as faster than a 200ms ease-in even though the stopwatch says otherwise (animations-emil-no-ease-in makes the same case at length). The 200ms ceiling is specifically for INTERACTION FEEDBACK — a press, a hover, a toggle, the response to something the user just did — not for every animation on the page: a drawer or a modal moves a large surface and legitimately runs longer (animations-emil-duration-budget budgets 200–500ms for those). Upstream says nothing about the curve for exits; do not infer ease-in from its absence.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Material Design Motion](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs)
### Pause Offscreen Animations
**ID:** `animations-ibelick-pause-offscreen` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-pause-offscreen](https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-pause-offscreen)
**Agent rule (MUST):** Pause looping animations when off-screen using IntersectionObserver. Saves CPU/battery on mobile. Resume when element enters viewport.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Pause looping animations when they're not visible to save battery and CPU
> MUST pause looping animations when off-screen
An off-screen loop is pure waste: it keeps the compositor awake, burns CPU and battery, and produces exactly zero pixels anyone sees. The browser does not stop it for you — a CSS animation on a scrolled-past element runs forever. ibelick's fixing-motion-performance skill names the mechanism ("use IntersectionObserver for visibility and pausing"): observe the element, and toggle `animation-play-state: paused` or stop the rAF loop when it leaves the viewport. Note the scope is LOOPING animations — a one-shot entrance that finishes off-screen costs nothing.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [ibelick — fixing-motion-performance SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/fixing-motion-performance/SKILL.md)
- [IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
### Pause, Stop, Hide Controls (WCAG 2.2.2)
**ID:** `animations-pause-stop-hide` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-pause-stop-hide](https://ui-guides-agent-rules.netlify.app/principles/animations-pause-stop-hide)
**Agent rule (MUST):** Auto-playing animations >5s MUST have pause/stop/hide controls; OR auto-stop after 5s. Applies to carousels, video backgrounds, infinite loops. Essential animations (loading spinners, progress) are exempt.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Auto-playing animations over 5 seconds must have pause/stop/hide controls
> Auto-playing animations >5s MUST have pause/stop/hide controls; OR auto-stop after 5s. Applies to carousels, video backgrounds, infinite loops. Essential animations (loading spinners, progress) are exempt.
WCAG 2.2.2 Level A requires that users can pause, stop, or hide moving content. This prevents distraction for users with attention disorders and reduces seizure risk. Loading indicators and progress bars are exempt as they provide essential feedback.
- [WCAG 2.2.2 Understanding](https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html)
- [Digital A11y Guide](https://www.digitala11y.com/understanding-sc-2-2-2-pause-stop-hide/)
### Animation Frame Budget (60fps)
**ID:** `animations-frame-budget` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-frame-budget](https://ui-guides-agent-rules.netlify.app/principles/animations-frame-budget)
**Agent rule (SHOULD):** Animation work SHOULD complete within 16ms frame budget (60fps). Use requestAnimationFrame for JS animations. Batch layout reads then writes — never interleave them — so a frame does not thrash between reflows and repaints.
**Source:** Custom
Keep per-frame animation work under ~10ms — the textbook 16ms is not all yours
> Animation work SHOULD fit the frame budget (60fps). Use requestAnimationFrame for JS animations. Batch DOM reads then writes to avoid layout thrashing in animation loops.
At 60fps a frame is 16.67ms, and that number is quoted everywhere as "the budget". It is not your budget. The browser needs part of every frame for its own housekeeping — style, layout, paint, compositing, plus whatever else the main thread is already queued to do — so the work you can actually spend is closer to 10ms, and less on a mid-range phone. Treat ~10ms as the target and 16ms as the cliff you are already falling off. Practically: do the work in requestAnimationFrame, batch every DOM read before every DOM write (interleaving them forces a synchronous layout per iteration), write only compositor-friendly properties (transform, opacity), and move anything genuinely expensive off the main thread entirely.
- [web.dev — Rendering performance](https://web.dev/articles/rendering-performance)
- [MDN — Performance fundamentals](https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/Fundamentals)
### View Transitions API
**ID:** `animations-view-transitions` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-view-transitions](https://ui-guides-agent-rules.netlify.app/principles/animations-view-transitions)
**Agent rule (SHOULD):** Use View Transitions API for page/state transitions. Add `@view-transition { navigation: auto; }` for MPA. Use `view-transition-name` for morphing elements. Progressive enhancement—works without JS.
**Source:** [Web Platform](https://web.dev/)
Use View Transitions for navigation-level changes — not for gestures or rapid toggles
> Use the View Transitions API for page/state transitions. Use `view-transition-name` for morphing elements. Same-document transitions are Baseline newly available; cross-document (`@view-transition { navigation: auto; }`) is still limited availability.
The View Transitions API snapshots the old DOM, lets you mutate it, and crossfades or morphs between the two states — a real fix for the "the page just popped" problem, and it degrades to an instant change where unsupported (feature-detect `document.startViewTransition`). Two guardrails. BROWSER SUPPORT: same-document transitions ship in Chromium and Safari, but cross-document navigation (`@view-transition { navigation: auto; }`) is NOT Baseline — treat it as limited availability and a progressive enhancement, never as the thing that makes the navigation work. SCOPE: a view transition cannot be interrupted or reversed once it starts. That makes it right for navigation-level changes (route change, page change, opening a detail view) and wrong for anything the user drives continuously or fires in quick succession — drag-to-reorder, rapid tab switching, a slider. Wrap those and you get a queue of uninterruptible crossfades that lag behind the input, which is exactly what animations-interruptible forbids.
- [MDN — View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API)
- [Chrome — View Transitions in 2025](https://developer.chrome.com/blog/view-transitions-in-2025)
### Spring Physics for Natural Motion
**ID:** `animations-spring-physics` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-spring-physics](https://ui-guides-agent-rules.netlify.app/principles/animations-spring-physics)
**Agent rule (SHOULD):** Prefer spring-based animations for interactive elements (buttons, modals, drag). Configure mass/tension/friction instead of duration/easing for natural feel and interruptible motion.
**Source:** Custom
Prefer spring-based animations for interactive elements like buttons, modals, and drag
> Prefer spring-based animations for interactive elements (buttons, modals, drag). Configure the spring instead of duration/easing so motion carries velocity through an interruption.
A duration-and-easing tween is a script: it always takes 300ms and always follows the same curve, whatever the element was doing a moment ago. Interrupt it — flick a drawer back while it is still closing — and it restarts from wherever it is with zero velocity, which reads as a stutter. A spring is a simulation: it carries the element's current velocity into the new target, so a reversal continues the gesture instead of contradicting it. That is the reason to reach for one, not "it feels bouncier". Configure it in Motion's modern terms — `{ type: "spring", duration: 0.5, bounce: 0.2 }` — rather than hand-tuning mass/stiffness/damping, and keep bounce low (see animations-emil-subtle-bounce): a spring with zero bounce is still a spring, and still interruptible.
- [Motion — spring transitions](https://motion.dev/docs/react-transitions#spring)
- [MDN — linear() easing (approximating springs in CSS)](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function/linear)
### Motion Library + Tailwind Conflicts
**ID:** `animations-motion-tailwind-conflict` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-motion-tailwind-conflict](https://ui-guides-agent-rules.netlify.app/principles/animations-motion-tailwind-conflict)
**Agent rule (SHOULD):** When using Framer Motion/Motion with Tailwind, remove conflicting `transition-*` classes from animated elements. Let Motion handle transitions to prevent stuttery/weird motion.
**Source:** Custom
Remove Tailwind transition classes from elements animated by Framer Motion
> When using Framer Motion/Motion with Tailwind, remove conflicting `transition-*` classes from animated elements. Let Motion handle transitions to prevent stuttery/weird motion.
Tailwind's transition-* classes apply CSS transitions. When combined with Framer Motion's JS-based animations, they conflict and cause stuttering. Use Motion's transition prop instead.
- [Motion + Tailwind Guide](https://motion.dev/docs/react-tailwind)
- [Framer Motion + Tailwind 2025](https://dev.to/manukumar07/framer-motion-tailwind-the-2025-animation-stack-1801)
### Tailwind motion-safe/motion-reduce Variants
**ID:** `animations-tailwind-motion-variants` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-tailwind-motion-variants](https://ui-guides-agent-rules.netlify.app/principles/animations-tailwind-motion-variants)
**Agent rule (MUST):** Use Tailwind `motion-safe:` and `motion-reduce:` variants to conditionally apply animations. Default pattern: `motion-safe:animate-*` ensures animations only run when user allows motion.
**Source:** [Tailwind](https://tailwindcss.com/docs)
Use Tailwind motion-safe: and motion-reduce: variants for accessible animations
> Use Tailwind `motion-safe:` and `motion-reduce:` variants to conditionally apply animations. Default pattern: `motion-safe:animate-*` ensures animations only run when user allows motion.
These variants respect the user's prefers-reduced-motion setting. motion-safe: applies styles only when motion is allowed. motion-reduce: applies alternative styles when user prefers reduced motion.
- [Tailwind Animation](https://tailwindcss.com/docs/animation)
- [Motion-Safe Animations Guide](https://dev.to/hexshift/building-fluid-motion-safe-animations-in-tailwind-css-that-respect-user-preferences-3i6e)
### No Transitions on Theme Switch
**ID:** `animations-theme-switch-no-transitions` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-theme-switch-no-transitions](https://ui-guides-agent-rules.netlify.app/principles/animations-theme-switch-no-transitions)
**Agent rule (MUST):** Suppress `transition` and `animation` on every element while flipping the theme, then restore them on the next frame — otherwise each colour-animating element ripples across the page.
**Source:** [Rauno](https://interfaces.rauno.me/)
Switching themes should not trigger transitions and animations on elements
> Switching themes should not trigger transitions and animations on elements.
When toggling between light and dark mode, elements with CSS transitions will visibly animate their color changes, creating a distracting ripple effect across the page. Temporarily disable transitions during theme switches for an instant, clean change.
```tsx
document.documentElement.classList.add("theme-switching");
setTheme(next);
requestAnimationFrame(() => document.documentElement.classList.remove("theme-switching"));
/* .theme-switching *, .theme-switching *::before, .theme-switching *::after { transition: none !important } */
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [next-themes](https://github.com/pacocoursey/next-themes)
### Proportional Animation Values
**ID:** `animations-proportional-values` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-proportional-values](https://ui-guides-agent-rules.netlify.app/principles/animations-proportional-values)
**Agent rule (SHOULD):** Keep animation values proportional to the element: dialogs scale from ~0.95–0.8 (never 0), buttons compress to ~0.96 on press (never 0.8).
**Source:** [Rauno](https://interfaces.rauno.me/)
Animation values should be proportional to the trigger size — don't scale from 0, use subtle values
> Animation values should be proportional to the trigger size: Don't animate dialog scale in from 0 → 1, fade opacity and scale from ~0.8. Don't scale buttons on press from 1 → 0.8, but ~0.96, ~0.9, or so.
Exaggerated animations feel cartoonish and disproportionate. A dialog appearing should scale from 0.95, not 0. A button press should compress to 0.97, not 0.5. Match the animation magnitude to the element's visual size and the interaction's importance.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Smooth Scroll for Anchors
**ID:** `animations-smooth-scroll-anchors` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-smooth-scroll-anchors](https://ui-guides-agent-rules.netlify.app/principles/animations-smooth-scroll-anchors)
**Agent rule (SHOULD):** Use `scroll-behavior: smooth` for in-page anchors, with `scroll-margin-top` on targets so headings clear any sticky header.
**Source:** [Rauno](https://interfaces.rauno.me/)
Use scroll-behavior: smooth for navigating to in-page anchors with appropriate offset
> Use scroll-behavior: smooth for navigating to in-page anchors, with an appropriate offset.
Instant jumps to anchor links are disorienting — users lose context of where they are on the page. Smooth scrolling maintains spatial awareness and creates a more polished navigation experience.
```tsx
html { scroll-behavior: smooth; }
:target { scroll-margin-top: 5rem; }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN scroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior)
### Match Motion to Frequency
**ID:** `animations-emil-frequency` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-frequency](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-frequency)
**Agent rule (SHOULD):** Match motion to how often an action happens. Keyboard-initiated and 100+/day actions get no animation; reserve delight for rare or first-time moments.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Match motion to how often an action is seen — frequent and keyboard-initiated actions get no animation
> Match motion to how often it's seen. Keyboard-initiated and 100+/day actions get no animation.
Animation that delights during a first-run onboarding becomes friction on an action performed hundreds of times a day. Give high-frequency and keyboard-driven actions instant feedback; reserve motion for rare or first-time moments.
- [Emil Kowalski — review-animations](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
### Asymmetric Enter and Exit
**ID:** `animations-emil-asymmetric` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-asymmetric](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-asymmetric)
**Agent rule (SHOULD):** Use asymmetric timing: deliberate user actions animate slower, system responses and exits snap. Symmetric enter/exit timing makes dismissals feel sluggish.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Deliberate actions animate slower; system responses and exits snap
> Deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap.
Symmetric timing makes dismissals feel sluggish. Let user-initiated, deliberate motion take its time, and let the system snap back quickly so the interface always feels responsive.
- [Emil Kowalski — review-animations](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
### Stagger Group Entrances
**ID:** `animations-emil-stagger` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-stagger](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-stagger)
**Agent rule (SHOULD):** Stagger entrance animations for groups of items by 30–80ms. A whole list animating at once reads as a single flash.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Entrance animations for a group of items should stagger 30–80ms, not fire all at once
> Entrance animations for groups without 30–80ms stagger is a finding.
When a whole list appears at once, the motion reads as a single flash. A small 30–80ms stagger guides the eye down the list and makes the group feel orchestrated rather than abrupt.
- [Emil Kowalski — review-animations](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
### Never ease-in on UI
**ID:** `animations-emil-no-ease-in` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-no-ease-in](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-no-ease-in)
**Agent rule (NEVER):** Reach for `ease-in` on UI motion: it barely moves during the first ~100ms the user is watching, so a 200ms `ease-in` reads slower than a 200ms `ease-out`. Default both entrances and exits to `ease-out`; `ease-in-out` for on-screen morphs, `ease` for hover/color, `linear` for constant motion. (This is the strict Emil position; other sources here allow `ease-in` on exits — pick one stance per product and apply it consistently.)
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Default to ease-out for enters and exits — ease-in stalls the frames the user is actually watching
> Never `ease-in` on UI. It starts slow, delaying the exact moment the user is watching. `ease-out` at 200ms *feels* faster than `ease-in` at 200ms.
Perceived speed is decided in the first ~100ms of a transition. ease-in spends those frames barely moving, so a 200ms ease-in reads as slower than a 200ms ease-out even though the stopwatch disagrees. Emil's decision order: enter/exit → ease-out, on-screen morph → ease-in-out, hover/color → ease, constant motion → linear. Note that this is a real disagreement in the field — other sources in this corpus (see "Easing" and "Timing") permit ease-in specifically for exits, on the argument that a departing element may accelerate away; the value of holding both is seeing the tradeoff illustrated rather than asserted.
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — easing-function](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function)
### Use Strong Custom Easing Curves
**ID:** `animations-emil-strong-easing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-strong-easing](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-strong-easing)
**Agent rule (SHOULD):** Replace the weak built-in easing keywords with strong custom curves, defined once as tokens rather than hand-rolled per component.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Replace the built-in easing keywords with a strong cubic-bezier for any motion meant to feel deliberate
> Built-in CSS easings are too weak. Use strong custom curves: `--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);`
The keyword `ease-out` is cubic-bezier(0, 0, 0.58, 1) — a shallow curve that, over any real distance, reads as near-linear with a mushy stop. cubic-bezier(0.23, 1, 0.32, 1) reaches roughly 80% of the distance in the first third of the duration and then decelerates, which is what "responsive but composed" looks like. Define the curves once as tokens (ease-out for UI, ease-in-out for on-screen movement, the iOS-like 0.32/0.72/0/1 for drawers) rather than hand-rolling a bezier per component. DIRECT CONFLICT, deliberately kept: ibelick's baseline-ui states the opposite — "NEVER introduce custom easing curves unless explicitly requested". Both are right for their reader, and the axis is DEFAULTS vs MASTERY. ibelick is writing for an agent, where an unprompted cubic-bezier is a guess: a model that invents curves per component produces exactly the incoherent motion that animations-emil-motion-cohesion warns about, so the conservative default — reach for the keyword, do not improvise — is correct for the party that was not asked. Emil is writing for a human tuning motion on purpose, where the keyword is the ceiling you eventually hit: `ease-out` really is cubic-bezier(0, 0, 0.58, 1), and no amount of duration tuning will make it feel composed. So: an agent should not invent a curve unprompted; a designer who has decided the motion matters should reach for a real one — and then ship it as a token, which is what makes it a request rather than an improvisation.
```tsx
: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);
}
```
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [easings.co — curve reference](https://easings.co/)
- [MDN — easing-function](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function)
### Keep UI Animations Under 300ms
**ID:** `animations-emil-duration-budget` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-duration-budget](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-duration-budget)
**Agent rule (MUST):** Keep UI animations under 300ms, budgeted per element: press feedback 100–160ms, tooltip/small popover 125–200ms, dropdown/select 150–250ms, modal/drawer 200–500ms (the only type allowed past the 300ms ceiling). Marketing and explanatory motion is exempt — it is content, not interface.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Budget duration per element type and keep every UI animation under the 300ms ceiling
> Rule: UI animations stay under 300ms. Button press feedback 100–160ms. Tooltips, small popovers 125–200ms. Dropdowns, selects 150–250ms. Modals, drawers 200–500ms. A 180ms dropdown feels more responsive than a 400ms one.
Duration is the single easiest thing to get wrong, because a slow animation looks great in isolation and feels terrible on the hundredth use. Hold a per-element budget: press 100–160ms, tooltip 125–200ms, dropdown 150–250ms, modal or drawer 200–500ms (the one type allowed past the 300ms ceiling, because it moves a large surface). Marketing and explanatory motion is exempt — it is content, not interface.
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [animations.dev](https://animations.dev/)
### Transitions Over Keyframes for Retriggered Motion
**ID:** `animations-emil-transitions-over-keyframes` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-transitions-over-keyframes](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-transitions-over-keyframes)
**Agent rule (SHOULD):** Drive rapidly-retriggered motion (toasts, toggles) with CSS `transition` or a spring, not `@keyframes` — a transition retargets from the element's current value, while a keyframe animation restarts from its `from` and visibly teleports.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Drive rapidly-retriggered motion with CSS transitions, not keyframes, so it retargets instead of restarting
> CSS transitions can be interrupted and retargeted mid-animation; keyframes restart from zero. For anything triggered rapidly (toasts being added, toggles), transitions are smoother.
A transition interpolates from the element's current computed value, so interrupting it mid-flight simply re-aims at the new target from wherever the element is. A keyframe animation is absolute: it always plays its declared `from` → `to`, so retriggering it teleports the element back to the start before it moves again. Spam-click a toggle and the difference is unmistakable. Springs share the transition property here — they carry velocity through an interruption — which is why they suit reversible gestures.
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — Using CSS transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Transitions/Using)
- [MDN — Using CSS animations](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Animations/Using)
### Use @starting-style for Entry Animations
**ID:** `animations-emil-starting-style` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-starting-style](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-starting-style)
**Agent rule (SHOULD):** Animate first-render entrances with `@starting-style` instead of a `useEffect` mounted-flag plus double `requestAnimationFrame` — it degrades to an instant appearance where unsupported.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Animate elements in on first render with @starting-style instead of a JS mounted-flag hack
> Use `@starting-style` for entry without JS. Legacy fallback: `useEffect(() => setMounted(true), [])` + `data-mounted` attribute.
A CSS transition needs a previous value to interpolate from, and a freshly-inserted element has none — so declaring a transition alone makes the element pop in. `@starting-style` supplies the "before it existed" style, letting the browser animate the very first frame. That removes the classic React workaround (mount flag + double requestAnimationFrame), which costs an extra render and produces intermittent "sometimes it does not animate" bugs when the browser paints before the flag flips. Where unsupported it degrades to an instant appearance.
```tsx
.popover { opacity: 1; transition: opacity 200ms var(--ease-out); }
@starting-style { .popover { opacity: 0; } }
```
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — @starting-style](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@starting-style)
### Mask Imperfect Crossfades with Blur
**ID:** `animations-emil-blur-crossfade` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-blur-crossfade](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-blur-crossfade)
**Agent rule (SHOULD):** When a crossfade still reads as a ghosted double exposure, add a small transition-scoped `filter: blur(2px)` (2–4px, single element, only for the duration of the transition, always < 20px) so the two states fuse into one morph. This is the narrow carve-out from "never animate blur", which targets large, long-lived, or continuously-animating blurs — stay especially conservative in Safari.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Add a small blur to a crossfade so two overlapping states read as one transformation, not a double image
> When a crossfade shows two overlapping states despite tuning easing/duration, add subtle `filter: blur(2px)` during the transition to blend them into one perceived transformation. Keep blur < 20px (heavy blur is expensive, especially Safari).
In a straight opacity crossfade both layers sit near 50% at the midpoint and stay separately legible, so the user perceives a ghosted double exposure rather than a change. A 2px blur on the outgoing layer destroys its legibility exactly where the overlap happens, and the eye fuses the two into a single morph. Note the tension with the blanket "never animate blur" performance rule: that rule targets large, long-lived, or continuously-animating blurs, which force expensive re-rasterization. A small (2–4px), short, transition-scoped blur on a single element is the deliberate carve-out — stay well under 20px, and be especially conservative in Safari.
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — filter](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/filter)
### Keep Spring Bounce Subtle
**ID:** `animations-emil-subtle-bounce` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-subtle-bounce](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-subtle-bounce)
**Agent rule (SHOULD):** Keep spring bounce within 0.1–0.3 (`{ type: "spring", duration: 0.5, bounce: 0.2 }`) and reserve any bounce for drag-to-dismiss and playful interactions — everyday menus, dropdowns and modals are usually better with none.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Keep spring bounce between 0.1 and 0.3, and reserve real bounce for drag-to-dismiss and playful moments
> Keep bounce subtle (0.1–0.3); avoid bounce in most UI — reserve for drag-to-dismiss and playful interactions.
Bounce is a claim that the element has mass and momentum. That claim is true for something you flung with your finger, and false for a settings popover that simply appeared. Above roughly 0.3 the overshoot becomes a visible wobble that reads as cartoonish on utility UI and, on a control opened dozens of times a day, as broken. In Apple-style spring terms: `{ type: "spring", duration: 0.5, bounce: 0.2 }`. Everyday menus, dropdowns and modals are usually better with no bounce at all.
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [animations.dev](https://animations.dev/)
### Motion Cohesion and Personality
**ID:** `animations-emil-motion-cohesion` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-motion-cohesion](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-motion-cohesion)
**Agent rule (SHOULD):** Define duration and easing once as motion tokens and spend them everywhere, matching the curve to the product's personality (crisp and bounce-free for a dashboard, slower and bouncier for a playful app) rather than letting each component be animated to whoever built it's taste.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Match motion to the component's personality and to the rest of the product — one shared motion language, not per-component taste
> Match motion to the component's personality: playful can be bouncier; a professional dashboard should be crisp and fast. Sonner feels right partly because easing, duration, design, and even the name are in harmony.
There is no universally correct curve — there is a curve that is correct for this product. A monitoring dashboard wants short, ease-out, bounce-free motion that gets out of the way; a playful consumer app can legitimately be slower and bouncier. What is never right is a surface where each element was animated by whoever happened to build it: one row bouncing, one crawling linearly, one snapping. Each may be defensible alone, but together they make the product feel assembled rather than designed. Define motion tokens (duration + easing) once and spend them everywhere.
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [emilkowalski.com](https://emilkowalski.com/)
### Set transform-box on Animated SVG
**ID:** `animations-svg-transform-box` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-svg-transform-box](https://ui-guides-agent-rules.netlify.app/principles/animations-svg-transform-box)
**Agent rule (MUST):** Transform animated SVG shapes on a `<g>` wrapper with `transform-box: fill-box` and `transform-origin: center` — the CSS default `view-box` resolves the origin against the `viewBox`, so an off-centre shape orbits the canvas instead of spinning in place.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Wrap animated SVG shapes in a <g> and set transform-box: fill-box with transform-origin: center
> SVG animation: apply transforms to a <g> wrapper with transform-box: fill-box and transform-origin: center; otherwise the origin resolves against the SVG viewBox.
CSS transforms on SVG elements default to `transform-box: view-box`, so `transform-origin: center` resolves to the centre of the SVG viewport — not the centre of the shape. Rotate or scale an icon that sits anywhere off-centre in its viewBox and it orbits the canvas instead of spinning in place. Setting `transform-box: fill-box` re-points the origin at the element's own bounding box, which is almost always what you meant. Put it on a <g> wrapper so the whole shape shares one origin and the transform does not have to be repeated per path.
```tsx
g.spinner { transform-box: fill-box; transform-origin: center; animation: spin 1s linear infinite; }
```
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN — transform-box](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform-box)
- [MDN — <g>](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/g)
### No Bounce or Elastic Easing
**ID:** `animations-impeccable-no-bounce-easing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-impeccable-no-bounce-easing](https://ui-guides-agent-rules.netlify.app/principles/animations-impeccable-no-bounce-easing)
**Agent rule (NEVER):** Let a UI element overshoot its final position: no `animate-bounce`, no animation named `bounce|elastic|wobble|jiggle|spring`, and no `cubic-bezier()` whose y1 or y2 falls outside `[-0.1, 1.1]`. Ease out with exponential curves (ease-out-quart / quint / expo).
**Source:** [impeccable](https://impeccable.style/)
Ease out with exponential curves and never let a UI element overshoot its final position
> Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic.
The detector flags any animation-name matching bounce|elastic|wobble|jiggle|spring, Tailwind's animate-bounce, and — the precise part — any cubic-bezier() whose y1 or y2 falls outside the range [-0.1, 1.1], because a control point past the 0–1 band is exactly what overshoot is. Real objects decelerate into a stop; they do not sail past their destination and spring back. Overshoot on a modal or a toast reads as dated and, on anything a user opens dozens of times a day, as broken. Note this is not a duplicate of "Easing Fits Subject", which only asks that a curve match its subject and never bans overshoot — this principle draws the hard line.
- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [MDN — easing-function](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function)
### Never scale(0)
**ID:** `animations-emil-no-scale-zero` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-emil-no-scale-zero](https://ui-guides-agent-rules.netlify.app/principles/animations-emil-no-scale-zero)
**Agent rule (NEVER):** Enter from `scale(0)` — nothing in the real world appears from a point. Start from `scale(0.9–0.97)` (0.96 is a safe popover/menu default) plus `opacity: 0`. Equally never enter on opacity alone: without a transform the element materializes instead of arriving.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Enter from scale(0.9–0.97) with opacity, never from scale(0) and never from opacity alone
> Never `scale(0)`. Start from `scale(0.9–0.97)` + `opacity: 0`. Nothing in the real world appears from nothing.
scale(0) claims the element had no size a moment ago — it grows out of a mathematical point, which no physical object does. Start somewhere between 0.9 and 0.97 (0.96 is a safe default for popovers and menus) and let opacity carry the rest of the appearance. The opposite failure is just as common: a pure opacity fade with no transform at all, which gives the element no body and no origin, so it materializes rather than arrives. This is about HOW SMALL the motion starts; animations-correct-transform-origin is about WHERE it starts — a popover can have a perfect trigger-anchored origin and still be wrong because it scales up from 0.
```tsx
@keyframes enter { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }
```
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — scale](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/scale)
- [MDN — transform-origin](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform-origin)
### clip-path: inset() as an Animation Primitive
**ID:** `animations-clip-path-reveal` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-clip-path-reveal](https://ui-guides-agent-rules.netlify.app/principles/animations-clip-path-reveal)
**Agent rule (SHOULD):** Reach for `clip-path: inset(t r b l)` for reveals, wipes, hold-to-delete overlays, seamless tab-color swaps, and comparison sliders — each value eats in from that side, the element keeps its box, so nothing below it moves and no layout is recalculated (unlike animating `height`).
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Reach for clip-path: inset() to reveal, wipe, and mask without touching layout
> `clip-path: inset(t r b l)` is a powerful animation tool: each value eats in from that side. Uses: reveal-on-scroll (`inset(0 0 100% 0)` → `inset(0 0 0 0)`), hold-to-delete overlay, seamless tab color transitions (duplicate + clip the active copy), comparison sliders.
Read inset(top right bottom left) as "how far the clip eats in from each side": inset(0 0 100% 0) removes the element entirely from the bottom up, and animating to inset(0 0 0 0) wipes it back open. The element keeps its box the whole time, so unlike a height animation nothing below it moves and no layout is recalculated. The same primitive gives you a hold-to-delete overlay (fill left-to-right while the button is held), a seamless tab color change (duplicate the label and clip the active copy, so there is no crossfade), and a comparison slider. A hold-to-delete overlay is also the canonical place to spend asymmetric timing — clip-path 2s linear on press, 200ms ease-out on release; see animations-emil-asymmetric.
```tsx
.reveal { clip-path: inset(0 0 100% 0); transition: clip-path 400ms ease-out; }
.reveal.is-visible { clip-path: inset(0 0 0 0); }
```
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — clip-path](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/clip-path)
- [MDN — basic-shape (inset)](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/basic-shape)
### Translate by Percentage, Not Pixels
**ID:** `animations-percentage-translate` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-percentage-translate](https://ui-guides-agent-rules.netlify.app/principles/animations-percentage-translate)
**Agent rule (SHOULD):** Park off-screen elements with translate percentages, not hardcoded px: `translateY(100%)` resolves against the element's own height, so it stays correct when a toast wraps or a drawer gains a row. Add edge gaps with `calc(100% + 12px)` rather than a magic number.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Offset off-screen elements with translate percentages so the value stays correct at any size
> `translate` percentages are relative to the element's own size — `translateY(100%)` moves by the element's height regardless of dimensions (how Sonner/Vaul position toasts/drawers). Prefer over hardcoded px.
A translate percentage resolves against the element itself, not its parent: translateY(100%) always moves it by exactly its own height, and translateX(-100%) by its own width. That is why a hardcoded translateY(300px) is a latent bug — it was measured against the content that existed the day it was written, and the moment a toast wraps to a second line or a drawer gains a row, the offset is wrong. Too small and the "hidden" element peeks into view; too large and it flies in from further away than it should, stretching the perceived duration. Percentages are self-correcting, which is exactly how Sonner parks toasts and Vaul parks drawers. Add the gap to the edge with calc(100% + 12px) rather than baking it into a magic number.
```tsx
.toast { transform: translateY(calc(100% + 12px)); } /* not translateY(300px) */
.toast[data-open] { transform: translateY(0); }
```
- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — translateY()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/transform-function/translateY)
- [Sonner](https://github.com/emilkowalski/sonner)
- [Vaul](https://github.com/emilkowalski/vaul)
### Input-driven
**ID:** `animations-input-driven` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-input-driven](https://ui-guides-agent-rules.netlify.app/principles/animations-input-driven)
**Agent rule (NEVER):** Autoplay motion just because a component mounted — the only thing that happened is that the user arrived. Animate in response to an input: hover, press, drag, scroll-into-view, or a request completing. Autoplay loops also keep the compositor awake and become a WCAG 2.2.2 problem past 5s.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Avoid autoplay — animate in response to a user action, not on arrival
> Input-driven. Avoid autoplay; animate in response to actions.
An animation that plays because the component mounted is asserting that something happened, when the only thing that happened is that the user arrived. Motion should be the answer to an input — a hover, a press, a drag, a scroll bringing the element into view, a request completing. Autoplay loops are the worst offender: they never stop competing for attention, they keep the compositor awake and drain battery, and past 5 seconds they also become a WCAG 2.2.2 problem. This is not the same rule as animations-interruptible (which is about a running animation yielding to input) or animations-pause-stop-hide (which accepts autoplay and demands controls for it). This one asks the prior question: should it have started by itself at all?
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [NN/g — Animation for Attention and Comprehension](https://www.nngroup.com/articles/animation-usability/)
- [MDN — Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
### Three Motion Layers
**ID:** `animations-lottie-motion-layers` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-motion-layers](https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-motion-layers)
**Agent rule (SHOULD):** Build a hero moment in three layers — primary (the action the eye follows), secondary (shadow trailing ~50ms, contents arriving ~100ms after the card lands), ambient (background drift) — instead of pushing one flat sprite. SCOPE: three layers is three times the motion, so spend it only on rare first-run/hero surfaces (empty-state illustration, onboarding card, marketing hero). On anything repeated — a row rendered 400 times, a menu opened fifty times a day — `animations-necessity-check` and `animations-emil-frequency` win and the correct layer count is one, or zero.
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
Build a hero moment in three layers — primary, secondary, ambient — instead of moving one flat sprite
> Three motion layers (flat animation = missing layers): Primary: Main action the viewer follows. Secondary: Supporting richness (shadows, icons shifting). Ambient: Background life (gradients, subtle pulses).
A card that only translates and fades is one layer: the shadow rides along at a fixed depth, the contents are fully legible before the card has landed, and nothing lives behind it, so the eye reads a single flat sprite being pushed into place. Adding the missing layers is cheap — the shadow becomes its own opacity-animated element that trails the card by ~50ms so it settles onto the surface, the row content arrives ~100ms after the card lands, and a few pixels of background drift keep the scene from being dead. HONEST TENSION: this rule pulls directly against animations-necessity-check and animations-emil-frequency. Three layers is three times the motion, and motion you have to justify. Spend it on the rare first-run or hero moment — an empty-state illustration, an onboarding card, a marketing surface — and never on a table row that renders 400 times or a menu opened fifty times a day, where the correct number of layers is one, or zero. This entry is kept alongside the necessity rule the same way animations-emil-no-ease-in is kept alongside animations-easing: the disagreement is the point, and you should read both before choosing.
- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [LottieFiles — quality checklist](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/reference/quality-checklist.md)
- [MDN — box-shadow](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/box-shadow)
### Cap Simultaneous Motion
**ID:** `animations-lottie-concurrency-cap` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-concurrency-cap](https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-concurrency-cap)
**Agent rule (SHOULD):** With 3+ elements, keep at most ~1/3 in active motion at any instant so the eye keeps an anchor: land a hero element first, then bring the rest in waves. This is NOT stagger — stagger sets when items START, this caps how many are MOVING. A 30ms stagger over 9 cards with a 220ms duration still has all 9 in flight; you need both rules.
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
Keep no more than a third of a group in active motion at any instant so the eye keeps an anchor
> 1/3 Rule (elements): With 3+ elements, no more than 1/3 in active motion simultaneously.
Nine cards that all scale, fade and slide on mount give the eye nothing to hold on to: the grid is perceived as a burst of noise that resolves, not as a reveal you can follow. Land a hero element first so there is an anchor, then bring the rest in waves — with a 220ms per-card duration and waves at 0/200/340/480ms, at most 3 of 9 are ever in flight and the cascade still finishes inside the 500ms stagger budget. DISTINCT FROM animations-emil-stagger, which prescribes a per-item DELAY (30–80ms) and says nothing about how many items overlap: a 30ms stagger across 9 cards with a 220ms duration still has all 9 moving at the same time, because item 9 starts at 240ms while item 1 is still animating. Stagger controls when things start; the 1/3 rule controls how many are moving at once. You need both, and satisfying one does not satisfy the other.
- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [LottieFiles — quality checklist](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/reference/quality-checklist.md)
- [MDN — transition-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transition-delay)
### Cap the Total Stagger
**ID:** `animations-lottie-stagger-budget` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-stagger-budget](https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-stagger-budget)
**Agent rule (MUST):** Treat a cascade as a TOTAL budget, not a per-item constant: keep total stagger under 500ms (micro 20–40ms/item under 200ms; standard 50–100ms under 400ms; dramatic 100–200ms under 600ms for theatre only). Take the per-item value from `animations-emil-stagger` (30–80ms), take the CEILING from here — and on long lists the ceiling wins, because `i * 60ms` on 30 rows delays the last row 1740ms and pops content in below the scroll position. Clamp, or stagger only the rows in view.
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
Clamp a cascade to a total budget under 500ms instead of multiplying a per-item delay by the list length
> Critical: Total stagger must stay under 500ms.
A stagger is a budget, not a per-item constant. The budgets: micro cascade 20–40ms per item, total under 200ms; standard 50–100ms, under 400ms; dramatic 100–200ms, under 600ms — and the hard ceiling is 500ms of total stagger for anything that is interface rather than theatre. Write animationDelay: i * 60ms on a 30-row list and the last row does not begin moving until 1740ms after the first: the user has already scrolled past it, so the animation is still introducing content they have finished reading, and rows visibly pop in beneath the scroll position. Fix it by clamping — delay = Math.min(i * step, MAX_TOTAL) — or by only staggering the rows currently in view. COLLISION: animations-emil-stagger prescribes 30–80ms per item and states no cap, which ACTIVELY CAUSES this bug the moment the list is long. At 60ms per item the rule is safe up to about 8 rows and broken by 30. Take the per-item value from Emil, take the ceiling from here, and let the ceiling win.
```tsx
const MAX_TOTAL = 400;
const delay = Math.min(i * 60, MAX_TOTAL);
```
- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [LottieFiles — quality checklist](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/reference/quality-checklist.md)
- [MDN — animation-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-delay)
### Scale Duration With Travel Distance
**ID:** `animations-lottie-distance-duration` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-distance-duration](https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-distance-duration)
**Agent rule (SHOULD):** Derive duration from travel distance, sublinearly — 100px = base, 200px = 1.3x, 400px = 1.6x — then clamp to a ~140ms floor and ~400ms ceiling. One `--duration-md` cannot serve an 8px tooltip nudge and a full-height sheet. This is a third axis, not a duplicate: `animations-proportional-values` scales transform AMPLITUDE, `animations-emil-duration-budget` assigns duration by element TYPE, and distance is what reconciles them (it is why drawers are the one type allowed past 300ms — a drawer is not special, it is far).
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
Derive duration from how far the element actually travels, sublinearly — one token cannot serve 8px and 200px
> Distance scales duration: 100px = base. 200px = 1.3x. 400px = 1.6x.
Spend a single --duration-md: 200ms on both an 8px tooltip nudge and a full-height bottom sheet and the same number is wrong in both directions at once: the tooltip crawls the last few pixels of a journey the eye finished instantly, and the sheet covers 200px faster than the eye can track, so it reads as a teleport. Derive it instead: factor = 1 + 0.3 * log2(px / 100), giving 100px = 1×, 200px = 1.3×, 400px = 1.6× — sublinear, because doubling the distance must not double the duration or long journeys feel like wading — then clamp to a floor (~140ms) and a ceiling (~400ms). On a 250ms base that puts the sheet at ~325ms and the tooltip at 140ms. THIS IS A THIRD AXIS, not a duplicate: animations-proportional-values (Rauno) scales the AMPLITUDE of a transform to the trigger SIZE — how far to move — and animations-emil-duration-budget assigns duration by element TYPE (press 100–160ms, tooltip 125–200ms, dropdown 150–250ms, drawer 200–500ms). Distance is what reconciles them, and it is precisely why that budget lists drawers as the one type allowed past the 300ms ceiling: a drawer is not special, it is simply far.
```tsx
const factor = 1 + 0.3 * Math.log2(px / 100); // 100px=1x, 200px=1.3x, 400px=1.6x
const duration = Math.min(400, Math.max(140, 250 * factor));
```
- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [NN/g — Executing UX Animations: Duration and Motion](https://www.nngroup.com/articles/animation-duration/)
- [MDN — Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties)
### Don't Signal State With Opacity Alone
**ID:** `animations-lottie-never-opacity-only` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-never-opacity-only](https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-never-opacity-only)
**Agent rule (NEVER):** Signal an important state change with opacity alone — peripheral vision reads movement, not luminance, so a "Saved" pill that fades in place off-axis is frequently never seen. Pair the fade with position or scale (a 6px rise is enough). Motion is the REINFORCEMENT channel only: the state still needs `role="status"` / `aria-live="polite"`, and since the transform is dropped under `prefers-reduced-motion`, the opacity change must remain sufficient on its own.
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
Pair any important state change with position or scale — a pure fade is nearly invisible off-axis
> Never opacity-only for important state changes — combine with position or scale.
Peripheral vision is poor at absolute luminance and very good at movement. A "Saved" confirmation that fades 0 → 1 in place, in a corner the user is not looking at because they are still looking at the button they just pressed, frequently never registers at all — so they press Save again. Add a 6px rise and the same pill is caught off-axis without a glance. ACCESSIBILITY: motion is the REINFORCEMENT channel here, never the accessible one. The confirmation still needs to be a real live region (role="status" / aria-live="polite"), because a screen-reader user gets nothing from either the fade or the rise, and WCAG 1.4.1 has the same shape — no important state may depend on a single perceptual channel. And under prefers-reduced-motion the transform is dropped, which means the opacity change alone must remain sufficient on its own: the rise makes a good signal better, it is not allowed to be the only thing carrying the state.
- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [MDN — ARIA live regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions)
- [WCAG 2.2 — Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html)
### Disney Principles Are For Character Motion, Not For Chrome
**ID:** `animations-lottie-disney-scope` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-disney-scope](https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-disney-scope)
**Agent rule (SHOULD):** Ask "character or control" before reaching for Disney motion. Anticipation, squash-and-stretch, follow-through and overshoot belong to CHARACTERS — a mascot, an illustration, an empty-state figure, a confetti burst, a once-per-session celebration — where implied mass and personality are the point. They do NOT belong to CHROME: a dropdown, toast, menu or button is a control the user operates, and overshoot there reads as dated (that is `animations-impeccable-no-bounce-easing`, and it is not in conflict — it describes a different object). LottieFiles scopes it the same way: skip anticipation for micro-feedback (<150ms), skip squash-and-stretch for premium/luxury, and its exaggeration budget is 15-25% Playful but 0-5% Corporate and 0% Premium. Controls: ease-out, no overshoot.
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
Anticipation, squash-and-stretch and overshoot belong to a mascot, an illustration or a celebration — not to a dropdown, a toast or a menu
> Anticipation: Small motion opposite to main direction before action. Duration: 100-200ms, magnitude: 10-20% of main action. Skip for micro-feedback (<150ms). Squash and Stretch: Skip for premium/luxury brands. Exaggeration: Playful 15-25% | Energetic 20-30% | Corporate 0-5% | Premium 0%.
This is a REAL and deliberately preserved tension in the corpus, and the resolution is scope, not a winner. animations-impeccable-no-bounce-easing bans overshoot outright — any cubic-bezier whose control points leave the [-0.1, 1.1] band — and it is right about the surface it is talking about: a dropdown that wobbles, a toast that springs past its mark, a menu that squashes on open reads as dated on first sight and as broken by the fiftieth, because those elements are chrome the user operates, not characters the user watches. LottieFiles is not contradicting that; it is talking about a different object. Disney's vocabulary was built for CHARACTERS — things with implied mass, personality and intent — and it survives on the web wherever that is still true: a mascot, an illustration, an empty-state figure, a confetti burst, a success celebration, an onboarding moment seen once. And LottieFiles scopes it itself, which is the tell: squash-and-stretch says "Skip for premium/luxury brands", anticipation says "Skip for micro-feedback (<150ms)" — which is nearly every button and hover in a product — and its own exaggeration budget drops to 0-5% for Corporate and 0% for Premium, i.e. to exactly the flat, no-overshoot motion the impeccable rule demands. So the operative question is never "is bounce allowed" but "is this thing a character or a control". Controls: ease-out, no overshoot, done. Characters, once per session: anticipate, overshoot, follow through — that is where the charm lives, and refusing it there buys you nothing. animations-emil-subtle-bounce marks the one genuine middle ground (bounce 0.1–0.3, reserved for drag-to-dismiss, where the user's own thrown gesture has already established momentum, so the mass is real rather than asserted).
- [LottieFiles — Disney principles, UI adapted](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/director/disney-principles.md)
- [LottieFiles — motion personality archetypes](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/director/motion-personality.md)
- [MDN — easing-function](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function)
### Name Every Timing Value
**ID:** `animations-named-timing-constants` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-named-timing-constants](https://ui-guides-agent-rules.netlify.app/principles/animations-named-timing-constants)
**Agent rule (SHOULD):** Hoist every delay, duration and easing into one named block and drive a multi-stage sequence with a single integer stage, not scattered magic numbers and boolean flags (`isCardVisible` / `isHeadingVisible` / `areRowsVisible`) — otherwise retuning the tempo means hunting five call sites and the heading silently starts arriving after the rows it introduces.
**Source:** Custom
Hoist every delay, duration and easing into one named block and drive the sequence with a single integer
> Every value that affects timing or appearance should be a named constant, trivially adjustable. A single integer state drives the entire sequence; no scattered boolean flags.
From Josh Puckett's Interface Craft. A multi-stage reveal wired as delay: 0.3 here, delay: 0.9 there, stagger: 0.2 inline in the JSX, sequenced by isCardVisible / isHeadingVisible / areRowsVisible, has no readable choreography: the order exists only in the author's head, and retuning it means hunting magic numbers across five call sites. The failure is concrete — change the tempo, miss one of those sites, and the heading now arrives after the rows it was supposed to introduce, a bug that survives code review precisely because no number is named. The fix is two moves: hoist the values into one block (const TIMING = { cardAppear: 300, heading: 900, rows: 1500 }) and collapse the flags into one integer stage, so the JSX reads stage >= 2 ? ... : ... and the sequence is legible top to bottom. ADJACENT TO BUT DISTINCT FROM animations-emil-motion-cohesion: cohesion is about one shared motion vocabulary ACROSS the product, so that a dropdown here and a toast there feel like the same hand made them. This is about one sequence, inside one component, being readable and tunable at all. You can be perfectly cohesive and still have an unmaintainable timeline; and a single well-named TIMING block does not, by itself, make the product coherent.
```tsx
const TIMING = { cardAppear: 300, heading: 900, rows: 1500 } as const;
// then: stage >= 2 ? ... : ...
```
- [Interface Craft — Josh Puckett](https://interfacecraft.dev/)
- [joshpuckett.me](https://joshpuckett.me/)
- [MDN — Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties)
### Never Drive Animation From Scroll Events
**ID:** `animations-no-scroll-event-animation` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-no-scroll-event-animation](https://ui-guides-agent-rules.netlify.app/principles/animations-no-scroll-event-animation)
**Agent rule (NEVER):** Drive animation from `scroll` events, `scrollY`, or `scrollTop`. Scroll is composited off the main thread, so a scroll listener is a notification that scrolling ALREADY happened — the animation is permanently one frame late and freezes outright during any long task while the page keeps scrolling under it. Throttling or rAF-wrapping the handler only makes the lag cheaper; move the timeline off the main thread instead (`animations-scroll-driven-css`).
**Source:** [@Ibelick](https://www.ui-skills.com/)
A scroll listener that writes styles always renders one frame late and couples the effect to main-thread health
> do not drive animation from scrollTop, scrollY, or scroll events
Scroll is composited off the main thread; a `scroll` listener is a notification that scrolling already happened. By the time the handler reads `scrollY` and writes a style, the browser has painted that scroll position — so the animation is permanently one frame behind, and it drifts further under load. Worse, the effect now inherits the health of the main thread: any long task (hydration, a JSON parse, a third-party script) freezes the animation while the page keeps scrolling underneath it. ibelick states the same ban twice, once as a never-pattern and once under scroll: "do not poll scroll position for animation". The fix is not to throttle or rAF-wrap the handler — that just makes the lag cheaper. It is to move the timeline itself off the main thread (see animations-scroll-driven-css).
- [ibelick — fixing-motion-performance SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-motion-performance/SKILL.md)
- [MDN — CSS scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations)
### Prefer Scroll and View Timelines
**ID:** `animations-scroll-driven-css` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-scroll-driven-css](https://ui-guides-agent-rules.netlify.app/principles/animations-scroll-driven-css)
**Agent rule (SHOULD):** Use a CSS Scroll or View Timeline (`animation-timeline: view()`/`scroll()`) for scroll-linked motion — the compositor advances it, so it stays locked to the scrollbar even while the main thread is busy. This is still input-driven motion (`animations-input-driven`): the scroll IS the input. IntersectionObserver is a one-shot trigger, not a timeline — it cannot scrub, ignores scroll speed, and snaps on scroll-up; keep it for visibility and pausing. Chromium + Firefox 144+ only (Safari flagged), so treat it as an enhancement and make the un-animated state readable.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Scroll-linked motion belongs on a CSS timeline, not on an observer that toggles a class once and cannot scrub
> prefer Scroll or View Timelines for scroll-linked motion when available
This is the constructive half of the ban on scroll listeners. `animation-timeline: view()` binds an animation's progress to an element's position in the scrollport instead of to time, and the compositor advances it — so it stays locked to the scrollbar even while the main thread is busy. The common alternative, IntersectionObserver plus a class toggle, is not the same thing: it is a one-shot trigger. It fires at a single threshold, it cannot scrub, it plays at its own duration regardless of how fast you scrolled, and on scroll-up it either does nothing or snaps back. ibelick keeps IntersectionObserver for what it is actually good at — "use IntersectionObserver for visibility and pausing". Support is Chromium and Firefox 144+; Safari is still behind a flag, so treat the animation as an enhancement and make the un-animated state the readable one.
```tsx
.reveal { animation: fade-up linear both; animation-timeline: view(); animation-range: entry 0% cover 40%; }
```
- [ibelick — fixing-motion-performance SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-motion-performance/SKILL.md)
- [MDN — animation-timeline](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline)
### Budget Animation by Surface Size
**ID:** `animations-surface-size-budget` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-surface-size-budget](https://ui-guides-agent-rules.netlify.app/principles/animations-surface-size-budget)
**Agent rule (MUST):** Budget by property TIMES area, not property alone: paint- or layout-triggering animation is acceptable only on small, isolated surfaces. Repainting a 32px icon is ~1,024px of raster work per frame; the same property on a full-bleed hero band is ~102,400px — roughly 100x — and it will miss frames. So the rule is not "never transition `filter`/`box-shadow`" — small and isolated, go ahead; large surface, move to `transform`/`opacity` on a promoted layer or do not animate it. Never blur a large surface. Duration is the second axis: one-shot effects are affordable far more often than continuous motion.
**Source:** [@Ibelick](https://www.ui-skills.com/)
The cost of a paint or layout animation scales with the pixels it touches, so surface area decides what is affordable
> paint or layout animation is acceptable only on small, isolated surfaces
This is the idea ibelick repeats more than any other, and it is the one that turns the property bans from dogma into engineering. The skill says it five ways: "do not animate layout continuously on large or meaningful surfaces", "do not animate paint-heavy properties on large containers", "paint-triggering animation is allowed only on small, isolated elements", "never animate blur on large surfaces", and the quote above. Read together they say something the flat bans do not: the property is not the whole cost — the property times the area is. Repainting a 32px icon is a few thousand pixels of raster work the GPU will not even notice; repainting a full-bleed hero is a million-plus pixels of raster work every frame, on the main thread, and it will miss frames. So the rule is not "never transition filter" — it is: on a small, isolated element, go ahead; on a large surface, move to transform and opacity on a promoted layer, or do not animate it at all. Duration is the second axis: "one-shot effects are acceptable more often than continuous motion".
- [ibelick — fixing-motion-performance SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-motion-performance/SKILL.md)
- [web.dev — Animations guide](https://web.dev/articles/animations-guide)
### Use FLIP for Layout-Like Motion
**ID:** `animations-flip-technique` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-flip-technique](https://ui-guides-agent-rules.netlify.app/principles/animations-flip-technique)
**Agent rule (SHOULD):** When a layout change genuinely must animate (list reorder, card expanding into a detail view), use FLIP — First, Last, Invert, Play: read the start rect, apply the final layout, read the end rect, apply the inverse `transform`, then release it in one rAF and let the compositor interpolate back to zero. Layout runs twice instead of 60x/sec. Measure ONCE and batch all DOM reads before writes — a `getBoundingClientRect()` inside the loop forces a synchronous layout every tick. (Does not contradict `layout-flex-over-measurement`: that bans JS measurement to BUILD a static layout; FLIP is the sanctioned way to ANIMATE a layout change.)
**Source:** [@Ibelick](https://www.ui-skills.com/)
When a layout change genuinely must animate, measure once and play it back as a transform instead of animating geometry
> prefer FLIP-style transitions for layout-like effects
Sometimes the thing that changes really is layout: a list reorders, a card expands into a detail view. FLIP — First, Last, Invert, Play — lets you animate that without ever animating a layout property. Read the start rect, apply the final layout in one go, read the end rect, apply the inverse transform so the element still *looks* like it is at the start, then release it in a single rAF and let the compositor interpolate the transform back to zero. The result is pixel-identical to animating `top`/`left`/`width`, but the browser does layout exactly twice instead of sixty times a second. The two supporting rules are the ones people break: "measure once, then animate via transform or opacity" and "batch all DOM reads before writes" — a `getBoundingClientRect()` inside the animation loop forces a synchronous layout on every tick, which is layout thrashing with extra steps. Note this does not contradict layout-flex-over-measurement: that rule says do not use JS measurement to *build* a static layout. FLIP is the sanctioned technique for the case where a layout change must be *animated*.
- [ibelick — fixing-motion-performance SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-motion-performance/SKILL.md)
- [Paul Lewis — FLIP Your Animations](https://aerotwist.com/blog/flip-your-animations/)
### Decompose 2D Motion Into X and Y Springs
**ID:** `animations-apple-xy-springs` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-apple-xy-springs](https://ui-guides-agent-rules.netlify.app/principles/animations-apple-xy-springs)
**Agent rule (MUST):** Decompose 2D motion into two independent springs — one on `x`, one on `y`, each seeded with its OWN release velocity. A single spring on the 2D distance (`Math.hypot(x, y)` → 0) can hold only one velocity, so both axes arrive together, the card slides home along a rigid radial line, and the tangential half of the throw is discarded.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
A gesture that moves in two dimensions needs two springs, not one spring on the distance
> Decompose 2D motion into independent X and Y springs. A single spring on a 2D distance desyncs when X and Y have different velocities.
This is the answer to a question the corpus has not asked before: not how to configure a spring, but HOW MANY springs a gesture needs. animations-spring-physics argues for springs over tweens because they carry velocity through an interruption; animations-emil-subtle-bounce bounds the bounce. Neither says anything about dimensionality — and a 2D drag is where the naive implementation quietly breaks. The tempting shortcut is to spring the scalar distance home: `Math.hypot(x, y)` → 0, one spring, one velocity. But one spring can only hold one velocity, so both axes are forced to share it and to arrive at the same instant. Throw a card diagonally with more speed on X than Y and the faster axis drags the slower one along with it; whatever direction you actually flicked, the card slides home along a rigid radial line, and the tangential half of your gesture is simply discarded. Two springs — one on x, one on y, each seeded with its OWN release velocity — let each axis settle on its own clock. The emergent 2D path curves: it continues the way you threw the thing and hooks back, which is what "it followed my finger" actually means. This is Apple's point from Designing Fluid Interfaces, and it also explains why Motion's `x`/`y` values are separate animatable channels rather than a single vector.
```tsx
animate(el, { x: 0 }, { type: "spring", velocity: vx });
animate(el, { y: 0 }, { type: "spring", velocity: vy }); // not one spring on hypot(x, y)
```
- [Emil Kowalski — apple-design SKILL.md](https://raw.githubusercontent.com/emilkowalski/skills/main/skills/apple-design/SKILL.md)
- [Apple — Designing Fluid Interfaces (WWDC 2018)](https://developer.apple.com/videos/play/wwdc2018/803/)
- [Motion — spring transitions](https://motion.dev/docs/react-transitions)
### Enter and Exit Along the Same Path
**ID:** `animations-symmetric-exit-path` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-symmetric-exit-path](https://ui-guides-agent-rules.netlify.app/principles/animations-symmetric-exit-path)
**Agent rule (MUST):** Exit along the path the element entered on — in-from-the-right means out-to-the-right — with the easing mirrored via inverse cubic-bezier control points: enter `(x1, y1, x2, y2)` → exit `(1 - x2, 1 - y2, 1 - x1, 1 - y1)`. This is DIRECTION and curve shape, orthogonal to `animations-emil-asymmetric` (DURATION — exits snap); a correct panel obeys both: back out the right edge, on the mirrored curve, faster than it came in.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
A panel that slides in from the right must dismiss to the right, with the easing mirrored
> Enter and exit along the same path. A panel that slides in from the right must dismiss to the right. In-from-right / out-the-bottom feels disconnected and confusing. Mirror the easing on reversible transitions so the outbound path matches the return path (use inverse cubic-bézier control points for the two directions).
Motion is how an interface teaches spatial layout: the enter animation is a claim about where the panel LIVES when it is not on screen. Slide it in from the right and the user now believes it is parked off the right edge — so dismissing it downward silently revokes that, and they are left with no model of where the thing went or how to get it back. The exit must retrace the enter. Mirroring the easing is the same argument one level down: a cubic-bezier is a shape, and reversing endpoints does not reverse the shape. Take the enter's (x1, y1, x2, y2) and use its inverse control points — (1 - x2, 1 - y2, 1 - x1, 1 - y1) — for the return, so an ease-out entrance leaves on the ease-in that is literally its mirror image. IMPORTANT — this does NOT contradict animations-emil-asymmetric, and the two are constantly confused. That rule is about DURATION (deliberate actions take their time; exits snap). This one is about DIRECTION and CURVE SHAPE. They are orthogonal, and the correct panel obeys both: it exits back out the right edge, on the mirrored curve, in less time than it took to come in. Nor is this animations-correct-transform-origin, which fixes where a scale ORIGINATES — a popover can have a perfectly anchored origin and still be wrong because it travels out an edge it never came in through.
```tsx
/* enter */ transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
/* exit */ transition-timing-function: cubic-bezier(0.7, 0, 0.84, 0);
```
- [Emil Kowalski — apple-design SKILL.md](https://raw.githubusercontent.com/emilkowalski/skills/main/skills/apple-design/SKILL.md)
- [Apple — Designing Fluid Interfaces (WWDC 2018)](https://developer.apple.com/videos/play/wwdc2018/803/)
- [MDN — cubic-bezier() easing function](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function/cubic-bezier)
### Reduce Ambient 3D Motion
**ID:** `animations-ambient-motion-reduced` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-ambient-motion-reduced](https://ui-guides-agent-rules.netlify.app/principles/animations-ambient-motion-reduced)
**Agent rule (SHOULD):** prefers-reduced-motion applies to 3D/WebGL, not just CSS — and the rAF loop must read it in JS (matchMedia). Under reduce: stop auto-rotation, disable pointer parallax/tilt, replace scroll-driven camera moves with a static framing, and drop idle ambient motion to zero. Keep the scene interactive on demand (drag-to-orbit is user-initiated and allowed).
**Source:** Custom
Auto-rotating models, parallax tilt, and scroll-driven cameras are motion too — pause or flatten them under prefers-reduced-motion
> prefers-reduced-motion applies to WebGL and 3D, not only to CSS. Auto-rotation, pointer parallax, and scroll-linked camera moves must be reduced like any other motion.
This is animations-prefers-reduced-motion followed onto the canvas, and it is written here because the 3D ecosystem almost universally forgets it — a survey of the major three.js / R3F skills turned up no reduced-motion rule at all. The reason it gets dropped is structural: the media query lives in CSS while the motion lives in a `requestAnimationFrame` loop, so the loop has to read the preference itself and nobody wires it up. Yet ambient 3D is the MOST provocative motion on the page for a vestibular user — a model idling on a slow auto-rotate, a hero that tilts toward the pointer, a camera that dollies as you scroll: large, continuous, viewport-filling, and self-initiated by the site rather than the user. Under `prefers-reduced-motion: reduce` (check it in JS with `matchMedia`, not only in CSS): stop auto-rotation, disable pointer parallax and tilt, replace scroll-driven camera moves with a static framing or an instant cut, and drop idle ambient motion to zero. Keep the scene interactive on demand — a user who drags to orbit is initiating that motion, which is allowed, the same carve-out WCAG 2.3.3 makes for motion from interaction. Pairs with animations-ibelick-pause-offscreen (don't even run the loop when it is not visible) and performance-webgl-gpu-budget (the cost side of the same loop).
```tsx
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) controls.autoRotate = true; // otherwise hold a static frame
```
- [MDN — prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
- [WCAG 2.3.3 — Animation from Interactions](https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions.html)
- [Poimandres — react-three-a11y](https://github.com/pmndrs/react-three-a11y)
### Animate Text With Transform, Not Metrics
**ID:** `animations-text-motion-uses-transform` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-text-motion-uses-transform](https://ui-guides-agent-rules.netlify.app/principles/animations-text-motion-uses-transform)
**Agent rule (SHOULD):** Emphasize or pulse text with transform/opacity, never by animating font-weight, font-size, letter-spacing, or font-variation-settings — those are layout properties that relayout the line every frame and shove neighbours around. A real variable-font weight animation must be a deliberate, isolated, reduced-motion-gated effect.
**Source:** [Web Platform](https://web.dev/)
Pulse or emphasize text with transform/opacity — animating font weight, size, or letter-spacing relayouts the line
> transform and opacity are the only properties that can be animated without triggering layout or paint. Text metrics — font-weight, font-size, letter-spacing, font-variation-settings — resize the glyphs, so every frame relayouts.
This is the general "only animate transform and opacity" rule at its least obvious edge. A "breathing" heading built by animating font-weight, or a word that emphasizes itself by growing its letter-spacing, changes the glyphs' advance widths on every frame — which reflows the whole line and shoves neighbouring content around, the exact jank the compositor was meant to avoid. Variable fonts make this especially tempting because `font-variation-settings: "wght"` animates smoothly, but it is still a layout property, not a composited one. Get the same effect with transform: an inline-block word scaling from 1 to 1.1, or a crossfade in opacity, runs entirely on the GPU and never moves its neighbours. If a genuine weight animation is the point, treat it as a deliberate, isolated, reduced-motion-gated effect — not a default.
```tsx
/* reflows */ @keyframes b{50%{letter-spacing:.2em}}
/* composited */ @keyframes g{50%{transform:scale(1.1)}}
```
- [web.dev — Animations and performance](https://web.dev/articles/animations-guide)
- [MDN — CSS performance: reflow](https://developer.mozilla.org/en-US/docs/Web/Performance/CSS_JavaScript_animation_performance)
### Split Text by Length, Not by Habit
**ID:** `animations-text-split-granularity` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-text-split-granularity](https://ui-guides-agent-rules.netlify.app/principles/animations-text-split-granularity)
**Agent rule (MUST):** Choose the split unit from the string length, not by reflex: per-character only under ~40 chars (22–46ms stagger), per-word beyond that (70–95ms), per-line for paragraphs (90–120ms). Total reveal = stagger × unit count, so the count is the variable that matters. Do not fix a long per-character cascade by clamping the delay — that collapses the tail into a flash. Preserve spaces: a gap that ends up inside an inline-block shard is collapsed away, so set white-space: pre-wrap on the wrapper or emit the space as a text node between tokens.
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
Pick per-character, per-word, or per-line from the string length — a per-letter cascade on a long headline is still arriving after the reader has finished it
> Works best on hero titles 48px+ against solid backgrounds. Avoid on very long strings (>40 chars) — total stagger becomes too long; in that case switch target to 'per-word'.
The default move — split on `''`, map, multiply the index by a delay — is correct for "Hello" and wrong for a sentence, because total reveal time is stagger × unit COUNT, and only the count changes. Pixel Point's catalog prices this by unit: per-character effects run a 22–46ms stagger, per-word 70–95ms, per-line 90–120ms. The stagger goes UP as the unit gets bigger precisely because there are fewer of them, so every family lands in roughly the same total. Run soft-blur-in's 25ms across a 62-character headline and the last glyph does not begin until 1550ms, then takes its own 900ms to finish: 2.4 seconds of a title assembling itself while the reader has already read it and scrolled. Split the same string per word — eleven units at 70ms — and the cascade completes in 770ms with its rhythm intact. IMPORTANT — this is NOT animations-lottie-stagger-budget in different words. That rule clamps the delay (`delay = Math.min(i * step, MAX)`), which is the right fix for a list of unknown length; applied to text it is the WRONG fix, because clamping collapses the tail of the cascade so the last fourteen letters all fire on the same frame — a flash glued to the end of a stagger. For text you change the unit, not the ceiling. Whichever unit you pick, spaces must survive the split, and neither of the obvious ways works by default: a space that becomes its own `inline-block` shard, or that trails inside a word token, is collapsed away and the headline silently loses its word gaps. Either set `white-space: pre-wrap` on the wrapper, or emit the gap as a text node BETWEEN the animated tokens.
```tsx
// bad — 45 chars x 60ms = last letter starts at 2.6s
text.split("").map((c, i) => <span style={{ animationDelay: `${i * 60}ms` }}>{c}</span>)
// good — 8 words x 70ms, cascade intact, done in 1.2s, gaps outside the shards
text.split(" ").map((w, i, all) => (
<Fragment key={i}>
<span className="inline-block" style={{ animationDelay: `${i * 70}ms` }}>{w}</span>
{i < all.length - 1 ? " " : null}
</Fragment>
))
```
- [animate-text — soft-blur-in spec (usage_notes)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/soft-blur-in.json)
- [animate-text — per-word / per-line staggers (catalog)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/references/catalog.md)
- [MDN — animation-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-delay)
### Overlap a Text Swap; Never Hard-Cut the Slot
**ID:** `animations-text-swap-overlap` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-text-swap-overlap](https://ui-guides-agent-rules.netlify.app/principles/animations-text-swap-overlap)
**Agent rule (MUST):** Rotating/replaced text must overlap its exit and enter (100–300ms, or at minimum a 28–85ms micro-delay) so no frame shows an empty slot, and both layers must share one grid cell (grid-area: 1/1) sized to the longest string so surrounding content never reflows. A looping swap also needs a pause control and a reduced-motion path.
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
A rotating word must start entering before the old one has finished leaving, in a slot sized so the line around it never moves
> Start old text exit at t=0ms. Start new text enter at t=exit_total_ms-overlap_ms. Keep both text layers mounted only during the overlap window. Verification: no hard-cut frame appears between old and new text.
The "Build ___ faster" rotating headline is usually a `setInterval` that swaps a string, and it fails twice on the same frame. First the cut: with no overlap there is a moment where the old word is gone and the new one has not arrived, and an empty slot reads as a bug, not a transition. The catalog overlaps 100–300ms on crossfade effects (soft-blur-in 300, mask-reveal-up 210, per-word-crossfade 170) and — this is the part that gets skipped — even the effects with `overlap_ms: 0` still insert a `micro_delay_ms` of 28–85ms rather than cutting at exactly t=0, because the beat is what makes a replacement read as choreography instead of a glitch. Second the reflow: mount both layers in normal flow during the overlap and the line doubles in height, then snaps back. Stack them in one grid cell instead — a `grid` container with both children on `grid-area: 1 / 1` — which also sizes the slot to the WIDEST string automatically, so the words on either side stop shuffling every four seconds. Reserving that width is the same argument as performance-no-image-cls, one text node down. And a swap that loops is looping text: content-moving-text-can-be-paused still applies, so it needs a reduced-motion path and a way to stop it.
```tsx
// bad — hard cut, slot resizes every tick
setInterval(() => setI(i => (i + 1) % words.length), 2000)
// good — stacked layers, enter starts before exit ends
<span style={{ display: "inline-grid" }}>
{words.map((w, i) => (
<span key={w} style={{ gridArea: "1 / 1" }} className={i === index ? "enter" : i === leaving ? "exit" : "invisible"}>{w}</span>
))}
</span>
```
- [animate-text — soft-blur-in swap.scenario_spec](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/soft-blur-in.json)
- [animate-text — SKILL.md](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/SKILL.md)
- [MDN — grid-area](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/grid-area)
### Reveal Blur Is Priced by Type Size
**ID:** `animations-text-reveal-scales-with-type` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-text-reveal-scales-with-type](https://ui-guides-agent-rules.netlify.app/principles/animations-text-reveal-scales-with-type)
**Agent rule (SHOULD):** Reprice a text reveal for the type size it lands on. Hero (48px+): blur up to 12px, 25ms stagger, per-character. Body (<24px): blur 6px, 15ms stagger, per-word. Never copy a hero preset onto body copy — an absolute blur radius that softens a 5px stem erases a 1.5px one.
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
A 12px reveal blur that reads as premium on a 48px hero erases 16px body text — halve the blur and the stagger with the type
> Works best on hero titles 48px+ against solid backgrounds. On body text (<24px), reduce blur_px to 6 and stagger_ms to 15.
Blur radius is in absolute pixels; the stroke width of a glyph is not. On a 48px headline the stems are roughly 5px wide, so a 12px blur displaces each edge by about two stem widths — the letter is soft but still a letter, which is exactly the Apple-keynote effect the spec is after. Drop the same 12px onto 16px body text whose stems are nearer 1.5px and the glyph is gone: not blurred, erased, for most of the 900ms it takes to resolve. The catalog therefore halves both knobs below 24px, blur to 6 and stagger to 15, and that pairing is deliberate — smaller type means more units per line, so the cascade has to tighten as well. IMPORTANT — 12px is over the ≤8px ceiling in performance-ibelick-no-blur-animation, and it is licensed rather than exempt: that rule prices radius, AREA and duration together, and a hero title is one short strip of text animating once, small in two of the three variables. Body text is where you lose both arguments at the same time — you are over the performance threshold and under the legibility one. The 300ms ceiling in animations-emil-duration-budget does not bind a 900ms hero reveal either, because that rule exempts marketing and explanatory motion as content; it binds again the moment the effect is applied to interface text like a label, a table cell, or a toast.
```tsx
const HERO = { blurPx: 12, staggerMs: 25, unit: "char" };
const BODY = { blurPx: 6, staggerMs: 15, unit: "word" };
```
- [animate-text — soft-blur-in spec (usage_notes)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/soft-blur-in.json)
- [animate-text — focus-blur-resolve spec](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/focus-blur-resolve.json)
- [MDN — filter: blur()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Functions/blur)
### A Hard Cut Is steps(), Not a Fast Fade
**ID:** `animations-hard-cut-uses-steps` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-hard-cut-uses-steps](https://ui-guides-agent-rules.netlify.app/principles/animations-hard-cut-uses-steps)
**Agent rule (MUST):** Any effect whose identity is discreteness — typewriter, per-word hard cut, blinking caret — must use steps(1, end), not an eased opacity ramp. A fade puts every unit in a half-present state for its whole duration, so at a normal stagger several are ghosting at once and the line smears instead of typing. The rhythm belongs to the stagger; the duration is only a gate.
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
A typewriter or word-by-word cut needs a stepped easing — a 240ms opacity ramp per character is not typing, it is a smear resolving
> typewriter — signature_easing: "steps(1, end)". Good for short copy. Keep line length moderate so stepping stays intentional. · shared-axis-y — Use for bold word-by-word hard cuts. No overlap keeps phrase swaps visually clean.
Two of the catalog's 24 effects reach for `steps(1, end)`, and both are effects whose entire identity is discreteness. A typewriter is a machine setting one glyph at a time: the character is struck or it is not, and there is no state in between. Build it from a 240ms opacity ramp at a 46ms stagger and roughly five characters are half-present at any moment, so the line reads as a grey smear resolving rather than as typing — the effect you asked for is the one thing you cannot see. `steps(1, end)` collapses the whole duration into a single transition at its end, which is also why a stepped effect survives being retimed: the RHYTHM lives entirely in the stagger and the duration is just a gate. The same argument covers `shared-axis-y`, a staircase of per-word hard swaps — ease it and a deliberate edit becomes a cross-dissolve. This is the one place in this corpus where an instant transition is the craft rather than the shortcut, and it is not in tension with animations-emil-no-ease-in: that rule chooses among CONTINUOUS curves and has nothing to say about a transition with no in-between. A stepped reveal is still motion, so `prefers-reduced-motion` should still land the whole string at once.
```tsx
// bad — 240ms ramp, ~5 glyphs half-present at a 46ms stagger
animation: fade 240ms ease-out both;
// good — struck or absent, never in between
animation: type 240ms steps(1, end) both;
animationDelay: `${i * 46}ms`;
```
- [animate-text — typewriter spec (steps(1, end))](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/typewriter.json)
- [animate-text — shared-axis-y spec (per-word hard cut)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/shared-axis-y.json)
- [MDN — steps() easing function](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function/steps)
### A Stagger Below One Frame Is Not a Stagger
**ID:** `animations-stagger-floor-one-frame` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-stagger-floor-one-frame](https://ui-guides-agent-rules.netlify.app/principles/animations-stagger-floor-one-frame)
**Agent rule (MUST):** Never set a per-unit stagger below ~16ms. One frame at 60Hz is 16.7ms, so a smaller delay quantizes multiple units onto the same paint and the cascade renders as a flash — you ship N elements and N animations for nothing. Hold the 16ms floor even though a 120Hz panel could resolve less, or the effect differs between displays. Floor 16ms, per-unit 22–95ms by unit type, total under 500ms.
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
Per-unit delays under ~16ms quantize onto the same frame — the cascade flattens into a flash no matter what number you typed
> Works on 40px+ headlines. Stagger 24ms gives it quicker momentum; don't go below 16ms or it flattens.
The catalog's floor is not taste, it is the refresh rate. At 60Hz a frame is 16.7ms, so an 8ms stagger means the first two units begin on the same frame and the browser physically cannot render the difference you asked for. The cascade does not get subtler, it disappears — you pay the full cost of splitting the string, shipping N elements and N animations, and get a flash. That is why the catalog's tightest per-character stagger is 22ms and its stated floor is 16ms, one frame. The tempting objection is that a 120Hz panel halves the frame to 8ms and would render it: that makes the situation worse, not better, because the effect then differs between the reviewer's laptop and the user's phone. Hold 16ms regardless of the display. The other end of the window is animations-lottie-stagger-budget's 500ms total ceiling, and the pair defines the whole usable range: N units must fit between one frame each and half a second overall. When N is large enough that those two constraints cross, the answer is a coarser unit (animations-text-split-granularity), never a smaller delay. Distinct from animations-frame-budget, which is about how much WORK fits inside a frame — this is about the smallest DELAY a frame can express.
```tsx
// bad — 8ms is half a frame; letters 1-2 start together
const STAGGER_MS = 8;
// good — clears the frame floor, total stays under the 500ms ceiling
const STAGGER_MS = 24; // >= 16.7ms (one frame at 60Hz)
// if units * STAGGER_MS > 500, coarsen the unit — do not shrink the delay
```
- [animate-text — per-character-rise spec (16ms floor)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/per-character-rise.json)
- [animate-text — stagger-from-center spec (22ms, tightest)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/stagger-from-center.json)
- [MDN — requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)
### Overlap Only When the Slot Holds Still
**ID:** `animations-text-swap-mode-matches-layout` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-text-swap-mode-matches-layout](https://ui-guides-agent-rules.netlify.app/principles/animations-text-swap-mode-matches-layout)
**Agent rule (MUST):** Pick the swap mode from whether the slot holds still, not from habit. Opacity/blur replacement in a fixed slot: crossfade with 100–300ms overlap. Anything that travels, pushes, or restacks layout: exit fully, then a 70–220ms micro-delay before enter — overlapping two moving phrases sends them through the same space on opposite vectors and reads as a glitch.
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
Crossfade a swap that replaces text in a fixed slot; run exit fully before enter when the effect travels, or the two phrases cross in mid-air
> This variant keeps swap non-overlapping to avoid content intersections. · This variant uses no overlap on swap to avoid content crossing during transitions.
This is the boundary of animations-text-swap-overlap, and without it that rule gets over-applied. Read which of the catalog's specs set `overlap_ms: 0` and the pattern is exact: every effect that MOVES layout runs sequential — the kinetic builds that push a line sideways, the letter staircases that travel 46px, the line-by-line slide that carries a whole line 48px across. Four separate specs give the same reason in the same words: to avoid content intersections. Overlap is safe when both phrases are pinned in one slot and the only things changing are opacity and blur, because the layers sit exactly on top of each other and read as a single dissolve. The moment either phrase is travelling, the overlap window sends two moving strings through the same space on different vectors, and the eye cannot assign a glyph to a word — it reads as a glitch, which is worse than the hard cut you were avoiding. So the test is not "is this a swap" but "does the slot hold still": stable slot means crossfade with 100–300ms of overlap; moving layout means exit fully, then spend the budget on a micro-delay instead (70–220ms across the build effects) so the replacement still reads as a beat rather than a cut. Under `prefers-reduced-motion` both collapse to the same instant replacement.
```tsx
// stable slot (opacity/blur only) — overlap
const enterDelay = exitMs - 220;
// travelling / pushing layout — exit, then a beat
const enterDelay = exitMs + 70;
```
- [animate-text — micro-scale-fade spec (overlap_ms: 0)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/micro-scale-fade.json)
- [animate-text — line-by-line-slide spec (travelling, sequential)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/line-by-line-slide.json)
- [Material Design — Shared axis transitions](https://m2.material.io/design/motion/the-motion-system.html)
### Transform Order Is Not Cosmetic
**ID:** `animations-transform-order-changes-result` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/animations-transform-order-changes-result](https://ui-guides-agent-rules.netlify.app/principles/animations-transform-order-changes-result)
**Agent rule (MUST):** Compose transform as translate → rotate → scale, and keep that order everywhere in the system. transform is a right-to-left matrix chain, so a translate written after a scale is measured in the scaled space: scale(0.5) translateY(60px) travels 30px, and the distance grows as the scale animates, bending the path into an easing you never wrote. Hoist the order with the rest of the motion constants; this bug never throws.
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
translate-then-scale is a different result from scale-then-translate — the second scales the distance, so a 60px lift at scale 0.5 travels 30px
> rendering_contract.transform_order: "translate3d(x_px, y_px * y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)"
The catalog does not argue this in prose — it pins the order in a machine-readable `rendering_contract` field on every one of its 24 effects, which is the tell: reproductions diverged until the order became part of the contract. The reason is that `transform` is a chain of matrix multiplications applied right to left, so every function operates inside the coordinate space the functions after it have already established. Write `scale(0.5) translateY(60px)` and the translation happens in a space that is already half size, so the glyph rises 30px rather than 60 — and as the scale animates toward 1 the effective distance GROWS, which bends the path into an easing you never authored and cannot find in your easing constant. Write `translateY(60px) scale(0.5)` and the two are independent: 60px is 60px at every scale, which is why the catalog puts translation first and scale last. The discipline generalises past text: pick one order, hoist it with the rest of the motion constants (animations-named-timing-constants), and never let two components in the same system disagree, because this bug does not throw — it just makes the motion quietly wrong in a way that reads as "the animation feels off". Distinct from animations-correct-transform-origin, which fixes WHERE a scale starts from; this fixes what every other function in the same chain means once a scale is in it. The individual `translate` / `rotate` / `scale` properties avoid the ambiguity — the spec fixes their order — but they still compose with `transform` in a defined sequence, so mixing the two forms needs the same care.
```tsx
// bad — translate lives in the scaled space; 60px becomes 30px at scale 0.5
transform: scale(0.5) translateY(60px);
// good — translation first, scale last: 60px is 60px at every scale
transform: translate3d(0, 60px, 0) scale(0.5);
```
- [animate-text — soft-blur-in effect (rendering_contract.transform_order)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/effects/soft-blur-in.json)
- [animate-text — schema reference](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/references/schema.md)
- [MDN — transform function order](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform)
---
## Layout
Responsive design, alignment, and visual structure. 29 rules.
### Optical Alignment
**ID:** `layout-optical-alignment` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-optical-alignment](https://ui-guides-agent-rules.netlify.app/principles/layout-optical-alignment)
**Agent rule (SHOULD):** Optical alignment; adjust by ±1px when perception beats geometry
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Adjust ±1px when perception beats geometry
> Optical alignment. Adjust ±1px when perception beats geometry.
Mathematical centering doesn't always look centered to the human eye. Icons with more visual weight on one side may need to be shifted slightly to appear balanced. Trust your eye over the numbers, but limit adjustments to 1-2px.
- [Optical Alignment](https://marvelapp.com/blog/optical-adjustment-logic-vs-designers/)
### Deliberate Alignment
**ID:** `layout-deliberate-alignment` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-deliberate-alignment](https://ui-guides-agent-rules.netlify.app/principles/layout-deliberate-alignment)
**Agent rule (MUST):** Deliberate alignment to grid/baseline/edges/optical centers—no accidental placement
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Every element aligns with something intentionally
> Deliberate alignment. Every element aligns with something intentionally whether to a grid, baseline, edge, or optical center. No accidental positioning.
Nothing should be randomly placed. Every element should visibly align with another element, a grid line, or have a clear relationship to its neighbors. This creates visual harmony and helps users scan content efficiently.
- [Visual Alignment](https://www.nngroup.com/articles/gestalt-proximity/)
### Responsive Coverage
**ID:** `layout-responsive-coverage` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-responsive-coverage](https://ui-guides-agent-rules.netlify.app/principles/layout-responsive-coverage)
**Agent rule (MUST):** Verify mobile, laptop, ultra-wide (simulate ultra-wide at 50% zoom)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Verify on mobile, laptop, and ultra-wide screens
> Responsive coverage. Verify on mobile, laptop, & ultra-wide. For ultra-wide, zoom out to 50% to simulate.
Test your layouts at 320px (small mobile), 768px (tablet), 1280px (laptop), and 2560px+ (ultra-wide). Content should reflow appropriately, never horizontally scroll unexpectedly, and use available space effectively at all sizes.
- [Responsive Design](https://web.dev/articles/responsive-web-design-basics)
### No Excessive Scrollbars
**ID:** `layout-no-excessive-scrollbars` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-no-excessive-scrollbars](https://ui-guides-agent-rules.netlify.app/principles/layout-no-excessive-scrollbars)
**Agent rule (MUST):** Avoid unwanted scrollbars; fix overflows
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Only render useful scrollbars; fix overflow issues
> No excessive scrollbars. Only render useful scrollbars; fix overflow issues to prevent unwanted scrollbars.
Unexpected scrollbars indicate layout problems. Fix the root cause (usually overflow issues) rather than hiding scrollbars with CSS. On macOS, set "Show scroll bars" to "Always" during development to catch these issues.
- [CSS Overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overflow)
### Balance Contrast in Lockups
**ID:** `layout-balance-contrast` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-balance-contrast](https://ui-guides-agent-rules.netlify.app/principles/layout-balance-contrast)
**Agent rule (SHOULD):** Balance icon/text lockups (stroke/weight/size/spacing/color)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Adjust weight, size, spacing when text and icons sit together
> Balance contrast in lockups. When text & icons sit side by side, adjust weight, size, spacing, or color so they don't clash. For example, a thin-stroke icon may need a bolder stroke next to medium-weight text.
Icons and text have different visual weights. A light icon next to bold text feels unbalanced. Either use heavier icons, lighter text, adjust sizing, or tweak color to create visual harmony in icon-text combinations.
- [Visual Balance](https://www.nngroup.com/articles/visual-hierarchy-ux-definition/)
### Respect Safe Areas
**ID:** `layout-safe-areas` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-safe-areas](https://ui-guides-agent-rules.netlify.app/principles/layout-safe-areas)
**Agent rule (MUST):** Respect safe areas (use env(safe-area-inset-*))
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Account for notches and insets with safe-area variables
> Respect safe areas. Account for notches & insets with safe-area variables.
Mobile devices with notches and rounded corners have safe areas where content shouldn't be placed. Use CSS environment variables like env(safe-area-inset-top) to ensure your content isn't obscured by device UI elements.
- [Safe Area Insets](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/env)
- [Designing for iPhone X](https://webkit.org/blog/7929/designing-websites-for-iphone-x/)
### @layer Is Native CSS, Not a Tailwind Directive
**ID:** `layout-layer-directives` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-layer-directives](https://ui-guides-agent-rules.netlify.app/principles/layout-layer-directives)
**Agent rule (MUST):** Use @layer (base/components/utilities) for custom CSS to integrate with Tailwind specificity
**Source:** [Tailwind](https://tailwindcss.com/docs)
Use @layer base for element defaults, but never define a utility inside @layer utilities — that no longer produces a real utility
> In v4, `@layer` is a native CSS cascade layer, not a Tailwind directive. Use `@layer base` for base styles. To add a custom utility, use the `@utility` directive instead — utilities defined this way work with variants and are sorted by property count.
The three-layer habit from v3 — `@layer base`, `@layer components`, `@layer utilities` — reads identically in v4 but means something else. Tailwind no longer intercepts these blocks; they are the browser's own cascade layers (`@layer` is real CSS now, and Tailwind registers `theme`, `base`, `components`, and `utilities` as native layers). One of the three still works exactly as you expect: `@layer base { h1 { ... } }` for element defaults and resets is correct v4. The other two changed. A class you write inside `@layer utilities` is no longer registered as a Tailwind utility — it is just a rule that happens to sit in a low-priority layer. So `hover:` and `md:` cannot be applied to it (the compiler has never heard of the name and will not generate the variant), and it has no place in Tailwind's property-count sort order, so its precedence against real utilities is whatever the layer position gives it. It looks like a utility, it is styled like a utility, and it fails the moment anyone tries to use it like a utility. The v4 answer is `@utility`, which registers the name with the compiler. See layout-custom-utilities.
- [Tailwind v4: Adding custom styles](https://tailwindcss.com/docs/adding-custom-styles)
- [Tailwind v4: Functions and directives](https://tailwindcss.com/docs/functions-and-directives)
- [MDN: @layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer)
### Create Custom Utilities with @utility
**ID:** `layout-custom-utilities` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-custom-utilities](https://ui-guides-agent-rules.netlify.app/principles/layout-custom-utilities)
**Agent rule (SHOULD):** Create custom utilities in @layer utilities for missing CSS properties (text-wrap, scrollbar)
**Source:** [Tailwind](https://tailwindcss.com/docs)
Register project-specific utilities with the @utility directive, the only form that supports variants
> Use the `@utility` directive to add custom utilities to your project that work with variants like `hover`, `focus`, and `lg`: `@utility scrollbar-hide { scrollbar-width: none; }`
Tailwind does not ship every CSS property — `scrollbar-width`, `text-wrap: balance` on an older target, `mask-type` — so you will need to add your own. In v4 there is exactly one correct way, and it is `@utility name { … }`. This is not a stylistic preference over the old `@layer utilities { .name { … } }` form: that form no longer registers anything with the compiler, so `md:scrollbar-hide` and `hover:scrollbar-hide` simply do not exist and quietly do nothing at the call site. `@utility` puts the name in Tailwind's registry, which buys three things a plain class cannot have: every variant works, the utility is sorted by property count alongside the built-ins so overriding it behaves predictably, and it is only emitted when actually used. Two details worth knowing: `@utility` requires a top-level, unnested rule (no `&` selectors, no nesting — that is what makes it variant-composable), and a trailing `-*` makes it take a value, so `@utility tab-*` plus `--tab-size-*` theme tokens gives you `tab-2`, `tab-4`, and `tab-[13]`.
- [Tailwind v4: Adding custom utilities](https://tailwindcss.com/docs/adding-custom-styles)
- [Tailwind v4: @utility directive](https://tailwindcss.com/docs/functions-and-directives)
### Use Fixed Z-Index Scale
**ID:** `layout-ibelick-z-index-scale` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-ibelick-z-index-scale](https://ui-guides-agent-rules.netlify.app/principles/layout-ibelick-z-index-scale)
**Agent rule (MUST):** Use a fixed z-index scale (z-10, z-20, z-30) not arbitrary values. Document the scale. Arbitrary z-index values lead to escalating z-index wars.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use a defined z-index scale instead of arbitrary values like z-[999]
> MUST use a fixed `z-index` scale (no arbitrary `z-*`)
Arbitrary values are an escalation game: z-[999] wins today, so the next thing that must sit above it becomes z-[9999], and nobody can answer "should a tooltip be above a modal?" by reading the code. A fixed, named scale (base, dropdown, sticky, modal, popover, tooltip) turns stacking into a decision made once. The semantic layer names are our elaboration, not upstream's words — upstream only forbids the arbitrary values.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Tailwind Z-Index](https://tailwindcss.com/docs/z-index)
### Use size-* Utility
**ID:** `layout-ibelick-size-utility` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-ibelick-size-utility](https://ui-guides-agent-rules.netlify.app/principles/layout-ibelick-size-utility)
**Agent rule (SHOULD):** Use size-* for square elements instead of separate w-* and h-* classes. Reduces class count and communicates intent (this element is square).
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use size-* instead of separate w-* and h-* for square elements
> SHOULD use `size-*` for square elements instead of `w-*` + `h-*`
w-8 h-8 states two independent facts that happen to agree. size-8 states one fact: this is square. The difference shows up on edit — resize a w-8 h-8 icon and it is trivially easy to change one and not the other, producing a 32x24 "square" that nobody notices until it is next to a real one. It is a SHOULD, and it applies only where squareness is the intent (icons, avatars, icon buttons); an element that is coincidentally square is still correctly written as w-* + h-*.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Tailwind Size Utility](https://tailwindcss.com/docs/width#setting-both-width-and-height)
### Use dvh Instead of h-screen
**ID:** `layout-ibelick-viewport-height` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-ibelick-viewport-height](https://ui-guides-agent-rules.netlify.app/principles/layout-ibelick-viewport-height)
**Agent rule (NEVER):** Use h-screen or 100vh on mobile. Use h-dvh (dynamic viewport height) instead to account for mobile browser chrome (address bar, navigation).
**Source:** [@Ibelick](https://www.ui-skills.com/)
Never use h-screen or 100vh on mobile - use h-dvh for dynamic viewport height
> NEVER use `h-screen`, use `h-dvh`
On mobile, 100vh is the LARGE viewport — the height the page would have if the browser chrome were hidden. It does not shrink when the address bar is showing, so a "full height" screen is taller than the visible area and its bottom row (usually the primary action) sits under the browser UI. dvh tracks the viewport as the chrome shows and hides. The tradeoff worth knowing: because dvh changes during scroll, a dvh-sized element can resize mid-gesture — where that reflow is worse than the clipping, svh (the small viewport, i.e. chrome always visible) is the stable choice.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Dynamic Viewports](https://web.dev/blog/viewport-units)
### Respect Safe Area Insets on Fixed Elements
**ID:** `layout-ibelick-safe-areas` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-ibelick-safe-areas](https://ui-guides-agent-rules.netlify.app/principles/layout-ibelick-safe-areas)
**Agent rule (MUST):** Respect safe-area-inset for fixed/sticky elements on notched devices. Use pb-safe, pt-safe or env(safe-area-inset-*) to prevent content obscuring.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Pad fixed elements with env(safe-area-inset-*) so they clear the notch and the home indicator
> MUST respect `safe-area-inset` for fixed elements
The scope is FIXED elements, and that is the whole point of the rule: normal document flow already stops short of the unsafe regions, so a paragraph does not need this. A `position: fixed` header, bottom bar, FAB or drawer is positioned against the viewport, which extends underneath the notch and the home indicator — so it, and only it, lands in hardware. Requires `viewport-fit=cover` in the viewport meta tag; without it the env() values all resolve to 0 and the rule silently does nothing. Write the padding as `padding-bottom: env(safe-area-inset-bottom)` (or, keeping the element's own padding, `calc(0.75rem + env(safe-area-inset-bottom))`), and note that `pb-safe` / `pt-safe` are NOT core Tailwind — they come from the tailwindcss-safe-area plugin or a hand-written `@utility`, and they do not exist in this project, so writing them here produces no CSS at all.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [MDN — env()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/env)
- [Designing for Notches](https://webkit.org/blog/7929/designing-websites-for-iphone-x/)
### Min-Width for Truncation
**ID:** `layout-min-width-truncation` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-min-width-truncation](https://ui-guides-agent-rules.netlify.app/principles/layout-min-width-truncation)
**Agent rule (MUST):** Add `min-w-0` (`min-width: 0`) to any flex child that must truncate — flex items default to `min-width: auto` and refuse to shrink below their content, so `text-overflow: ellipsis` never has an overflow to clip. Grid children need `min-w-0` or `minmax(0, 1fr)`.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Add min-w-0 to flex children so text can actually truncate instead of overflowing
> Flex children need `min-w-0` to allow text truncation.
A flex item defaults to min-width: auto, which refuses to shrink below its content's min-content size. A long unbroken filename or URL therefore keeps the item wide, text-overflow: ellipsis never has an overflow to clip, and the row blows out — pushing siblings like a trailing button outside the container. Setting min-width: 0 (min-w-0) on the shrinking child restores the shrink and makes truncate work. The same applies to grid children via min-w-0 or minmax(0, 1fr).
```tsx
<div class="flex items-center gap-2">
<span class="min-w-0 flex-1 truncate">{longFileName}</span>
<button class="shrink-0">Open</button>
</div>
```
- [MDN: min-width](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/min-width)
- [MDN: text-overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow)
### Flex/Grid Over JS Measurement
**ID:** `layout-flex-over-measurement` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-flex-over-measurement](https://ui-guides-agent-rules.netlify.app/principles/layout-flex-over-measurement)
**Agent rule (SHOULD):** Solve layout with flex and grid instead of reading `getBoundingClientRect`/`offsetWidth` and writing the result back as inline styles — measurement forces a synchronous reflow, can only run after first paint (so the UI visibly jumps), is wrong during SSR, and goes stale without a `ResizeObserver`.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Solve layout with flex and grid instead of measuring elements and positioning them in JavaScript
> Flex/grid over JS measurement for layout.
Reading getBoundingClientRect or offsetWidth forces a synchronous style and layout recalculation, and writing the result back into inline styles triggers another one — layout thrash. Worse, the measurement can only run after the first paint, so the UI visibly jumps into place, is wrong during SSR and before hydration, and goes stale the moment the content or the container changes unless you also wire up a ResizeObserver. Flex and grid express the same intent declaratively: the browser solves it during its own layout pass, correct on the first paint and on every resize.
- [MDN: getBoundingClientRect](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect)
- [MDN: CSS grid layout](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout)
### Never Nest Cards
**ID:** `layout-impeccable-nested-cards` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-nested-cards](https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-nested-cards)
**Agent rule (NEVER):** Nest a card inside a card — an element counts as card-like when it has (a shadow OR a border) AND (a radius OR a background). Build hierarchy inside a card with padding, one hairline divider, and type weight, not a second chrome layer.
**Source:** [impeccable](https://impeccable.style/)
Never put a card inside a card — use padding, dividers, and type weight for internal hierarchy
> Nested cards are always wrong. Cards inside cards create visual noise and excessive depth.
impeccable defines "card-like" structurally — an element is a card when it has (a shadow OR a border) AND (a radius OR a background). The detector walks an element's ancestors and, when it finds a second card-like box inside a first, reports the innermost offender. The damage is twofold: the border and shadow noise doubles, and the depth hierarchy dies, because you now have two elevation levels that mean nothing relative to each other — the inner card is not "further forward" than its parent, it is just louder. Internal hierarchy inside a card is a job for padding, a single hairline divider, and type weight, not for a second chrome layer. Before the nesting question, though, comes the one nobody asks: should this be a card at all? Every Inc's ce-frontend-design skill supplies the missing test — default to cardless layouts, and allow a card only when it is the container for a user interaction (a clickable item, a draggable unit, a selectable option). If removing the card styling would not hurt comprehension, it should not be a card. That test dissolves most nesting violations at the root: the inner box was never earning its border and shadow, it was just grouping content that padding and a heading already grouped.
- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [ce-frontend-design (EveryInc/compound-engineering-plugin)](https://github.com/EveryInc/compound-engineering-plugin)
### Padding Must Scale With Type
**ID:** `layout-impeccable-cramped-padding` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-cramped-padding](https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-cramped-padding)
**Agent rule (MUST):** Scale padding inside any bordered, outlined, or filled container from its font size: vertical >= `max(4px, fontSize * 0.3)` and horizontal >= `max(8px, fontSize * 0.5)` — at least 8px, ideally 12–16px. Also catch the `padding: 28px 0 0` shorthand bug, where the sides get quietly zeroed and text sits flush against the border.
**Source:** [impeccable](https://impeccable.style/)
Derive container padding from the font size instead of hardcoding a value that 20px text will burst
> Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.
The detector is thresholded, not a matter of taste: inside any container with a visible boundary it requires vertical padding >= max(4px, fontSize * 0.3) and horizontal padding >= max(8px, fontSize * 0.5). Horizontal gets the bigger multiplier because line-height already supplies vertical breathing room, while nothing pads the sides of a glyph. It catches a second shape too: a wrapper with a visible boundary and <= 2px of padding whose text children sit flush against the border — the classic `padding: 28px 0 0` shorthand bug, where someone set the top and quietly zeroed the sides. This is the thresholded sibling of the vague "Crowded Elements" (Rams) principle in this corpus: that one says give elements room, this one says exactly how much.
- [impeccable.style](https://impeccable.style/)
- [MDN — padding](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/padding)
### Body Text Never Touches the Viewport Edge
**ID:** `layout-impeccable-body-text-viewport-edge` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-body-text-viewport-edge](https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-body-text-viewport-edge)
**Agent rule (MUST):** Never let a paragraph or list item land within 16px of the left or right viewport edge — give the wrapping container at least 16px (ideally 24–32px) of horizontal padding, or a `max-width` plus `mx-auto` once there is room for one.
**Source:** [impeccable](https://impeccable.style/)
Give body copy a gutter — never let paragraphs bleed flush against the left or right edge of the screen
> Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding.
This is distinct from cramped padding: there the container exists and is too tight, here the container is missing entirely. The detector flags a <p> or <li> with more than 40 characters of text, wider than 50% of the viewport, whose left edge sits less than 16px from the viewport edge (or whose right edge is within 16px of the far side). Text that runs into the physical edge of the screen has no margin for the eye to return to on each line wrap, and on a phone it collides with the rounded corners and the palm holding the device. The fix is at least 16px — ideally 24–32px — of horizontal padding on a wrapping container, or a max-width plus mx-auto once there is room for one.
- [impeccable.style](https://impeccable.style/)
- [MDN — max-width](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/max-width)
- [Practical Typography — line length](https://practicaltypography.com/line-length.html)
### Overflow Containers Clip Popovers
**ID:** `layout-impeccable-clipped-overflow` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-clipped-overflow](https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-clipped-overflow)
**Agent rule (NEVER):** Leave an absolutely-positioned tooltip, menu, or popover inside an ancestor with `overflow: hidden` or `overflow: clip` — the layer gets silently cut off. Clip the image rather than the whole card, or promote the layer out of the subtree with the native Popover API (top layer) or a portal.
**Source:** [impeccable](https://impeccable.style/)
Never trap a menu, tooltip, or popover inside an overflow-hidden ancestor — let it escape the clip
> A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape.
The detector walks up from every absolutely-positioned layer looking for an ancestor with overflow: hidden or overflow: clip, and this is by far the most common cause of "my dropdown is cut off inside a card". Nobody adds overflow: hidden to break a menu — it gets added to clip an image to a rounded corner or to stop a stray child from bleeding out, and then it silently traps every popover rendered inside that subtree forever. Two fixes: let overflow stay visible (clip the image itself, not the whole card), or move the layer out of the clip — a portal, or better, the native Popover API, which promotes the element into the top layer where no ancestor overflow can reach it.
- [impeccable.style](https://impeccable.style/)
- [MDN — overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overflow)
- [MDN — Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)
- [MDN — top layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer)
### Symmetrical Padding
**ID:** `layout-interface-symmetrical-padding` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-interface-symmetrical-padding](https://ui-guides-agent-rules.netlify.app/principles/layout-interface-symmetrical-padding)
**Agent rule (NEVER):** Give a box four different padding values (`padding: 24px 16px 12px 16px`) — that is residue from nudging one side, not a decision, and it de-centres the content so a stack of cards loses its rhythm. Use one uniform value (`padding: 16px`) or at most a single horizontal/vertical pair (`padding: 12px 16px`).
**Source:** [interface-design](https://github.com/Dammyjay93/interface-design)
Give a box one padding value on all four sides, or at most a single horizontal/vertical pair — never four different numbers
> TLBR must match. If top padding is 16px, left/bottom/right must also be 16px. Exception: when content naturally creates visual balance.
The rule is narrow and mechanical, which is what makes it enforceable: padding is uniform (padding: 16px), or a single axis pair (padding: 12px 16px) when the horizontal side genuinely needs more room than the vertical. Four distinct values — padding: 24px 16px 12px 16px — is almost never a decision; it is the residue of someone nudging the top once and the bottom once and never reconciling them. The cost is visible: the content is no longer centred inside its own box, so a stack of such cards has no shared rhythm, and the eye reads the drift as sloppiness even when it cannot name it. This is a high-frequency tell in AI-generated UI, because a model emits each side independently and has no reason to make them agree. Distinct from the padding-scale rule (layout-impeccable-cramped-padding), which asks whether there is enough padding; this one asks whether the four values agree.
- [interface-design (Damola Akinleye)](https://github.com/Dammyjay93/interface-design)
- [MDN: padding](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/padding)
### Screens Need Grounding
**ID:** `layout-interface-screen-grounding` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-interface-screen-grounding](https://ui-guides-agent-rules.netlify.app/principles/layout-interface-screen-grounding)
**Agent rule (MUST):** Ground every screen with three anchors: navigation (where you can go), a location indicator (breadcrumbs / page title / active nav state — where you are), and user context (who is signed in, which workspace). A data table with none of them is a component demo, not a product. Give the sidebar the same background as the canvas and separate it with a border, not a different fill.
**Source:** [interface-design](https://github.com/Dammyjay93/interface-design)
Surround a screen with navigation, a location indicator, and user context so it reads as part of an app, not as a floating widget
> Screens need grounding. A data table floating in space feels like a component demo, not a product.
Three anchors ground a screen: navigation (a sidebar or top nav showing where you can go), a location indicator (breadcrumbs, a page title, or an active nav state showing where you are), and user context (who is signed in, and which workspace or org). Strip them and a perfectly good data table becomes an artifact with no address — the user cannot tell what surrounds it, what they can leave it for, or whose data they are even looking at. This is the single most reliable structural tell of LLM-authored UI: the model renders the widget it was asked for and forgets that widgets live inside applications. The corpus already covers alignment and spacing within a screen, but nothing else in layout covers wayfinding — where the screen sits in the product. When you build the sidebar, give it the same background as the canvas and separate it with a border; a different fill fragments the page into a "sidebar world" and a "content world".
- [interface-design (Damola Akinleye)](https://github.com/Dammyjay93/interface-design)
- [NN/g: Breadcrumbs](https://www.nngroup.com/articles/breadcrumbs/)
- [MDN: <nav>](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/nav)
### Landmark Regions
**ID:** `layout-aria-landmarks` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-aria-landmarks](https://ui-guides-agent-rules.netlify.app/principles/layout-aria-landmarks)
**Agent rule (MUST):** Put all perceivable content inside a landmark, built from the native elements (`<header>` banner, `<nav>`, `<main>`, `<aside>`, `<footer>` contentinfo) rather than roles on divs — a div soup gives the screen-reader rotor nothing to jump to. Give each DUPLICATE landmark a unique `aria-label` (or `aria-labelledby` on its heading), and never put the role name in the label: "Site Navigation" announces as "Site Navigation navigation" — label it "Primary" and "Breadcrumb".
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
Put all perceivable content inside a landmark, use the native sectioning elements, and label duplicates
> Including all perceivable content on a page in one of its landmark regions and giving each landmark region a semantically meaningful role is one of the most effective ways of ensuring assistive technology users will not overlook information that is relevant to their needs. [...] If a specific landmark role is used more than once on a page, provide each instance of that landmark with a unique label. [...] Do not use the landmark role as part of the label. For example, a navigation landmark with a label "Site Navigation" will be announced by a screen reader as "Site Navigation Navigation". The label should simply be "Site".
Landmarks are the structural layer of a page, and nothing else in this corpus covers them. layout-interface-screen-grounding is about visible wayfinding chrome — a sidebar, breadcrumbs, user context — which is a different substance: you can build all of it out of divs and still ship a page with zero landmarks. Screen readers expose a landmark rotor as the primary way to jump around a page; a div soup produces an empty rotor, so the only way to reach the main content is to walk every node from the top. Four rules cover it. First, every perceivable region belongs to a landmark. Second, use the native elements — header (banner at body scope), nav (navigation), main (main), aside (complementary), footer (contentinfo at body scope) — rather than role attributes on divs. Third, when a landmark type appears more than once, give each instance a unique label with aria-label, or aria-labelledby pointing at its heading; two unlabelled navs are indistinguishable in the rotor. Fourth, keep the role name out of the label: the role is announced already, so "Site Navigation" becomes "Site Navigation navigation". Label it "Primary" and "Breadcrumb", not "Primary Navigation".
```tsx
<nav aria-label="Primary">…</nav>
<nav aria-label="Breadcrumb">…</nav>
```
- [APG: Landmark Regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/)
- [MDN: <nav>](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/nav)
### Reusable Components Query Their Container
**ID:** `layout-container-queries` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-container-queries](https://ui-guides-agent-rules.netlify.app/principles/layout-container-queries)
**Agent rule (SHOULD):** A reusable component must respond to ITS OWN width, not the viewport: `md:flex-row` asks about the browser window, so the card that reads right in the main column goes horizontal inside a 300px sidebar. Put `@container` on the wrapper and `@md:` on the children — now it asks "is my container ≥28rem", with no `isCompact` prop threaded through three layers. Container queries are core in v4; installing `@tailwindcss/container-queries` is a mistake, not a dependency. The `@` variants read the nearest `@container` ANCESTOR, so the wrapper must not be the element you are sizing.
**Source:** [Tailwind](https://tailwindcss.com/docs)
Size a reusable component against its own container with @container and @md:, not against the viewport
> Container queries are now built into the framework in v4 — the `@tailwindcss/container-queries` plugin is no longer needed. Use the `@container` class to mark an element as a container, then style its children based on the container's size with variants like `@sm:` and `@md:`.
A breakpoint variant like `md:flex-row` asks a question about the browser window, but a reusable `<Card>` does not live in the browser window — it lives in whatever box you dropped it into. So the card that looks right in the main column goes horizontal inside a 300px sidebar, because the *viewport* is 1400px wide and the card has no idea it is not. The component is not broken; the question it is asking is. Container queries let it ask the right one: put `@container` on the card's wrapper and the `@md:flex-row` on its child now means "when my container is at least 28rem", which is true in the main column and false in the sidebar, with no props, no context, and no `isCompact` boolean threaded through three layers. This is core in v4 — installing `@tailwindcss/container-queries` is now a mistake, not a dependency. Two things to keep straight: the `@` variants read the nearest `@container` ancestor, so the wrapper must be a different element from the one you are sizing; and named containers (`@container/sidebar` with `@md/sidebar:`) exist for when containers nest and you need to skip past the nearest one.
```tsx
<div class="@container">
<div class="flex flex-col @md:flex-row">…</div>
</div>
```
- [Tailwind v4: Responsive design](https://tailwindcss.com/docs/responsive-design)
- [Tailwind CSS v4.0 announcement](https://tailwindcss.com/blog/tailwindcss-v4)
- [MDN: @container](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container)
### @apply Is Not How You Share Styles
**ID:** `layout-no-apply-abstraction` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-no-apply-abstraction](https://ui-guides-agent-rules.netlify.app/principles/layout-no-apply-abstraction)
**Agent rule (NEVER):** Collapse repeated markup into a `.btn { @apply … }` class to make it look "cleaner" — it reintroduces exactly what utilities removed: you invent a name, you jump between files to read the button's appearance, and one edit silently repaints every button. It also collapses at the first variant (`.btn-primary`, `.btn-sm`, disabled), where you start hand-rolling the variant system you already have. Extract a COMPONENT instead — a React component with CVA — which encapsulates behaviour too, not just the class string. `@apply` is legitimate only for markup you do not control (a `.prose` subtree, a third-party widget).
**Source:** [Tailwind](https://tailwindcss.com/docs)
Extract a component when markup repeats, instead of collapsing utilities into a .btn class with @apply
> Whatever you do, don't use `@apply` just to make things look "cleaner". Using `@apply` to extract a class like `.btn` reintroduces all the problems utility classes were meant to solve: you have to invent a name, you have to jump between files, and you have to think about how to override it.
The instinct is understandable: a long `class="inline-flex items-center rounded-md px-4 py-2 …"` looks like duplication, and `.btn { @apply … }` makes it go away. But the duplication was never the problem utility classes had — it was the naming, the indirection, and the coupling. `.btn` brings all three back. You now maintain a name that means nothing to the browser, you cannot read the button's appearance from the button's markup, and any change to `.btn` silently repaints every button in the product, which is exactly the cascade problem Tailwind exists to remove. It also collapses the moment you need a variant: a `.btn-primary`, a `.btn-sm`, a disabled state, and you are hand-rolling the variant system Tailwind already ships. Tailwind's own guidance is a ladder, and `@apply` is not on it: if the repetition is in a list, use a loop; if it is a real UI element, extract a *component* — a React component, with CVA for the variants — because a component encapsulates the markup and the behaviour too, not just the class string. `@apply` earns its place in one narrow case: styling markup you do not control (a `.prose` subtree, a third-party widget) or an unavoidable single element, where a component is not an option. Reach for it there, not to tidy up.
```tsx
/* no */ .btn { @apply inline-flex rounded-md px-4 py-2 …; }
// yes: const button = cva("inline-flex rounded-md", { variants: { size: { sm: "px-2 py-1", md: "px-4 py-2" } } });
```
- [Tailwind v4: Managing duplication](https://tailwindcss.com/docs/styling-with-utility-classes)
- [Tailwind v4: Functions and directives](https://tailwindcss.com/docs/functions-and-directives)
### Locking Body Scroll Must Not Shift the Page
**ID:** `layout-dialog-scroll-lock` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-dialog-scroll-lock](https://ui-guides-agent-rules.netlify.app/principles/layout-dialog-scroll-lock)
**Agent rule (SHOULD):** Opening a dialog must not shift the page behind it. `document.body.style.overflow = "hidden"` removes the scrollbar track, so the viewport grows ~15px and everything behind the scrim jumps sideways (and back on close). Reserve the track with `scrollbar-gutter: stable` (or pad by `window.innerWidth - document.documentElement.clientWidth` on legacy targets), and put `overscroll-behavior: contain` on the dialog's own scroll area so a flick inside it does not chain out.
**Source:** [@Ibelick](https://www.ui-skills.com/)
The usual overflow:hidden scroll lock removes the scrollbar, which reflows the whole page ~15px wider behind the scrim
> opening a dialog should not scroll the page unexpectedly
Locking background scroll while a dialog is open is correct. The one-line way everyone does it — `document.body.style.overflow = "hidden"` — is what causes the lurch. On any platform with classic overlay-less scrollbars, hiding overflow removes the scrollbar track, the viewport gets roughly 15px wider, and every centered or full-width element behind the scrim jumps sideways at the exact moment the user's attention moves to the dialog. Then it jumps back on close. It reads as a bug even to people who cannot name it. Two fixes, use both: `scrollbar-gutter: stable` on the scroll container reserves the track permanently so removing the scrollbar changes nothing (or, on legacy targets, pad by `window.innerWidth - documentElement.clientWidth`), and `overscroll-behavior: contain` on the dialog's own scroll area stops a flick inside it from chaining out to the page. That second half is interactions-overscroll-behavior and it is a genuinely different failure — chaining is about where a scroll *goes*, this is about the layout *shifting* when scrolling is taken away.
```tsx
html { scrollbar-gutter: stable; }
.dialog-body { overscroll-behavior: contain; }
```
- [ibelick — fixing-accessibility SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [MDN — scrollbar-gutter](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter)
- [MDN — overscroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior)
### Same Padding Everywhere Is Monotony
**ID:** `layout-impeccable-monotonous-spacing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-monotonous-spacing](https://ui-guides-agent-rules.netlify.app/principles/layout-impeccable-monotonous-spacing)
**Agent rule (SHOULD):** Vary spacing to encode grouping: ~8-12px BETWEEN siblings, ~48-96px BETWEEN sections. Uniform padding everywhere does not just look boring, it does not parse — when a label sits as far from its input as from the next section, nothing groups and every element floats at equal weight. The detector rounds every padding/margin/gap to 4px and, given >=10 samples, fires when ONE value exceeds 60% of them AND there are <=3 unique values. The opposite ditch is `design-rams-inconsistent-spacing` (arbitrary, unrepeatable values); the target is a small scale, applied to mean something.
**Source:** [impeccable](https://impeccable.style/)
Vary spacing for rhythm — tight inside a group, generous between sections, because spacing is what encodes grouping
> Vary spacing for rhythm. Same padding everywhere is monotony.
The detector is precise about what "everywhere" means: it collects every padding, margin, and gap value on the page, rounds each to the nearest 4px, and — once it has at least 10 of them — flags the page when ONE value accounts for more than 60% of the total AND there are 3 or fewer unique values. A page where every gap is 16px scores 100% dominance across a single unique value. The failure is not that it looks boring, it is that it does not parse. Spacing is the primary encoder of grouping: when a label sits as far from its own input as it does from the next section, nothing groups, and every element floats at equal weight. impeccable gives the rhythm as ranges — tight groupings of 8-12px BETWEEN siblings, generous separations of 48-96px BETWEEN distinct sections — so that proximity alone makes the structure legible, without a single border or divider doing the work. Note that `design-rams-inconsistent-spacing` is the opposite ditch: that one fires when spacing is arbitrary and unrepeatable, this one when it is uniform and meaningless. The target is neither — a small scale, applied to mean something.
- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
### Logical Properties for Direction
**ID:** `layout-logical-properties` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-logical-properties](https://ui-guides-agent-rules.netlify.app/principles/layout-logical-properties)
**Agent rule (MUST):** Use direction-agnostic properties so the layout mirrors in RTL: `margin-inline-start`/`-end` not `margin-left`/`-right`, `padding-inline-end` not `padding-right`, `inset-inline-start`/`-end` not `left`/`right`, `text-align: start`/`end` not `left`/`right` (Tailwind: `ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end`). Set `lang` — it picks quotes, hyphenation, font fallback and the screen-reader voice — and `dir="rtl"` where needed. Under `dir="rtl"` the browser mirrors flex/grid order while every hardcoded left and right stays put, so icons detach and padding collides.
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
Use direction-agnostic CSS — margin-inline-start, not margin-left — so a layout mirrors correctly in right-to-left languages
> To support right-to-left content, use direction-agnostic properties: `margin-inline-start` instead of `margin-left`, `text-align: start` instead of `left`. Set `lang` so browsers pick the right quotes and hyphenation, and `dir="rtl"` where needed.
Physical properties encode a hardcoded assumption that text flows left-to-right. `margin-left` means "the left edge of the screen"; `margin-inline-start` means "the edge the reading starts at", which is the left in English and the right in Arabic, Hebrew, Persian and Urdu. Set `dir="rtl"` on a subtree built from physical properties and the browser dutifully mirrors the flex/grid order while every hardcoded left and right stays put — so the icon that hugged the text now detaches from it, the padding that kept a dismiss button clear of the label is now on the wrong side and the two collide, and the copy is still ragged-left in a language that is read right-to-left. The full mapping is small: `margin-inline-start`/`-end` for `margin-left`/`-right`, `padding-inline-start`/`-end`, `inset-inline-start`/`-end` for `left`/`right`, `border-inline-start`, and `text-align: start`/`end` for `left`/`right`. Tailwind ships all of them (`ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end`), so the direction-agnostic version costs the same number of characters as the broken one. `lang` is the other half and is not decorative: it selects the right quotation marks, hyphenation dictionary and font fallbacks, and it is what a screen reader uses to pick a voice. Note this is a distinct gap from the internationalisation rules the guide already covers — content-language-detection and content-translate-no are about the words; this is about which way the box points.
```tsx
<html lang="ar" dir="rtl">
/* .card { margin-left: 1rem; text-align: left; } ✗ */
.card { margin-inline-start: 1rem; text-align: start; } /* ✓ */
```
- [jakubkrehel/skills — better-typography](https://github.com/jakubkrehel/skills/tree/main/skills/better-typography)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values)
### z-index Only Works Inside Its Stacking Context
**ID:** `layout-stacking-context-zindex` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-stacking-context-zindex](https://ui-guides-agent-rules.netlify.app/principles/layout-stacking-context-zindex)
**Agent rule (SHOULD):** z-index is compared only within a stacking context, not globally. transform, opacity<1, filter, will-change, position:fixed/sticky, isolation, and flex/grid children with z-index all create one — trapping descendants no matter how high their z-index. Do not fix overlap with a bigger number; remove the accidental context or lift the overlay out of the trapping ancestor.
**Source:** [Web Platform](https://web.dev/)
A parent with transform, opacity, or filter makes a new stacking context that traps its children's z-index — the fix is not a bigger number
> New stacking contexts reset z-index. z-index only compares elements within the same stacking context, so a z-index:9999 child cannot escape a parent that sits below a later sibling.
The recurring bug: a dropdown set to `z-index: 9999` still renders under the next section and nobody can explain why the number does not win. z-index is not global — it is compared only among siblings inside the same STACKING CONTEXT, and a long list of ordinary CSS silently creates one: `transform`, `opacity` below 1, `filter`, `backdrop-filter`, `will-change`, `position: fixed`/`sticky`, `isolation: isolate`, and being a flex/grid child that has a z-index. Once a parent forms a context, its descendants' z-index are resolved only against each other and the whole subtree is clamped to the parent's own place in the stack — so a `9999` inside a `transform`ed card can never climb above a sibling card that comes later in the DOM. Raising the number is the wrong fix and starts the arms race that layout-ibelick-z-index-scale exists to prevent. The right fixes: remove the accidental context (drop the stray `transform`/`opacity`), or lift the overlay out of the trapping ancestor entirely — the same escape move layout-impeccable-clipped-overflow prescribes for a popover trapped in an `overflow` ancestor.
```tsx
/* z-index:9999 inside a transformed parent can't beat a later sibling. */
.card { transform: none; } /* or move the overlay to a portal / higher context */
```
- [MDN — The stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context)
- [nextlevelbuilder — ui-ux-pro-max](https://skills.sh/nextlevelbuilder/ui-ux-pro-max-skill/ui-ux-pro-max)
- [MDN — z-index](https://developer.mozilla.org/en-US/docs/Web/CSS/z-index)
### Fixed Elements Need a Space Budget
**ID:** `layout-fixed-element-budget` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-fixed-element-budget](https://ui-guides-agent-rules.netlify.app/principles/layout-fixed-element-budget)
**Agent rule (SHOULD):** A fixed/sticky element is out of flow, so reserve its space: offset content by the bar height (padding-top) and add scroll-padding-top so anchors and focus land clear. Do not position multiple fixed elements independently — budget them together with each other and env(safe-area-inset-*) so they never collide or hide content.
**Source:** Custom
Reserve room for a fixed or sticky bar by offsetting content, and don't stack multiple fixed elements into collisions
> A fixed nav should not obscure content — offset the content by its height. Don't stack multiple fixed elements carelessly; account for safe areas and for each other. (nextlevelbuilder, ui-ux-pro-max)
A `position: fixed` (or sticky) element is lifted out of normal flow, so the layout no longer reserves space for it — and by default the first section of content renders UNDERNEATH a fixed header, its top lines hidden behind the bar. Pay the space back: offset the content by the bar's height with `padding-top` on the scroll container, and add `scroll-padding-top` so anchored jumps and keyboard focus also land clear of it (that focus case is interactions-focus-not-obscured). The second failure is stacking: a fixed header, a fixed cookie banner, and a fixed chat button, each positioned on its own, will overlap one another and the device notch because none of them knows the others exist. Budget them together — collapse them into one fixed region, or give each an explicit offset that accounts for the others and for `env(safe-area-inset-*)` (layout-ibelick-safe-areas). The principle under both halves: taking an element out of flow is borrowing space you now have to repay by hand.
```tsx
.scroll { padding-top: var(--nav-h); scroll-padding-top: var(--nav-h); }
```
- [nextlevelbuilder — ui-ux-pro-max](https://skills.sh/nextlevelbuilder/ui-ux-pro-max-skill/ui-ux-pro-max)
- [MDN — position: fixed](https://developer.mozilla.org/en-US/docs/Web/CSS/position)
- [MDN — scroll-padding](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-padding)
### Wide Tables Need a Scroll Wrapper or Cards
**ID:** `layout-responsive-table-overflow` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/layout-responsive-table-overflow](https://ui-guides-agent-rules.netlify.app/principles/layout-responsive-table-overflow)
**Agent rule (SHOULD):** A table wider than the viewport must not widen the page. Wrap it in an overflow-x:auto container so only the table scrolls, or reflow rows into label–value cards on narrow screens. Add an edge fade/shadow so the scroll is discoverable, and keep the header reachable.
**Source:** Custom
A table wider than the viewport must scroll inside its own overflow-x container or reflow to cards — never push the whole page wide
> Tables can overflow on mobile. Use a horizontal-scroll wrapper (overflow-x: auto) or a card layout — don't let a wide table break the page layout. (nextlevelbuilder, ui-ux-pro-max)
A table sizes to its columns, and on a phone a real data table is almost always wider than the screen. Left alone it does the worst possible thing: it stretches its containing block, so the WHOLE page gains a horizontal scrollbar and every other element overflows with it — now the body copy scrolls sideways to reveal nothing. Two correct fixes. Wrap the table in an `overflow-x: auto` container so ONLY the table scrolls, within its own bounds, while the page stays put — this is the specific, useful case that layout-no-excessive-scrollbars carves out (a scrollbar on the one thing that genuinely needs it). Or, for the best small-screen result, reflow each row into a stacked card of label–value pairs, so there is no horizontal scroll at all. Give the scroll container an edge affordance (a fade or shadow) so people know more is there, and keep the header reachable. This is the inverse framing of layout-no-excessive-scrollbars: that rule says do not create scrollbars you do not need; this one says when a table truly needs one, contain it to the table instead of the page.
```tsx
<div class="overflow-x-auto"><table>…</table></div>
```
- [nextlevelbuilder — ui-ux-pro-max](https://skills.sh/nextlevelbuilder/ui-ux-pro-max-skill/ui-ux-pro-max)
- [MDN — overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow)
- [web.dev — Responsive tables](https://web.dev/articles/responsive-web-design-basics)
---
## Content
Typography, accessibility, and content presentation. 82 rules.
### Inline Help First
**ID:** `content-inline-help-first` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-inline-help-first](https://ui-guides-agent-rules.netlify.app/principles/content-inline-help-first)
**Agent rule (SHOULD):** Inline help first; tooltips last resort
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Prefer inline explanations; use tooltips as last resort
> Inline help first. Prefer inline explanations; use tooltips as a last resort.
Tooltips hide information and require hovering, which doesn't work on touch devices. Whenever possible, include help text inline where it's always visible. Use tooltips only for supplementary information that would clutter the interface.
- [Tooltip Usability](https://www.nngroup.com/articles/tooltip-guidelines/)
### Stable Skeletons
**ID:** `content-stable-skeletons` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-stable-skeletons](https://ui-guides-agent-rules.netlify.app/principles/content-stable-skeletons)
**Agent rule (MUST):** Skeletons mirror final content to avoid layout shift
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Skeletons mirror final content exactly to avoid layout shift
> Stable skeletons. Skeletons mirror final content exactly to avoid layout shift.
Loading skeletons should match the dimensions and layout of the actual content. This prevents cumulative layout shift (CLS) when content loads. If content varies significantly, use the most common dimensions or a safe maximum. Two checks catch most of what "matches the dimensions" actually means. First, an image placeholder needs the correct **aspect ratio**, not merely some height: a 200px-tall grey box standing in for a 16:9 photo still reflows the column the moment the real image arrives, so reserve the box with `aspect-ratio` (or explicit `width`/`height`) taken from the asset. Second, a text skeleton bar must match the typography's **line-box height**, not its font-size: `text-sm` is 14px of glyph inside a 20px line box, so a 14px bar is 6px short per line and a three-line paragraph shifts everything below it by 18px. Size the bars from the computed `line-height` and keep the same gaps the real lines will have.
- [Skeleton Screens](https://www.nngroup.com/articles/skeleton-screens/)
- [Cumulative Layout Shift](https://web.dev/articles/cls)
- [Optimize CLS](https://web.dev/articles/optimize-cls)
### No Dead Ends
**ID:** `content-no-dead-ends` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-no-dead-ends](https://ui-guides-agent-rules.netlify.app/principles/content-no-dead-ends)
**Agent rule (MUST):** No dead ends; always offer next step/recovery
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Every screen offers a next step or recovery path
> No dead ends. Every screen offers a next step or recovery path.
Empty states, error pages, and completion screens should always provide clear next actions. Never leave users stuck wondering what to do. Offer suggestions, links to help, or ways to start over.
- [Empty States](https://www.nngroup.com/articles/empty-state-interface-design/)
### Typographic Quotes
**ID:** `content-typographic-quotes` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-typographic-quotes](https://ui-guides-agent-rules.netlify.app/principles/content-typographic-quotes)
**Agent rule (SHOULD):** Curly quotes (" "); avoid widows/orphans
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Prefer curly quotes over straight quotes
> Typographic quotes. Prefer curly quotes (" ") over straight quotes (" ").
Curly quotes (also called smart quotes or typographer's quotes) look more polished and professional than straight quotes. Use curly double quotes and curly single quotes. Most design tools and modern editors can auto-convert these.
- [Smart Quotes](https://practicaltypography.com/straight-and-curly-quotes.html)
### Tabular Numbers
**ID:** `content-tabular-numbers` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-tabular-numbers](https://ui-guides-agent-rules.netlify.app/principles/content-tabular-numbers)
**Agent rule (MUST):** Tabular numbers for comparisons (`font-variant-numeric: tabular-nums` or a mono like Geist Mono)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use tabular-nums for comparisons and tables
> Tabular numbers for comparisons. Use font-variant-numeric: tabular-nums or a monospace like Geist Mono.
Proportional numbers have varying widths (1 is narrower than 8), making columns misalign. Tabular numbers have uniform width, so digits stack vertically in tables and make comparisons easier. Essential for prices, metrics, and data tables. The same property carries a second switch worth reaching for on identifiers: `font-variant-numeric: slashed-zero` cuts a stroke through the zero so an order number, an API key, a serial, or a licence plate cannot be misread as an `O` by someone transcribing it.
- [font-variant-numeric](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variant-numeric)
- [jakubkrehel — variable-fonts-and-opentype.md (slashed zero)](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/variable-fonts-and-opentype.md)
### Icons Have Labels
**ID:** `content-icons-have-labels` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-icons-have-labels](https://ui-guides-agent-rules.netlify.app/principles/content-icons-have-labels)
**Agent rule (MUST):** Icon-only buttons have descriptive `aria-label`
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Convey the same meaning with text for non-sighted users
> Icons have labels. Convey the same meaning with text for assistive tech. Icon-only buttons are named. Provide a descriptive aria-label.
Icons aren't universally understood and are invisible to screen readers. Either show a visible text label alongside the icon, or provide an aria-label for icon-only buttons. The label should describe the action, not the icon itself.
- [Accessible Icon Buttons](https://www.sarasoueidan.com/blog/accessible-icon-buttons/)
### Non-breaking Spaces
**ID:** `content-non-breaking-spaces` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-non-breaking-spaces](https://ui-guides-agent-rules.netlify.app/principles/content-non-breaking-spaces)
**Agent rule (MUST):** Use non-breaking spaces to glue terms: `10 MB`, `⌘ + K`, `Vercel SDK`
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use non-breaking spaces to keep units and terms together
> Non-breaking spaces for glued terms. Use a non-breaking space to keep units, shortcuts & names together: 10 MB → 10 MB, ⌘ + K → ⌘ + K, Vercel SDK → Vercel SDK.
Certain text elements should never wrap onto separate lines. Use (non-breaking space) in HTML or \u00A0 in JavaScript to keep numbers with their units, keyboard shortcuts together, and brand names intact.
- [Non-breaking space](https://en.wikipedia.org/wiki/Non-breaking_space)
### Accurate Page Titles
**ID:** `content-page-titles` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-page-titles](https://ui-guides-agent-rules.netlify.app/principles/content-page-titles)
**Agent rule (MUST):** `<title>` matches current context
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Title element reflects the current context
> Accurate page titles. <title> reflects the current context.
The page title appears in browser tabs, bookmarks, and search results. Update it dynamically to reflect the current view (e.g., "Edit Profile - Settings - MyApp"). This helps users navigate between tabs and understand their location.
- [Title element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/title)
### All States Designed
**ID:** `content-all-states` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-all-states](https://ui-guides-agent-rules.netlify.app/principles/content-all-states)
**Agent rule (MUST):** Design empty/sparse/dense/error states
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Design empty, sparse, dense, and error states
> All states designed. Empty, sparse, dense, & error states.
Every interface has multiple states beyond the ideal happy path. Design and implement empty states (no data yet), sparse states (minimal data), dense states (lots of data), error states (something went wrong), and loading states. Don't leave users facing blank screens.
- [Empty States](https://www.nngroup.com/articles/empty-state-interface-design/)
### Redundant Status Cues
**ID:** `content-redundant-cues` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-redundant-cues](https://ui-guides-agent-rules.netlify.app/principles/content-redundant-cues)
**Agent rule (MUST):** Redundant status cues (never color alone) — add a text label, icon, or pattern as a second indicator; icons have text labels. ~8% of males cannot distinguish certain colors
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Don't rely on color alone; include text labels
> Redundant status cues. Don't rely on color alone; include text labels.
Colour is the case everyone knows: WCAG SC 1.4.1 Use of Color, which the Rams review files as a Serious accessibility failure. Roughly 8% of men have some colour vision deficiency — but colour-only status also fails on monochrome displays, in bright sun, and on a printout, so the audience is far wider than the one people picture. The general rule is bigger than colour, though, and it is WCAG SC 1.3.3 Sensory Characteristics: no single sensory channel may carry meaning on its own. Colour is one channel. So is SOUND (an error chime with no on-screen message — see interactions-sound-not-sole-channel), SHAPE ("click the round button"), SIZE, and POSITION ("the panel on the right", "see the box below"), each of which means nothing to a screen reader user, and position means nothing to anyone on a narrow viewport where your two columns just became one. Test it by asking what survives if the channel is removed: greyscale the screen, mute the audio, read the DOM order aloud. Whatever is left must still identify the thing. Always carry the meaning on a second channel: an icon or glyph beside the status colour, a text label on the badge, an underline on the link, a pattern or a direct label on a chart series, a visible message beside the sound, a name beside the "round button". Green alone is not success; green with a checkmark and the word "Success" is.
- [WCAG 1.4.1: Use of Color](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html)
- [WCAG 1.3.3: Sensory Characteristics](https://www.w3.org/WAI/WCAG21/Understanding/sensory-characteristics.html)
- [Rams design review skill](https://rams.ai/rams.md)
### Use Ellipsis Character
**ID:** `content-ellipsis-character` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-ellipsis-character](https://ui-guides-agent-rules.netlify.app/principles/content-ellipsis-character)
**Agent rule (MUST):** Use the ellipsis character `…` (not ``)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use … instead of three periods
> Use the ellipsis character. … over three periods ....
The proper ellipsis character (…) is a single character with correct spacing. Three periods (...) have wider spacing and look unprofessional. Use the real ellipsis character (Unicode U+2026) in all text.
- [Ellipsis](https://en.wikipedia.org/wiki/Ellipsis)
### Anchored Headings
**ID:** `content-anchored-headings` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-anchored-headings](https://ui-guides-agent-rules.netlify.app/principles/content-anchored-headings)
**Agent rule (MUST):** `scroll-margin-top` on headings for anchored links; include a "Skip to content" link; hierarchical `<h1–h6>`
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Set scroll-margin-top for headers when linking to sections
> Anchored headings. Set scroll-margin-top for headers when linking to sections.
When users click anchor links to jump to sections, fixed headers can cover the target heading. Use scroll-margin-top on headings to add offset so they appear below fixed headers. Also provide hierarchical h1-h6 structure and a "Skip to content" link.
- [scroll-margin-top](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/scroll-margin-top)
### Resilient to User-generated Content
**ID:** `content-resilient-ugc` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-resilient-ugc](https://ui-guides-agent-rules.netlify.app/principles/content-resilient-ugc)
**Agent rule (MUST):** Resilient to user-generated content (short/avg/very long)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Layouts handle short, average, and very long content
> Resilient to user-generated content. Layouts handle short, average, & very long content.
User-generated content is unpredictable. Test your layouts with short text, average text, and extremely long text without spaces. Use text overflow strategies like ellipsis, word-wrap, or scrolling to handle edge cases gracefully.
- [Text Overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow)
### Locale-aware Formats
**ID:** `content-locale-formats` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-locale-formats](https://ui-guides-agent-rules.netlify.app/principles/content-locale-formats)
**Agent rule (MUST):** Locale-aware dates/times/numbers/currency
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Format dates, times, numbers, and currency for user locale
> Locale-aware formats. Format dates, times, numbers, delimiters, & currencies for the user's locale.
Different regions format data differently (12/31/2024 vs 31.12.2024, $1,000.00 vs 1.000,00€). Use Intl API in JavaScript to format dates, numbers, and currency according to the user's locale automatically.
- [Internationalization API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)
### Accessible Content
**ID:** `content-accessible-content` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-accessible-content](https://ui-guides-agent-rules.netlify.app/principles/content-accessible-content)
**Agent rule (MUST):** Accurate names (`aria-label`), decorative elements `aria-hidden`, verify in the Accessibility Tree
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Set accurate names, hide decorations, verify in accessibility tree
> Accessible content. Set accurate names (aria-label), hide decoration (aria-hidden) & verify in the accessibility tree.
Assistive technologies rely on proper ARIA attributes. Give interactive elements descriptive names with aria-label. Hide purely decorative elements with aria-hidden="true". Always test in the accessibility tree to ensure screen readers get meaningful information.
- [ARIA Labels](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-label)
- [Accessibility Tree](https://developer.chrome.com/docs/devtools/accessibility/reference/)
### Semantics Before ARIA
**ID:** `content-semantics-first` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-semantics-first](https://ui-guides-agent-rules.netlify.app/principles/content-semantics-first)
**Agent rule (MUST):** Prefer native semantics (`button`, `a`, `label`, `table`) before ARIA
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Prefer native elements before ARIA attributes
> Semantics before ARIA. Prefer native elements (button, a, label, table), before aria-*.
Native HTML elements have built-in accessibility, keyboard support, and expected behaviors. Use <button> instead of <div role="button">, <a> instead of <span role="link">. Only add ARIA when native elements don't provide the semantics you need.
- [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
- [No ARIA is Better Than Bad ARIA](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/)
### Class Formatting
**ID:** `content-class-formatting` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-class-formatting](https://ui-guides-agent-rules.netlify.app/principles/content-class-formatting)
**Agent rule (SHOULD):** Use prettier-plugin-tailwindcss for automatic class ordering (layout → spacing → typography → colors)
**Source:** [Tailwind](https://tailwindcss.com/docs)
Follow consistent class ordering and formatting conventions
> Maintain consistent class ordering: layout → spacing → sizing → typography → colors → effects. Use Prettier plugin for automation.
Consistent class ordering makes code scannable and maintainable. The Prettier plugin for Tailwind CSS automatically sorts classes following the recommended order.
- [Prettier Plugin](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)
- [Class Sorting](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier)
### Official Plugins
**ID:** `content-official-plugins` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-official-plugins](https://ui-guides-agent-rules.netlify.app/principles/content-official-plugins)
**Agent rule (SHOULD):** Use official plugins (@tailwindcss/typography for prose, @tailwindcss/forms for inputs)
**Source:** [Tailwind](https://tailwindcss.com/docs)
Load the plugins you actually still need with @plugin in CSS, and drop the ones v4 absorbed into core
> Load a plugin from your CSS with the `@plugin` directive: `@plugin "@tailwindcss/typography";`. Note that container queries are now included in the framework by default — the `@tailwindcss/container-queries` plugin is no longer needed.
Two things changed in v4 and stale advice gets both wrong. First, how plugins load: there is no `plugins: []` array, because there is no config file. A plugin is imported from your stylesheet with `@plugin "@tailwindcss/typography";` next to the `@import "tailwindcss"` at the top. Second, and more important, *which* plugins are still plugins. `@tailwindcss/container-queries` is deprecated — container queries are core now, so `@container` and `@md:` work out of the box. Installing that package today does not add a feature; it adds a dead dependency that shadows a built-in and tells the next reader this codebase has not been upgraded. What survives is the stuff that is genuinely opinionated content styling rather than a CSS primitive: `@tailwindcss/typography` for the `prose` classes, which is still the right answer for markdown and CMS output you do not control, and `@tailwindcss/forms` for normalizing native control appearance. Before adding any plugin to a v4 project, check whether core already does it — the same absorption happened to several utilities that used to require a package.
- [Tailwind v4: Functions and directives (@plugin)](https://tailwindcss.com/docs/functions-and-directives)
- [Tailwind v4: Typography plugin](https://tailwindcss.com/docs/typography-plugin)
- [Tailwind CSS v4.0 announcement](https://tailwindcss.com/blog/tailwindcss-v4)
### IDE IntelliSense
**ID:** `content-intellisense` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-intellisense](https://ui-guides-agent-rules.netlify.app/principles/content-intellisense)
**Agent rule (SHOULD):** Configure Tailwind CSS IntelliSense extension for autocomplete and invalid class linting
**Source:** [Tailwind](https://tailwindcss.com/docs)
Configure IDE extension for autocomplete and linting
> Install and configure Tailwind CSS IntelliSense extension for autocomplete, linting, and hover documentation.
The official Tailwind CSS IntelliSense extension provides autocomplete, syntax highlighting, and linting. It catches errors like non-existent classes and suggests valid utilities.
- [IntelliSense Extension](https://tailwindcss.com/docs/editor-setup#intelli-sense-for-vs-code)
- [Tailwind CSS IntelliSense](https://github.com/tailwindlabs/tailwindcss-intellisense)
### Images Missing Alt Text
**ID:** `content-rams-alt-text` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-rams-alt-text](https://ui-guides-agent-rules.netlify.app/principles/content-rams-alt-text)
**Agent rule (MUST):** All images must have alt attribute - descriptive for informative images, empty alt="" for decorative images. Screen readers announce images; missing alt creates confusion.
**Source:** [RAMS](https://www.rams.ai/)
All images must have descriptive alt text for screen reader users
> Images without alt — `<img>` without `alt` attribute
Rams lists this as a Critical accessibility check (WCAG 1.1.1) in its review table; the upstream skill is a checklist, so that terse row is the whole rule. Why it matters: images without alt text are invisible to screen reader users. Decorative images should carry alt="" so they are skipped, while meaningful images need text conveying their content and purpose.
- [rams.md (skill source)](https://rams.ai/rams.md)
- [WCAG 1.1.1](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html)
### Skipped Heading Levels
**ID:** `content-rams-heading-levels` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-rams-heading-levels](https://ui-guides-agent-rules.netlify.app/principles/content-rams-heading-levels)
**Agent rule (MUST):** Heading levels must be hierarchical (h1-h6) without skipping levels. Never go from h1 to h3. Screen reader users navigate by headings; skipped levels break navigation.
**Source:** [RAMS](https://www.rams.ai/)
Heading levels should not skip (e.g., h1 to h3 without h2)
> Heading hierarchy — Skipped heading levels (h1 to h3)
Rams lists this as a Moderate ("consider fixing") check (WCAG 1.3.1) in its review table; the upstream skill is a checklist, so that terse row is the whole rule. Why it matters: headings create a document outline that screen reader users navigate by. Going from h1 to h3 suggests missing content. Keep the hierarchy sequential.
- [rams.md (skill source)](https://rams.ai/rams.md)
- [WCAG 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
### Links Without Descriptive Text
**ID:** `content-descriptive-link-text` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-descriptive-link-text](https://ui-guides-agent-rules.netlify.app/principles/content-descriptive-link-text)
**Agent rule (MUST):** Link text must describe its destination - avoid generic text like "click here" or "read more". Links should make sense out of context for screen reader link lists.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Links should have descriptive text, not generic phrases like "click here"
> The purpose of each link can be determined from the link text alone or from the link text together with its programmatically determined link context, except where the purpose of the link would be ambiguous to users in general.
WCAG 2.1 Success Criterion 2.4.4 Link Purpose (In Context), Level A. Screen reader users often navigate by pulling up a list of links, stripped of surrounding prose — "click here" and "read more" tell them nothing. Note: this principle previously carried a Rams badge, but Rams has no descriptive-link-text rule (its only link check is "Missing link destination"), so it is now attributed to the WCAG criterion it actually comes from.
- [WCAG 2.1 SC 2.4.4 (Understanding)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
- [WCAG 2.1 SC 2.4.4 (Quick Reference)](https://www.w3.org/WAI/WCAG21/quickref/#link-purpose-in-context)
### Use text-balance and text-pretty
**ID:** `content-ibelick-text-balance` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-ibelick-text-balance](https://ui-guides-agent-rules.netlify.app/principles/content-ibelick-text-balance)
**Agent rule (MUST):** Use text-balance for headings (prevents orphans) and text-pretty for body paragraphs (optimizes line breaks). Improves readability without manual tweaking.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use text-balance for headings and text-pretty for body text to improve readability
> MUST use `text-balance` for headings and `text-pretty` for body/paragraphs
From the Typography constraints in ibelick's baseline-ui skill. text-balance evens out line lengths in headings; text-pretty prevents orphans (a single word stranded on the last line) in paragraphs. Both improve readability without hand-managed line breaks. DIRECT CONFLICT, deliberately kept: jakubkrehel's better-typography draws the scope line tighter. It agrees on `balance` for headings, but scopes `pretty` to *descriptions* — short blocks, a card subtitle, a meta line — and rules both out of long-form prose: "Skip both in long-form text: browsers ignore `balance` past a few lines anyway, and evening out a whole paragraph wastes space and makes it harder to read." Both positions stand, and the axis is the length of the block. On a two-line heading or a one-sentence description, the browser has few enough lines that rebalancing them is cheap and the orphan is glaring. On an article paragraph, `balance` is a no-op the engine declines to run past a handful of lines, and `pretty` buys an orphan fix by pulling text up from earlier lines, which loosens the whole rag. Take ibelick's rule as the default for UI text, which is nearly all of it, and take jakubkrehel's exception the moment the block is genuinely long-form.
- [baseline-ui (skill source)](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [jakubkrehel — wrapping-and-punctuation.md (the counter-position)](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/wrapping-and-punctuation.md)
- [CSS text-wrap](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-wrap)
### Use Tabular Numbers for Data
**ID:** `content-ibelick-tabular-nums` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-ibelick-tabular-nums](https://ui-guides-agent-rules.netlify.app/principles/content-ibelick-tabular-nums)
**Agent rule (MUST):** Use tabular-nums (font-variant-numeric: tabular-nums) for numerical data in tables, counters, and prices. Numbers align in columns without jumping.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Always use tabular-nums for numbers in tables and data displays for proper alignment
> MUST use `tabular-nums` for data
From the Typography constraints in ibelick's baseline-ui skill (the Tailwind `tabular-nums` utility maps to font-variant-numeric: tabular-nums). Proportional figures have varying widths, so columns of numbers misalign and counters jitter as they tick. Tabular figures share one advance width, so data tables, prices, and numeric lists line up.
- [baseline-ui (skill source)](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [font-variant-numeric MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variant-numeric)
### Handle Text Overflow Properly
**ID:** `content-ibelick-text-overflow` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-ibelick-text-overflow](https://ui-guides-agent-rules.netlify.app/principles/content-ibelick-text-overflow)
**Agent rule (SHOULD):** Use truncate or line-clamp-* for dense UI to prevent layout breaking. Add title attribute for full text on hover. Long text should never break layout.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use truncate or line-clamp for predictable text overflow in dense layouts
> SHOULD use `truncate` or `line-clamp` for dense UI
From the Typography constraints in ibelick's baseline-ui skill — a SHOULD, not a MUST. User-generated content can be any length; without overflow handling, cards expand unexpectedly and grids break. Use truncate for single lines, line-clamp for a multi-line cap.
- [baseline-ui (skill source)](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Tailwind Line Clamp](https://tailwindcss.com/docs/line-clamp)
### Don't Modify Letter Spacing
**ID:** `content-ibelick-letter-spacing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-ibelick-letter-spacing](https://ui-guides-agent-rules.netlify.app/principles/content-ibelick-letter-spacing)
**Agent rule (NEVER):** Modify letter-spacing (tracking-*) unless explicitly requested. Default tracking is optimized for readability. Custom tracking often hurts legibility.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Avoid changing letter-spacing unless explicitly requested - fonts are designed with proper spacing
> NEVER modify `letter-spacing` (`tracking-*`) unless explicitly requested
From the Typography constraints in ibelick's baseline-ui skill. Type designers craft spacing per weight and optical size; overriding it usually costs readability and reads as amateurish. If the text needs more presence, reach for weight or size before tracking. Counter-view worth reading alongside this: content-tracking-is-size-specific argues from Emil Kowalski's apple-design skill that tracking IS size-dependent at the extremes — display type wants it slightly tighter, caption type slightly looser. The two are not really in conflict: this rule is the correct default for body text, that one is the correction for very large and very small sizes, ideally applied through a variable font's optical-sizing axis rather than a hardcoded value.
- [baseline-ui (skill source)](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Typography Best Practices](https://fonts.google.com/knowledge/using_type)
### Font Smoothing
**ID:** `content-font-smoothing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-font-smoothing](https://ui-guides-agent-rules.netlify.app/principles/content-font-smoothing)
**Agent rule (SHOULD):** Apply `-webkit-font-smoothing: antialiased` so subpixel rendering does not make text look heavier than the designed weight.
**Source:** [Rauno](https://interfaces.rauno.me/)
Apply -webkit-font-smoothing: antialiased for better text legibility
> Fonts should have -webkit-font-smoothing: antialiased applied for better legibility.
Subpixel antialiasing can make text appear heavier than designed, especially on macOS. Applying antialiased rendering produces text that more closely matches the intended font weight and appears crisper on high-DPI displays.
```tsx
body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN font-smooth](https://developer.mozilla.org/en-US/docs/Web/CSS/font-smooth)
### Text Rendering Legibility
**ID:** `content-text-rendering` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-text-rendering](https://ui-guides-agent-rules.netlify.app/principles/content-text-rendering)
**Agent rule (SHOULD):** Apply `text-rendering: optimizeLegibility` to enable kerning and ligatures, most visible at heading sizes.
**Source:** [Rauno](https://interfaces.rauno.me/)
Apply text-rendering: optimizeLegibility for proper kerning and ligatures
> Fonts should have text-rendering: optimizeLegibility applied for better legibility.
The optimizeLegibility value enables kerning and optional ligatures, improving the visual quality of text, especially at larger font sizes. This is particularly noticeable in letter pairs like AV, WA, and Ty where default spacing can look uneven.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN text-rendering](https://developer.mozilla.org/en-US/docs/Web/CSS/text-rendering)
### Font Subsetting
**ID:** `content-font-subsetting` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-font-subsetting](https://ui-guides-agent-rules.netlify.app/principles/content-font-subsetting)
**Agent rule (SHOULD):** Subset webfonts to the alphabets/languages actually used and declare `unicode-range` — full font files ship glyphs you never render.
**Source:** [Rauno](https://interfaces.rauno.me/)
Subset fonts based on content, alphabet, or relevant languages to reduce file size
> Fonts should be subset based on the content, alphabet or relevant language(s).
Full font files include glyphs for many languages and scripts you may not need. Subsetting to only the character ranges used on your site can reduce font file sizes by 80-90%, improving load times significantly. Subset into the right container while you are at it: serve `.woff2` on the web and never ship a raw `.ttf` or `.otf`, which carry no web compression and land roughly 30-50% heavier for exactly the same glyphs (jakubkrehel's choosing-fonts calls them "Desktop only unless there is no other option"; `.woff` is a fallback for very old browsers, nothing more).
```tsx
@font-face { font-family: Inter; src: url(/inter-latin.woff2) format("woff2"); unicode-range: U+0000-00FF; }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [jakubkrehel — choosing-fonts.md (formats)](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/choosing-fonts.md)
- [Google Fonts CSS2 API](https://developers.google.com/fonts/docs/css2)
### Stable Font Weight on Hover
**ID:** `content-font-weight-hover-stable` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-font-weight-hover-stable](https://ui-guides-agent-rules.netlify.app/principles/content-font-weight-hover-stable)
**Agent rule (NEVER):** Change `font-weight` on hover or selected state — bolder text takes more space and reflows its neighbors. Reserve the space up front (or use color/background/opacity instead).
**Source:** [Rauno](https://interfaces.rauno.me/)
Font weight should not change on hover or selected state to prevent layout shift
> Font weight should not change on hover or selected state to prevent layout shift.
Changing font weight on hover causes text to take up different amounts of space, which shifts surrounding content. Use color, background, or opacity changes instead to indicate hover and selected states.
```tsx
/* reserve bold width so the label never reflows */
.item::after { content: attr(data-label); font-weight: 600; height: 0; visibility: hidden; }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Minimum Font Weight 400
**ID:** `content-min-font-weight` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-min-font-weight](https://ui-guides-agent-rules.netlify.app/principles/content-min-font-weight)
**Agent rule (NEVER):** Use font weights below 400 — thin and light weights (100–300) hurt readability on low-DPI screens and for low-vision users.
**Source:** [Rauno](https://interfaces.rauno.me/)
Font weights below 400 should not be used — they reduce readability
> Font weights below 400 should not be used.
Thin and light font weights (100-300) can be difficult to read, especially on low-resolution screens, small sizes, or for users with visual impairments. Stick to regular (400) and above for body text and UI elements.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Medium Heading Weight
**ID:** `content-heading-weight` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-heading-weight](https://ui-guides-agent-rules.netlify.app/principles/content-heading-weight)
**Agent rule (SHOULD):** Give medium-sized headings a `font-weight` of 500–600; 800–900 reads heavy and disconnected from body text.
**Source:** [Rauno](https://interfaces.rauno.me/)
Medium-sized headings look best with font weight between 500-600
> Medium sized headings generally look best with a font weight between 500-600.
Very bold weights (800-900) on medium-sized headings can feel heavy and disconnected from body text. Weights of 500-600 create clear hierarchy while maintaining visual balance with surrounding content.
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Fluid Typography with clamp()
**ID:** `content-fluid-clamp` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-fluid-clamp](https://ui-guides-agent-rules.netlify.app/principles/content-fluid-clamp)
**Agent rule (SHOULD):** Scale type fluidly with `clamp(min, preferred-vw, max)` instead of stacking font-size breakpoints.
**Source:** [Rauno](https://interfaces.rauno.me/)
Use CSS clamp() for fluid font sizes that scale between viewport sizes without breakpoints
> Adjust values fluidly by using CSS clamp(), e.g. clamp(48px, 5vw, 72px) for the font-size of a heading.
Fixed font sizes require multiple breakpoints to look good across devices. CSS clamp() provides a minimum, preferred, and maximum value that scales smoothly with the viewport, creating responsive typography without media queries.
```tsx
h1 { font-size: clamp(48px, 5vw, 72px); }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN clamp()](https://developer.mozilla.org/en-US/docs/Web/CSS/clamp)
### Text Size Adjust for iOS
**ID:** `content-text-size-adjust` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-text-size-adjust](https://ui-guides-agent-rules.netlify.app/principles/content-text-size-adjust)
**Agent rule (MUST):** Set `-webkit-text-size-adjust: 100%` so iOS Safari does not inflate text on landscape rotation (this still allows user zoom).
**Source:** [Rauno](https://interfaces.rauno.me/)
Prevent unexpected text resizing in landscape mode on iOS with -webkit-text-size-adjust: 100%
> Prevent text resizing unexpectedly in landscape mode on iOS with -webkit-text-size-adjust: 100%.
iOS Safari inflates text when rotating to landscape to improve readability. While well-intentioned, this can break carefully designed layouts. Setting text-size-adjust to 100% prevents this behavior while still allowing user zoom.
```tsx
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN text-size-adjust](https://developer.mozilla.org/en-US/docs/Web/CSS/text-size-adjust)
### Write in Active Voice
**ID:** `content-active-voice` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-active-voice](https://ui-guides-agent-rules.netlify.app/principles/content-active-voice)
**Agent rule (MUST):** Write instructions, empty states, and errors in active voice — "Install the CLI", not "The CLI will be installed".
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Lead every instruction with the verb the user performs, not with what happens to them
> Use active voice: "Install the CLI" not "The CLI will be installed."
Passive constructions hide the actor, so the reader has to reconstruct who does what before they can act. Active voice puts the verb first and cuts word count, which matters most in instructions, empty states, and error copy where the user is already stuck.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Google Style: Voice and Tone](https://developers.google.com/style/voice)
### Title Case for Headings and Buttons
**ID:** `content-title-case` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-title-case](https://ui-guides-agent-rules.netlify.app/principles/content-title-case)
**Agent rule (SHOULD):** Use Chicago-style Title Case for headings, buttons, and menu items — capitalize principal words, lowercase short articles, conjunctions, and prepositions.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use Chicago-style Title Case consistently for headings, buttons, and menu items
> Headings & buttons use Title Case (Chicago). On marketing pages, use sentence case.
Mixed casing across a single surface makes headings stop reading as headings and buttons look half-finished. Chicago Title Case gives one deterministic rule — capitalize principal words, lowercase short articles, conjunctions, and prepositions — so labels stay predictable as the product grows. The rule is scoped to product UI: marketing pages deliberately invert it and use sentence case, because long persuasive headlines set in Title Case read as shouting. Pick the casing from the surface you are on, then never mix the two within it.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Google Style: Capitalization](https://developers.google.com/style/capitalization)
### Numerals for Counts
**ID:** `content-numerals-for-counts` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-numerals-for-counts](https://ui-guides-agent-rules.netlify.app/principles/content-numerals-for-counts)
**Agent rule (MUST):** Write counts and quantities as numerals — "8 deployments", not "eight deployments" — so they can be scanned, compared, and aligned.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Write counts and quantities as digits so they can be scanned instead of read
> Use numerals for counts: "8 deployments" not "eight deployments."
Digits have a different visual shape than surrounding words, so the eye locks onto them without parsing the sentence. Spelled-out numbers force serial reading and make values impossible to compare, sort, or align in a stat row or table.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Google Style: Numbers](https://developers.google.com/style/numbers)
### Specific Button Labels
**ID:** `content-specific-button-labels` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-specific-button-labels](https://ui-guides-agent-rules.netlify.app/principles/content-specific-button-labels)
**Agent rule (MUST):** Label buttons with verb plus object ("Save API Key", "Delete Project"), never a generic "Continue" or "OK" — the label is read out of context by a screen reader cycling controls.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Name the action in the button label instead of using generic words like Continue or OK
> Use specific labels: "Save API Key" not "Continue."
A button label is often read out of context — by a screen reader cycling the controls list, or by a user who skipped the body copy. A verb-plus-object label ("Save API Key", "Delete Project") stays meaningful alone and tells the user what will happen before they commit.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Google Style: UI Elements](https://developers.google.com/style/ui-elements)
### Errors Include the Fix
**ID:** `content-actionable-errors` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-actionable-errors](https://ui-guides-agent-rules.netlify.app/principles/content-actionable-errors)
**Agent rule (MUST):** Every error message states the fix, not just the problem: the expected format, the missing permission, or a link/button to the place the user can resolve it.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
State what went wrong, what correct looks like, and give the user the next step
> Error messages should include the fix or next step, not just the problem.
An error that only names the problem ("Invalid input") leaves the user guessing which of several rules they broke. Pair the diagnosis with a concrete remedy — the expected format, the missing permission, a link or button to the place where they can fix it — so the message ends the dead end instead of announcing it.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [NN/g: Error Message Guidelines](https://www.nngroup.com/articles/error-message-guidelines/)
### Second Person, Not First
**ID:** `content-second-person-voice` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-second-person-voice](https://ui-guides-agent-rules.netlify.app/principles/content-second-person-voice)
**Agent rule (SHOULD):** Address the reader as "you" and avoid "we"/"I" in product copy — "your build failed", not "we were unable to complete the build".
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Address the reader as "you" and avoid "we" or "I" in product copy
> Use second person ("you"); avoid first person ("we"/"I").
First-person copy quietly recenters the interface on the company instead of the person trying to finish a task. Second person keeps the subject on the reader — your build, your key, your next step — and usually shortens the sentence, since "we were unable to complete the build" collapses to "your build failed".
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Google Style: Second Person](https://developers.google.com/style/person)
### Ampersand in Tight Space
**ID:** `content-ampersand-in-tight-space` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-ampersand-in-tight-space](https://ui-guides-agent-rules.netlify.app/principles/content-ampersand-in-tight-space)
**Agent rule (SHOULD):** Use `&` instead of "and" in width-constrained labels (sidebar items, tabs, table headers) so compound labels like "Billing & Invoices" stay on one line; keep "and" in prose.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Prefer "&" over "and" in labels where horizontal space is constrained
> Use "&" over "and" where space is constrained.
Sidebar items, tabs, and table headers have a hard width budget, and "and" spends three characters plus two spaces on a word that carries no meaning the ampersand does not. Swapping it keeps compound labels like "Billing & Invoices" on one line instead of truncating them mid-word or wrapping the row. Keep "and" in prose, where space is not the constraint.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Vercel Design: Writing](https://vercel.com/design/writing)
### Mark Untranslatable Strings
**ID:** `content-translate-no` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-translate-no](https://ui-guides-agent-rules.netlify.app/principles/content-translate-no)
**Agent rule (MUST):** Wrap brand names, code tokens, commands, and identifiers in `translate="no"` so browser auto-translate leaves them runnable while the surrounding prose stays translatable.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Wrap brand names, code tokens, and identifiers in translate="no" so machine translation leaves them alone
> Brand names, code tokens, and identifiers: wrap with translate="no" to prevent garbled auto-translation.
Browser auto-translate rewrites every text node it can reach, including shell commands, env var names, and product names, which turns "npm install vercel" into something that does not run. The HTML translate attribute (and Google's equivalent notranslate class) marks those subtrees as off-limits while leaving the surrounding prose translatable.
```tsx
<code translate="no">npm install vercel</code>
```
- [MDN: translate attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/translate)
- [W3C i18n: Using the translate flag](https://www.w3.org/International/questions/qa-translate-flag)
### Detect Language, Not Location
**ID:** `content-language-detection` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-language-detection](https://ui-guides-agent-rules.netlify.app/principles/content-language-detection)
**Agent rule (MUST):** Pick the UI language from `Accept-Language` / `navigator.languages`, never from IP geolocation (VPNs, travel, and multilingual countries break that assumption), and still expose an explicit language switcher as the override.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Choose the UI language from Accept-Language or navigator.languages, never from IP geolocation
> Detect the user's language via Accept-Language / navigator.languages, not IP geolocation.
Where a request originates says nothing about what the reader can read: VPNs, travel, corporate proxies, and multilingual countries all break the IP-to-language assumption. Accept-Language and navigator.languages carry the preference the user actually configured, ordered by priority — read those, and still expose an explicit language switcher as the override.
- [MDN: Accept-Language](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)
- [MDN: navigator.languages](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/languages)
- [W3C i18n: Language priorities](https://www.w3.org/International/questions/qa-lang-priorities)
### Render Images with img, Not background-image
**ID:** `content-image-tag-not-background` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-image-tag-not-background](https://ui-guides-agent-rules.netlify.app/principles/content-image-tag-not-background)
**Agent rule (MUST):** Render content images with `<img>` + meaningful `alt`, never CSS `background-image` — a background has no role, no accessible name, and no "copy image" context menu. Reserve `background-image` for textures and gradients.
**Source:** [Rauno](https://interfaces.rauno.me/)
Use an img element for content images so they carry alt text and native save and copy affordances
> Images should always be rendered with the <img> tag rather than a CSS background-image, for screen readers and to preserve right-click "copy image".
A CSS background is decoration as far as the platform is concerned: it has no role, no accessible name, and no context menu entries, so screen readers skip it and users cannot save or copy it. Reserve background-image for textures and gradients, and give anything that is content an img with meaningful alt text (or an empty alt when it is purely decorative).
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN: img element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img)
- [W3C WAI: Images tutorial](https://www.w3.org/WAI/tutorials/images/)
### Label HTML Illustrations
**ID:** `content-illustration-label` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-illustration-label](https://ui-guides-agent-rules.netlify.app/principles/content-illustration-label)
**Agent rule (MUST):** Give a div-built illustration or chart `role="img"` + `aria-label` on the wrapper and `aria-hidden="true"` on its inner nodes, so screen readers announce it once instead of walking every group.
**Source:** [Rauno](https://interfaces.rauno.me/)
Give an illustration built from HTML an explicit aria-label so it is announced as one image, not as a DOM tree
> Illustrations built with HTML should have an explicit aria-label instead of being announced by screen readers.
A chart or illustration assembled from divs has no single accessible node, so assistive tech walks it element by element and reads out a stream of meaningless groups. Put role="img" plus an aria-label that states what the picture conveys on the wrapper, and mark the inner nodes aria-hidden, so the whole thing collapses into one announcement.
```tsx
<div role="img" aria-label="Revenue grew 40% in Q3">
<div className="bar" aria-hidden="true" />
</div>
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN: img role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/img_role)
- [ARIA APG: Names and Descriptions](https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/)
### Cap the Measure
**ID:** `content-impeccable-line-length` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-line-length](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-line-length)
**Agent rule (MUST):** Cap the measure: give prose containers `max-width: 65ch`–`75ch`. The detector estimates characters per line as `width / (fontSize * 0.5)` and flags anything above 85.
**Source:** [impeccable](https://impeccable.style/)
Constrain text containers to 65ch-75ch so the eye can find the start of the next line
> Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.
Reading is a sequence of saccades ending in a return sweep to the next line. The longer the line, the further that sweep travels and the more often it lands on the wrong one, which is why a full-width paragraph feels tiring even when every word is legible. impeccable's detector estimates characters-per-line as rect.width / (fontSize * 0.5) and flags anything above 85, so a max-width of 65ch to 75ch on the prose container keeps you well clear without constraining the page around it.
- [impeccable](https://impeccable.style/)
- [Practical Typography: Line length](https://practicaltypography.com/line-length.html)
- [MDN: max-width](https://developer.mozilla.org/en-US/docs/Web/CSS/max-width)
### Line Height Below 1.3 Is Unreadable
**ID:** `content-impeccable-tight-leading` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-tight-leading](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-tight-leading)
**Agent rule (MUST):** Set body `line-height` to 1.5–1.7 and never below 1.3 (flagged on any non-heading carrying more than 50 characters); derive the whole spacing scale from the resulting line box (16px × 1.5 = 24px).
**Source:** [impeccable](https://impeccable.style/)
Set body line-height between 1.5 and 1.7, and derive the spacing scale from it
> Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.
When lines sit too close, the descenders of one collide with the ascenders of the next and the eye can no longer isolate a single line, so it re-reads. impeccable's detector flags any non-heading element carrying more than 50 characters where lineHeight / fontSize falls below 1.3. The payoff of getting this right is structural, not just cosmetic: the body line box is the base unit of vertical rhythm, so 16px x 1.5 = 24px, and every margin, gap, and section break becomes a multiple of that one number instead of an arbitrary guess.
- [impeccable](https://impeccable.style/)
- [MDN: line-height](https://developer.mozilla.org/en-US/docs/Web/CSS/line-height)
- [Practical Typography: Line spacing](https://practicaltypography.com/line-spacing.html)
### Minimum Readable Body Size
**ID:** `content-impeccable-tiny-text` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-tiny-text](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-tiny-text)
**Agent rule (MUST):** Set prose at 14px minimum, 16px ideally, in `rem` so browser font settings still apply — anything under 12px on an element with more than 20 characters of text is a bug. UI chrome (buttons, links, labels, nav, badges, code, captions) is exempt.
**Source:** [impeccable](https://impeccable.style/)
Set prose at 14px minimum and 16px ideally, in rem so browser font settings still apply
> Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.
impeccable's detector fires on font-size below 12px only where an element carries more than 20 characters of direct text, and it explicitly excludes UI chrome: buttons, links, labels, nav, footers, badges, code, captions, and uppercase labels. That exclusion is the whole point. A 10px button label is fine; a 10px paragraph of terms of service is the bug. Pair the size floor with rem or em units rather than px, so a reader who raises their default browser font size actually gets larger text.
- [impeccable](https://impeccable.style/)
- [MDN: font-size](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size)
- [WCAG 1.4.4: Resize text](https://www.w3.org/WAI/WCAG21/Understanding/resize-text.html)
### Uppercase Is for Labels, Not Prose
**ID:** `content-impeccable-all-caps-body` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-all-caps-body](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-all-caps-body)
**Agent rule (NEVER):** Apply `text-transform: uppercase` to running text — flagged on any non-heading over 30 characters, because capitals erase the word shape readers recognize. Reserve uppercase for short labels and headings, and give those 0.05em–0.12em of letter-spacing.
**Source:** [impeccable](https://impeccable.style/)
Keep running text in sentence case and reserve uppercase for short labels and headings
> We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.
Fluent readers identify words by their silhouette, and capitals flatten every word into the same rectangle, forcing letter-by-letter decoding. impeccable's detector flags text-transform: uppercase on any non-heading element carrying more than 30 characters, which is why the all-caps legal disclaimer, the format chosen precisely to look important, is the one nobody reads. The companion rule matters too: short all-caps labels do need 5-12% letter-spacing (0.05em to 0.12em), because capitals are spaced to sit beside lowercase letters and crowd each other at default tracking.
- [impeccable](https://impeccable.style/)
- [MDN: text-transform](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform)
- [Practical Typography: All caps](https://practicaltypography.com/all-caps.html)
### Don't Justify Without Hyphenation
**ID:** `content-impeccable-justified-text` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-justified-text](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-justified-text)
**Agent rule (NEVER):** Ship `text-align: justify` without `hyphens: auto` — stretched word-spacing carves rivers of white through the paragraph. Left-align body text; if a justified column is non-negotiable, set `hyphens: auto` plus a `lang` attribute so the browser has a dictionary.
**Source:** [impeccable](https://impeccable.style/)
Left-align body text, or enable hyphens: auto if the design demands a justified column
> Justified text without hyphenation creates uneven word spacing ('rivers of white'). Use text-align: left for body text, or enable hyphens: auto if you must justify.
Browsers justify a line by stretching word-spacing until it reaches both margins. On a narrow measure there are only a few gaps to absorb the slack, so each one grows, and when wide gaps stack across consecutive lines they carve visible vertical channels through the paragraph. impeccable's detector flags exactly the combination text-align: justify where hyphens is not auto. Left alignment sidesteps the problem entirely; hyphens: auto (plus a lang attribute so the browser knows the dictionary) is the escape hatch when a justified column is non-negotiable.
```tsx
p { text-align: justify; hyphens: auto; } /* requires <html lang="en"> */
```
- [impeccable](https://impeccable.style/)
- [MDN: hyphens](https://developer.mozilla.org/en-US/docs/Web/CSS/hyphens)
- [MDN: text-align](https://developer.mozilla.org/en-US/docs/Web/CSS/text-align)
### Fewer Sizes, More Contrast
**ID:** `content-impeccable-type-scale-contrast` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-type-scale-contrast](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-type-scale-contrast)
**Agent rule (SHOULD):** Generate every type step from one ratio of at least 1.25 (major third), 1.333, or 1.5 rather than adding near-identical sizes — a page with 3 or more distinct sizes whose largest-to-smallest ratio is under 2.0 has no hierarchy left to squint at.
**Source:** [impeccable](https://impeccable.style/)
Generate every step from one ratio of at least 1.25 instead of adding near-identical sizes
> Font sizes are too close together - no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).
The named failure is "too many font sizes that are too close together (14px, 15px, 16px, 18px...)": each new size was added to solve a local problem and none of them are far enough apart to signal rank, so the page reads as one grey slab. impeccable's detector flags a page with 3 or more distinct sizes whose largest-to-smallest ratio is under 2.0. Pick a single ratio (1.25 major third, 1.333 perfect fourth, or 1.5), generate five steps from it, and the hierarchy survives a squint test.
- [impeccable](https://impeccable.style/)
- [Practical Typography: Type composition](https://practicaltypography.com/type-composition.html)
- [MDN: font-size](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size)
### Em-Dashes Are an AI Tell
**ID:** `content-impeccable-em-dash-overuse` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-em-dash-overuse](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-em-dash-overuse)
**Agent rule (SHOULD):** Punctuate with commas, colons, semicolons, periods, or parentheses instead of em-dashes (and never the ASCII `--`) — 5 or more in body text is a machine-written cadence readers register even when each individual dash is defensible.
**Source:** [impeccable](https://impeccable.style/)
Punctuate with commas, colons, semicolons, periods, or parentheses instead of em-dashes
> No em dashes. Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
The skill itself bans the em dash outright, but its detector is deliberately lenient: it only fires at 5 or more em-dashes (or the ASCII `--`) in body text. Be accurate about that distinction. A single dash is a style choice; a page where every paragraph pivots on the same mid-sentence interruption has a machine-written cadence, and it is the cadence readers register, not any individual mark. Repunctuating forces you to pick the mark that actually carries the relationship, which varies the rhythm as a side effect.
- [impeccable](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [Practical Typography: Type composition](https://practicaltypography.com/type-composition.html)
### Say What the Product Does
**ID:** `content-impeccable-marketing-buzzwords` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-marketing-buzzwords](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-marketing-buzzwords)
**Agent rule (NEVER):** Ship generic SaaS phrasing — "streamline your", "empower your", "supercharge", "unleash the power", "best-in-class", "industry-leading", "world-class", "enterprise-grade", "next-generation", "cutting-edge", "seamless experience", "harness the power" (~30 blocked phrases, any single hit fires). If the sentence would fit a CRM, a CDN, and a coffee machine equally well, replace it with a specific verb and noun. Same for 3 or more manufactured-contrast lines ("Not a feature. A platform.").
**Source:** [impeccable](https://impeccable.style/)
Replace generic SaaS phrases with a specific verb and noun from the actual product
> Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.
impeccable ships a literal blocklist of roughly 30 phrases and fires on any single hit: "streamline your", "empower your", "supercharge your", "unleash the power", "best-in-class", "industry-leading", "world-class", "enterprise-grade", "next-generation", "cutting-edge", "transform your business", "revolutionize", "game-changer", "mission-critical", "future-proof", "seamless experience", "seamlessly integrate", "harness the power", "trusted by leading", "built for the modern". The test is interchangeability: if the sentence would fit a CRM, a CDN, and a coffee machine equally well, it says nothing. A companion detector, aphoristic-cadence, fires at 3 or more manufactured-contrast constructions ("Not a feature. A platform.").
- [impeccable](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [Google Style: Word list](https://developers.google.com/style/word-list)
### Light-on-Dark Text Needs Three Fixes
**ID:** `content-impeccable-dark-mode-text-compensation` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-dark-mode-text-compensation](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-dark-mode-text-compensation)
**Agent rule (MUST):** Compensate light-on-dark type on all three axes at once — `line-height` +0.05 to +0.1, `letter-spacing` +0.01em to +0.02em, and optionally one step of body weight — because light glyphs optically bloom. One type spec cannot serve both themes.
**Source:** [impeccable](https://impeccable.style/)
Compensate light-on-dark type on line-height, letter-spacing, and weight, not just one
> Light text on dark backgrounds needs compensation on three axes, not just one. Bump line-height by 0.05-0.1, add a touch of letter-spacing (0.01-0.02em), and optionally step the body weight up one notch.
Light glyphs on a dark field optically bloom: the bright strokes spill into the surrounding dark, so the same type reads heavier and tighter than it does inverted. The concrete remedy is all three axes at once - line-height up by 0.05 to 0.1, letter-spacing up by 0.01em to 0.02em, and optionally one step of body weight - because fixing only one leaves the block feeling wrong without telling you why. Ship a single type spec for both themes and one of the two is always mis-set.
- [impeccable](https://impeccable.style/)
- [MDN: letter-spacing](https://developer.mozilla.org/en-US/docs/Web/CSS/letter-spacing)
- [NN/g: Dark mode](https://www.nngroup.com/articles/dark-mode/)
### Don't Ship the Schema
**ID:** `content-dont-ship-schema` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-dont-ship-schema](https://ui-guides-agent-rules.netlify.app/principles/content-dont-ship-schema)
**Agent rule (MUST):** Don't ship the schema—visuals may omit labels but accessible names still exist
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Keep accessible names even when the visual design omits visible labels
> Don’t ship the schema. Visual layouts may omit visible labels, but accessible names/labels still exist for assistive tech.
A dense or minimal layout is free to drop visible labels — a search field can rely on a placeholder, an action row can be icon-only. What it cannot drop is the underlying schema: every control still needs an accessible name in the tree. A placeholder is not a name (it disappears on input and is not reliably announced), and an icon is not a name. Use a visually-hidden `<label>` or `aria-label`, and mark the icon `aria-hidden`. The visual design is a projection of the schema, not a replacement for it.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN: Accessible name](https://developer.mozilla.org/en-US/docs/Glossary/Accessible_name)
- [WCAG 2.1: Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)
### Brand Resources from the Logo
**ID:** `content-brand-resources` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-brand-resources](https://ui-guides-agent-rules.netlify.app/principles/content-brand-resources)
**Agent rule (SHOULD):** Right-clicking the nav logo surfaces brand assets
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Surface brand assets from a right-click on the nav logo
> Brand resources from the logo. Right-click the nav logo to surface brand assets for quick access.
People who need your logo — press, partners, conference organizers, someone building an integration — reach for it at the moment they see it, in the nav. If a right-click offers nothing, they screenshot the mark and ship a blurry, recolored, wrongly-cropped version of your brand. Intercepting the context menu on the logo to offer "copy as SVG", "download assets", and a link to the guidelines puts the correct file in their hands exactly when they want it, instead of behind a Footer → About → Press Kit hunt.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN: contextmenu event](https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event)
### Be Clear and Concise
**ID:** `content-be-concise` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-be-concise](https://ui-guides-agent-rules.netlify.app/principles/content-be-concise)
**Agent rule (MUST):** Write the sentence, then delete words until deleting one more would change the meaning. Cut padding phrases ("in order to", "please note that", "at this time"); keep the nouns and the verb, drop the ceremony.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Cut every word that does not change the meaning, then ship the shortest version that still says it
> Be clear & concise. Use as few words as possible.
Nobody reads interface copy — they scan it, and every extra word is another thing to skip past before the action becomes visible. Padding phrases ("in order to", "please note that", "at this time") add length without adding information, and on a button they push the verb past the point where the eye stops. Write the sentence, then delete words until deleting one more would change what it means. Concise is not terse: keep the nouns and the verb, drop the ceremony.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Plain Language: Be Concise](https://www.plainlanguage.gov/guidelines/concise/)
- [Microsoft Style: Simple Words, Concise Sentences](https://learn.microsoft.com/en-us/style-guide/word-choice/use-simple-words-concise-sentences)
### Keep Nouns Consistent
**ID:** `content-consistent-nouns` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-consistent-nouns](https://ui-guides-agent-rules.netlify.app/principles/content-consistent-nouns)
**Agent rule (MUST):** Name each object once and reuse that exact noun in every label, heading, toast, and error — project/app/site/deployment for one concept reads as four things. Introduce as few unique terms as possible and keep a glossary.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Name each object once and reuse that exact noun everywhere it appears in the product
> Keep nouns consistent. Introduce as few unique terms as possible.
When one screen calls the same object a project, an app, a site, and a deployment, the reader has to decide whether those are four things or one — and the safe assumption is four. Synonyms feel like good writing and behave like a bug: they break search, they break the mental model, and they make docs, support replies, and API names drift apart. Pick one noun per concept, write it in a glossary, and use it in every label, heading, toast, and error until the concept itself changes.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [NN/g: Consistency and Standards](https://www.nngroup.com/articles/consistency-and-standards/)
- [Google Style: Word List](https://developers.google.com/style/word-list)
### Consistent Placeholders in Samples
**ID:** `content-consistent-placeholders` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-consistent-placeholders](https://ui-guides-agent-rules.netlify.app/principles/content-consistent-placeholders)
**Agent rule (MUST):** Use one loud, obviously-fake placeholder convention across all code samples — `YOUR_API_TOKEN_HERE` for strings, `0123456789` for numbers — never a mix of `<your-token>` / `xxx` / `abc123` / `[INSERT KEY]`. (This is about fill-me-in tokens in docs, not the HTML `placeholder` attribute.)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use one placeholder convention across every code sample: YOUR_API_TOKEN_HERE for strings, 0123456789 for numbers
> Use consistent placeholders. Strings: YOUR_API_TOKEN_HERE. Numbers: 0123456789.
This is about the fill-me-in tokens inside documentation and code samples, not the HTML placeholder attribute on an input — that one is covered by the separate rule against using a placeholder as a label or a value. When a snippet mixes <your-token>, xxx, abc123, and [INSERT KEY], the reader cannot tell which strings are literal and which are theirs, so they paste a sample verbatim and get a 401. A single loud, obviously-fake convention — screaming snake case for strings, a monotone digit run for numbers — is unmistakably a slot, greppable, and safe to search-and-replace across a whole page.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Google Style: Placeholders](https://developers.google.com/style/placeholders)
### Consistent Currency Formatting
**ID:** `content-currency-formatting` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-currency-formatting](https://ui-guides-agent-rules.netlify.app/principles/content-currency-formatting)
**Agent rule (MUST):** Pick 0 or 2 decimal places per context and format every amount that way, including round ones — never mix `$12` with `$8.50` in one column. Pair with tabular figures and right alignment so decimal separators stack.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Pick 0 or 2 decimal places for a given context and apply it to every amount, never mixing the two
> Consistent currency formatting. In any given context, display currency with either 0 or 2 decimal places, never mix both.
A column that reads $12, $8.50, $1,204, $99.00 forces the eye to re-parse each row: the decimal point lands in a different place every time, so the digits no longer line up and the magnitudes stop being comparable at a glance. Choose the precision the context needs — whole dollars for a pricing page, cents for an invoice — and format every amount that way, including the round ones. Pair the choice with tabular figures and right alignment so the decimal separators stack into a single vertical line.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Microsoft Style: Numbers](https://learn.microsoft.com/en-us/style-guide/numbers)
- [Google Style: Numbers](https://developers.google.com/style/numbers)
### Default to Positive Language
**ID:** `content-positive-language` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-positive-language](https://ui-guides-agent-rules.netlify.app/principles/content-positive-language)
**Agent rule (SHOULD):** Frame messages around what can be done next, not what the person did wrong — drop "you failed to", "invalid", "aborted". Stay specific about the cause and the limit; positive framing is a change of frame, not a loss of detail.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Frame messages around what can be done next rather than what the person did wrong
> Default to positive language. Frame messages in an encouraging, problem-solving way, even for errors.
This is about framing and tone, not content: the separate rule on actionable errors says the message must include the fix, while this one says the sentence around that fix must not read as an accusation. "You failed to upload" and "Operation aborted" blame the reader and describe a dead end, which is both unpleasant and useless. Same facts, constructive frame: name what happened without a culprit, then point forward. Positive framing is not vagueness — stay specific about the cause and the limit, just drop the "you failed", the "invalid", and the "aborted".
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [Google Style: Tone and Voice](https://developers.google.com/style/tone)
- [NN/g: Error Message Guidelines](https://www.nngroup.com/articles/error-message-guidelines/)
### Loading Copy Sets an Expectation
**ID:** `content-impeccable-loading-copy-expectation` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-loading-copy-expectation](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-loading-copy-expectation)
**Agent rule (SHOULD):** Name the work and its expected duration inside a loading state ("Analyzing your data… this usually takes 30–60 seconds") instead of a bare "Loading…", which says the same thing at 1s and at 90s so the user cannot tell working from hung. Show a signal that advances, and give anything past ~10s an escape hatch.
**Source:** [impeccable](https://impeccable.style/)
Name the work being done and how long it usually takes, instead of shipping a bare "Loading…"
> Bad: 'Loading…' (for 30+ seconds). Good: 'Analyzing your data… this usually takes 30-60 seconds.'
The corpus already governs how a loading state looks — stable skeletons, matched dimensions, a minimum display duration — but nothing until now governed the words inside it, and the words are what carry the expectation. "Loading…" is a dead end: it says the same thing at one second and at ninety, so on a long job the user cannot distinguish "still working" from "hung", and the rational move becomes reloading the page and losing the work. Name the work ("Importing 1,240 rows"), state the expected duration ("this usually takes about a minute"), and show a signal that advances. Anything past ten seconds also needs an escape hatch, because a bounded wait the user chose to abandon is far better than an unbounded one they had to guess about.
- [impeccable — clarify](https://impeccable.style/)
- [NN/g: Progress Indicators](https://www.nngroup.com/articles/progress-indicators/)
- [NN/g: Response Times — The 3 Important Limits](https://www.nngroup.com/articles/response-times-3-important-limits/)
### Frame Progress as Milestones, Not Remaining Work
**ID:** `content-progress-as-milestones` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-progress-as-milestones](https://ui-guides-agent-rules.netlify.app/principles/content-progress-as-milestones)
**Agent rule (SHOULD):** Chunk long task lists into a handful of named phases and show the current one ("Complete Phase 1 of 4") rather than leading with remaining work ("10 / 47 tasks complete"), which makes the 37 unfinished items the salient fact. Collapse finished phases into a done marker; keep the honest total reachable, just do not lead with it.
**Source:** Custom
Group a long task list into named phases so progress reads as ground gained rather than debt outstanding
> Showing '10 / 47 tasks complete' immediately communicates that 37 tasks remain… 'Complete Phase 1 of 4' is psychologically very different — it frames the same progress as achievable milestones rather than an endless checklist.
This one comes from Josh Puckett's Interface Craft, and it is purely about framing: the underlying progress is identical, only the sentence changes. "10 / 47 complete" is mathematically accurate and motivationally hostile — it makes the 37 unfinished items the salient fact, and each completed task moves the bar by two percent, which reads as futility. Chunk the same work into a handful of named phases, show only the current one, and collapse finished phases into a done marker so the remaining work visibly shrinks as the user advances. Keep the honest total reachable for anyone who wants it, but do not lead with it.
- [Josh Puckett — Interface Craft](https://interfacecraft.dev/)
- [Laws of UX: Goal-Gradient Effect](https://lawsofux.com/goal-gradient-effect/)
- [NN/g: Progress Indicators](https://www.nngroup.com/articles/progress-indicators/)
### Survive User Text Spacing
**ID:** `content-text-spacing-override` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-text-spacing-override](https://ui-guides-agent-rules.netlify.app/principles/content-text-spacing-override)
**Agent rule (MUST):** Text containers must survive user-applied `line-height: 1.5`, `letter-spacing: 0.12em`, `word-spacing: 0.16em` and 2× paragraph spacing with no loss of content — WCAG SC 1.4.12 Text Spacing, Level AA. The failure is the box, not the type: fixed `height` + `overflow: hidden` slices the last line off. Size with `min-height` and padding, let flex/grid tracks size to content, and never clip text you mean to be read.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Size text containers so nothing is lost when the user overrides line, letter, word, and paragraph spacing
> In content implemented using markup languages that support the following text style properties, no loss of content or functionality occurs by setting all of the following and by changing no other style property: Line height (line spacing) to at least 1.5 times the font size; Spacing following paragraphs to at least 2 times the font size; Letter spacing (tracking) to at least 0.12 times the font size; Word spacing to at least 0.16 times the font size.
SC 1.4.12 Text Spacing (Level AA) is not about the spacing you choose — content-ibelick-letter-spacing and content-impeccable-tight-leading cover that. It is about what happens when a reader with low vision or dyslexia overrides your choice with a user stylesheet, a bookmarklet, or a reading extension, and sets line-height to 1.5x the font size, spacing after paragraphs to 2x, letter-spacing to 0.12em, and word-spacing to 0.16em, all at once. The typical failure is not the type: it is the box. A container with a fixed height plus overflow: hidden cannot grow, so the last line is sliced off mid-glyph and the content is simply gone — a "no loss of content" failure, not a cosmetic one. Fix it by sizing with min-height and padding instead of height, letting flex and grid tracks size to content, and never clipping text you intend the user to read. Test it by applying the four values to the whole page and looking for clipped, truncated, or overlapping text.
```tsx
/* clips at 1.5 line-height */ .card { height: 96px; overflow: hidden; }
/* grows with the user's spacing */ .card { min-height: 96px; padding-block: 12px; }
```
- [Understanding SC 1.4.12: Text Spacing](https://www.w3.org/WAI/WCAG21/Understanding/text-spacing.html)
- [MDN: min-height](https://developer.mozilla.org/en-US/docs/Web/CSS/min-height)
### Never Let aria-label Contradict Visible Text
**ID:** `content-aria-label-overrides-visible-text` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-aria-label-overrides-visible-text](https://ui-guides-agent-rules.netlify.app/principles/content-aria-label-overrides-visible-text)
**Agent rule (NEVER):** Give a control an `aria-label` that contradicts or omits its visible text — on any role that names from child content, `aria-label` REPLACES that text, so `<button aria-label="Submit form">Save</button>` answers only to "Submit form" and a voice-control user saying "click Save" hits nothing (WCAG SC 2.5.3 Label in Name, Level A requires the accessible name to contain the visible string). Name from child content; use `aria-labelledby` to point at visible text elsewhere; reserve `aria-label` for controls with no visible text. Extending is fine — `aria-label="Save draft"` on a button reading "Save".
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
Take the accessible name from the visible text; if both exist, the name must contain the visible string
> When applied to an element with one of the roles that supports naming from child content, aria-label hides descendant content from assistive technology users and replaces it with the value of aria-label. [...] Rule 2: Prefer Visible Text — when a user interface includes visible text that could be used to provide an appropriate accessible name, using the visible text for the accessible name simplifies maintenance, prevents bugs, and reduces language translation requirements.
This is the mismatch case, and it is distinct from every neighbouring rule in the corpus. content-dont-ship-schema and content-icons-have-labels (and interactions-rams-aria-labels) cover the inverse problem: an icon-only control with no visible text, where aria-label is exactly right. Here the control already has text. On a button, div, link, or any role that supports naming from child content, aria-label does not add to that text — it replaces it, hiding the child content from assistive technology. So <button aria-label="Submit form">Save</button> is a button that reads "Save" and answers to "Submit form". A voice-control user who says "click Save" hits nothing, which is why WCAG SC 2.5.3 Label in Name (Level A) requires the accessible name to contain the visible label text. Prefer naming from child content; when you need to reference text elsewhere, use aria-labelledby pointing at the visible element. Reserve aria-label for controls with no visible text at all, and if you must extend a name, keep the visible string inside it (aria-label="Save draft" on a button reading "Save" is fine).
- [APG: Providing Accessible Names and Descriptions](https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/)
- [Understanding SC 2.5.3: Label in Name](https://www.w3.org/WAI/WCAG21/Understanding/label-in-name.html)
### Lists Are ul/li, Tables Are th/td
**ID:** `content-list-and-table-semantics` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-list-and-table-semantics](https://ui-guides-agent-rules.netlify.app/principles/content-list-and-table-semantics)
**Agent rule (MUST):** Lists are `<ul>`/`<ol>` + `<li>`; tables are `<table>` with `<th scope="col|row">` for headers. A `<div className="space-y-2">` of divs announces nothing — no "list, 5 items", no way to skip it — and a div grid with a bold header row reads a cell as a naked "42" instead of "Revenue, Q3, 42". Bold is not a header. Tailwind's `list-none` drops the marker without dropping the semantics, so there is no styling reason to use divs.
**Source:** [@Ibelick](https://www.ui-skills.com/)
A stack of divs looks like a list and a grid of divs looks like a table, but neither announces any structure to a screen reader
> lists must use ul or ol with li
These are the two most common div-soup offences in generated markup, because a `<div className="space-y-2">` of `<div>`s and a flexbox grid with a bold header row *look* exactly right. What they lose is everything a non-visual user navigates by. A real `<ul>` announces "list, 5 items" and gives the reader a way to jump to the next item or skip the list entirely; a stack of divs announces nothing, and the reader has no idea how much is left. The table case is worse, because the loss is per-cell: ibelick's companion rule is "tables must use th for headers when applicable", and a `<th scope="col">` is what makes a screen reader read a cell in the middle of the grid as "Revenue, Q3, 42" instead of a naked "42" with no idea which column or row it belongs to. That association cannot be recovered from visual weight — bold is not a header. content-semantics-first states the principle in general; this is what it actually costs in the two places it is most often ignored. Note that Tailwind's `list-none` removes the marker without removing the semantics, so there is no styling reason to reach for divs.
- [ibelick — fixing-accessibility SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [MDN — <th> element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/th)
- [W3C — Web Accessibility Tutorials: Tables](https://www.w3.org/WAI/tutorials/tables/)
### Bound Your clamp()
**ID:** `content-impeccable-fluid-type-bounds` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-fluid-type-bounds](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-fluid-type-bounds)
**Agent rule (SHOULD):** Bound every `clamp()`: max-size <= ~2.5x min-size. `clamp(1rem, 5vw, 6rem)` is a 6x span that renders the heading at body size on a phone and shouts at 1400px, and a bare `vw` middle term ignores the reader's font-size preference — add a rem offset (`clamp(1.5rem, 1.2rem + 1.5vw, 2.5rem)`, a 1.7x range) to put zoom and reflow back in the calculation. Second half of the rule: do NOT use fluid type in product UI at all — Material, Polaris, Primer and Carbon all ship fixed rem scales, because dense container-based layouts need spatial predictability. Fluid type is for headings and display text on marketing/content pages; body copy stays fixed even there.
**Source:** [impeccable](https://impeccable.style/)
Keep the max at most ~2.5x the min — and keep fluid type out of product UI entirely
> Bound your clamp(): keep max-size ≤ ~2.5 × min-size. Wider ratios break the browser's zoom and reflow behaviour and make large viewports feel like the page is shouting.
This extends `content-fluid-clamp`, which teaches the clamp() technique but sets no bounds on it. The bound is the whole rule: max-size <= ~2.5 x min-size. `clamp(1rem, 5vw, 6rem)` spans 16px to 96px — a 6x ratio — and it is absurd at both ends: at 320px the heading renders at its 16px floor, identical to the body text it is supposed to outrank, and at 1400px it is shouting. A ratio that wide also breaks zoom and reflow, because the viewport unit in the middle term ignores the reader's font-size preference; adding a rem offset (`clamp(1.5rem, 1.2rem + 1.5vw, 2.5rem)` — a 1.7x range) puts that preference back into the calculation. The second half of the rule is the one people miss: do not use fluid type in product UI at all. No major app design system does — Material, Polaris, Primer and Carbon all ship fixed rem scales with optional breakpoint adjustments — because a dense, container-based layout needs spatial predictability, and an h1 that shrinks when the sidebar opens looks worse, not better. Fluid type belongs to headings and display text on marketing and content pages where text dominates the layout. Body copy stays fixed even there, since the size difference across viewports is too small to be worth it.
- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [MDN — clamp()](https://developer.mozilla.org/en-US/docs/Web/CSS/clamp)
### Never Ship a Broken Image
**ID:** `content-impeccable-broken-image` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-broken-image](https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-broken-image)
**Agent rule (NEVER):** Ship an `<img>` with an empty, missing, or placeholder `src` — it renders the browser's broken-image box. The value is rarely a literal; it is `user.avatar` and the CMS returned null, so `<img src={undefined} />` compiles, type-checks, passes review, and breaks for every record missing the field. Two failure modes, two guards: MISSING URL — guard the RENDER, do not emit an `<img>` at all, ship a real fallback (initials avatar, skeleton, neutral box); URL THAT 404s — guard the NETWORK with `onError` and swap in the same fallback. Reserve identical dimensions for both, or the swap adds a layout shift on top of the bug you just fixed.
**Source:** [impeccable](https://impeccable.style/)
Guard the render and guard the network — an <img> with no resolvable src ships as a broken-image box
> <img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.
The detector catches three shapes: `<img src="">` (also `src=" "` and `src="#"`), an `<img>` with no `src` attribute at all, and a src that is a placeholder rather than a real asset. It is the most trivial rule in impeccable's registry and it reaches production constantly, because the value is usually not a literal — it is `user.avatar` or `post.cover`, and the CMS returned null. React then omits the attribute entirely, so `<img src={undefined} />` compiles, type-checks, passes review, and renders the browser's broken-image chrome to every user whose record happens to be missing that field. There are two distinct failure modes and each needs its own guard. Missing URL: guard the RENDER — if there is no src, do not emit an `<img>`; ship a real fallback instead (an initials avatar, a skeleton, a neutral placeholder box) so the layout is intact and the meaning still reads. URL that fails to load: guard the NETWORK — a remote src can be present and still 404, so attach `onError` and swap in the same fallback the moment the browser gives up. Reserve identical dimensions for image and fallback, or the swap introduces a layout shift on top of the broken image you just fixed.
- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [MDN — <img>: The Image Embed element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img)
### No Synthetic Weights or Italics
**ID:** `content-no-synthetic-weights` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-no-synthetic-weights](https://ui-guides-agent-rules.netlify.app/principles/content-no-synthetic-weights)
**Agent rule (MUST):** Set `font-synthesis: none` on the root so a weight or style you never loaded fails visibly instead of being faked. The browser draws a missing bold as the 400 outlines doubled a hair apart and a missing italic as a `skewX` on the roman — neither looks broken enough to file a ticket, so the missing files ship. The fix is always the same: load the file you asked for.
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
Set font-synthesis: none so a missing bold or italic file fails visibly instead of being faked
> When a weight or style is not loaded, the browser synthesizes it. That is a safety mechanism, not a feature. Set `font-synthesis: none` so missing files fail visibly instead of rendering a faked bold or italic.
Synthesis is the browser covering for you, and it covers so competently that the bug never surfaces. Ask for a bold the `@font-face` block never loaded and you do not get an error — you get the 400 outlines drawn twice, a hair apart, so the stems thicken and the counters clog. Ask for an italic and you get a `skewX` on the roman: a mechanical slant, not an italic, with no redrawn single-storey `a` and no descending `f`. Neither looks broken enough to file a ticket, so the missing files ship and every heading in the product is quietly mush. `font-synthesis: none` on the root removes the safety net: the `<strong>` renders at plain 400, which is unmistakably wrong, which is the point — it fails in review instead of in production. The fix is then always the same, load the file you actually asked for.
```tsx
html { font-synthesis: none; }
```
- [jakubkrehel — better-typography SKILL.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/SKILL.md)
- [jakubkrehel — variable-fonts-and-opentype.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/variable-fonts-and-opentype.md)
- [MDN — font-synthesis](https://developer.mozilla.org/en-US/docs/Web/CSS/font-synthesis)
### Properties Over Raw Font Tags
**ID:** `content-font-properties-over-tags` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-font-properties-over-tags](https://ui-guides-agent-rules.netlify.app/principles/content-font-properties-over-tags)
**Agent rule (MUST):** Use the CSS PROPERTY whenever one exists — `font-weight: 650`, `font-optical-sizing: auto`, `font-variant-numeric: tabular-nums` — not `font-variation-settings: "wght" 650` / `font-feature-settings: "tnum" 1`. Raw-tag declarations address axes that only exist inside the variable file, so when a non-variable fallback renders they SILENTLY DO NOTHING (weight collapses to 400, no console error). And `font-feature-settings` is ONE property, so a later declaration CLOBBERS an earlier one — add `"ss01" 1` to a rule that carried `"tnum" 1` and the tabular figures leave with it. Reserve the raw tags for custom axes (`"GRAD" 80`) and niche features (`"ss01" 1`) that have no property of their own.
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
Reach for font-weight and font-variant-numeric, not font-variation-settings and font-feature-settings
> When a CSS property exists, use it. `font-weight: 650` instead of `font-variation-settings: "wght" 650`, `font-optical-sizing: auto` instead of `"opsz"`, `font-variant-numeric: tabular-nums` instead of `font-feature-settings: "tnum" 1`. Properties keep working when a non-variable fallback renders. Reserve the raw-tag properties for custom axes (`"GRAD" 80`) and niche features (`"ss01" 1`) that have no property of their own.
This is an API rule, not a typography rule, and it is the one people get wrong precisely because both spellings look equivalent while the variable font is loading fine. They are not. `font-variation-settings: "wght" 650` addresses an axis that only exists inside a variable file, so the moment the fallback stack renders — the file 404s, the CDN is blocked, the user is on a metered connection with fonts disabled — the declaration is not overridden, it is inert. The weight collapses to 400, the console says nothing, and the semibold heading you designed is now body copy. `font-weight: 650` is a real property that every font stack understands: the browser picks the closest available face and the hierarchy survives. The same asymmetry applies below the surface for features: `font-feature-settings` is a single low-level property rather than a set of independent switches, so the day someone adds `"ss01" 1` to a rule that already carried `"tnum" 1`, the tabular figures leave with it and the ticker starts jittering again. `font-variant-numeric: tabular-nums` cannot be clobbered that way, because it lives on its own property. Note this is orthogonal to content-tabular-numbers, which says *what* to apply and never *which API* — this is the rule that picks the API. Raw tags are not banned; they are the escape hatch, and the only correct use of them, for custom axes like `"GRAD" 80` and niche features like `"ss01" 1` that have no property of their own.
```tsx
/* ✗ inert under a non-variable fallback; "tnum" clobbered by the next rule */
.price { font-variation-settings: "wght" 650; font-feature-settings: "tnum" 1; }
/* ✓ real properties, survive the fallback, cannot clobber each other */
.price { font-weight: 650; font-variant-numeric: tabular-nums; font-feature-settings: "ss01" 1; }
```
- [jakubkrehel — better-typography SKILL.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/SKILL.md)
- [jakubkrehel — variable-fonts-and-opentype.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/variable-fonts-and-opentype.md)
- [MDN — font-variant-numeric](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variant-numeric)
- [MDN — font-weight](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-weight)
### Heading Sizes Descend with Level
**ID:** `content-heading-size-descends` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-heading-size-descends](https://ui-guides-agent-rules.netlify.app/principles/content-heading-size-descends)
**Agent rule (MUST):** Pick the heading tag from the document outline and the size from a DESCENDING step of the type scale — a lower level must never render larger than a higher one on the same page. Never reach for a tag because it "looks right". Adjacent levels may share a size at the small end if weight or spacing keeps them distinct; a line that must be the loudest thing on the card without owning a rank is a `<p>` at display size. Distinct from `content-rams-heading-levels`, which is about SKIPPING levels (a11y outline) — here the tags are sequential and lint passes, but the render contradicts them.
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
Map every heading level to a descending step of the scale so a lower level never renders larger than a higher one
> Map each heading level used on a page to a descending step of the type scale: a lower level must never render larger than a higher one on the same page. Adjacent levels may share a size toward the small end of the scale as long as weight or spacing keeps them distinct. Pick the tag from the document outline and control the size with CSS; never skip levels or reach for an `h4` because it "looks right".
This is a different failure from content-rams-heading-levels, and worth separating carefully. That rule is about SKIPPING levels — h1 straight to h3 — which lint catches and WCAG 1.3.1 names. This one is the inverse-SIZE failure, and every tag in it is sequential, so the lint passes: an `<h2>` set at `text-base` because it looked right in the card, wrapping an `<h3>` at `text-2xl` because that was the line the designer wanted read first. The tags are legal; the render contradicts them. A screen reader navigates the outline and hears "Revenue up 12%" as subordinate to "Q3 report"; the page says the opposite, in twice the pixels. Whenever a child level renders larger than its parent, the tag was picked for its default size instead of from the document structure. Take the tag from the outline, take the size from a descending step of the scale, and when a line genuinely needs to be the loudest thing on the card without owning a rank, make it a `<p>` at display size — it can be as big as it likes and it stays out of the outline entirely.
- [jakubkrehel — better-typography SKILL.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/SKILL.md)
- [jakubkrehel — spacing-and-sizing.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/spacing-and-sizing.md)
- [MDN — Heading elements](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements)
### Truncate Without Losing the Value
**ID:** `content-truncation-keeps-value` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-truncation-keeps-value](https://ui-guides-agent-rules.netlify.app/principles/content-truncation-keeps-value)
**Agent rule (MUST):** If the text an ellipsis hides discriminates rather than decorates, keep the full value reachable — a `title` (which also lands in the accessible name), a tooltip, an expanded view, or middle truncation that pins the discriminating tail (`invoice-2026-…-final-v2.pdf`) and collapses the boring head. Hover-only tooltips are not enough on their own: keyboard and touch users never trigger them, so pair them with something focusable.
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
If the text an ellipsis hides carries meaning, keep the full value reachable in a tooltip or an expanded view
> Truncation hides content, so if the missing text matters, keep the full value reachable in a tooltip or expanded view.
Two neighbouring rules already cover the mechanics and neither covers this. content-ibelick-text-overflow says to USE `truncate` or `line-clamp` in dense UI; layout-min-width-truncation makes truncation actually WORK inside a flex or grid child. Both are about the ellipsis appearing where it should. This rule is about what the ellipsis ate. A file column that renders `invoice-2026-q3-a…` on every row has not shortened three filenames, it has deleted the only part that told them apart, and the user is left choosing an invoice by its byte count. So: decide whether the hidden text is decoration or discrimination. If it discriminates, keep it reachable — a `title` (which also lands in the accessible name), a tooltip, a details view, or middle truncation that pins the tail (`invoice-2026-…-final-v2.pdf`) and collapses the boring head instead. Hover-only tooltips are not enough on their own, since keyboard and touch users never trigger them, so pair them with something focusable or an expand toggle.
- [jakubkrehel — better-typography SKILL.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/SKILL.md)
- [jakubkrehel — wrapping-and-punctuation.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/wrapping-and-punctuation.md)
- [MDN — text-overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow)
- [MDN — title attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/title)
### Tracking Is Size-Specific
**ID:** `content-tracking-is-size-specific` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-tracking-is-size-specific](https://ui-guides-agent-rules.netlify.app/principles/content-tracking-is-size-specific)
**Agent rule (SHOULD):** Do not ship one `letter-spacing` for all sizes. Leave body near 0, tighten display type (~ -0.02em at 36px+), and give small caps/captions a small positive nudge. Prefer a variable font's optical-sizing axis (`font-optical-sizing: auto`) over a hardcoded value. This refines, not contradicts, "don't track body text".
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Tighten letter-spacing as display type grows and leave body near zero — a single tracking value is wrong at some size
> Tracking (letter-spacing) is size-specific — never one value for all sizes. Large display text wants negative tracking; small text wants slightly positive tracking for legibility. Tighten headings, leave body near 0.
From the typography section of Emil Kowalski's apple-design skill, and it sits in DELIBERATE TENSION with content-ibelick-letter-spacing, which says never touch tracking unless asked. The corpus keeps both, because they are right about different sizes. ibelick's rule protects body text, where a face was hinted for reading and any manual tracking almost always makes it worse — that is the common case and the safe default. Emil's rule is about the extremes a text-optimised face was never spaced for: at display sizes (36px and up) the designer-intended spacing reads too loose, the letters drift apart, and the headline stops cohering, so it wants slightly NEGATIVE tracking; at caption sizes (11–12px) it wants a touch POSITIVE to hold the glyphs apart. The honest reconciliation is that the best tool is not manual tracking at all but optical sizing — `font-optical-sizing: auto` on a variable font with an `opsz` axis corrects spacing, contrast and detail per size automatically, which is exactly what "type is designed with proper spacing" should mean end to end. Reach for a manual `letter-spacing` step only when the face has no `opsz` axis. So: never randomly track body (that is ibelick's rule intact), do correct display and caption, and prefer `opsz` over a hardcoded value. Related: content-impeccable-type-scale-contrast, content-heading-size-descends, content-leading-tracks-size (its line-height counterpart).
```tsx
h1 { font-size: 48px; letter-spacing: -0.02em; }
.caption { font-size: 11px; letter-spacing: 0.04em; }
body { font-optical-sizing: auto; }
```
- [Emil Kowalski — apple-design SKILL.md](https://raw.githubusercontent.com/emilkowalski/skills/main/skills/apple-design/SKILL.md)
- [MDN — font-optical-sizing](https://developer.mozilla.org/en-US/docs/Web/CSS/font-optical-sizing)
- [MDN — letter-spacing](https://developer.mozilla.org/en-US/docs/Web/CSS/letter-spacing)
### Leading Tightens as Size Grows
**ID:** `content-leading-tracks-size` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-leading-tracks-size](https://ui-guides-agent-rules.netlify.app/principles/content-leading-tracks-size)
**Agent rule (SHOULD):** Set line-height inversely to font size — do not inherit one ratio everywhere. Large display type wants ~1.0–1.2 so wrapped lines cohere; body wants ~1.5–1.7. Give scripts with tall ascenders/descenders more, dense data UI less. Pair each type-scale step with its own ratio.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Set line-height inversely to font size — tight on large headings, generous on body — instead of one ratio for everything
> Leading (line-height) tracks size inversely. Tight on large headings, looser on body copy. Increase it for scripts with tall ascenders/descenders; tighten it for dense, information-heavy UI.
From the typography section of Emil Kowalski's apple-design skill, and the complement to content-impeccable-tight-leading rather than a repeat of it. That rule sets the FLOOR — body copy wants 1.5 to 1.7 so lines do not crowd. This rule is about the slope: as size climbs, the ratio must come DOWN. The reason is that line-height is proportional, so a 1.5 that is perfect at 16px body becomes 72px of leading on a 48px headline — the two lines of a wrapped title drift so far apart they read as unrelated rows instead of one heading. Large display type wants roughly 1.0 to 1.2; a bare 1 is often right for a single-line hero. Because unitless line-height inherits as a multiplier, the practical implementation is a scale that pairs each size step with its own ratio, not one global `line-height` on `body`. Two edge cases from the source: scripts with tall ascenders and descenders (and any mixed-language UI) need MORE leading than Latin at the same size, and dense information UI (tables, compact lists) can go tighter than prose. Related: content-heading-size-descends, content-impeccable-type-scale-contrast, content-tracking-is-size-specific (its letter-spacing counterpart).
```tsx
h1 { font-size: 48px; line-height: 1.1; }
p { font-size: 16px; line-height: 1.6; }
```
- [Emil Kowalski — apple-design SKILL.md](https://raw.githubusercontent.com/emilkowalski/skills/main/skills/apple-design/SKILL.md)
- [MDN — line-height](https://developer.mozilla.org/en-US/docs/Web/CSS/line-height)
### Legible Text on Translucent Surfaces
**ID:** `content-vibrant-text-on-translucent` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-vibrant-text-on-translucent](https://ui-guides-agent-rules.netlify.app/principles/content-vibrant-text-on-translucent)
**Agent rule (SHOULD):** Text over a `backdrop-filter` surface faces a background whose luminance changes as content scrolls. Raise contrast and weight, nudge letter-spacing up slightly, and keep the opacity/color on a SOLID backing layer while the text itself stays fully opaque — never fade the text with the surface.
**Source:** [Emil Kowalski](https://emilkowalski.com/)
Over a blurred or translucent surface, raise text contrast and weight and keep color on a solid layer — flat gray text vanishes as the backdrop shifts
> Vibrancy keeps text legible over changing backgrounds. Over blurred/translucent surfaces, don't use flat gray text — use higher-contrast, slightly heavier weight, and a small letter-spacing bump. Put color on a solid layer, not the translucent foreground.
From the vibrancy section of Emil Kowalski's apple-design skill, and it fills the gap between two rules the corpus already has about translucent surfaces. design-impeccable-no-glassmorphism decides WHEN glass is justified (only over content that genuinely scrolls behind it); design-reduced-transparency-contrast handles the accessibility fallback (go solid under prefers-reduced-transparency). Neither says how to make the text on the glass readable once you have decided to keep it — and that is the hard part, because a `backdrop-filter` surface shows whatever is behind it, so the luminance under any given word changes as the user scrolls. A muted gray tuned against one section of the page becomes unreadable over the next. Apple's answer is "vibrancy": foreground text that adapts to what it sits on. On the web you approximate it deliberately — raise the contrast (secondary text on glass should read closer to primary than it would on a solid card), add a touch of weight, and nudge letter-spacing up a hair so thin strokes survive the blur. The load-bearing detail is the last clause: keep any color and the opacity on a SOLID backing layer behind the text, and render the text itself fully opaque on top — never fade the text with the surface, or it dims exactly where the background is busiest. Related: content-impeccable-dark-mode-text-compensation (the same weight/spacing compensation for light-on-dark), design-minimum-contrast.
```tsx
.glass { background: rgba(20,18,28,0.55); backdrop-filter: blur(8px); }
.glass p { color: #fff; font-weight: 600; letter-spacing: 0.01em; }
```
- [Emil Kowalski — apple-design SKILL.md](https://raw.githubusercontent.com/emilkowalski/skills/main/skills/apple-design/SKILL.md)
- [Apple HIG — Materials (vibrancy)](https://developer.apple.com/design/human-interface-guidelines/materials)
- [MDN — backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter)
### Canvas and WebGL Need an Accessible Fallback
**ID:** `content-canvas-accessible-fallback` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-canvas-accessible-fallback](https://ui-guides-agent-rules.netlify.app/principles/content-canvas-accessible-fallback)
**Agent rule (MUST):** A <canvas> (2D or WebGL) is an opaque bitmap to assistive tech. Name it (role="img" + aria-label stating the data, or descriptive fallback children between the tags), mirror any interactive 3D objects in real focusable DOM with an announcer, and provide the same information without the canvas (a data table, a static-image fallback where WebGL is absent).
**Source:** [Web Platform](https://web.dev/)
A <canvas> renders pixels the accessibility tree cannot see — give it a name, fallback content, and a non-canvas path to the same information
> A canvas is an opaque bitmap to assistive technology. Provide fallback content inside the element, give interactive 3D objects a parallel focusable DOM layer with an announcer, and offer the same information without the canvas.
Distilled from the Poimandres react-three-a11y docs and the MDN canvas-accessibility guidance, and it is the hardest surface content-accessible-content and content-icons-have-labels ever point at. A `<canvas>` — 2D or WebGL — is a single flat bitmap to a screen reader: every bar in the chart, every clickable region, every label you painted is invisible and unreachable, because none of it is in the DOM. Three fixes stack, in order of effort. First, name the surface: a static visualisation takes `role="img"` plus an `aria-label` that states what it shows ("Revenue by quarter: Q1 $12k, Q2 $18k…"), and any markup placed BETWEEN the `<canvas>` tags is both the legacy no-canvas fallback and what AT reads. Second, for anything interactive, mirror it in real focusable DOM — WebGL objects cannot take Tab focus or receive key events, which is exactly why react-three-a11y injects a parallel HTML layer and an announcer node beside the R3F canvas. Third, provide the information without the canvas at all: a data table behind the chart, and a static image where WebGL is unavailable (the progressive-enhancement half — see performance-webgl-gpu-budget for the runtime side). The test is the corpus's standard one: turn the visual off and check the accessibility tree still answers what the picture said.
```tsx
<canvas role="img" aria-label="Revenue by quarter: Q1 $12k, Q2 $18k, Q3 $15k, Q4 $24k" />
{/* plus a visually-hidden <table> with the same data */}
```
- [Poimandres — react-three-a11y](https://github.com/pmndrs/react-three-a11y)
- [MDN — Canvas accessibility concerns](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Hit_regions_and_accessibility)
- [WCAG 1.1.1 — Non-text Content](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html)
### Three Dashes, Three Jobs
**ID:** `content-dash-taxonomy` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-dash-taxonomy](https://ui-guides-agent-rules.netlify.app/principles/content-dash-taxonomy)
**Agent rule (SHOULD):** Use the correct dash: hyphen (-) for compounds, en dash (–) for ranges and connections (2020–2024, pages 10–20), em dash (—) for sentence-level breaks. A hyphen in a range is a typesetting error. In JSX use the real UTF-8 character or a named entity — a \u2013 escape renders literally in a text node. (Use dashes sparingly per content-impeccable-em-dash-overuse; this is about picking the right one when you do.)
**Source:** Custom
Hyphen joins compounds, en dash spans ranges (1–10), em dash breaks a sentence — one glyph for all three is a typographic error
> The hyphen, en dash, and em dash are different characters with different jobs. Hyphen: compound words. En dash: ranges and connections. Em dash: sentence-level breaks. (Matthew Butterick, Practical Typography)
The keyboard has one dash key; English needs three characters. A hyphen (-) joins compounds — well-known, sign-in. An en dash (–) spans ranges and connections and reads as "to" or "through": 2020–2024, pages 10–20, the New York–London route. A hyphen in a range (2020-2024) is one of the most common tells that text was never typeset. An em dash (—) marks a sentence-level break. The distinction is invisible until it is wrong, at which point it quietly says "nobody set this type." A tension worth naming, because the corpus holds both: content-impeccable-em-dash-overuse warns that leaning on em dashes reads as an AI tell and pushes you toward commas, colons, and parentheses — that rule is about FREQUENCY, this one is about CORRECTNESS, and they agree. Use dashes sparingly; but when a range needs one, make it an en dash, not a hyphen. In JSX, use the real UTF-8 character or a named entity — a `\u2013` escape renders literally inside a text node. Neighbour to content-typographic-quotes and content-ellipsis-character: the same "use the designed character, not the typewriter approximation" instinct.
```tsx
2020–2024 {/* en dash, not 2020-2024 */}
pages 10–20 {/* en dash */}
well-known {/* hyphen: compound */}
```
- [Butterick — Practical Typography: hyphens & dashes](https://practicaltypography.com/hyphens-and-dashes.html)
- [bencium — typography skill (Butterick-derived)](https://skills.sh/bencium/bencium-marketplace/ui-typography)
- [MDN — en dash / em dash](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Character_references)
### Real Symbols, Not ASCII Look-alikes
**ID:** `content-math-symbol-glyphs` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-math-symbol-glyphs](https://ui-guides-agent-rules.netlify.app/principles/content-math-symbol-glyphs)
**Agent rule (SHOULD):** Use real typographic symbols, not ASCII look-alikes: × (U+00D7) for multiplication not the letter x, − (U+2212) for minus not a hyphen, and © ™ ® not (c) / (TM) / (R). The substitutes read as unfinished and can be ambiguous in data and dimensions.
**Source:** Custom
Use ×, −, © ™ ® for multiplication, minus, and marks — not the letter x, a hyphen, or (c) / (TM) / (R)
> Use real typographic symbols, not ASCII substitutes. Multiplication is ×, not x. Minus is −, not a hyphen. Use © ™ ® not (c) (TM) (R). (Matthew Butterick, Practical Typography)
The letter x is not a multiplication sign, a hyphen is not a minus, and "(c)" is not a copyright symbol — they are ASCII stand-ins from an era of 95 printable characters, and Unicode retired the excuse decades ago. "1920 x 1080" should be "1920 × 1080" (× is `×` / U+00D7); "-40°" should carry a real minus "−40°" (− is U+2212, which has the correct width and, unlike a hyphen, will not line-break away from its number); and (c) / (TM) / (R) should be © ™ ® (`©` `™` `®`). The substitutes are legible but read as unfinished, and in dimensions or data they are genuinely ambiguous — is "2 x 3" a product or a variable named x? This is the same rule as content-typographic-quotes and content-ellipsis-character, just the less-remembered corner of it: reach for the character that was designed for the job, not the one that happened to be on a 1970s keyboard.
```tsx
1920 × 1080 {/* not 1920 x 1080 */}
−40°C {/* U+2212, not a hyphen */}
© 2026 · Pro™
```
- [Butterick — Practical Typography: math symbols](https://practicaltypography.com/math-symbols.html)
- [bencium — typography skill (html entities)](https://skills.sh/bencium/bencium-marketplace/ui-typography)
- [MDN — Character references](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Character_references)
### One Emphasis Signal at a Time
**ID:** `content-emphasis-one-signal` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-emphasis-one-signal](https://ui-guides-agent-rules.netlify.app/principles/content-emphasis-one-signal)
**Agent rule (SHOULD):** Emphasize with bold OR italic, never both on the same text, and never underline for emphasis — on the web underline means link. Reserve underline for links, bold for strong emphasis, italic for titles/terms/mild stress, each used alone. Stacking emphasis markers cancels the contrast they exist to create.
**Source:** Custom
Emphasize with bold OR italic, never both at once, and never with underline — on the web, underline means link
> Bold and italic are mutually exclusive — never combine them. Never underline for emphasis; underlining is a typewriter workaround and, on the web, means a link. (Matthew Butterick, Practical Typography)
Emphasis is a contrast, and contrast only works while it is scarce. Stacking bold and italic on the same words does not make them twice as important — it makes the line look like it is shouting and spends the next level of emphasis you might have needed later. Pick one: bold for strong emphasis, italic for titles, terms, and mild stress. Underline is worse than redundant on the web, because it is the near-universal signal for a link: underlining for emphasis trains users to click text that does nothing, and it collides with design-underline-from-font, which is about styling the underline on actual links. Reserve underline for links, and use bold and italic one at a time. This is the character-formatting version of the over-signalling failure the corpus flags elsewhere (design-ibelick-color-restraint for accent colour, content-impeccable-em-dash-overuse for punctuation): more emphasis markers do not add emphasis, they cancel it.
```tsx
<strong>important</strong> · <em>term</em> · <a href="…">link</a>
// not: <span class="font-bold italic underline">…</span>
```
- [Butterick — Practical Typography: bold or italic](https://practicaltypography.com/bold-or-italic.html)
- [bencium — typography skill (Butterick-derived)](https://skills.sh/bencium/bencium-marketplace/ui-typography)
### Don't Center Body Text
**ID:** `content-no-centered-body` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-no-centered-body](https://ui-guides-agent-rules.netlify.app/principles/content-no-centered-body)
**Agent rule (SHOULD):** Set running body text flush-left (ragged right) so the eye returns to one fixed edge each line. Centered text moves every line's start and makes the reader hunt for it — acceptable only for short, deliberate lines (a hero, a pull quote), never for paragraphs.
**Source:** Custom
Center a heading or a short line if you must, but set running paragraphs flush-left — centered body makes the eye hunt for each line's start
> Don't center body text. Centered text has no consistent left edge, so the reader must find the start of every line. (Matthew Butterick, Practical Typography)
Reading is a loop: the eye sweeps left to right, then flicks back to a KNOWN horizontal position to begin the next line. Flush-left text keeps that return point fixed, so the flick is automatic and unconscious. Centered text moves the start of every line, so the reader has to visually hunt for where each one begins — tolerable for a two-line hero or a pull quote, genuinely tiring across a paragraph. A centered column of body copy is a reliable tell of template or AI layout: it looked "balanced" in the mock and reads like work at length. Set running text flush-left with a ragged right edge, and reserve centring for short, deliberate lines. This is distinct from content-impeccable-justified-text, which is about the RIGHT edge (do not justify without hyphenation); this rule is about the left edge existing at all.
```tsx
<p class="text-left">…paragraph…</p> {/* not text-center */}
```
- [Butterick — Practical Typography: centered text](https://practicaltypography.com/centered-text.html)
- [bencium — typography skill (Butterick-derived)](https://skills.sh/bencium/bencium-marketplace/ui-typography)
### Animated Text Stays Readable
**ID:** `content-animated-text-stays-readable` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-animated-text-stays-readable](https://ui-guides-agent-rules.netlify.app/principles/content-animated-text-stays-readable)
**Agent rule (MUST):** When splitting text into per-letter/word spans for animation, keep the accessible name on the wrapper (aria-label with the full text) and mark the shards aria-hidden="true". Otherwise a screen reader reads the word letter by letter or drops it. Drop the reveal to a fade under prefers-reduced-motion.
**Source:** Custom
Splitting text into per-letter or per-word spans for animation must not destroy its accessible name
> A word split into a span per letter is, to assistive tech, a list of single characters — not a word. Keep the accessible name on the wrapper and hide the shards.
Staggered letter/word reveals are built by wrapping each glyph in its own element. Visually it is still a word; to a screen reader it is ten unrelated characters read "A, n, n, o…", or dropped entirely — and the same fragmentation breaks find-in-page, translation, and text selection. The fix costs nothing: put the real text as an aria-label on the wrapper and mark every animated shard aria-hidden="true", so the DOM animates while the accessibility tree sees one intact word. Pair it with reduced-motion — under prefers-reduced-motion the reveal should drop to a plain fade or appear instantly. This is WCAG 1.3.1 (Info and Relationships) at the glyph level: the visual grouping into a word must survive into the semantics.
```tsx
<span aria-label="Announcing">
{letters.map(c => <span aria-hidden="true">{c}</span>)}
</span>
```
- [WCAG 1.3.1 — Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
- [MDN — aria-label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label)
### Moving Text Can Be Paused
**ID:** `content-moving-text-can-be-paused` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/content-moving-text-can-be-paused](https://ui-guides-agent-rules.netlify.app/principles/content-moving-text-can-be-paused)
**Agent rule (MUST):** Auto-scrolling tickers/marquees that run more than 5s alongside other content need a pause/stop/hide control (WCAG 2.2.2) and must stop under prefers-reduced-motion. Toggle animation-play-state and expose aria-pressed on the button.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Auto-scrolling tickers, marquees, and looping text need a pause control and must honor reduced-motion
> WCAG 2.2.2: For any moving, blinking, or scrolling information that starts automatically, lasts more than five seconds, and is presented in parallel with other content, there is a mechanism to pause, stop, or hide it.
A marquee or news ticker that scrolls on its own is a moving target the reader cannot catch — worse for anyone with a vestibular disorder, low vision, or a reading disability, for whom the movement is not decoration but an obstacle. WCAG 2.2.2 makes it concrete: anything that moves automatically for more than five seconds alongside other content must offer a pause, stop, or hide control. Two things satisfy it together: a visible Pause button (toggle animation-play-state, and expose aria-pressed), and a prefers-reduced-motion: reduce rule that stops the animation entirely for users who asked the OS to. Auto-motion is a convenience you offer, never one you impose.
```tsx
@media (prefers-reduced-motion: reduce){ .ticker{ animation: none } }
<button aria-pressed={paused} onClick={toggle}>Pause</button>
```
- [WCAG 2.2.2 — Pause, Stop, Hide](https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html)
- [MDN — prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
---
## Forms
Form controls, validation, and user input handling. 27 rules.
### Enter Submits
**ID:** `forms-enter-submits` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-enter-submits](https://ui-guides-agent-rules.netlify.app/principles/forms-enter-submits)
**Agent rule (MUST):** Enter submits focused text input
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
When a text input is focused, Enter should submit the form if it's the only control
> Enter submits. When a text input is focused, Enter submits if it's the only control. If there are many controls, apply to the last control.
Users expect pressing Enter in a form field to submit the form. This is a fundamental web convention that improves efficiency and meets user expectations. For single-input forms like search boxes or login fields, this should work from any input. For multi-field forms, Enter should submit when focus is on the last input.
- [WAI-ARIA Authoring Patterns](https://www.w3.org/WAI/ARIA/apg/)
### Textarea Behavior
**ID:** `forms-textarea-behavior` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-textarea-behavior](https://ui-guides-agent-rules.netlify.app/principles/forms-textarea-behavior)
**Agent rule (MUST):** In `<textarea>`, ⌘/Ctrl+Enter submits; Enter adds newline
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
In textarea, Enter should insert a new line while Cmd/Ctrl+Enter submits
> Textarea behavior. In <textarea>, ⌘/⌃+Enter submits; Enter inserts a new line.
Textareas are for multi-line input, so Enter must create new lines. Users who want to submit can use the keyboard modifier (Cmd on Mac, Ctrl on Windows/Linux) plus Enter. This prevents accidental submission when writing paragraphs.
- [HTML textarea element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/textarea)
### Labels Everywhere
**ID:** `forms-labels-everywhere` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-labels-everywhere](https://ui-guides-agent-rules.netlify.app/principles/forms-labels-everywhere)
**Agent rule (MUST):** Every control has a <label> or is associated with a label for assistive tech
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Every form control must have a visible label or accessible name
> Labels everywhere. Every control has a <label> or is associated with a label for assistive tech.
Labels are crucial for accessibility. Screen reader users need labels to understand what each form field is for. Sighted users benefit from clear labels too. Use the <label> element with the "for" attribute pointing to the input's id, or wrap the input inside the label.
- [MDN: Label element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label)
- [WebAIM: Creating Accessible Forms](https://webaim.org/techniques/forms/)
### Label Activation
**ID:** `forms-label-activation` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-label-activation](https://ui-guides-agent-rules.netlify.app/principles/forms-label-activation)
**Agent rule (MUST):** Clicking a <label> focuses the associated control
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Clicking a label should focus its associated input
> Label activation. Clicking a <label> focuses the associated control.
When labels are properly associated with their inputs, clicking the label text focuses the input. This increases the clickable area and improves usability, especially for checkboxes and radio buttons which have small hit targets.
- [MDN: Label element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label)
### Submission Rule
**ID:** `forms-submission-rule` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-submission-rule](https://ui-guides-agent-rules.netlify.app/principles/forms-submission-rule)
**Agent rule (MUST):** Keep submit enabled until request starts; then disable, show spinner, use idempotency key
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Keep submit button enabled until submission starts, then disable with loading state
> Submission rule. Keep submit enabled until submission starts; then disable during the in-flight request, show a spinner, & include an idempotency key.
Pre-disabling submit buttons prevents users from discovering validation errors. Let them try to submit, then show what needs fixing. Once submission starts, disable the button and show a loading indicator to prevent double submissions. Use idempotency keys on the backend to handle accidental duplicate requests.
- [Idempotency](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent)
### Don't Block Typing
**ID:** `forms-dont-block-typing` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-dont-block-typing](https://ui-guides-agent-rules.netlify.app/principles/forms-dont-block-typing)
**Agent rule (MUST):** Don't block typing; accept free text and validate after
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Allow all input and show validation feedback instead of blocking keystrokes
> Don't block typing. Even if a field only accepts numbers, allow any input & show validation feedback. Blocking keystrokes entirely is confusing because the user gets no explanation.
When you prevent users from typing certain characters, they don't understand why their keyboard isn't working. It's better to accept all input and provide clear validation messages that explain what format is expected.
- [Form Validation UX](https://www.nngroup.com/articles/errors-forms-design-guidelines/)
### Don't Pre-disable Submit
**ID:** `forms-dont-pre-disable-submit` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-dont-pre-disable-submit](https://ui-guides-agent-rules.netlify.app/principles/forms-dont-pre-disable-submit)
**Agent rule (MUST):** Allow submitting incomplete forms to surface validation
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Allow submitting incomplete forms to surface validation feedback
> Don't pre-disable submit. Allow submitting incomplete forms to surface validation feedback.
When the submit button is disabled before the user has tried to submit, they can't discover what's wrong with their form. Enable the button, let them click it, then show comprehensive validation feedback that guides them to fix all issues.
- [Form validation UX](https://www.smashingmagazine.com/2022/09/inline-validation-web-forms-ux/)
### Error Placement
**ID:** `forms-error-placement` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-error-placement](https://ui-guides-agent-rules.netlify.app/principles/forms-error-placement)
**Agent rule (MUST):** Errors inline next to fields; on submit, focus first error
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Show errors next to their fields and focus the first error on submit
> Error placement. Show errors next to their fields; on submit, focus the first error.
Errors should appear immediately adjacent to the field they relate to. When a form is submitted with errors, automatically focus the first problematic field so users can start fixing issues immediately. This is especially important for long forms.
- [WebAIM: Accessible Form Validation](https://webaim.org/techniques/formvalidation/)
### Autocomplete & Names
**ID:** `forms-autocomplete` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-autocomplete](https://ui-guides-agent-rules.netlify.app/principles/forms-autocomplete)
**Agent rule (MUST):** `autocomplete` + meaningful `name`; correct `type` and `inputmode`
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Set autocomplete and meaningful name values to enable browser autofill
> Autocomplete & names. Set autocomplete & meaningful name values to enable autofill.
Modern browsers can autofill form data, saving users time. Use the autocomplete attribute with standard values like "email", "tel", "street-address", etc. Also use semantic name attributes. This helps password managers and other assistive tools.
- [HTML autocomplete attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete)
### Spellcheck Selectively
**ID:** `forms-spellcheck` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-spellcheck](https://ui-guides-agent-rules.netlify.app/principles/forms-spellcheck)
**Agent rule (SHOULD):** Disable spellcheck for emails/codes/usernames
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Disable spellcheck for emails, codes, usernames, etc.
> Spellcheck selectively. Disable for emails, codes, usernames, etc.
Spellcheck is helpful for prose content but annoying for technical input. Email addresses, usernames, codes, and similar fields should have spellcheck="false" to prevent red squiggly underlines on valid input.
- [HTML spellcheck attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/spellcheck)
### Correct Types & Input Modes
**ID:** `forms-correct-types` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-correct-types](https://ui-guides-agent-rules.netlify.app/principles/forms-correct-types)
**Agent rule (MUST):** Use correct `type` and `inputmode` for better keyboards & validation
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Use the right type and inputmode for better keyboards and validation
> Correct types & input modes. Use the right type & inputmode for better keyboards & validation.
Mobile devices show different keyboards based on the input type and inputmode. Use type="email" for emails, type="tel" for phones, inputmode="numeric" for numbers, etc. This provides users with the most relevant keyboard layout.
- [HTML input types](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input)
- [inputmode attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inputmode)
### Placeholder Value
**ID:** `forms-placeholder-value` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-placeholder-value](https://ui-guides-agent-rules.netlify.app/principles/forms-placeholder-value)
**Agent rule (SHOULD):** Placeholders end with ellipsis and show example pattern (eg, `+1 (123) 456-7890`, `sk-012345…`)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Set placeholder to an example value or pattern, ending with ellipsis
> Placeholders signal emptiness. End with an ellipsis. Placeholder value. Set placeholder to an example value or pattern e.g., +1 (123) 456-7890 & sk-012345679…
Placeholders should show example values, not labels (that's what <label> is for). The ellipsis signals that the value continues or represents a pattern. For example: "name@example.com" or "sk-proj_abc123…"
- [Placeholder vs Label](https://www.nngroup.com/articles/form-design-placeholders/)
### Unsaved Changes
**ID:** `forms-unsaved-changes` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-unsaved-changes](https://ui-guides-agent-rules.netlify.app/principles/forms-unsaved-changes)
**Agent rule (MUST):** Warn on unsaved changes before navigation
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Warn before navigation when data could be lost
> Unsaved changes. Warn before navigation when data could be lost.
Track form modifications and show a confirmation dialog if the user tries to navigate away or close the tab with unsaved changes. Use the beforeunload event for page unload and custom logic for in-app navigation.
- [beforeunload event](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event)
### No Dead Zones on Controls
**ID:** `forms-no-dead-zones` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-no-dead-zones](https://ui-guides-agent-rules.netlify.app/principles/forms-no-dead-zones)
**Agent rule (MUST):** No dead zones on checkboxes/radios; label+control share one generous hit target
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Checkboxes and radios share hit target with labels
> No dead zones on controls. Checkboxes & radios avoid dead zones; the label & control share a single generous hit target.
Small checkboxes and radio buttons are hard to click. Make the entire label clickable by properly associating it with the input. This creates a much larger, more forgiving hit target and improves usability.
- [Form Labels](https://www.w3.org/WAI/tutorials/forms/labels/)
### Password Managers & 2FA
**ID:** `forms-password-managers` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-password-managers](https://ui-guides-agent-rules.netlify.app/principles/forms-password-managers)
**Agent rule (MUST):** Compatible with password managers & 2FA; allow pasting one-time codes
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Ensure compatibility and allow pasting one-time codes
> Password managers & 2FA. Ensure compatibility & allow pasting one-time codes.
Don't block password managers or prevent pasting in password/2FA code fields. Use appropriate autocomplete values (current-password, new-password, one-time-code) to help password managers function correctly.
- [Autocomplete for Credentials](https://web.dev/articles/sign-in-form-best-practices)
### Use tailwind-merge
**ID:** `forms-tailwind-merge` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-tailwind-merge](https://ui-guides-agent-rules.netlify.app/principles/forms-tailwind-merge)
**Agent rule (MUST):** Use tailwind-merge for components accepting className props to ensure override classes win
**Source:** [Tailwind](https://tailwindcss.com/docs)
Use tailwind-merge for intelligent class conflict resolution
> Use tailwind-merge to intelligently merge Tailwind CSS classes, resolving conflicts without style duplication.
When combining base component styles with variant or override classes, tailwind-merge ensures the last conflicting class wins. This is essential for reusable components that accept className props.
- [tailwind-merge](https://github.com/dcastil/tailwind-merge)
- [Why tailwind-merge](https://github.com/dcastil/tailwind-merge/blob/main/docs/what-is-it-for.md)
### cn() Utility Pattern
**ID:** `forms-cn-utility` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-cn-utility](https://ui-guides-agent-rules.netlify.app/principles/forms-cn-utility)
**Agent rule (SHOULD):** Create cn() utility combining clsx + tailwind-merge for conditional class merging
**Source:** [Tailwind](https://tailwindcss.com/docs)
Create a cn() utility combining clsx and tailwind-merge
> Combine clsx for conditional classes with tailwind-merge for conflict resolution in a single cn() utility function.
The cn() utility is a standard pattern that combines clsx (for conditional class logic) with tailwind-merge (for conflict resolution). This provides the best of both worlds for component styling.
- [shadcn/ui cn()](https://ui.shadcn.com/docs/installation/manual#add-a-cn-helper)
- [clsx](https://github.com/lukeed/clsx)
### Form Inputs Missing Labels
**ID:** `forms-rams-form-labels` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-rams-form-labels](https://ui-guides-agent-rules.netlify.app/principles/forms-rams-form-labels)
**Agent rule (MUST):** Every form input must have associated <label> with htmlFor, be wrapped by <label>, or have aria-label. Placeholder is not a substitute for labels.
**Source:** [RAMS](https://www.rams.ai/)
All form inputs must have associated labels
> Form inputs without labels — `<input>`, `<select>`, `<textarea>` without associated `<label>` or `aria-label`
Rams lists this as a Critical accessibility check (WCAG 1.3.1) in its review table; the upstream skill is a checklist, so that terse row is the whole rule. Why it matters: the label is what a screen reader announces on focus, and what a click target extends to. Placeholder text is not a substitute — it disappears the moment the user types.
- [rams.md (skill source)](https://rams.ai/rams.md)
- [WCAG 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
### Use cn() Utility for Class Merging
**ID:** `forms-ibelick-cn-utility` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-ibelick-cn-utility](https://ui-guides-agent-rules.netlify.app/principles/forms-ibelick-cn-utility)
**Agent rule (MUST):** Use cn utility (clsx + tailwind-merge) for conditional class logic. Handles class conflicts, falsy values, and array inputs cleanly.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Always use the cn() utility (clsx + tailwind-merge) for conditional and merged class names
> MUST use `cn` utility (`clsx` + `tailwind-merge`) for class logic
From the Stack constraints in ibelick's baseline-ui skill. Naive string concatenation leaves both sides of a conflict in the class list (p-4 and p-2), and the winner is decided by stylesheet order, not by intent. cn() combines clsx for conditional logic with tailwind-merge for last-one-wins conflict resolution.
- [baseline-ui (skill source)](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [tailwind-merge](https://github.com/dcastil/tailwind-merge)
### Show Errors Where the Action Happens
**ID:** `forms-ibelick-error-placement` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-ibelick-error-placement](https://ui-guides-agent-rules.netlify.app/principles/forms-ibelick-error-placement)
**Agent rule (MUST):** Show errors inline next to where the action happens. Disconnected toasts or top-of-form error summaries force users to hunt for problems.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Put each error next to the thing that caused it: field errors on their field, submit errors by the submit button
> MUST show errors next to where the action happens
From the Interaction constraints in ibelick's baseline-ui skill. "Where the action happens" is deliberately general and resolves per error: a validation error on the email field happened at the field, so it belongs under the field (wired with aria-describedby and aria-invalid); a submit failure — the request was rejected or the network died — happened at the button, so it belongs by the button. The anti-pattern is a detached error: a summary parked at the top of the page, or a toast that fires and vanishes, leaving nothing next to the control the user was actually touching. Read this as the placement rule that generalizes forms-error-placement (Vercel: "Errors inline next to fields"), not as a competitor to it — Vercel names the field case, ibelick names the whole family.
- [baseline-ui (skill source)](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Form Error Handling](https://www.nngroup.com/articles/errors-forms-design-guidelines/)
### Never Block Paste
**ID:** `forms-ibelick-no-paste-blocking` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-ibelick-no-paste-blocking](https://ui-guides-agent-rules.netlify.app/principles/forms-ibelick-no-paste-blocking)
**Agent rule (NEVER):** Block paste in input or textarea elements. Users paste from password managers, notes, and other sources. Blocking paste creates friction and security issues.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Never prevent users from pasting into form fields - it breaks password managers and accessibility
> NEVER block paste in `input` or `textarea` elements
From the Interaction constraints in ibelick's baseline-ui skill. Blocking paste breaks password managers (which is how strong, unique passwords get used at all), blocks users with motor disabilities who rely on assistive input, and forces people to retype sensitive data by hand — where they make errors and pick weaker values.
- [baseline-ui (skill source)](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Why Paste Blocking Hurts Security](https://www.troyhunt.com/the-cobra-effect-that-is-disabling/)
### Input Decorations Positioning
**ID:** `forms-input-decorations-positioning` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-input-decorations-positioning](https://ui-guides-agent-rules.netlify.app/principles/forms-input-decorations-positioning)
**Agent rule (MUST):** Absolutely position input prefix/suffix icons on top of the input and pad the input around them — do not place them as flex siblings. Clicking a decoration must focus the input.
**Source:** [Rauno](https://interfaces.rauno.me/)
Input prefix and suffix decorations should be absolutely positioned inside the input with padding, not next to it
> Input prefix and suffix decorations, such as icons, should be absolutely positioned on top of the text input with padding, not next to it, and trigger focus on the input.
When decorative elements like search icons or currency symbols are placed next to an input in a flex layout, clicking them doesn't focus the input. Position them absolutely inside the input container so the entire area is clickable and the input receives focus.
```tsx
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input className="pl-9" />
</div>
```
- [interfaces.rauno.me](https://interfaces.rauno.me/)
### Lean on Native Constraint Validation
**ID:** `forms-native-validation` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-native-validation](https://ui-guides-agent-rules.netlify.app/principles/forms-native-validation)
**Agent rule (SHOULD):** Lean on native constraint validation (`required`, `type="email"`, `pattern`, `min`, `minlength`) instead of hand-rolled JS checks; drop `noValidate` and reach for `setCustomValidity` only for rules HTML cannot express.
**Source:** [Rauno](https://interfaces.rauno.me/)
Use required, type, pattern, min and max so the browser validates instead of hand-rolling every check in JS
> Inputs should leverage HTML form validation by using the `required` attribute when appropriate.
The platform already ships constraint validation: required, type="email", pattern, min, max and minlength are enforced by the browser on every submit path — click, Enter, autofill, programmatic — and it focuses the first offending control and announces the message to assistive tech for free. Hand-rolled JS checks miss those paths (a keyup handler never fires on a mouse paste), and hand-rolled email regexes routinely reject valid addresses like ada+work@example.com. Keep the submit button enabled and drop noValidate; reach for the Constraint Validation API (setCustomValidity, ValidityState) only for the rules HTML cannot express.
- [MDN: required attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/required)
- [MDN: Client-side form validation](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation)
- [MDN: ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState)
### Trim Text Expansions
**ID:** `forms-text-replacements` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-text-replacements](https://ui-guides-agent-rules.netlify.app/principles/forms-text-replacements)
**Agent rule (MUST):** Trim values to handle text expansion trailing spaces
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Trim input values so invisible whitespace never fails validation
> Text replacements & expansions. Some input methods add trailing whitespace. The input should trim the value to avoid showing a confusing error message.
Autocorrect, keyboard text-expansion snippets, password managers, and copy-paste from a spreadsheet all routinely append a trailing space. The value looks correct on screen, so when validation rejects it the user is told their own email or coupon code is invalid with nothing visibly wrong — an error with no discoverable fix. Trim before validating. Prefer trimming on blur or submit rather than on every keystroke, so you do not delete a space the moment a user legitimately types one.
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN: String.prototype.trim()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim)
### Do Not Summon Password Managers on Non-Auth Fields
**ID:** `forms-no-password-manager-nonauth` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-no-password-manager-nonauth](https://ui-guides-agent-rules.netlify.app/principles/forms-no-password-manager-nonauth)
**Agent rule (MUST):** Keep reserved names (`password`) and `type="password"` off non-auth fields — password managers run heuristics over `type`/`name`/`id` and will park a credential dropdown over your search results. Give filters boring names (`q`, `filter`, `search`) with `type="search"` and `autocomplete="off"`; give OTP fields `autocomplete="one-time-code"` and `inputmode="numeric"`. This is the narrow inverse of the autofill rules, which still apply to real credential and address fields.
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Keep reserved names off search and filter inputs, and claim a specific autocomplete token for OTP
> Don't trigger password managers for non-auth fields. For inputs like "Search" avoid reserved names (e.g., password), use autocomplete="off" or a specific token like autocomplete="one-time-code" for OTP fields.
This is the narrow inverse of the autofill rules, not a contradiction of them: on a real credential or address field you want the manager to fire, with meaningful autocomplete tokens and no paste blocking. This rule is about the fields that are not auth. Password managers do not read your intent — they run heuristics over type, name, and id, so a filter box that happens to be called "password" or an OTP input masked as type="password" gets a credential dropdown parked on top of the results the user was trying to read, plus a "save this login?" prompt for a code that expires in 30 seconds. Give non-auth fields boring names (q, filter, search), set type="search", and add autocomplete="off"; give OTP fields autocomplete="one-time-code" and inputmode="numeric" so the OS offers the SMS code instead of a stored password.
```tsx
<input type="search" name="q" autocomplete="off" />
<input name="otp" autocomplete="one-time-code" inputMode="numeric" />
```
- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN: The autocomplete attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete)
### No Redundant Entry
**ID:** `forms-redundant-entry` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-redundant-entry](https://ui-guides-agent-rules.netlify.app/principles/forms-redundant-entry)
**Agent rule (MUST):** Within one process (checkout, signup, application), never ask for information the user already entered: auto-populate it, or make it selectable — a "same as shipping" checkbox, a dropdown of addresses already given, or the value shown next to the field. WCAG SC 3.3.7 Redundant Entry, Level A. Browser autofill does NOT satisfy it (that is SC 1.3.5); "re-enter your email to confirm" is the classic violation. Exceptions are narrow: essential re-entry, security, or stale data.
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
Never ask for the same information twice in one process: auto-populate it, or let the user select it
> Information previously entered by or provided to the user that is required to be entered again in the same process is either: auto-populated, or available for the user to select. Except when: re-entering the information is essential, the information is required to ensure the security of the content, or previously entered information is no longer valid.
SC 3.3.7 Redundant Entry (Level A, WCAG 2.2) is about a single process — one checkout, one signup, one application — not about sessions. That is what separates it from forms-autocomplete, which is about the autocomplete attribute and browser autofill across visits (SC 1.3.5 Identify Input Purpose). The Understanding document is explicit that browser autofill does not satisfy this criterion: the content itself must carry the value forward. So a step-2 billing form that is an empty duplicate of the step-1 shipping form fails, even if every field is perfectly marked up for autofill. Satisfy it by auto-populating the second form, or by making the value available to select — a "same as shipping" checkbox, a dropdown of addresses already entered, or the value visibly printed next to the field so it can be copied. "Re-enter your email to confirm" is the classic violation: it exists to catch typos, but the previous value is right there on the page, so show it instead of demanding it again. The exceptions are narrow: re-entry that is essential (a password, a memory test), security-required re-entry, or data that has gone stale.
- [Understanding SC 3.3.7: Redundant Entry](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html)
- [Understanding SC 1.3.5: Identify Input Purpose](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html)
### Link Errors to Fields Programmatically
**ID:** `forms-error-programmatic-association` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/forms-error-programmatic-association](https://ui-guides-agent-rules.netlify.app/principles/forms-error-programmatic-association)
**Agent rule (MUST):** An invalid field must set `aria-invalid="true"` and point `aria-describedby` at its message. `aria-describedby` takes a space-separated LIST of ids — keep BOTH the persistent hint id and the error id; overwriting the hint with the error on validation is the common bug, and it makes the field stop explaining itself the moment it goes wrong. Orthogonal to `forms-ibelick-error-placement` (which governs WHERE the message sits): a well-placed error that is not associated still fails.
**Source:** [@Ibelick](https://www.ui-skills.com/)
An error is not an error until the field points at it: set aria-invalid on the input and list the error and helper text ids in aria-describedby
> errors must be linked to fields using aria-describedby; invalid fields must set aria-invalid; helper text must be associated with inputs
From the forms and errors rules in ibelick's fixing-accessibility skill. This is the rule that red text alone cannot satisfy. `<input id="email" /><span>Invalid email</span>` renders a perfectly visible error and communicates nothing to the accessibility tree: the input still computes as valid, its accessible description is empty, and a screen-reader user who tabs back to the field to fix it hears the label and nothing else. Two attributes close the gap. `aria-invalid="true"` flips the field's computed state, so the error is announced as a property of the control rather than as loose text somewhere on the page. `aria-describedby` attaches the message — and it takes a space-separated LIST of ids, which is the detail people get wrong. A field usually has two things to say: a persistent hint ("We only email you about your account") and a transient error. The common bug is to overwrite the hint id with the error id on validation, so the moment the field goes invalid the user stops hearing why it exists. Keep both: `aria-describedby="email-err email-hint"`. This is orthogonal to forms-ibelick-error-placement, which governs WHERE the message sits visually ("where the action happens"); this one governs whether the message is LINKED to the control at all, wherever it sits. A correctly placed error that is not associated still fails, and an associated error still needs to be placed well.
```tsx
<input id="email" aria-invalid={!!err} aria-describedby={err ? "email-err email-hint" : "email-hint"} />
<p id="email-err">Enter a valid email address</p>
<p id="email-hint">We only email you about your account</p>
```
- [fixing-accessibility (skill source)](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [MDN: aria-describedby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby)
- [MDN: aria-invalid](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid)
- [Understanding SC 3.3.1: Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html)
---
## Performance
Optimization techniques and rendering efficiency. 35 rules.
### Minimize Re-renders
**ID:** `performance-minimize-rerenders` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-minimize-rerenders](https://ui-guides-agent-rules.netlify.app/principles/performance-minimize-rerenders)
**Agent rule (MUST):** Track and minimize re-renders (React DevTools/React Scan)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Minimize and make re-renders fast
> Track re-renders. Minimize & make re-renders fast. Use React DevTools or React Scan.
Excessive re-renders slow down the UI, especially in controlled inputs. Use React.memo, useMemo, and useCallback judiciously. Profile with React DevTools to identify components that re-render unnecessarily.
- [React Performance](https://react.dev/learn/render-and-commit)
- [React DevTools](https://react.dev/learn/react-developer-tools)
### Large Lists
**ID:** `performance-large-lists` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-large-lists](https://ui-guides-agent-rules.netlify.app/principles/performance-large-lists)
**Agent rule (MUST):** Virtualize large lists (eg, `virtua`)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Virtualize large lists or use content-visibility
> Large lists. Virtualize large lists e.g., virtua or content-visibility: auto.
Rendering thousands of DOM nodes causes performance issues. Use virtualization libraries that only render visible items, or use CSS content-visibility: auto to let the browser skip rendering off-screen content.
- [content-visibility](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/content-visibility)
- [List Virtualization](https://web.dev/articles/virtualize-long-lists-react-window)
### No Image-caused CLS
**ID:** `performance-no-image-cls` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-no-image-cls](https://ui-guides-agent-rules.netlify.app/principles/performance-no-image-cls)
**Agent rule (MUST):** Prevent CLS from images (explicit dimensions or reserved space)
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Set explicit image dimensions and reserve space
> No image-caused CLS. Set explicit image dimensions & reserve space.
Images without dimensions cause layout shift when they load. Always set width and height attributes (or use aspect-ratio in CSS) so the browser can reserve the correct space before the image downloads.
- [Image Aspect Ratio](https://web.dev/articles/optimize-cls)
- [Cumulative Layout Shift](https://web.dev/articles/cls)
### Preload Fonts
**ID:** `performance-preload-fonts` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-preload-fonts](https://ui-guides-agent-rules.netlify.app/principles/performance-preload-fonts)
**Agent rule (MUST):** Preload fonts for critical text to avoid flash & layout shift
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Preload critical fonts to avoid flash and layout shift
> Preload fonts. For critical text to avoid flash & layout shift.
Font files take time to download, causing FOUT (flash of unstyled text) or FOIT (flash of invisible text). Preload critical fonts used for above-the-fold content using <link rel="preload"> to load them as early as possible.
- [Preloading Fonts](https://web.dev/articles/optimize-webfont-loading)
### Device/Browser Matrix
**ID:** `performance-device-matrix` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-device-matrix](https://ui-guides-agent-rules.netlify.app/principles/performance-device-matrix)
**Agent rule (SHOULD):** Test iOS Low Power Mode and macOS Safari
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Test iOS Low Power Mode and macOS Safari
> Device/browser matrix. Test iOS Low Power Mode & macOS Safari.
Performance varies dramatically across devices and browsers. iOS Low Power Mode throttles animations and JavaScript. Safari has different behavior than Chrome. Test on real devices, not just desktop browsers.
- [Cross Browser Testing](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Testing/Introduction)
### Throttle When Profiling
**ID:** `performance-throttle-profiling` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-throttle-profiling](https://ui-guides-agent-rules.netlify.app/principles/performance-throttle-profiling)
**Agent rule (MUST):** Profile with CPU/network throttling
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
Test with CPU and network throttling
> Throttle when profiling. Test with CPU & network throttling.
Developer machines are faster than average user devices. Use browser DevTools to throttle CPU (4x slowdown) and network (Fast 3G) to test how your app performs for users on slower devices and connections.
- [Chrome DevTools Throttling](https://developer.chrome.com/docs/devtools/device-mode/)
### Network Latency Budgets
**ID:** `performance-latency-budgets` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-latency-budgets](https://ui-guides-agent-rules.netlify.app/principles/performance-latency-budgets)
**Agent rule (MUST):** Mutations (`POST/PATCH/DELETE`) target <500 ms
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
POST/PATCH/DELETE operations complete in under 500ms
> Network latency budgets. POST/PATCH/DELETE complete in <500ms.
Users expect mutations (create, update, delete) to feel instant. Target 500ms or less for these operations. Use optimistic updates, show loading states immediately, and optimize backend performance to meet this budget.
- [Response Time Guidelines](https://www.nngroup.com/articles/response-times-3-important-limits/)
### Register Sources Only Where Detection Cannot Reach
**ID:** `performance-content-paths` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-content-paths](https://ui-guides-agent-rules.netlify.app/principles/performance-content-paths)
**Agent rule (MUST):** Configure Tailwind content paths to include all files using utility classes
**Source:** [Tailwind](https://tailwindcss.com/docs)
Tailwind v4 auto-detects sources — add @source only for files outside that sweep
> Tailwind automatically scans your project for source files. Use `@source` to register sources that automatic detection misses, and `@import "tailwindcss" source(none)` to opt out of detection entirely.
There is no `content` array in Tailwind v4. It was removed. Tailwind now walks the project itself, starting from where the CSS is imported, skipping anything in .gitignore and every binary/asset extension — so in a normal app the correct configuration is none at all, and pasting a v3 `content: [...]` block does nothing but mislead the next reader. Reach for `@source "…"` in exactly one situation: a file that holds class names but that the sweep cannot see — a component library inside node_modules (which is gitignored), or a template directory outside the project root. `@source "../node_modules/@acme/ui/dist"`. Two escape hatches complete the picture: `@source not "…"` excludes a path the sweep found but should not scan (a huge generated fixture directory), and `@import "tailwindcss" source(none)` disables auto-detection entirely so you can list every source explicitly — worth it only when you genuinely need that control.
- [Tailwind — Detecting classes in source files](https://tailwindcss.com/docs/detecting-classes-in-source-files)
- [Tailwind — Functions and directives (@source)](https://tailwindcss.com/docs/functions-and-directives)
- [Tailwind — v4 upgrade guide](https://tailwindcss.com/docs/upgrade-guide)
### Never transition-all
**ID:** `performance-no-transition-all` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-no-transition-all](https://ui-guides-agent-rules.netlify.app/principles/performance-no-transition-all)
**Agent rule (NEVER):** Use transition-all; explicitly specify transition-transform, transition-opacity, etc.
**Source:** [Tailwind](https://tailwindcss.com/docs)
Explicitly transition only needed properties
> Never use transition-all. Explicitly specify which properties to transition for better performance and predictable behavior.
transition-all animates every CSS property that changes, including layout-triggering properties you didn't intend to animate. This causes unexpected animations, performance issues, and harder debugging.
- [CSS Transition Property](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transition-property)
### Avoid Arbitrary Values
**ID:** `performance-avoid-arbitrary` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-avoid-arbitrary](https://ui-guides-agent-rules.netlify.app/principles/performance-avoid-arbitrary)
**Agent rule (SHOULD):** Avoid arbitrary values [17px]; use theme tokens (p-4, text-foreground)
**Source:** [Tailwind](https://tailwindcss.com/docs)
Prefer theme tokens over arbitrary bracket values
> Avoid arbitrary values like p-[17px] or text-[#1a1a1a]. Use theme tokens for consistency and smaller CSS output.
Arbitrary values create one-off utility classes that bypass your design system's spacing/color scale, create inconsistencies, generate additional CSS, and make maintenance harder with magic numbers scattered throughout.
- [Tailwind Customization](https://tailwindcss.com/docs/theme)
- [Tailwind Theme Configuration](https://tailwindcss.com/docs/theme)
### No Dynamic Class Construction
**ID:** `performance-dynamic-classes` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-dynamic-classes](https://ui-guides-agent-rules.netlify.app/principles/performance-dynamic-classes)
**Agent rule (NEVER):** Dynamically construct class names (`bg-${color}-500`); use object lookup with complete strings
**Source:** [Tailwind](https://tailwindcss.com/docs)
Write complete class names for Tailwind to detect them
> Never dynamically construct class names. Tailwind scans source files as strings and cannot detect computed class names.
Tailwind's build process scans your source files for complete class name strings. It cannot execute JavaScript or interpolate template literals. Dynamically constructed classes like bg-${color}-500 will be purged from production builds.
- [Dynamic Class Names](https://tailwindcss.com/docs/detecting-classes-in-source-files#dynamic-class-names)
- [Safelist Configuration](https://tailwindcss.com/docs/detecting-classes-in-source-files#safelisting-classes)
### Force-Include Classes with @source inline(), Not safelist
**ID:** `performance-purge-optimization` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-purge-optimization](https://ui-guides-agent-rules.netlify.app/principles/performance-purge-optimization)
**Agent rule (SHOULD):** Keep content paths specific; minimize safelist to only CMS-driven classes
**Source:** [Tailwind](https://tailwindcss.com/docs)
The safelist config option is gone in v4 — force-generate classes from CSS, and keep the set tiny
> Use `@source inline("…")` to force-generate utilities that never appear literally in your source. It supports brace expansion: `@source inline("{hover:,}bg-red-{50,{100..900..100},950}")`. Exclude paths with `@source not "…"`.
v4 removed `safelist` along with the rest of the JS config. The replacement lives in CSS: `@source inline("bg-red-500")` tells Tailwind to emit that utility even though it appears nowhere in the scanned source — the case that actually needs it being class names that arrive at runtime from a CMS, an API, or a database. Brace expansion keeps it from becoming a wall of strings: `@source inline("{hover:,}bg-red-{50,{100..900..100},950}")` generates the whole red scale plus every hover: variant of it. That is also the trap. That one line is ~22 utilities; do it for six colors and you have quietly force-shipped every one of them whether the CMS uses them or not, which is exactly the bloat the scanner exists to prevent. Inline the smallest set the data can actually produce, not the design system's full range. And note what this is NOT for: a class you build with string interpolation in your own code (`bg-${color}-500`) is not a safelist problem, it is a bug — write the complete class names out (see performance-dynamic-classes) instead of force-generating around it.
- [Tailwind — Safelisting classes (@source inline)](https://tailwindcss.com/docs/detecting-classes-in-source-files)
- [Tailwind — Functions and directives (@source)](https://tailwindcss.com/docs/functions-and-directives)
- [Tailwind — v4 upgrade guide](https://tailwindcss.com/docs/upgrade-guide)
### Tailwind Defaults First
**ID:** `performance-ibelick-tailwind-defaults` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-ibelick-tailwind-defaults](https://ui-guides-agent-rules.netlify.app/principles/performance-ibelick-tailwind-defaults)
**Agent rule (MUST):** Use Tailwind CSS defaults unless custom values already exist or are explicitly requested. Arbitrary values (w-[347px]) indicate design system gaps.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Use Tailwind CSS default spacing, colors, and sizing scales unless a custom design token exists
> MUST use Tailwind CSS defaults unless custom values already exist or are explicitly requested
The two escapes are the interesting part: a custom value is allowed when the project ALREADY has one (you are following an existing token, not inventing one) or when it was explicitly asked for. Everything else takes the default scale. The failure this prevents is the arbitrary-value drift — p-[13px] here, text-[15px] there, gap-[7px] somewhere else — where every value is individually defensible and the set has no rhythm at all. It is also the single most common tell of generated UI, because a model has no reason to make independently-emitted numbers agree.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [Tailwind Spacing Scale](https://tailwindcss.com/docs/theme)
### Never Animate Large or Continuous Blur
**ID:** `performance-ibelick-no-blur-animation` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-ibelick-no-blur-animation](https://ui-guides-agent-rules.netlify.app/principles/performance-ibelick-no-blur-animation)
**Agent rule (NEVER):** Animate large blur() or backdrop-filter surfaces. They are expensive paint operations that cause frame drops, especially on mobile devices.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Never animate blur on a large surface or on a loop — a small (≤8px), short, one-time blur is the permitted exception
> NEVER animate large `blur()` or `backdrop-filter` surfaces. [fixing-motion-performance §7] keep blur animation small (<=8px) · use blur only for short, one-time effects · never animate blur continuously · never animate blur on large surfaces · prefer opacity and translate before blur
Blur is expensive because it samples a neighbourhood of pixels for every pixel it produces: the cost scales with the AREA blurred and, worse, with the RADIUS, and animating it re-rasterizes the whole surface every frame. So the three variables are radius, area and duration — and ibelick prices all three rather than banning the property outright. The permitted case is a small radius (≤8px), on a small surface, running once. The forbidden cases are the ones that multiply: a full-screen backdrop-filter, a 40px radius, or any blur on a loop. Reach for opacity and translate first; where you truly need a big blur, pre-render it as a static layer and crossfade THAT with opacity, which is composited. IMPORTANT — this is what licenses animations-emil-blur-crossfade, which prescribes filter: blur(2px) during a crossfade to fuse two overlapping states. Under the old blanket phrasing ("never animate blur") the two principles in this corpus flatly contradicted each other. They do not: 2px, on one element, for the length of a transition, is exactly the small/short/one-time carve-out upstream defines. The threshold is the rule; the blanket ban was our overreach.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [ibelick — fixing-motion-performance SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/fixing-motion-performance/SKILL.md)
- [MDN — filter](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/filter)
- [CSS Filter Performance](https://web.dev/articles/simplify-paint-complexity-and-reduce-paint-areas)
### Use will-change Sparingly
**ID:** `performance-ibelick-will-change` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-ibelick-will-change](https://ui-guides-agent-rules.netlify.app/principles/performance-ibelick-will-change)
**Agent rule (NEVER):** Apply will-change outside an active animation. It wastes GPU memory when not needed. Add dynamically before animation, remove after completion.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Only apply will-change during active animations, never as a permanent style
> NEVER apply `will-change` outside an active animation
will-change is a promise, not an optimisation: it asks the browser to promote the element to its own compositor layer AHEAD of a change you are about to make. Left on permanently it is a promise you never keep — the layer is allocated, its memory is held, and nothing ever animates. Do it across a list and you get layer explosion, where the cost of compositing hundreds of layers exceeds whatever the promotion saved. ibelick's fixing-motion-performance skill states the discipline exactly: "use will-change temporarily and surgically", and "compositor motion requires layer promotion, never assume it" — so add it when the animation is about to start (or on hover, just before a press) and remove it when the animation ends.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [ibelick — fixing-motion-performance SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/fixing-motion-performance/SKILL.md)
- [will-change MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/will-change)
### Don't Use useEffect for Render Logic
**ID:** `performance-ibelick-no-effect-render` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-ibelick-no-effect-render](https://ui-guides-agent-rules.netlify.app/principles/performance-ibelick-no-effect-render)
**Agent rule (NEVER):** Use useEffect for anything that can be expressed as render logic. Derive state during render, not in effects. Effects are for synchronization with external systems.
**Source:** [@Ibelick](https://www.ui-skills.com/)
Never use useEffect to compute values that should be derived during render
> NEVER use `useEffect` for anything that can be expressed as render logic
The state-syncing effect — useEffect(() => setFullName(first + " " + last), [first, last]) — is not just slower, it is a second source of truth. React must render with the stale value, commit it, run the effect, set state, and render again: the user briefly sees the old value, and any bug in the dependency array leaves the two permanently out of step. Derived values are just expressions: compute them during render (const fullName = first + " " + last), and reach for useMemo only when the computation is genuinely expensive. Effects are for synchronising with something OUTSIDE React — the DOM, a subscription, the network — not with React's own state.
- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
### CSS Containment for Performance
**ID:** `performance-css-containment` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-css-containment](https://ui-guides-agent-rules.netlify.app/principles/performance-css-containment)
**Agent rule (SHOULD):** Use CSS `contain: layout paint` on reusable cards/list items to isolate layout/paint scope. Use `content-visibility: auto` with `contain-intrinsic-size` for offscreen content (up to 7x render improvement).
**Source:** [Web Platform](https://web.dev/)
Use CSS contain and content-visibility to isolate layout/paint scope
> Use CSS `contain: layout paint` on reusable cards/list items to isolate layout/paint scope. Use `content-visibility: auto` with `contain-intrinsic-size` for offscreen content (up to 7x render improvement).
CSS containment tells the browser that an element's layout/paint is independent from the rest of the page. content-visibility: auto skips rendering offscreen content entirely, dramatically improving initial render for long lists.
- [MDN CSS contain](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/contain)
- [web.dev content-visibility](https://web.dev/articles/content-visibility)
### LCP Hero Optimization
**ID:** `performance-lcp-hero-optimization` · **Permalink:** [https://ui-guides-agent-rules.netlify.app/principles/performance-lcp-hero-optimization](https://ui-guides-agent-rules.netlify.app/principles/performance-lcp-hero-optimization)
**Agent rule (MUST):** LCP images (hero, banner): Use `loading="eager"` + `fetchpriority="high"` + `decoding="async"`. Add `<link rel="preload" as="image">` in head. NEVER lazy-load above-the-fold content.
**Source:** [Web Platform](https://web.dev/)
Preload above-the-fold images and prioritize LCP elements for fast perceived loading
> LCP images (hero, banner): Use `loading="eager"` + `fetchpriority="high"` + `decoding="async"`. Add `<link rel="preload" as="image">` in head. NEVER lazy-load above-the-fold content.
Largest Contentful Paint (LCP) is a Core Web Vital measuring when the largest content becomes visible. Hero images are often the LCP element. Preloading and eager loading ensures they render as fast as possible.
- [Optimize LCP](https://web.dev/articles/optimize-lcp)
- [Preload critical assets](https://web.dev/articles/preload-critical-assets)
### Font Display Strategy
**ID:** `performance-font-display-strategy` · **Permalink:** [https://ui-guides-agent-rules.neDiscussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

