Motion

PreviousNext

A motion philosophy — seven spring tiers and a tween half, 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.

Seven tiers

Same distance, seven characters. Watch them land.

spring.fast0.24s · bounce 0.16
F

Toggles, checkboxes, a single-step surface lift.

spring.snappy0.14s · bounce 0
S

Zero-latency follow — a dragged sheet, a magnetic button, anything tracking the cursor. No bounce, shorter than fast.

spring.moderate0.38s · bounce 0.18
M

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

spring.slow0.52s · bounce 0.18
S

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

spring.gentle0.72s · bounce 0.06
G

Ambient, unhurried motion — a backdrop fading up, a section easing in on scroll. Longer and calmer than slow, barely any bounce.

spring.morph0.85s · 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.56s · bounce 0.42
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

Four pieces: spring tiers, a tween half, an elevation mapping, and entrance helpers.

  • Spring tiers — seven springs, named by character rather than by number. Three (fast, moderate, slow) are reached automatically from elevation; the other four (snappy, gentle, morph, playful) are opted into by hand, because none of them is a distance.
  • The tween halfease (six named curves) and duration (a handful of seconds values) for motion that is a shape over time rather than a mass arriving: a keyframe sequence, a colour or blur crossfade, a constant-velocity loop.
  • motionForOffset — the same offset an Elevated surface already declares picks the spring tier. How far a panel travels away from its substrate decides how it moves, so a component never chooses twice.
  • liftVariants / revealVariants / directionalVariants / slideVariants — 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 spring tiers

TierVisual durationBounceFor
fast0.24s0.16Micro-feedback: toggles, checkboxes, a single-step surface lift.
snappy0.14s0Zero-latency follow: a dragged sheet, a magnetic button, an element tracking the cursor. Opt in by hand.
moderate0.38s0.18Panels that must land exactly: dropdowns, tabs, drawers, selects.
slow0.52s0.18Dialogs and sheets — far enough that a touch of overshoot reads alive.
gentle0.72s0.06Ambient, unhurried: a backdrop fading up, a hero settling on load, a section easing in on scroll. Opt in by hand.
morph0.85s0.12Shape, not distance: a layout animation changing width, height and radius together.
playful0.56s0.42A 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 common 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.

snappy is the one deliberate exception. It is the opposite of playful: where playful adds character, snappy removes all of it — bounce 0 and a visual duration shorter than fast, because any overshoot on something the user is actively moving — dragging a sheet, pulling an element with the cursor — reads as lag, not as life. motionForOffset never returns it; a component opts in.

gentle is the calm pair of slow. Longer, with almost no bounce, for motion that should recede rather than announce itself — a background rising into place, a section easing in as it scrolls into the viewport. slow is still the dialog tier; gentle is for what happens behind it. It is opt-in by hand for the same reason snappy is: a mood is not a distance.

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.52s 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.

The tween half

A spring is the default for anything that travels — a panel, a lift, a list entrance. For motion that is a shape over time rather than a mass arriving somewhere — a multi-keyframe sequence, a colour or blur crossfade, a constant-velocity loop — the system has a duration-driven counterpart.

ease = { standard, decelerate, accelerate, emphasized, anticipate, linear }
duration = { instant: 0, fast: 0.26, moderate: 0.38, slow: 0.52, slower: 0.72 }
  • decelerate — fast start, soft landing: for something entering (a reveal, a value counting up).
  • accelerate — soft start, fast exit: for something leaving the frame.
  • emphasized — a slow, expressive start and a long glide out, for a hero element.
  • anticipate — dips backward before it moves: the twelve-principles anticipation beat, for a playful confirm.

duration.fast / moderate / slow are the exact JavaScript twin of --duration-* in the CSS and move together with them. slower has no CSS pair — it is for gentle-tier tweens. The ease curves are JS-only; the CSS side keeps just --ease-spring and --ease-lift.

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.3, 0.86, 0.36, 1);
  --duration-moderate: 380ms;
  --duration-slow: 520ms;
  --default-transition-duration: var(--duration-moderate);
  --default-transition-timing-function: var(--ease-spring);
}

