Skip to content

Springs

The three motion springs and their faster exit tweens.

Status
Stable
Level
Utility
Category
Utilities
Adoption
Not used yet
import { spring } from "@oration/canon/lib/springs";
packages/canon/src/lib/springs.ts
spring.fast80ms, no bounce
spring.moderate160ms, no bounce
spring.slow240ms, 0.12 bounce
import { 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 transition for an enter, move or resize: popups, panels, highlights, the save bar, layout and layoutId moves.
  • spring.fast for small, frequent movement: hover highlights, fades, chips, checkmarks.
  • spring.moderate for short travel that must land exactly: dropdowns, tab indicators, drawers, merged selection fills. It is the most used tier.
  • spring.slow for larger surfaces with more travel: the save bar, the floating action bar, sheets entering from an edge.
  • exit.* in an exit prop, and exitFallbackMs(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-150 on 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

Every transition uses cubic-bezier(0.23, 1, 0.32, 1). Only transform, opacity, filter, clip-path and disclosure height animate. Exits are faster than enters, lists that exist on load don't animate in, and no state change runs past 320ms except meter fills.

Reduced motion

Under reduced motion, scale, translate and blur are zeroed, sheets only fade and disclosures snap. The tiers don't do this for you: check useReducedMotion() and drop the transform.

Anatomy#

  1. type. "spring" for spring.*, "tween" for exit.*.
  2. duration. Seconds. For springs it's the perceived duration Motion solves for: 0.08, 0.16 and 0.24.
  3. bounce. 0 for fast and moderate (critically damped, lands exactly), 0.12 for slow (a small overshoot).
  4. exit. spring.*.exit.duration is 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.

Payment terms for Northwind FreightNet 30
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>    );}

A moving selection

A layoutId thumb on spring.moderate: short travel that lands exactly, with no overshoot past the segment edge.

Payment run cadence
import { spring } from "@oration/canon/lib/springs";import { cn } from "@oration/canon/lib/utils";import { motion } from "motion/react";import * as React from "react";export function SharedLayout() {    const id = React.useId();    const options = ["Weekly", "Biweekly", "Monthly"];    const [cadence, setCadence] = React.useState("Weekly");    return (        <fieldset className="inline-flex h-8 min-w-0 items-center gap-0.5 rounded-lg bg-muted p-0.5">            <legend className="sr-only">Payment run cadence</legend>            {options.map((option) => {                const active = option === cadence;                return (                    <button                        key={option}                        type="button"                        aria-pressed={active}                        onClick={() => setCadence(option)}                        className="relative h-7 rounded-md px-3 text-13 outline-none focus-visible:ring-3 focus-visible:ring-ring/40"                    >                        {active ? (                            <motion.span                                layoutId={`${id}-thumb`}                                transition={spring.moderate}                                className="absolute inset-0 rounded-md bg-background shadow-border"                            />                        ) : null}                        <span                            className={cn(                                "relative",                                active                                    ? "font-medium text-foreground"                                    : "text-muted-foreground",                            )}                        >                            {option}                        </span>                    </button>                );            })}        </fieldset>    );}

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 transition and exit tiers inside the exit target: exit={{ opacity: 0, transition: exit.moderate }}.
  • Each spring.* object carries a nested exit key. Passed whole as transition, Motion treats it as an override for a value named exit and ignores it, so the leave time is never applied for you. Use exit.*.
  • 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 if onAnimationComplete never fires in a throttled tab.
  • SpringSpeed is the union of tier names ("fast" | "moderate" | "slow") for components that take a speed prop.

Do and don't#

3 approvers
Do. Pass a tier: transition={spring.moderate} in, exit.moderate out.
3 approvers
Don't. Write one-off timings such as { duration: 0.6, ease: "easeInOut" }. They drift from every other component and leave slowly.
Do. Leave on the matching exit.* tween, one notch quicker than the enter.
Don't. Reuse the enter spring for the exit, so a bouncing panel lingers after the person has moved on.
Do. Zero translate and scale under useReducedMotion() and keep the opacity change.
Don't. Let a slide or scale play for everyone because the tier is short.

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 SpringSpeed and 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-selected or aria-current.
  • Popups that stay mounted during their exit should not trap focus or block clicks once they start leaving.

Design tokens#

Design tokens
TokenUsed for
spring.fastspring, 0.08s, bounce 0; exits in 0.06s
spring.moderatespring, 0.16s, bounce 0; exits in 0.12s
spring.slowspring, 0.24s, bounce 0.12; exits in 0.16s
exit.fast | moderate | slowtween, 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.

Props of spring
PropTypeDefaultDescription
fast{ type: "spring"; duration: 0.08; bounce: 0; exit: { duration: 0.06 } }No defaultSmall, frequent movement: highlights, fades, chips.
moderate{ type: "spring"; duration: 0.16; bounce: 0; exit: { duration: 0.12 } }No defaultShort travel that lands exactly: dropdowns, tabs, drawers, selection fills.
slow{ type: "spring"; duration: 0.24; bounce: 0.12; exit: { duration: 0.16 } }No defaultLarger surfaces with more travel: the save bar, the action bar.

exit

Leave tweens, one tier quicker than the matching spring.

Props of exit
PropTypeDefaultDescription
fast{ type: "tween"; duration: 0.06; ease: [0.23, 1, 0.32, 1] }No defaultPairs with spring.fast.
moderate{ type: "tween"; duration: 0.12; ease: [0.23, 1, 0.32, 1] }No defaultPairs with spring.moderate.
slow{ type: "tween"; duration: 0.16; ease: [0.23, 1, 0.32, 1] }No defaultPairs with spring.slow.

exitFallbackMs

exitFallbackMs(tier: { exit: { duration: number } }) => number.

Props of exitFallbackMs
PropTypeDefaultDescription
tierRequired{ exit: { duration: number } }No defaultA spring.* tier.
ReturnsnumberNo defaultThe exit duration in ms plus a 100ms buffer: 160, 220 or 260.

SpringSpeed

Type export.

Props of SpringSpeed
PropTypeDefaultDescription
SpringSpeed"fast" | "moderate" | "slow"No defaultkeyof 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.