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.
Toggles, checkboxes, a single-step surface lift.
Dropdowns, tabs, drawers — decelerates into place, no visible overshoot.
Dialogs and sheets, travelling far enough to earn a little overshoot.
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.
Three pieces: tiers, an elevation mapping, and entrance helpers.
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.
| Tier | Visual duration | Bounce | For |
|---|---|---|---|
fast | 0.15s | 0.1 | Micro-feedback: toggles, checkboxes, a single-step surface lift. |
moderate | 0.28s | 0.15 | Panels that must land exactly: dropdowns, tabs, drawers, selects. |
slow | 0.42s | 0.2 | Dialogs and sheets — far enough that a touch of overshoot reads alive. |
morph | 0.75s | 0.12 | Shape, not distance: a layout animation changing width, height and radius together. |
playful | 0.5s | 0.45 | 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 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.
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.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.
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 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 — playful is a tone
and cannot be reached by distance, so no ordinary overlay turns festive by
accident.
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 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>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}
/>
)springFive tiers, each a framer-motion Transition plus an exit duration.
spring.fast, spring.moderate, spring.slow, spring.morph,
spring.playful.
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. |
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.
stagger / staggerContainerstagger holds the four delays (0.02–0.08s) under the same tier names.
staggerContainer(tier?, delayChildren?) returns the container Variants.
attentionShake / attentionPulseStandalone 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.
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.