Motion

PreviousNext

A motion philosophy — four spring tiers, tied to elevation instead of chosen by hand. Components read their tier from how far they lift, so every overlay in the system settles at the same rate in both light and dark mode.

Five tiers

Same distance, five characters. Watch them land.

spring.fast0.15s · bounce 0.1
F

Toggles, checkboxes, a single-step surface lift.

spring.moderate0.28s · bounce 0.15
M

Dropdowns, tabs, drawers — decelerates into place, no visible overshoot.

spring.slow0.42s · bounce 0.2
S

Dialogs and sheets, travelling far enough to earn a little overshoot.

spring.morph0.75s · bounce 0.12
M

Shape, not distance — a layout animation changing width and height at once. Shown here at the same travel as the rest only so its character is comparable.

spring.playful0.5s · bounce 0.45
P

A tone, not a speed. Opt in by hand for the one moment worth celebrating.

motionForOffset — how far a surface lifts decides how it moves.

Inline paneloffset 1fast
Dropdown / popoveroffset 2moderate
Dialogoffset 4slow

The problem

Every component that animates has to answer the same question: how fast, and with how much character. Answered locally, each one picks its own stiffness: 260, damping: 20 — and the system ends up with forty timings that are all nearly the same and none of them equal.

The visible symptom is not that any single animation is wrong. It is that a dropdown, a dialog and a toast each settle at a slightly different rate, so nothing feels like it belongs to the same surface.

The solution

Three pieces: tiers, an elevation mapping, and entrance helpers.

  • Tiers — five springs, named by character rather than by number. Each carries a little overshoot, slow a bit more, morph is for shape rather than distance, and playful is loud on purpose.
  • motionForOffset — the same offset an Elevated surface already declares picks the tier. How far a panel travels away from its substrate decides how it moves, so a component never chooses twice.
  • liftVariants / directionalVariants — the entrance half. Elevation is the fill, the shadow, and the arrival: one decision, not three.

Motion and elevation are the same story told twice — see Elevated for the fill and shadow half.

The tiers

TierVisual durationBounceFor
fast0.15s0.1Micro-feedback: toggles, checkboxes, a single-step surface lift.
moderate0.28s0.15Panels that must land exactly: dropdowns, tabs, drawers, selects.
slow0.42s0.2Dialogs and sheets — far enough that a touch of overshoot reads alive.
morph0.75s0.12Shape, not distance: a layout animation changing width, height and radius together.
playful0.5s0.45A tone, not a speed. Opt in by hand, never reached by offset.

The column says visual duration, and the distinction is the whole reason these numbers are trustworthy. A spring's duration covers the settling tail as well as the travel, and nobody perceives the tail — so a tier tuned on duration always lands earlier than its own number claims. visualDuration is the time to visual arrival; the tail falls after it. Framer takes either, and visualDuration wins when both are set.

moderate is not simply "faster than slow". Both land at roughly the same perceived speed; the difference is where they end. moderate's bounce is small enough to have no visible overshoot at the distances a panel travels, so a select menu still settles under the cursor rather than past the item the user is already reaching for — it just decelerates into place instead of stopping dead. Nothing in the scale is critically damped any more: bounce: 0 on a short transition is what makes movement read as a state swap rather than as movement. If one specific panel ever reads as unstable, take that tier to 0, not the pair of them.

morph is the odd one out: it is the only tier that is longer than the ones above it, because it is the only one measuring a different thing. The others move an element that stays itself — a panel sliding four pixels while it fades. morph is for an element becoming something else, where the box crosses hundreds of pixels of width and height at once. At slow's 0.42s that reads as a snap, and at slow's bounce the far edge of a wide box wobbles visibly after it lands. It is opt-in by hand: motionForOffset never returns it, because how far a surface sits above its substrate says nothing about whether it is changing shape.

Each tier carries its own exit.duration — a plain tween, held at roughly 70% of its entrance. Nothing needs character on the way out, and an overlay leaving should get out of the way faster than it arrived.

Installation

pnpm dlx shadcn@latest add https://matos-ui.com/r/motion-tokens.json

The CLI copies the tokens and adds framer-motion to your dependencies.

CSS counterpart

Framer covers what JavaScript drives. For plain CSS transitions — hover fills, focus rings, colour changes — the same character lives in two theme variables:

@theme inline {
  --ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
  --duration-moderate: 280ms;
  --duration-slow: 420ms;
  --default-transition-duration: var(--duration-moderate);
  --default-transition-timing-function: var(--ease-spring);
}

These two match spring.moderate and spring.slow exactly, and that is a result rather than a coincidence: both sides were tuned by feel, independently, and both landed on 280ms and 420ms. The CSS pair got there first — which is the clearest evidence available that the spring scale, back when it read 0.16s and 0.24s, was calibrated on the wrong number. Keep them in step: a tier and its CSS counterpart drifting apart is a hover fill and the panel it sits in visibly disagreeing about how fast this interface moves.

Because --default-transition-* is set, any bare transition-colors or transition-opacity utility already inherits this — you only reach for duration-moderate ease-spring explicitly when a rule sets its own timing.

The hover lift

Buttons, badges and clickable Elevated tiles all rise slightly under the cursor. That is one token, hover-lift, not a per-component recipe:

