Motion

Three easing curves and three durations, exposed as Tailwind utilities and mirrored in lib/motion.ts for JS animation. Never inline a raw cubic-bezier or ms value.

Easing#

Same duration, different curves - press Play to compare.

ease-out

UI enter / exit

ease-in-out

On-screen movement

ease-drawer

Sheets / drawers

ease-sine

Oscillation

Duration#

Same curve (ease-out), different durations.

duration-fast

160ms

duration-base

300ms

duration-slow

450ms

Spring: overshoot for confirmation#

A fourth curve, ease-spring, overshoots past its end value before settling back. Reserve it for moments that should feel rewarding - a confirmation, a checkmark, a control that pops into place - never for routine UI movement, where the bounce reads as nervous. Press Play to compare the overshoot against a flat ease-out.

ease-spring

Overshoots, then settles

ease-out

Eases in, no overshoot

confirm.tsxTSX
// Spring overshoots past the end, then settles - a "pop" on confirmation.<div  className={cn(    // Name 'scale', not 'transform': in Tailwind v4 scale-* sets its own CSS property.    "transition-[scale,opacity] duration-base ease-spring",    confirmed ? "scale-100 opacity-100" : "scale-50 opacity-0",  )}/>// JS-driven animationimport { easing, duration } from "@/lib/motion"element.animate(keyframes, {  duration: duration.base,        // 300  easing: easing.spring,          // cubic-bezier(0.34, 1.56, 0.64, 1)})

Sine: for values that oscillate#

ease-sine is symmetric and leaves and arrives at zero velocity. Reach for it when a value swings back and forth through a multi-stop @keyframes rather than travelling from A to B once - an error shake, a pendulum, a breathing loop. The reason is easy to miss: a timing function applies to every segment between stops, so ease-out makes each swing shoot to its extreme and stall there, and the motion reads as a buzz instead of a wobble.

globals.cssTSX
/* Keyframe only the extremes; ease-sine draws the arc between them. */--animate-otp-shake: otp-shake 650ms var(--ease-sine);@keyframes otp-shake {  0%, 100% { transform: translate3d(0, 0, 0); }  17% { transform: translate3d(calc(var(--otp-shake-distance) * -1), 0, 0); }  50% { transform: translate3d(calc(var(--otp-shake-distance) * 0.55), 0, 0); }  83% { transform: translate3d(calc(var(--otp-shake-distance) * -0.25), 0, 0); }}

Usage#

Compose the utilities directly in components, or import the constants from lib/motion.ts when animating from JS.

example.tsxTSX
// CSS / Tailwind - preferred<div className="transition-colors duration-base ease-out" />// JS-driven animationimport { easing, duration } from "@/lib/motion"element.animate(keyframes, {  duration: duration.base,        // 300  easing: easing.out,             // cubic-bezier(0.23, 1, 0.32, 1)})

Interruptible transitions#

Use CSS transition for any animation driven by interactive state - hover, focus, active, data attributes. CSS transitions can be reversed mid-flight; keyframe animations cannot. Reserve @keyframes for staged sequences that run once (spinners, skeleton pulses, entrance choreography).

button.tsxTSX
// ✓ CSS transition - interruptible, reverses cleanly on mouse-out<button className="bg-primary transition-colors duration-fast ease-out hover:bg-primary/80">  Save</button>// ✗ Keyframe - fires once, cannot be reversed mid-animation<button className="animate-pulse">Save</button>

Specify properties, never transition-all#

transition: all triggers a style recalc on every CSS property change, including layout-affecting ones like width and padding. Always name the properties you actually animate. Tailwind’s transition-transform covers transform, translate, scale, rotate in one utility.

card.tsxTSX
// ✓ Specific - only the GPU-composited properties<div className="transition-[opacity,transform] duration-base ease-out" /><div className="transition-transform duration-fast ease-out hover:scale-[1.02]" /><div className="transition-colors duration-fast ease-out" />// ✗ Transitions layout + paint + composite on every change<div className="transition-all duration-base" />

Press and hover feedback#

Every interactive control confirms a press with a small scale-down - active:scale-[0.96], never below 0.95 - and cards add a subtle hover lift. The feedback is what makes a surface feel tappable. Hover and press the cards below; expose a static prop to opt out where the motion would be distracting.

