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.
Toggles, checkboxes, a single-step surface lift.
Zero-latency follow — a dragged sheet, a magnetic button, anything tracking the cursor. No bounce, shorter than fast.
Dropdowns, tabs, drawers — decelerates into place, no visible overshoot.
Dialogs and sheets, travelling far enough to earn a little overshoot.
Ambient, unhurried motion — a backdrop fading up, a section easing in on scroll. Longer and calmer than slow, barely any bounce.
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.
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.
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.
Four pieces: spring tiers, a tween half, an elevation mapping, and entrance helpers.
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.ease (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.
| Tier | Visual duration | Bounce | For |
|---|---|---|---|
fast | 0.24s | 0.16 | Micro-feedback: toggles, checkboxes, a single-step surface lift. |
snappy | 0.14s | 0 | Zero-latency follow: a dragged sheet, a magnetic button, an element tracking the cursor. Opt in by hand. |
moderate | 0.38s | 0.18 | Panels that must land exactly: dropdowns, tabs, drawers, selects. |
slow | 0.52s | 0.18 | Dialogs and sheets — far enough that a touch of overshoot reads alive. |
gentle | 0.72s | 0.06 | Ambient, unhurried: a backdrop fading up, a hero settling on load, a section easing in on scroll. Opt in by hand. |
morph | 0.85s | 0.12 | Shape, not distance: a layout animation changing width, height and radius together. |
playful | 0.56s | 0.42 | A 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.
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.
pnpm dlx shadcn@latest add https://matos-ui.com/r/motion-tokens.jsonThe CLI copies the tokens and adds framer-motion to your dependencies.
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.
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 entirelyThe 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.
import {
spring,
motionForOffset,
liftVariants,
} from '@/lib/motion-tokens'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.
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.
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.
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>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>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}
/>
)springSeven 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 / durationease 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.
motionForOffsetmotionForOffset(offset: number): SpringTier — mirrors the conventional
Elevated offsets.
| Offset | Tier |
|---|---|
≤ 1 | fast |
2 | moderate |
> 2 | slow |
liftVariantsliftVariants(offset, options?): Variants — hidden/visible pair whose
transition comes from motionForOffset(offset).
| Option | Type | Default | Description |
|---|---|---|---|
y | number | 4 | Travel distance. Raise it when the surface is large enough that 4px reads as a twitch. |
scale | number | 0.98 | Starting scale. |
revealVariantsrevealVariants(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.
directionalVariantsdirectionalVariants(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.
slideVariantsslideVariants(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 / useMagneticPullpressable(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.
withReducedMotionwithReducedMotion(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.
marqueeTransitionmarqueeTransition(seconds?): Transition — a constant-velocity linear loop for
a marquee, a logo strip or a ticker.
stagger / staggerContainerstagger holds the four delays (0.02–0.08s) under the same tier names.
staggerContainer(tier?, delayChildren?) returns the container Variants.
attentionShake / attentionPulse / attentionGlowStandalone 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.
useExitAnimationuseExitAnimation(open, tier?) → { mounted, onAnimationComplete }. Keeps a
portal mounted through its exit, with exitFallbackMs(tier) as the backstop.
exitFallbackMsexitFallbackMs(tier): number — the tier's exit duration in milliseconds plus a
100ms safety buffer.
Install Matos UI
Choose a package manager and copy one command for every component.