These two match spring.moderate and spring.slow — not a coincidence: both sides are tuned by feel and move together. The scale has been through three passes now, each in the same direction — every tier a little longer, a little softer — because each earlier version still read as too quick to register as comfort rather than just presence. 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: 380ms;
--duration-lift-press: 150ms;

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. snappy is a response, gentle a mood, playful a tone and morph a kind of change — none of them a distance — so no ordinary overlay turns draggable, ambient, festive or shape-shifting by accident.

Reveal on scroll

revealVariants is the premium in-view entrance: a fade, a longer travel than liftVariants, and an optional focus-pull (blur(6px) → blur(0)) that reads as content coming into focus rather than sliding on. It runs on a decelerate tween — a reveal should glide to a stop, not bounce. The blur is GPU work: keep it to text blocks, cards and media, and always pair it with withReducedMotion, which strips the travel and the blur a vestibular-sensitive reader needs gone while keeping the crossfade.

const reduce = useReducedMotion()
const v = reduce ? withReducedMotion(revealVariants()) : revealVariants()

<motion.section variants={v} initial="hidden" whileInView="visible" viewport={{ once: true }}>

slideVariants(direction) is a whole surface sliding in from an edge — a drawer, a sheet, a toast rail. Bigger travel than directionalVariants (that one is a 6px hint on an anchored popup; this is the panel entering the screen) and it settles on the slow tier by default, for the same reason a sheet does.

Interaction helpers

pressable() is press/hover feedback for a surface that is itself the target — a card, a tile, a custom button: it floats and scales a hair on hover, presses back in on tap, on the fast spring. Spread the return onto a motion element, and pass reduced to collapse it to nothing.

const reduce = useReducedMotion()
<motion.button {...pressable({ reduced: !!reduce })} />

useMagneticPull(strength?) leans a button or card toward the pointer on hover and springs it back on leave, on the snappy tier so it feels attached to the hand. It returns { ref, style, handlers } and no-ops under prefers-reduced-motion.

marqueeTransition(seconds?) is a constant-velocity loop for a marquee, a logo strip or a ticker — pair it with a track translated by exactly one copy of its content.

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, attentionPulse and attentionGlow 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 (shake), a total that changed underneath them (pulse), a row that needs a glance (glow, a ring that blooms and fades on var(--color-ring)). 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

Seven tiers, each a framer-motion Transition plus an exit duration: spring.fast, spring.snappy, spring.moderate, spring.slow, spring.gentle, spring.morph, spring.playful.

ease / duration

ease holds six named cubic-bézier curves (standard, decelerate, accelerate, emphasized, anticipate, linear), JS-only. duration holds five seconds values (instant 0, fast 0.26, moderate 0.38, slow 0.52, slower 0.72); fast/moderate/slow are the twin of the CSS timing.

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.

revealVariants

revealVariants(options?): Variants — a premium in-view entrance: fade + a longer travel than liftVariants + an optional focus-pull blur, on a decelerate tween. Options: y / x (14 / 0), blur (6), scale (1), tier ("slow"). Always pair with withReducedMotion.

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.

slideVariants

slideVariants(direction, options?): Variants — a whole surface sliding in from an edge: a drawer, a sheet, a toast rail. Options: distance (24), tier (spring.slow), fade (true).

pressable / useMagneticPull

pressable(options?) returns whileHover / whileTap / transition for a surface that is itself the clickable target — spread it onto a motion element. useMagneticPull(strength?){ ref, style, handlers } for cursor-magnetism on the snappy tier; no-ops under prefers-reduced-motion.

withReducedMotion

withReducedMotion(variants): Variants — the accessibility filter for the tiered-not-all-or-nothing pattern: keeps the opacity crossfade, drops every transform, blur and rotation. Run a variants object through it when useReducedMotion() is true.

marqueeTransition

marqueeTransition(seconds?): Transition — a constant-velocity linear loop for a marquee, a logo strip or a ticker.

stagger / staggerContainer

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

attentionShake / attentionPulse / attentionGlow

Standalone Variants with a single shake / pulse / glow key. Meant to live inside existing form and feedback components rather than be wrapped in one of their own. attentionGlow uses var(--color-ring), so it follows the active palette.

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.