One Tailwind v4 trap lives here: scale-* and translate-* set their own standalone CSS properties, not transform. A transition-[box-shadow,transform] will silently fail to tween them - they snap. Name scale and translate in the list, or use the transition-transform utility, which already covers all four.

action-card.tsxTSX
// ✓ Press scale + hover lift, with scale/translate named in the transition.<button  className="cursor-pointer rounded-xl border border-border bg-card p-4             transition-[scale,translate,box-shadow,border-color] duration-fast ease-out             hover:-translate-y-0.5 hover:shadow-md             active:scale-[0.96]"/>// ✗ scale-* and translate-* are standalone props in v4 - 'transform' won't reach them.<button className="transition-[box-shadow,transform] hover:-translate-y-0.5 active:scale-[0.96]" />

Enter animations: split and stagger#

Animating a container as one block looks mechanical. Break the content into chunks and stagger each by ~70-100 ms so the reader perceives hierarchy rather than a single blob appearing. Reach for the Stagger primitive rather than hand-rolling per-item delays. It sets each child’s delay off the motion tokens, plays once on mount, and falls back to no animation under reduced-motion.

Items mount in a cascade, 70 ms apart.
Overview
Analytics
Reports
Members
Billing
Settings
stagger.tsxTSX
import { Stagger } from "@/lib/stagger"// ✓ Lists / grids: one wrapper, the cascade is automatic.<Stagger className="grid grid-cols-3 gap-2">  {items.map((item) => (    <Card key={item.id}>{item.name}</Card>  ))}</Stagger>// ✓ Below the fold: reveal as it scrolls in, with a soft focus pull.<Stagger inView blur className="grid grid-cols-3 gap-2">  {items.map((item) => (    <Card key={item.id}>{item.name}</Card>  ))}</Stagger>// ✗ Whole container in one shot<div className="animate-fade-in">  <h2>Title</h2>  <p>Body</p>  <Button>Action</Button></div>

Stagger animates on mount by default: the right gate for “load” cascades. With stable keys, reordering reuses the DOM and won’t replay; genuinely new content (a filter, a fresh page, a layout switch) mounts and cascades. The DataTable’s cards layout uses it. Tune the gap with step (default 70 ms); add blur for the 4px → 0 focus pull and inView to hold the cascade until it scrolls into view (the same props a Section Header takes, so a header and the grid below it can reveal as one). Direct children must forward className and style.

Exit animations: stay subtle#

Exit animations should be softer than enters. A full-height collapse or a large translate draws too much attention to something leaving. Use a small fixed translateY (4–8 px) with a fade - the element slips away rather than crashing out.

toast.tsxTSX
// ✓ Subtle exit - short distance, fades out// Enter: slides up 8px + fades in// Exit:  slides down 4px + fades out (half the travel, softer)<div  className={cn(    "transition-[opacity,transform] duration-base ease-out",    visible      ? "translate-y-0 opacity-100"      : "translate-y-1 opacity-0",   // 4 px - barely moves  )}/>// ✗ Full collapse - too dramatic<div className={cn(visible ? "h-auto opacity-100" : "h-0 opacity-0")} />

Scale from the origin#

A surface that scales open - a menu, a popover, a tooltip - should grow out of the control that opened it, not its own center. A menu that zooms from its midpoint looks like it materialized in space; one anchored to its trigger looks like it unfolded from the button. Radix exposes the resolved corner as a CSS variable, so pin transform-origin to it and let the zoom-in-95 / zoom-out-95 enter and exit do the rest. Open both panels and watch where each one grows from.

Trigger

Menu panel

Scales from the trigger corner.

origin-(--radix-popper-transform-origin)
Trigger

Menu panel

Scales from its own center.

default origin - detached from the trigger

Morphing#

A surface that changes size along with what it shows (a pill opening into a card, a dialog moving to a taller step) should not swap one box for another. It keeps one box, eases it to the new size and crossfades the contents inside. The technique, useMorphBox, and its rules have a page of their own: Morphing.

Icon animations#

Never toggle icon visibility with a conditional render - the outgoing icon disappears instantly with no exit animation. Keep both icons in the DOM, position the secondary one absolutely, and cross-fade using opacity, scale, and blur. Enter: scale 0.25 → 1, opacity 0 → 1, blur 4px → 0.

