Springs
The three motion springs and their faster exit tweens.
spring.fast80ms, no bouncespring.moderate160ms, no bouncespring.slow240ms, 0.12 bounceimport { Button } from "@oration/canon/components/button";import { spring } from "@oration/canon/lib/springs";import { motion } from "motion/react";import * as React from "react";export function Tiers() { const [forward, setForward] = React.useState(true); const tiers = [ { name: "fast", detail: "80ms, no bounce", transition: spring.fast }, { name: "moderate", detail: "160ms, no bounce", transition: spring.moderate, }, { name: "slow", detail: "240ms, 0.12 bounce", transition: spring.slow }, ]; return ( <div className="flex w-full max-w-md flex-col gap-4"> {tiers.map((tier) => ( <div key={tier.name} className="flex flex-col gap-1.5"> <div className="flex items-baseline justify-between gap-3"> <code className="font-mono text-xs text-foreground"> spring.{tier.name} </code> <span className="text-xs text-muted-foreground"> {tier.detail} </span> </div> <div className="relative h-6 w-full rounded-full bg-muted"> <motion.span className="absolute top-1 left-1 h-4 w-[calc(100%-1.5rem)]" initial={{ x: "0%" }} animate={{ x: forward ? "100%" : "0%" }} transition={tier.transition} > <span className="block size-4 rounded-full bg-foreground/80" /> </motion.span> </div> </div> ))} <div> <Button type="button" variant="outline" size="sm" onClick={() => setForward((value) => !value)} > Play </Button> </div> </div> );}Usage#
spring holds the three motion tiers every Motion-driven component uses: fast (80ms), moderate (160ms) and slow (240ms with a 0.12 bounce). exit holds the matching leave tweens, one notch quicker, on the house ease-out, and exitFallbackMs turns a tier into a safety timer for deferred unmounts. Pass them straight to Motion's transition. The usual mistake is writing a one-off duration or curve inline, or reusing the enter spring for the exit.
When to use
- Any Motion
transitionfor an enter, move or resize: popups, panels, highlights, the save bar, layout andlayoutIdmoves. spring.fastfor small, frequent movement: hover highlights, fades, chips, checkmarks.spring.moderatefor short travel that must land exactly: dropdowns, tab indicators, drawers, merged selection fills. It is the most used tier.spring.slowfor larger surfaces with more travel: the save bar, the floating action bar, sheets entering from an edge.exit.*in anexitprop, andexitFallbackMs(tier)for a timer that force-unmounts when an exit animation stalls.
When not to use
- CSS hover and color changes. Those are
transition-colors duration-150on the house ease-out, not Motion. Use Motion - Continuous loops such as spinners, shimmer and marching edges. They are linear CSS keyframes. Use Spinner
- Meter and progress fills, which are allowed to run longer than 320ms. Use Meter
- Counting a number up or down. Use Animated number
- Revealing generated text over time. Use Simulated stream
Motion grammar
Reduced motion
useReducedMotion() and drop the transform.Anatomy#
- type.
"spring"forspring.*,"tween"forexit.*. - duration. Seconds. For springs it's the perceived duration Motion solves for: 0.08, 0.16 and 0.24.
- bounce. 0 for fast and moderate (critically damped, lands exactly), 0.12 for slow (a small overshoot).
- exit.
spring.*.exit.durationis the leave time: 0.06, 0.12 and 0.16.exit.*wraps it as a tween with the house ease.
Examples#
Enter and exit
The save bar pattern: in on spring.slow from 12px below at 0.98 scale, out on exit.slow, which is quicker. Under reduced motion only the opacity changes.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { exit, spring } from "@oration/canon/lib/springs";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import * as React from "react";export function EnterExit() { const reduceMotion = useReducedMotion(); const [saved, setSaved] = React.useState("Net 30"); const [terms, setTerms] = React.useState("Net 30"); const dirty = terms !== saved; const next = terms === "Net 30" ? "Net 45" : "Net 30"; return ( <div className="flex w-full max-w-md flex-col gap-3"> <div className="flex items-center justify-between gap-3 rounded-[10px] bg-muted/70 px-3 py-2.5"> <div className="flex flex-col"> <span className="text-13 font-medium text-foreground"> Payment terms for Northwind Freight </span> <span className="text-xs text-muted-foreground"> {terms} </span> </div> <Button type="button" variant="outline" size="sm" onClick={() => setTerms(next)} > Use {next} </Button> </div> <div className="h-12"> <AnimatePresence> {dirty ? ( <motion.section key="save-bar" aria-label="Unsaved changes" initial={{ opacity: 0, y: reduceMotion ? 0 : 12, scale: reduceMotion ? 1 : 0.98, }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: reduceMotion ? 0 : 8, scale: reduceMotion ? 1 : 0.99, transition: exit.slow, }} transition={spring.slow} className="flex items-center gap-2 rounded-xl bg-popover py-2 pr-2 pl-4 text-popover-foreground shadow-popover" > <p className="min-w-0 flex-1 truncate text-13 text-muted-foreground"> 1 unsaved change </p> <Button type="button" variant="ghost" onClick={() => setTerms(saved)} > Discard </Button> <Button type="button" onClick={() => { setSaved(terms); toast.add({ type: "success", title: "Payment terms saved", description: `Northwind Freight is now on ${terms}.`, }); }} > Save changes </Button> </motion.section> ) : null} </AnimatePresence> </div> </div> );}Exit fallback timer
Keep a popup mounted while it leaves, unmount on onAnimationComplete, and back that up with a timer of exitFallbackMs(tier) in case a background tab stalls the animation.
- fast
- 160ms
- moderate
- 220ms
- slow
- 260ms
import { Button } from "@oration/canon/components/button";import { exit, exitFallbackMs, spring } from "@oration/canon/lib/springs";import { motion } from "motion/react";import * as React from "react";export function ExitFallback() { const [open, setOpen] = React.useState(false); const [present, setPresent] = React.useState(false); React.useEffect(() => { if (open || !present) return; const id = window.setTimeout( () => setPresent(false), exitFallbackMs(spring.fast), ); return () => window.clearTimeout(id); }, [open, present]); return ( <div className="flex w-full max-w-sm flex-col items-start gap-3"> <Button type="button" variant="outline" aria-expanded={open} onClick={() => { if (open) { setOpen(false); } else { setPresent(true); setOpen(true); } }} > {open ? "Hide run summary" : "Show run summary"} </Button> <div className="h-24 w-full"> {present ? ( <motion.div initial={{ opacity: 0, scale: 0.96 }} animate={ open ? { opacity: 1, scale: 1, transition: spring.fast, } : { opacity: 0, scale: 0.98, transition: exit.fast, } } onAnimationComplete={() => { if (!open) setPresent(false); }} className="flex origin-top-left flex-col gap-1 rounded-xl bg-popover p-3 text-popover-foreground shadow-popover" > <span className="text-13 font-medium"> Friday payment run </span> <span className="text-xs text-muted-foreground tabular-nums"> 212 invoices to 48 suppliers, $1,284,310.42 </span> </motion.div> ) : null} </div> <dl className="grid w-full grid-cols-3 gap-2 text-center"> {(["fast", "moderate", "slow"] as const).map((tier) => ( <div key={tier} className="rounded-[10px] bg-muted/70 px-2 py-1.5" > <dt className="font-mono text-xs text-muted-foreground"> {tier} </dt> <dd className="text-13 font-medium text-foreground tabular-nums"> {exitFallbackMs(spring[tier])}ms </dd> </div> ))} </dl> </div> );}Behavior#
- The springs are defined by duration and bounce, not stiffness and damping, so the tier names map to how long a move feels.
- Pass enter tiers to
transitionand exit tiers inside the exit target:exit={{ opacity: 0, transition: exit.moderate }}. - Each
spring.*object carries a nestedexitkey. Passed whole astransition, Motion treats it as an override for a value namedexitand ignores it, so the leave time is never applied for you. Useexit.*. exitFallbackMs(tier)is the tier's exit duration in ms plus 100: 160 for fast, 220 for moderate and 260 for slow. Use it for the timer that unmounts a portal ifonAnimationCompletenever fires in a throttled tab.SpringSpeedis the union of tier names ("fast" | "moderate" | "slow") for components that take a speed prop.
Do and don't#
transition={spring.moderate} in, exit.moderate out.{ duration: 0.6, ease: "easeInOut" }. They drift from every other component and leave slowly.exit.* tween, one notch quicker than the enter.useReducedMotion() and keep the opacity change.Content#
- In specs and reviews, name the tier (moderate spring, fast exit) rather than a millisecond value, so the intent survives a retune.
- When a component takes a speed prop, type it as
SpringSpeedand default to"moderate".
Accessibility#
- The tiers don't read the reduced-motion preference. Each component checks
useReducedMotion()and swaps movement for a fade. - All tiers settle within 240ms, inside the 320ms ceiling, so motion never delays input.
- Motion never carries meaning on its own: a panel that slides in also has a title, and a selection that glides also changes
aria-selectedoraria-current. - Popups that stay mounted during their exit should not trap focus or block clicks once they start leaving.
Design tokens#
| Token | Used for |
|---|---|
spring.fast | spring, 0.08s, bounce 0; exits in 0.06s |
spring.moderate | spring, 0.16s, bounce 0; exits in 0.12s |
spring.slow | spring, 0.24s, bounce 0.12; exits in 0.16s |
exit.fast | moderate | slow | tween, 0.06, 0.12 and 0.16s on cubic-bezier(0.23, 1, 0.32, 1) |
API reference#
spring
From @oration/canon/lib/springs. Three Motion spring transitions, as const.
| Prop | Type | Default | Description |
|---|---|---|---|
fast | { type: "spring"; duration: 0.08; bounce: 0; exit: { duration: 0.06 } } | No default | Small, frequent movement: highlights, fades, chips. |
moderate | { type: "spring"; duration: 0.16; bounce: 0; exit: { duration: 0.12 } } | No default | Short travel that lands exactly: dropdowns, tabs, drawers, selection fills. |
slow | { type: "spring"; duration: 0.24; bounce: 0.12; exit: { duration: 0.16 } } | No default | Larger surfaces with more travel: the save bar, the action bar. |
exit
Leave tweens, one tier quicker than the matching spring.
| Prop | Type | Default | Description |
|---|---|---|---|
fast | { type: "tween"; duration: 0.06; ease: [0.23, 1, 0.32, 1] } | No default | Pairs with spring.fast. |
moderate | { type: "tween"; duration: 0.12; ease: [0.23, 1, 0.32, 1] } | No default | Pairs with spring.moderate. |
slow | { type: "tween"; duration: 0.16; ease: [0.23, 1, 0.32, 1] } | No default | Pairs with spring.slow. |
exitFallbackMs
exitFallbackMs(tier: { exit: { duration: number } }) => number.
| Prop | Type | Default | Description |
|---|---|---|---|
tierRequired | { exit: { duration: number } } | No default | A spring.* tier. |
Returns | number | No default | The exit duration in ms plus a 100ms buffer: 160, 220 or 260. |
SpringSpeed
Type export.
| Prop | Type | Default | Description |
|---|---|---|---|
SpringSpeed | "fast" | "moderate" | "slow" | No default | keyof typeof spring. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
exitFallbackMs is exported, but nothing in packages/canon or apps/web calls it yet.
Six files, including Segmented control and the record view tabs, hard-code { type: "spring", duration: 0.3, bounce: 0 } instead of a tier, and DESIGN.md describes view tabs on that 0.3s spring. 0.3s isn't on the ladder.
DESIGN.md quotes CSS timings (150ms hovers, 200 to 250ms disclosure, dialogs in over 200ms and out in 140ms) that don't map one to one onto the 80, 160 and 240ms tiers. For Motion-driven components the tiers are the source.