--ease-lift: cubic-bezier(0.4, 0, 0.2, 1);
--duration-lift: 320ms;
--duration-lift-press: 120ms;

The curve is deliberately not --ease-spring. That one covers roughly 90% of its travel in the first 15% of the time — right for a panel crossing real distance, wrong for a 2px hover, where it reads as a flinch rather than a lift. --ease-lift eases in before it eases out, so the control drifts up instead of snapping.

Unusually for a token, the utility owns the transition property list too:

@utility hover-lift {
  --lift: 2px;
  transition-property: translate, scale, box-shadow, background-color, …;
  transition-duration: var(--duration-lift);
  transition-timing-function: var(--ease-lift);
}

That is not tidiness. Tailwind v4 compiles -translate-y-0.5 to the translate property and scale-[0.98] to scale — neither of which is covered by transition-[…,transform]. A call site that lists transform, as every one of ours once did, gets an untransitioned jump and no error to explain it. Folding the list into the token is the only way that mistake cannot come back.

Distance is a local custom property, so a call site can retune the travel without touching the timing:

<span className="hover-lift [--lift:1px]" />   // badge: smaller control, smaller lift
<a className="hover-lift [--lift:0px]" />      // link variant: opts out entirely

The utility carries its own @media (hover: hover) guard — otherwise :hover latches after a tap on touch — and its own prefers-reduced-motion guard, so neither has to be repeated at the call site.

Usage

import {
  spring,
  motionForOffset,
  liftVariants,
} from '@/lib/motion-tokens'

Tie the tier to the elevation

The offset a surface already declares is enough to pick its timing. Nothing else in the component has to know how fast it should be.

// One node, not a wrapper: a <motion.div> around <Elevated> adds a layout box
// and breaks flex chains.
const MotionElevated = motion.create(Elevated)

const POPOVER_OFFSET = 2

<MotionElevated
  offset={POPOVER_OFFSET}
  variants={liftVariants(POPOVER_OFFSET)}
  initial="hidden"
  animate="visible"
/>

motionForOffset maps onto fast/moderate/slow only — playful is a tone and cannot be reached by distance, so no ordinary overlay turns festive by accident.

Stagger a group

  • Build passed2s ago
  • Preview deployed4s ago
  • Migrations applied9s ago
  • Cache warmed12s ago
  • Release tagged20s ago

staggerChildren: 0.04s

Spread staggerContainer(tier) onto the container and give each child a matching visible variant. liftVariants is the natural child, so the rows arrive with the same character as the panel around them.

<motion.ul variants={staggerContainer('moderate')} initial="hidden" animate="visible">
  {items.map((item) => (
    <motion.li key={item.id} variants={liftVariants(1)}>
      {item.label}
    </motion.li>
  ))}
</motion.ul>

Interrupt something already on screen

Available$1,250

Over the available balance — Confirm to see the shake.

attentionShake and attentionPulse are cues, not tiers: no offset, no elevation, nothing to compose with. They interrupt a component the user is already looking at — a field that just failed validation, a total that changed underneath them. Drive them by flipping animate to the variant name and back.

<motion.div variants={attentionShake} animate={invalid ? 'shake' : undefined}>
  <Input aria-invalid={invalid} />
</motion.div>

Guard an exit animation

A portal has to stay mounted through its exit tween, but a throttled or background tab can stall the animation and onAnimationComplete may never fire. useExitAnimation keeps both: the callback when it works, a timer sized to the tier's own exit.duration when it doesn't.

const { mounted, onAnimationComplete } = useExitAnimation(open, spring.moderate)

if (!mounted) return null

return (
  <motion.div
    animate={open ? 'visible' : 'hidden'}
    onAnimationComplete={onAnimationComplete}
  />
)

Reference

spring

Five tiers, each a framer-motion Transition plus an exit duration. spring.fast, spring.moderate, spring.slow, spring.morph, spring.playful.

motionForOffset

motionForOffset(offset: number): SpringTier — mirrors the conventional Elevated offsets.

OffsetTier
≤ 1fast
2moderate
> 2slow

liftVariants

liftVariants(offset, options?): Variants — hidden/visible pair whose transition comes from motionForOffset(offset).

OptionTypeDefaultDescription
ynumber4Travel distance. Raise it when the surface is large enough that 4px reads as a twitch.
scalenumber0.98Starting scale.

directionalVariants

directionalVariants(direction, tier?): Variants — an element that grows out of the edge it is anchored to. direction is the side it travels from, which for an anchored popup is the opposite of its resolved placement: a menu that flipped above the cursor (side top) enters from bottom. Travels 6px, not 4 — this reads as origin, not as lift.

stagger / staggerContainer

stagger holds the four delays (0.020.08s) under the same tier names. staggerContainer(tier?, delayChildren?) returns the container Variants.

attentionShake / attentionPulse

Standalone Variants with a single shake / pulse key. Meant to live inside existing form and feedback components rather than be wrapped in one of their own.

useExitAnimation

useExitAnimation(open, tier?){ mounted, onAnimationComplete }. Keeps a portal mounted through its exit, with exitFallbackMs(tier) as the backstop.

exitFallbackMs

exitFallbackMs(tier): number — the tier's exit duration in milliseconds plus a 100ms safety buffer.