copy-button.tsxTSX
"use client"import { Check, Copy } from "@phosphor-icons/react"import { cn } from "@/lib/utils"const iconClass = (visible: boolean) =>  cn(    "transition-[opacity,transform,filter] duration-fast",    // cubic-bezier(0.2,0,0,1) - the snappy curve for icon swaps    "[transition-timing-function:cubic-bezier(0.2,0,0,1)]",    visible      ? "scale-100 opacity-100 blur-0"      : "scale-[0.25] opacity-0 blur-[4px]",  )export function CopyButton({ onCopy }: { onCopy: () => void }) {  const [copied, setCopied] = React.useState(false)  return (    <button      onClick={() => { onCopy(); setCopied(true); setTimeout(() => setCopied(false), 1500) }}      className="relative inline-flex size-7 items-center justify-center"    >      <Copy className={cn("absolute", iconClass(!copied))} />      <Check className={cn("absolute", iconClass(copied))} />    </button>  )}

Disclosure carets: turn, don’t swap#

The section above cross-fades two glyphs that mean different things. A caret reporting open or closed is the opposite case: it is one mark pointing two ways, so it rotates. Swapping a CaretUp for a CaretDown throws away the thing that carried the meaning, which is the turn itself.

Two utilities carry it, and which one you use is part of the convention rather than a judgement call. caret-turn (duration-base) is for a caret answering a content reveal, so the glyph and the height it announces move on one clock. caret-turn-fast (duration-fast) is for a caret on a popover trigger, which opens quicker than a region reveals. Both bundle the reduced-motion stand-down, so it cannot be forgotten; the rotation itself stays at the call site, because it varies (180° for a panel, 90° for a tree branch, 45° for a plus becoming a cross).

disclosure.tsxTSX
// ✓ One glyph, turned by state. The beat and the reduced-motion guard ride along.<CaretDown className="size-4 caret-turn group-data-[state=open]:rotate-180" />// ✓ A popover trigger opens faster than a region reveals.<CaretDown className="size-4 caret-turn-fast group-data-[state=open]:rotate-180" />// ✗ Two glyphs swapped: the turn, which was the whole signal, is gone.{open ? <CaretUp /> : <CaretDown />}// ✗ Longhand: animates, but never stands down under reduced motion.<CaretDown className="transition-transform duration-base ease-out" />

Skip animation on page load#

Elements that are visible on first render should not animate in - the user never asked for that transition. Gate CSS transitions behind a data-mounted attribute set in a useEffect. The attribute is absent during SSR and on the first paint, so the transition rule never fires.

panel.tsxTSX
"use client"import * as React from "react"export function Panel({ children }: { children: React.ReactNode }) {  const ref = React.useRef<HTMLDivElement>(null)  // Set the attribute after the first paint - transitions only fire after this.  React.useEffect(() => {    ref.current?.setAttribute("data-mounted", "")  }, [])  return (    <div      ref={ref}      // Without data-mounted the element is fully visible (no opacity-0 default).      // Once mounted, hovering or state changes can transition freely.      className="opacity-0 transition-opacity duration-base ease-out data-[mounted]:opacity-100"    >      {children}    </div>  )}

Respect reduced motion#

Some users set prefers-reduced-motion to avoid animation that triggers nausea or distraction. Honor it: a one-shot entrance should drop to an instant appear, and any ambient loop - a spinner, a shimmer, a marquee - should hold still. Tailwind’s motion-reduce: and motion-safe: variants cover the inline cases; the Stagger primitive already gates itself this way. Flip the preference below and Replay to feel the difference a reduced-motion user gets: the same grid, but the cascade is gone.

Simulate
Overview
Activity
Reports
Members
Billing
Settings

will-change#

will-change promotes an element to its own GPU layer before the animation starts, eliminating first-frame stutter. Only use it on properties the GPU can composite: transform, opacity, and filter. Overusing it wastes VRAM and can slow down everything else. Add it only when you observe a visible first-frame glitch.

drawer.tsxTSX
// ✓ Composited property - GPU can handle this without a layout pass<div className="will-change-transform transition-transform duration-slow ease-drawer" />// ✗ will-change: all - promotes the element for *every* property, wastes VRAM<div className="will-change-auto transition-all duration-base" />// ✗ Left on permanently - keeps the layer alive even when not animating// Only add will-change while animating; remove it after.