Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel, Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame (DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale. Design only: behaviour, texts and roles are unchanged. Tests that asserted the old DOM were updated; the tests now format numbers in English wherever they run. docs/reui-migration-plan.md records the plan and its decisions. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
8.9 KiB
8.9 KiB
Motion
Installed motion: never retime it
| Installed part | Duration |
|---|---|
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
Never add or change duration-*, ease-*, animate-* or transition-* on installed parts (components/ui, components/reui); Button already presses with active:not-aria-[haspopup]:translate-y-px.
House motion: your own code
| Use | Tailwind |
|---|---|
| Hover, press, color | transition-colors (150ms, no duration or ease class) |
| Reveal or expand | duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] |
| Large surface (ceiling) | duration-300 with the same curve |
| An overlay you build | duration-100 with the same curve |
| Media hover zoom | duration-500 with the same curve, scale-105 at most |
| Width tied to the sidebar | duration-200 ease-linear |
- Press on a custom surface (not Button):
transition-transform active:scale-[0.98] motion-reduce:transition-none. - Hover color plus press on 1 element:
transition-[color,background-color,border-color,scale](transition-colorsandtransition-transformoverride each other;scale-*setsscale). - motion/react takes the same values in seconds:
{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }; hover{ duration: 0.15 }. - Drag, reorder and layout use
const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }. DefineSPRINGonly when something drags, reorders or toggles layout.
Hard rules
- Animate
transformandopacity; color only for hover and press. Nevertransition-all; width, height, top or left only in the sidebar row. - Never
animate-bounceor a spring with bounce above 0.ease-linearonly for progress, spinners, loops and the sidebar; never Tailwind'sease-in,ease-outorease-in-out. motion/reactonly: never framer-motion, GSAP or react-spring.- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its
Skeleton) is a reveal, not an entrance: fade it in with the reveal row. - Never add named
duration-*utilities or redefine--ease-out. - Loading: a
Skeleton(it already pulses) in the real layout; a spinner only inside a busy button. - Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its
installAnimatedcommand; never retime it.
Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare
transition(it also animates box-shadow and filter). (reui/no-paint-animation; 240px: judgement (no check)) - A hover lift is
transformover a static shadow, or a shadow layer whoseopacityfades; never a transitionedhover:shadow-*. (reui/no-paint-animation) - CSS first: hover, press, reveals and ambient loops are Tailwind transitions,
tw-animate-cssor@keyframes. motion/react only for exit on unmount, drag, springs that follow input,layoutand scroll-linked values. (judgement (no check)) - In motion/react,
x,y,scale,rotate,pathLengthand every SVG target run on the main thread: neverrepeat: Infinityon them (loop with CSS@keyframes), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element withoutrepeatDelay,repeatType: "mirror"oronUpdate. (reui/no-main-thread-loop) - SVG geometry (a rect's
width, a circle'sr) does not reflow the page but runs in motion's JS loop: one-shot only. (reui/no-main-thread-loop) - Never set React state per frame (requestAnimationFrame,
useAnimationFrame,onUpdate, scroll or pointermove listeners): bind styles touseMotionValue,useTransformor a CSS variable on the element. (reui/no-frame-setstate) - A JS-driven loop (motion
repeat, requestAnimationFrame, canvas) pauses offscreen withuseInViewor IntersectionObserver;content-visibility: autonever stops script, and a setInterval loop also pauses onvisibilitychange. (reui/loop-offscreen) - No permanent
will-change: nowill-change-*class orwillChangestyle, except during a drag, removed when it ends. (reui/will-change) - Stagger 50ms or less per step (
staggerChildren: 0.05), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals usewhileInViewwithviewport={{ once: true }}. (reui/stagger-budget; item count and tail: judgement (no check)) - A height reveal (
grid-template-rows) starts only on a click or key press, runs 200ms or less, 1 at a time; neverinitial={{ height: 0 }}. (reui/no-layout-transition) - The motion
layoutprop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through@reui/sortableor@reui/kanban. (judgement (no check)) - AnimatePresence
mode="wait"only with an exit of 150ms or less;initial={false}around content present at first render. (reui/stagger-budget) - Never hide the hero or the largest heading behind a motion/react
initialat opacity 0 (hidden until JavaScript runs). (judgement (no check)) - At most 3 ambient loops per view: CSS
@keyframeson transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (acontent-visibility: autowrapper withcontain-intrinsic-size: auto <h>where nothing paints outside it, or IntersectionObserver togglinganimation-play-state). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (
reui/reduced-motion) - The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (
reui/reduced-motion) - In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (
reui/reduced-motion; half the viewport: judgement (no check)) <MotionConfig reducedMotion="user">once at the app root (its default ignores the OS) AND amotion-reduce:path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (reui/reduced-motion)- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a
transformstring keep animating, so a loop or those keys branch onuseReducedMotion()and rest on the final value. (reui/reduced-motion) useReducedMotion()is null on the server: never let it pick markup (JSX, className,initial) or reachanimateunderinitial={false}; branch only animate, transition,while*or exit values. (reui/reduced-motion-ssr)- No SMIL (
<animate>,<animateMotion>,<animateTransform>); canvas and requestAnimationFrame loops draw 1 resting frame underprefers-reduced-motion: reduce. (reui/no-smil,reui/reduced-motion) - Smooth scroll only under
motion-safe:scroll-smooth. A focus ring appears at once: nevertransition-all,transition-shadowor a baretransitionon it. (reui/reduced-motion,reui/focus-transition) - Loaders and typing dots keep their opacity pulse; every decorative loop gets
motion-reduce:animate-none, never an inlinestyle={{ animation }}(it beats the class). (reui/reduced-motion) - Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (
reui/pausable-autoplay) - Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))