{"topic":"motion","format":"markdown","content":"# Motion & microinteractions\n\nGoal: the interface responds to the user. Motion confirms actions, connects\nstates, and directs attention — it never performs for its own sake. Everything\nhere is CSS-first; do not add an animation library.\n\n## The principle: least motion that keeps context\n\nEvery change of state animates, so the user never loses track of what became\nwhat. But it animates with the least motion that gets there:\n\n- **Only what differs moves.** If a property is the same in both states, it\n  stays still. A field turning invalid changes its edge color and its message;\n  nothing else.\n- **Shortest distance, shortest duration that still reads.** Rises are 2–4px,\n  scale stays within 0.97–1.03, and if the change reads at 150ms it doesn't\n  get 300.\n- **Move or morph the element that's already there** into its new place\n  rather than fading one out and another in. Crossfade only when the two\n  states have no spatial relationship (a hint trading places with an error,\n  one panel replacing another).\n- **No decorative secondary motion** riding along with the real one, and no\n  stagger unless it shows an order the user should read (words arriving in\n  reading order, yes; a row of chips, no).\n\n## 1. Tokens: one curve per job\n\nDefine (or reuse) the motion vocabulary before writing any animation.\nScattered literal values are how motion languages fall apart. UI Polish names\ncurves after the job they do, not their shape, and pairs each with the\nduration it was designed for. These are the roles in `tokens.css`:\n\n```css\n:root {\n  /* Ladder for fades, enters, exits and height. */\n  --ui-dur-instant: 120ms;  /* color feedback; small things leaving */\n  --ui-dur-fast:    160ms;  /* small things arriving; blocks leaving */\n  --ui-dur-base:    200ms;  /* blocks arriving; height closing; strokes drawing */\n  --ui-dur-panel:   280ms;  /* height opening */\n\n  --ui-ease-enter:    cubic-bezier(0.2, 1, 0.3, 1);    /* arriving: answers at once, short settle */\n  --ui-ease-exit:     cubic-bezier(0.4, 0, 1, 1);      /* leaving: accelerates away, never lingers */\n  --ui-ease-expand:   cubic-bezier(0.32, 0.72, 0, 1);  /* height opening: prompt push, firm stop */\n  --ui-ease-collapse: cubic-bezier(0.4, 0, 0.2, 1);    /* height closing: eases in, folds, no tail */\n  --ui-ease-color:  cubic-bezier(0.2, 0, 0, 1);      /* color/opacity feedback in place */\n  --ui-ease-draw:   cubic-bezier(0.35, 0, 0.15, 1);  /* SVG strokes drawing on */\n  --ui-ease-pulse:  cubic-bezier(0.37, 0, 0.63, 1);  /* breathing loops, with `alternate` */\n\n  /* Springs: a little underdamped, sampled into linear() over their own duration. */\n  --ui-dur-move: 380ms;  --ui-ease-move: linear(0, 0.0174, ...);  /* k 484, damping 0.72 */\n  --ui-dur-snap: 240ms;  --ui-ease-snap: linear(0, 0.0228, ...);  /* k 1156, damping 0.8 */\n  --ui-dur-glide: 320ms; --ui-ease-glide: linear(0, 0.0319, ...); /* k 484, critically damped */\n\n  --ui-dur-pulse: 700ms;   /* half a breathing cycle */\n  --ui-dur-sweep: 2400ms;  /* one pass of a light band, `linear` */\n}\n```\n\nWhich curve for which job, and why:\n\n| Job | Curve | Duration | Why this shape |\n|---|---|---|---|\n| Something arrives: popover, menu, tooltip, toast, row, message | `--ui-ease-enter` | fast (small) or base (block) | Over 90% there by a third of the time, so it answers the click, with a shorter settle than quint-out so it reads as done sooner. Still a gentler first frame than expo-out, so opacity doesn't pop. |\n| Something leaves | `--ui-ease-exit` | one step shorter than its entrance | Starts gently and accelerates out, so the eye lets go and it never hangs half-visible. |\n| Something moves from where it was to where it is: FLIP, tab pill, toast stack, text morph letters and width | `--ui-ease-move` | `--ui-dur-move` | A spring with a little give (stiffness 484, damping ratio 0.72): it passes its target by about 4% and settles back, so things that move land with a touch of life instead of stopping dead. Interrupting and restarting from the current value looks continuous, which a cubic-bezier can't promise. |\n| Small hops: press release, a highlight moving one row, a height that retargets as you type | `--ui-ease-snap` | `--ui-dur-snap` | Stiffer (1156, damping 0.8): about 1.5% past, then back, all in 240ms. Short distances on a long spring look floaty. |\n| What a track or a count holds: a pill inside its group, a sheet against an edge, a height, a number counting to its value | `--ui-ease-glide` | `--ui-dur-glide` | The move spring without the give (critically damped): it lands without passing its target, so nothing swings past the end of its track or counts below where it stops. |\n| Height opening (`grid-template-rows`) | `--ui-ease-expand` | `--ui-dur-panel` | Starts moving at once, so content below is pushed promptly, and brakes firmly instead of crawling. Not a spring: height drives reflow, so it should finish decisively. |\n| Height closing | `--ui-ease-collapse` | `--ui-dur-base` | Eases in while the content fades, then folds and stops with no tail. On the expand curve a close loses its content in the first frames and then crawls shut, which reads as a jump and a drag. |\n| Hover, highlight, selected, edge and tint changes; opacity as feedback | `--ui-ease-color` | `--ui-dur-instant` | Responds on the first frame with no travel to feel. |\n| A stroke drawing on (`stroke-dashoffset`) | `--ui-ease-draw` | `--ui-dur-base` | A brief pen-down acceleration, then a long deceleration into the end point, like a hand. |\n| Breathing loops (a working dot) | `--ui-ease-pulse` with `alternate` | `--ui-dur-pulse` each way | Sine in-out: smooth at both turns, no visible snap. |\n| Light band sweeping across text | `linear` | `--ui-dur-sweep` | Constant travel; there are no ends to ease. Same for scroll-driven animation (scroll position is already the easing). |\n\nUsage rules:\n\n- **The spring tokens only mean what they say at their own duration.** A\n  `linear()` spring sampled over 400ms and played over 600ms is a softer\n  spring, not a slower one. Always pair `--ui-ease-move` with `--ui-dur-move`\n  and `--ui-ease-snap` with `--ui-dur-snap`.\n- **Exits are faster than entrances**, one step down the ladder. Appearing\n  should feel gentle; disappearing, decisive.\n- **Never use the enter curve for an exit.** Decelerating out leaves a ghost\n  hanging at low opacity.\n- **A crossfade in one cell offsets its halves:** the leaving layer drops on\n  `--ui-dur-instant` with `--ui-ease-color` (the exit curve would hold it\n  on top of the arriving one), and the arriving one enters on\n  `--ui-ease-enter` half a beat later, so the two never read on top of each\n  other.\n- Overshoot stays small and belongs to things that move: the two springs pass\n  their target by 4% and 1.5% and settle. Never on opacity or on height (a\n  box that grows past its content shows a gap), and the bounce curve only\n  for a thumb landing on a stop.\n- A quiet default worth setting once if the project has no motion language:\n  `* { transition-timing-function: var(--ui-ease-color); }`, so every\n  transition inherits a designed curve instead of browser `ease`.\n\nSprings are generated, not hand-written: simulate `x'' = -k(x - 1) - c x'`\nfrom rest, sample it evenly over the duration (25–35 points, 4 decimals),\nforce the last point to 1, and write the stiffness, damping and duration next\nto the token.\n\n## 2. Hover & press: restraint is the signature\n\n**Hover changes color only.** No scale, no lift, no shadow transitions on\ncontrols when the pointer arrives.\n\n- Primary buttons: shift background one step, e.g.\n  `color-mix(in oklch, var(--ui-accent) 88%, var(--ui-surface))`, or a small\n  alpha ramp (rest 8% → hover 12% → active 16% of foreground). Color\n  transition only, `--ui-dur-instant` on `--ui-ease-color`.\n- **Press may scale, a little.** `scale: var(--ui-scale-press)` (0.985) on `:active`, applied with\n  `transition-duration: var(--ui-dur-press)` (80ms), so the press lands at once\n  without reading as a cut, released back on\n  `--ui-ease-snap` over `--ui-dur-snap`. Never below 0.97, never on hover, never on\n  `[aria-haspopup]` triggers (the menu opens from a shrunken anchor), and\n  never on disabled or busy controls.\n- Press color goes down in the same 80ms; the release eases back:\n\n```css\n.button { transition: background-color var(--ui-dur-instant) var(--ui-ease-color), scale var(--ui-dur-snap) var(--ui-ease-snap); }\n.button:active { scale: var(--ui-scale-press); transition-duration: var(--ui-dur-press); }\n```\n\n- Links/nav: `hover:opacity-80` or one color step. In grouped nav/menus,\n  dim siblings instead of highlighting the target:\n  `group-hover:opacity-50 hover:!opacity-100` — the group recedes, the\n  hovered item holds.\n- The arrow nudge is the classic link affordance:\n  `group-hover:translate-x-0.5` on a trailing arrow icon, ~150–200ms,\n  `transform` only.\n- **Scale never moves more than 3%.** Anything from `0.97` to `1.03`, in any\n  state: press, enter, exit, icon swaps, stacked layers (3% per layer). A\n  bigger scale reads as a toy. Images inside cards may go to `1.03` on hover,\n  `--ui-dur-panel` on `--ui-ease-enter`, inside `overflow: hidden`; never\n  scale text on hover.\n- Focus appears instantly — never transition focus styles.\n- Kill the mobile tap flash once globally:\n  `html { -webkit-tap-highlight-color: transparent; }` and make sure\n  `:active` states exist to replace it.\n\n## 3. Enter & reveal\n\nGrammar for on-scroll or on-mount entrances:\n\n- **Blocks**: `opacity: 0; translate: 0 4px` → settled, `--ui-dur-base` on\n  `--ui-ease-enter`. Popovers and cards may use `scale(0.97)` instead of a\n  rise; not both.\n- **Things that float in** (popovers, menus, list popups, tooltips, toasts)\n  also clear a 2px blur on the same timing: `filter: blur(2px)` → `none`.\n  It reads as coming into focus instead of switching on, and it hides the\n  first frame's half-drawn edges. Only on small surfaces that appear and\n  leave; never on long lists, page sections or anything that scrolls. Rises stay at 2–4px for UI (on-scroll page sections may go\n  to 8px over `--ui-dur-panel`); long travel reads as theatrical.\n- **Never animate reading content from opacity 0 without a fallback.** If JS\n  fails or the element is above the fold, text must be visible. Set the final\n  state when reduced motion is on, fire reveals for above-the-fold content on\n  mount, and disconnect observers after first fire (content does not\n  re-animate on scroll-back).\n- **Stagger** only when it shows an order the user reads (words in reading\n  order, steps in sequence). Then 60–100ms between siblings, capped (after ~6\n  items, bring remaining delays to 0). A group with no order arrives as one.\n  Character-level stagger, if ever, is ~10–15ms.\n- **Popovers/dropdowns/tooltips**: `opacity 0 → 1` + `scale(0.97 → 1)` with\n  `transform-origin` at the trigger side, `--ui-dur-fast` on `--ui-ease-enter`\n  in, `--ui-dur-instant` on `--ui-ease-exit` out.\n- Native `<dialog>`/popover animation without JS:\n\n```css\ndialog { transition: opacity var(--ui-dur-instant) var(--ui-ease-exit), display var(--ui-dur-instant) allow-discrete, overlay var(--ui-dur-instant) allow-discrete; opacity: 0; }\ndialog[open] { opacity: 1; transition-duration: var(--ui-dur-fast); transition-timing-function: var(--ui-ease-enter); @starting-style { opacity: 0; } }\n```\n\n- IntersectionObserver reveals: threshold ~0.15–0.25 for sections, plus a\n  negative bottom `rootMargin` (e.g. `0px 0px -10% 0px`) so elements reveal\n  after actually entering, not at the pixel edge. Use one shared observer,\n  not one per element.\n\n## 4. Expanding content (the auto-height problem)\n\nAnimate `grid-template-rows`, not `height`/`max-height`:\n\n```html\n<div class=\"grid transition-[grid-template-rows] duration-[var(--ui-dur-panel)] ease-[var(--ui-ease-expand)]\n            data-[open=true]:grid-rows-[1fr] grid-rows-[0fr]\">\n  <div class=\"overflow-hidden\"><!-- content --></div>\n</div>\n```\n\nThe `overflow-hidden` must be on the inner element. Open over `--ui-dur-panel`\non `--ui-ease-collapse`; close over `--ui-dur-base` on `--ui-ease-collapse`.\nPair with opacity: content fades in (`--ui-dur-fast`, `--ui-ease-enter`) half\na beat *after* the box starts opening, and fades out (`--ui-dur-instant`) on\n`--ui-ease-exit` as it closes: the collapse barely moves for its first frames,\nso the text is gone before the edge reaches it, and the fold that follows is\nquick and has no slow tail. When one panel closes because another opens (an accordion that\nkeeps one open), close it on the opening's duration and curve, so the two\nheights trade off exactly and what's below holds still.\n\n**Removing an item from a list** (a file, an attachment, a dismissed alert,\na toast) is the same collapse, never its own curve: the item fades out over\n`--ui-dur-instant` on `--ui-ease-exit`, starting on the press, and its space\ncloses over `--ui-dur-base` on `--ui-ease-collapse` once the fade is done\n(delay it by `--ui-dur-instant`). A close that starts with the fade crushes\nan item that's still visible and drags everything below up past it. Nothing slides\nsideways or shrinks.\nNever close a height on `--ui-ease-exit`: an accelerating collapse starts\nslow and lands hard, so what's below drifts and then jumps.\n\n### Dialogs enter like popovers\n\nAt block size: opacity on `--ui-dur-fast`, scale 0.97 and a 2px blur\nclearing on `--ui-dur-base`, all on `--ui-ease-enter`. No spring: nothing\ntravels. On a phone it docks at the bottom and rises from its bottom edge.\n\n### Numbers that change in place\n\nNever animate their width or position. Keep the box a fixed width, swap only\nthe digits that changed (transform and opacity), and nothing on first paint.\nA number fed faster than it can be read (a count ticking forty times a\nsecond) shows its latest value at most about five times a second, and its\nlast value always lands; a rate or an estimate settles once a second. The\nsame goes for a name swapping in place: pace it, or it never finishes\nfading in.\nA pill that marks one item in a row (the current page, the chosen tab) is\nsized and placed from the same variables as the item boxes, with odd-width\nitems like arrows counted explicitly, so the number sits dead center in it.\n\n### A status that changes in place keeps its box\n\nEvery label a status can show sits in one grid cell, so the box holds the\nwidest and nothing beside it moves. Its color changes over `--ui-dur-base`\non `--ui-ease-color`, and the label swaps as an offset crossfade. A spinner,\nwhere one is earned (a step running in a list of steps), is a 16px glyph in\nthe slot of the icon it replaces, never a new element beside it; with reduced\nmotion it stops turning and breathes.\n\n### One tooltip per rail or toolbar\n\nTriggers in a row share a single tooltip. Once it's on screen it glides to\nthe next trigger (glide spring, no overshoot), eases its width to the new\nlabel and crossfades the label with a small shift in the direction of travel.\nIt never re-enters while you move along the row, and the first one still\nwaits for the delay.\n\n### A sidebar folds to its rail without moving an icon\n\nLay the rows out from the collapsed rail: each icon sits where it would be\ncentered in the rail's capsule (content inset = (capsule − icon) / 2), so\nwhen the width changes no icon moves. The width eases like a height\n(`--ui-ease-expand` over `--ui-dur-panel` opening, `--ui-ease-collapse` over\n`--ui-dur-base` closing). Rows narrow with it, so the current row's pill\nbecomes the rail's capsule; their labels leave on `--ui-dur-instant` in the\nfirst frames and come back half a beat after it starts opening, so no word\nis read while it moves. What has no icon (headings, a history list) is held\nat the open width so it never rewraps, fades first, and goes `inert`. With\nreduced motion it switches at once.\n\n### A field that waits folds into its answer\n\nA field that can't take input while work runs (a one-time code being\nchecked) may fold into a pill the same height on the glide spring, and that\npill carries the result: a success tint and a check, or it opens back into\nthe field with the input intact and the error below. Lock it at once, fold\nonly after the 300ms busy delay, and keep the space it held so nothing below\nmoves. Focus waits on the group while it's folded and returns to the field\nonly if it was there before.\n\n### Results land, they don't bounce\n\nA change that shows an outcome (a card resizing to its result, a link\nturning into a check) glides into place on `--ui-ease-glide`. Overshoot is\nfor things a person moves or toggles; on a result it reads as a wobble.\n\n### Views that page\n\nA view that pages (calendar months, carousel steps) slides 16–24px in the\ndirection of travel on the glide spring while the old one fades out first\nand the new one fades in just after, so the two never sit on top of each\nother. Clip it inside its panel and fade the clip edge through the panel's\npadding with a mask; nothing at rest sits in the fade. Interrupted midway,\na view leaves from where it visibly is.\n\n### Nothing below moves while content streams\n\nReserve the space before content arrives. A thinking window, a progress\narea or a result panel opens once, to its full height, and stays that size\nwhile text streams into it; it doesn't grow line by line and push what's\nbelow. A label whose width changes glides to the new width (the glide spring)\nso what sits beside it slides rather than jumps, and at rest has no fixed\nwidth, so a late webfont can't cut it.\n\nA streaming log follows its end only while the reader is there. Once they\nscroll up it holds still, keeps what they're reading pinned (a stage\nswitching underneath counts as moving it), and offers one floating \"3 new\nlines\" control back to the end.\n\n### Reveals with no hard edge\n\nOpening height clips its content, and a solid box swept by the clip edge\nshows a hard line. While the space opens, the content stays faint and soft:\nfade, a blur clearing and a small scale from the top, all over the full\nopening; and fade the bottom few pixels of the clip with a mask. Done right,\nyou can't tell how it animated.\n\n### Page transitions\n\nA straight crossfade, both pages visible the whole time (`plus-lighter` so\nthere's no dip halfway), and never a blank frame. Chrome that doesn't change\nbetween pages (a docs sidebar) gets its own `view-transition-name` and stays\nstill, and when it appears or leaves with the page it moves a short way (24px)\nfrom its own edge as it fades. Opening an item from an overview (a tile into\nits page) shares a name between the two, so the item glides into place on the\nglide spring, no overshoot, while everything else fades.\n\n## 5. Micro-recipes\n\n- **Disclosure chevron**: which motion follows from its axis. At the end of a\n  row or under a list (\"Show all\"), the content opens below it, so it points\n  down and turns up: render the icon for the state\n  (`<Icon name={open ? \"chevron-up\" : \"chevron-down\"} />`) and let the icon\n  set morph the arms; no CSS rotate. Trailing or leading a short inline\n  label (\"4 sources ›\", \"Thought for 8s ›\"), it points right and opens\n  downward: that changes axis, and a morph between the two shapes reads as\n  the arrow coming apart, so it turns a quarter turn instead\n  (`<Icon name=\"chevron-right\" data-turn={open ? \"open\" : \"\"} />`), over\n  `--ui-dur-base` on `--ui-ease-glide`, never with overshoot. Either way it\n  rests in `--ui-muted` and goes to `--ui-foreground` while its trigger is\n  hovered, a color change only (`--ui-dur-instant`, `--ui-ease-color`). If\n  the open state can change between mount and first paint (opened by the\n  URL), remount the icon after first paint (`key={String(ready)}`) so it\n  shows the open shape instead of moving into it.\n- **Underline that doesn't shift layout**: a `scaleX(0 → 1)` pseudo-element\n  with `transform-origin: left`, `--ui-dur-base` on `--ui-ease-enter` — not\n  `text-decoration` toggling.\n- **Toast**: enter with fade + a 4px rise, `--ui-dur-base` on\n  `--ui-ease-enter`; exit with fade + a 4px drop, `--ui-dur-fast` on\n  `--ui-ease-exit`; the stack moves on `--ui-ease-move`. Defer unmount until\n  the exit finishes.\n- **Text morph** (a label changing in place: Copy → Copied, Save → Saving…):\n  match whole words the two labels share first, so a shared word slides as\n  one piece (\"Send invitation\" → \"Invitation sent\"). Then pair leftover\n  letters only within the same gap between those words, in order, so no\n  letter ever crosses a word. Paired letters slide from their old x to the\n  new one (FLIP: measure, invert with `translateX`, play to zero on\n  `--ui-ease-move` over `--ui-dur-move`). Letters that leave fade and lift\n  out in `--ui-dur-instant` on `--ui-ease-exit`; letters that arrive fade in\n  on the move spring with ~15ms stagger. No blur.\n  The wrapper's width animates on the same spring so the container grows into\n  the new word. This is one sanctioned exception to \"never animate width\": it\n  is one inline box, and the alternative is a jump. Screen readers get the\n  full label from a visually hidden copy; the letters are `aria-hidden`.\n- **Content swap in place** (theme, route, a hint becoming an error):\n  crossfade two stacked layers in the same grid cell rather than animating\n  one element through empty. The cell holds the taller of the two, so\n  nothing below moves.\n- **Shimmer** (work in progress): the label dims and a light band sweeps\n  across it, `linear` over `--ui-dur-sweep` (`sweep` in `registry/text`).\n  Never a fast blink, and still under reduced motion.\n- **Marquee** (if one exists): duplicate content, animate `translateX(0 → -50%)`\n  linear, mask the edges with a gradient, pause on hover, and pause offscreen.\n- Derive related delays from durations instead of hardcoding: if a line draws\n  in 600ms and its endpoint dot fades in 300ms, delay the dot by 300ms so it\n  lands as the line arrives.\n\n## 6. Performance discipline\n\n- Animate **`transform`, `opacity`, `filter`, and colors only.** Never\n  `width`/`height`/`top`/`left`/`margin` (exception: `grid-template-rows`\n  above). Never `transition: all` on anything that moves.\n- `will-change: transform` only on elements that continuously animate\n  (marquee tracks, pinned sections) — a handful per page, never globally.\n- Scroll-linked effects: `requestAnimationFrame`-coalesced with a passive\n  listener writing a transform, or CSS `animation-timeline: scroll()/view()`\n  behind `@supports (animation-timeline: scroll())` with a static fallback.\n- Ambient/looping decoration (if kept at all): slow (3s+) and low-amplitude,\n  paused when offscreen. Nothing loops fast enough to draw the eye while\n  reading.\n\n## 7. Reduced motion — non-negotiable\n\nEvery animation you introduce is guarded, and the guard produces the **end\nstate**, not a missing element:\n\n```css\n@media (prefers-reduced-motion: reduce) {\n  .reveal { animation: none; opacity: 1; transform: none; }\n  html { scroll-behavior: auto; }   /* often forgotten next to scroll-smooth */\n}\n```\n\n- With utility classes, prefer a `motion-safe` variant for anything that moves.\n- JS-driven animation branches on\n  `matchMedia('(prefers-reduced-motion: reduce)').matches` and sets final\n  values instantly. Reduced motion means *instant state change*, not *no\n  state change*.\n- Sub-200ms opacity/color transitions may remain — they are feedback, not\n  motion.\n- Colocate the reduce override with each animation it guards, so copies of\n  the component keep the guard.\n\n## 8. What NOT to do\n\n- No entrance animation on every element — reveal groups/sections, not each\n  paragraph.\n- No re-triggering reveals on every scroll pass.\n- No animated focus rings, no animated disabled states.\n- No spring physics on functional UI (menus, accordions, inputs).\n- No hover-dependent information (touch devices exist); hover only enhances.\n- No motion added to an app that already has a motion language you haven't\n  matched — extend theirs.\n"}