# Motion kit

> Small animations on the motion tokens: an error shake, a collapsible height, children that follow through, and a sheet slide.

Utility · [Docs](https://ui.ssntl.app/docs/components/motion-kit) · `npx shadcn@latest add @ssntl/motion-kit`

Installs `styles/ssntl/motion-kit.css`. Read this file before you use, change or review it.

## When to use it

- For error nudges (`shake`), disclosures (`collapsible`, `follow-through`) and sheets.

## When not to

- Decorative motion that explains nothing.

## Usage

```css
/* app/globals.css */
@import "../styles/ssntl/motion-kit.css";

<input class={invalid ? "shake" : ""} aria-invalid={invalid} />
<div class="collapsible" data-state="open"><div class="follow-through">…</div></div>
```

## Rules for Motion kit

- `shake`: ±4px (spacing-1) over `--duration-menu`; pair it with `aria-invalid` and a message, since it never runs with reduced motion.
- `collapsible` animates a Radix disclosure's height; `follow-through` lifts its children in 40ms apart, after the container.
- `sheet-in` and `sheet-out` slide a panel from the bottom edge, out faster than in.
- Every animation is off under reduced motion; no state lives in motion alone.

## How it is built

Motion kit: small, purposeful animations built on the motion tokens. Every one turns off under reduced motion, and none carries state on its own: a color, an icon or a word stays behind when it doesn't run.

- `shake`: a noticeable but gentle error nudge
- `collapsible`: height for a disclosure (Radix --*-content-height)
- `follow-through`: children enter just after their container

Noticeable errors: ±4px (spacing-1) over --duration-menu. Big enough to catch the eye, too small to jar; the message explains the error.

Follow through & overlap: the container opens first, then its rows lift in 40ms apart, so the motion settles instead of landing at once.

Put follow-through on the element inside an open disclosure (Radix sets data-state="open" on its parent).

Sheets slide up from the bottom edge and leave faster than they came.

## UI foundations it applies

- **Clear motion paths** (Animation principles): Move an element straight to its new place, with no spin or detour.
- **Follow through & overlap** (Animation principles): Move the container first and let its children follow, slightly staggered.
- **Arcs** (Animation principles): Move things across the screen along a gentle curve, not a straight line.
- **Timing** (Animation principles): Keep micro-interactions short and make exits faster than entrances.
- **Noticeable errors** (Animation principles): Nudge an invalid field a few pixels, briefly, and explain the fix in words.

## Motion

- Animate only to explain a change; the interface is complete without motion.
- **Durations from tokens.** `--duration-fast` 150 (press, hover), `--duration-pop` 180 (tooltips, icon swaps), `--duration-menu` 220 (menus, small moves), `--duration-panel` 320 (modals, sheets).
- **Easing from tokens.** `--ease-out` for entrances, exits and presses; `--ease-in-out` for things already on screen moving to a new place; linear for progress only.
- Use transitions for interactions so they can be interrupted; keyframes only for one-off sequences.
- Name the properties you transition (transition-[scale,opacity]); never transition: all.
- **Don't animate on page load.** Content that is already there appears at once; entrance motion is for things that arrive later.
- No custom animation on high-frequency interactions (typing, toggling in a list, hovering a table, keyboard shortcuts, a command palette); the cost repeats every time.
- Never `ease-in` on interface motion: it starts slow, exactly when the person is watching.
- **Never animate from scale(0).** Start at 0.9–0.97 with opacity 0 (`pop-in` starts at 0.96). The one exception is a cross-fading icon (`icon-swap`), which starts at 0.25 behind a blur.
- Popovers and menus grow from their trigger (transform-origin at the trigger); modals stay centered.
- Keep animated blur under 20px; a 2–4px blur masks an imperfect cross-fade.
- `will-change` only once you see first-frame stutter, naming the property that animates (scale, not transform), and never on an element that doesn't animate; it also makes the element the container of fixed descendants.
- Entrances stagger 100ms apart (80ms per word in a headline): each chunk rises 12px out of a 4px blur over `--duration-panel`, and exits travel less, faster.
- Icon cross-fades run on `--duration-pop` with cubic-bezier(0.2, 0, 0, 1), and only for infrequent swaps; a tab's icon or an action revealed on hover swaps instantly. (better-ui suggests 300ms; the system keeps its 180ms token, under the 300ms limit for interface motion.)
- Exits are quicker and travel less than entrances; animate opacity, translate, scale and filter, never layout properties.
- Under reduced motion keep opacity and color only. No state lives in motion alone.

## Design tokens only

- Use the system's tokens for every design value: type roles (`text-caption` 13, `text-label` 15, `text-body` 17, `text-lead` 20, `text-heading` 22, `text-title` 32, `text-headline` 48, `text-display` 72), radii (`rounded-xs` 8 … xl 44), spacing steps, colors, shadows and motion. If a value doesn't exist, take the nearest token. Never write text-[15px], rounded-[24px] or bg-[#…]; `pnpm lint` rejects them.
- Type roles already carry line height, tracking and weight. Pick the role; never add `leading-*` or `tracking-*` on top.
- **Nested corners are concentric.** Inner radius = outer radius − padding − border width (`rounded-outer`-* p-concentric-* `rounded-inner`). Past 24px of padding, or with deliberately uneven padding, each layer keeps its own radius token.
- Separate surfaces by tone, not borders. A border is for structure (a divider) or state (an input, a selection).

## Accessibility baseline

- Text meets 4.5:1 against its surface (3:1 at 24px and up, or 18.67px bold); input borders, focus rings and chart marks meet 3:1, in light and dark. Text on a translucent color (`white/80`, /50, color-mix) is measured as rendered over its background; the theme pairs are checked in lint.
- Meaning never rides on color alone: a state carries a word, an icon or a pattern as well.
- Every page fits a 320px screen at 200% zoom with no horizontal scroll; a grid that holds content uses grid-cols-[minmax(0,1fr)] and flex children get min-w-0. Never set maximum-scale=1 or user-scalable=no.
- A focused element is never hidden under sticky chrome: set scroll-padding to the header's height (and to footers or banners at the bottom).
- On a client-side route change, update the title and move focus to the new page's main content.
- **Respect prefers-reduced-motion.** Keep opacity and color, drop movement, scale and blur.
- Content is never truncated without a way to read all of it; long names wrap. A hover-only title tooltip doesn't count as a way back.

## Writing

- Sentence case everywhere; no ALL CAPS except the Eyebrow role.
- One concept, one word, across navigation, headings and buttons.
- Buttons are a verb and its object and say what happens (Delete product, not OK).
- An error says what went wrong and how to fix it, in the user's language.
- **Status copy follows one pattern.** In progress is the verb in -ing (Saving changes…), done names the object and the past tense with no “successfully” (Changes saved), a failure gives the next step, partial results give counts. Cut “Please”.
- A confirmation names the action and the object (Delete Ceramic cup?), never “Are you sure?”. Deleting an account, a workspace or a store asks for its name to be typed.
- Show known requirements as helper text before input, not only as an error after it. Links never say “Click here”, and no two links on a page say “Learn more”.
- Build each sentence as one translatable message, never concatenated around a variable; join lists with Intl.`ListFormat`.
- **Use real characters.** An en dash for ranges (09:30–17:45), × for dimensions, curly quotes, an ellipsis (…).

## Fixing

- Take the cheapest fix that works, in this order: delete (a separator space would carry, an animation on a frequent action, an ARIA attribute a native element makes redundant), use the platform (the native element or control, the browser's focus ring), reuse what the system has (a token, a spacing step, a motion curve), correct the value, and only then add something new.
- Code that adds what a deletion would fix is a problem in itself; fix it by removing.
- A documented convention settles matters of taste, but never excuses a release blocker.

## Worst-case content

- Build and test with the worst real values, not the happy ones: long hyphenated names with diacritics, unbreakable emails and URLs, German compounds, translations about 30% longer, emoji and right-to-left names.
- Unbreakable strings wrap anywhere; nothing pushes its siblings off a row or out of a container.
- Every image has a fallback and a box: a failed avatar shows initials, a frame keeps its ratio, so loading never shifts the layout.
- **Show data as data.** Escape HTML, never render null, undefined or NaN, and round floating point before display.
- **Check the environment as well.** A 280px column, a 2560px screen, 200% zoom, dark mode, `dir="rtl"` and touch.

## Icons

- One icon set (Lucide) at one stroke: 1.6, the Essential Design spec, beside regular and medium text; a heavier stroke only where a small glyph must read (a 16px check). (better-ui proposes rendered strokes of 1.5–2px with `absoluteStrokeWidth`; the system's documented stroke takes precedence as a matter of style.)
- Inline icons are 1em–1.25em of the text beside them.
- Icons are 20px beside labels and 16px in dense controls, at the size they were drawn; a well or a button, not a bigger glyph, gives them room.
- One SVG per icon, colored with `currentColor`; hover, selected and disabled states come from color and opacity, never from separate assets.
- Decorative icons are `aria-hidden`; an icon that is the only content of a control gets its name from the control.

## Light, dark and languages

- Design both themes from the tokens; dark mode is chosen, not inverted. Check every new pair in both.
- Pass every visible and accessible string as a prop or child, so the component works in any language; never hard-code copy inside it.
- Use logical properties (ms-, me-, start-, end-) so layouts mirror in right-to-left languages; `translateX` and background-position need a right-to-left override. Mirror only icons whose meaning follows reading direction (arrows, chevrons), and wrap mixed-direction values in `<bdi>`.
- **Visual order matches DOM order.** No order, row-reverse or grid placement that reorders content.
- Switching themes pauses transitions for the switch, so every surface changes at once.

## Before you ship

- [ ] Every interactive control has an accessible name.
- [ ] Every keyboard-reachable control shows a visible focus indicator.
- [ ] Nothing is reachable by pointer but not by keyboard.
- [ ] Motion and auto-playing content respect prefers-reduced-motion.
- [ ] Nothing is clipped, overlapped or unreachable at 320px width or 200% zoom.
- [ ] Body and control text pass their contrast ratio.
- [ ] No state or meaning is carried by color alone.
- [ ] Destructive actions have a confirmation, an undo, or a distinct treatment.
- [ ] No content is truncated without a way to reach the full value.
- [ ] No content or control hides past a scroll edge or disclosure without a visible cue.
- [ ] Every error names a way to recover.
- [ ] Semantic colors are used for their meaning only (danger never on a safe action).
- [ ] No state change is carried by motion alone.
- [ ] Nothing focused hides under sticky chrome, and overlays contain their own scroll.
- [ ] Worst-case values (long, empty, many, missing, right-to-left) don't break the layout.

## Review with these skills

Every interface built with ssntl/ui is reviewed with the same skills the library is. Install them once and keep them current:

```bash
npx skills@latest add https://github.com/jakubkrehel/skills --skill better-interface
npx skills@latest add jakubkrehel/make-interfaces-feel-better
npx skills@latest add emilkowalski/skills
```

- **better-interface** (Jakub Krehel): The full review: accessibility, layout, writing, typography, color and polish. Its escalation triggers block a release.
- **make-interfaces-feel-better** (Jakub Krehel): The details: concentric radii, optical alignment, press scale, interruptible and subtle motion, hit areas.
- **emilkowalski/skills** (Emil Kowalski): Design engineering: easing, durations, springs and gestures (emil-design-eng, review-animations, animation-performance) and the worst-case values that break a UI (break-ui).

## Related

- [Accordion](accordion.md): Disclosure sections that open first and let their lines follow through.
- [Sheet](sheet.md): A bottom sheet you can drag away: it follows you, says when release will close it, and springs back.
- [Field](field.md): A visible, clickable label above the control and a hint below that names the fix when there is an error.

---

Generated from the ssntl/ui source by `scripts/build-rules.mjs`. The whole rulebook is [ssntl-ui.md](ssntl-ui.md); every component's rules are at https://ui.ssntl.app/rules.
