Skip to content

Motion

Fluid Functionalism: three springs, faster exits, a gliding hover and a strict reduced-motion story.

Fluid Functionalism#

Motion in Canon explains a change and gets out of the way. It shows where a surface came from, where a selection went and what just happened, then stops.

Everything moves quickly and eases out hard, so a surface arrives almost at once and settles softly. Enters ride a spring; exits are a plain tween one step faster, because a person who dismissed something has already moved on. Nothing loops except work in progress, nothing bounces more than a hair, and motion steps aside entirely on keyboard-driven surfaces and for people who ask for less of it.

The Three Springs Rule

Motion uses spring.fast, spring.moderate and spring.slow, with exits one tier faster that never bounce. No other timings exist except the skeleton reveal, shimmer, the voice orb and meter fills, and keyboard-driven surfaces don't animate.

The Skeleton Reveal Rule

Every skeleton that stands in for loading content reaches the screen through DataState (data from a query) or SkeletonReveal (any other load), which pulse it, then cross-fade and cross-blur to the content over 400ms. Never loading ? <Skeleton /> : content, an AnimatePresence fade or a FadeSwap for a load. Suspense fallbacks, static skeleton galleries, AILoader and button spinners are not reveals.

The motion grammar

Every transition eases on cubic-bezier(0.23, 1, 0.32, 1). Hovers and color changes take 150ms, disclosure 200 to 250ms, and no state change runs past 320ms except meter fills. Only transform, opacity, filter, clip-path and disclosure height animate.
  • Motion-driven components (highlights, floating bars, the save bar, layout moves) use the spring tiers from Springs.
  • Primitives built on Base UI (menus, popovers, tooltips, dialogs, sheets, collapsibles) animate in CSS, with timings set once in globals.css.
  • Motion never carries meaning on its own. A panel that slides in has a title; a selection that glides also changes aria-selected or aria-current.

Three springs#

spring.fast, spring.moderate and spring.slow, defined by how long they feel rather than stiffness and damping. Each has a matching exit tween one tier quicker.

The tiers side by side

Each dot crosses on its enter spring and returns on its exit tween. Slow the playback to see slow's 0.12 bounce settle.

spring.fast80ms in, 60ms out
spring.moderate160ms in, 120ms out
spring.slow240ms in, 160ms out, bounce 0.12
import { Button } from "@oration/canon/components/button";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { exit, 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 [speed, setSpeed] = React.useState<"1" | "6">("1");    const factor = Number(speed);    const tiers = [        { name: "fast", enter: spring.fast, leave: exit.fast },        { name: "moderate", enter: spring.moderate, leave: exit.moderate },        { name: "slow", enter: spring.slow, leave: exit.slow },    ];    return (        <div className="flex w-full max-w-lg flex-col gap-5">            {tiers.map((tier) => {                const transition = forward                    ? {                          type: tier.enter.type,                          bounce: tier.enter.bounce,                          duration: tier.enter.duration * factor,                      }                    : {                          type: tier.leave.type,                          ease: tier.leave.ease,                          duration: tier.leave.duration * factor,                      };                return (                    <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 tabular-nums">                                {Math.round(tier.enter.duration * 1000)}ms in,{" "}                                {Math.round(tier.leave.duration * 1000)}ms out                                {tier.enter.bounce                                    ? `, bounce ${tier.enter.bounce}`                                    : ""}                            </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={transition}                            >                                <span className="block size-4 rounded-full bg-foreground/80" />                            </motion.span>                        </div>                    </div>                );            })}            <div className="flex flex-wrap items-center gap-2">                <Button                    type="button"                    variant="outline"                    size="sm"                    onClick={() => setForward((value) => !value)}                >                    {forward ? "Play exit" : "Play enter"}                </Button>                <SegmentedControl<"1" | "6">                    label="Playback speed"                    value={speed}                    onValueChange={setSpeed}                    options={[                        { value: "1", label: "Real time" },                        { value: "6", label: "6× slower" },                    ]}                />            </div>        </div>    );}
Spring tiers
TierEnterBounceExit tweenUsed for
spring.fast80ms060msSmall, frequent movement: the fluid hover highlight, fades, chips, checkmarks.
spring.moderate160ms0120msShort travel that must land exactly: dropdowns, tab indicators, drawers, merged selection fills, the switch thumb. The most used tier.
spring.slow240ms0.12160msLarger surfaces with more travel: the save bar, the floating action bar, panels entering from an edge.

A spring in, a tween out

Select an invoice. The bar rises 12px from a 0.98 scale on spring.slow and leaves on exit.slow, 80ms sooner.

import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { exit, spring } from "@oration/canon/lib/springs";import { SendIcon, XIcon } from "lucide-react";import { AnimatePresence, motion } from "motion/react";import * as React from "react";export function FloatingBar() {    const invoices = [        {            id: "INV-20931",            supplier: "Northwind Freight",            amount: "$18,240.00",        },        { id: "INV-20932", supplier: "Halcyon", amount: "$9,612.50" },        { id: "INV-20935", supplier: "Orchard Street", amount: "$4,120.00" },    ];    const [selected, setSelected] = React.useState<string[]>([]);    const toggle = (id: string) =>        setSelected((list) =>            list.includes(id)                ? list.filter((item) => item !== id)                : [...list, id],        );    return (        <div className="relative h-64 w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border">            <ul className="divide-y divide-border">                {invoices.map((invoice) => (                    <li key={invoice.id}>                        <label className="flex h-10 cursor-pointer items-center gap-3 px-3 text-13 hover:bg-surface">                            <Checkbox                                checked={selected.includes(invoice.id)}                                onCheckedChange={() => toggle(invoice.id)}                            />                            <span className="flex-1">{invoice.supplier}</span>                            <span className="tabular-nums">                                {invoice.amount}                            </span>                        </label>                    </li>                ))}            </ul>            <AnimatePresence>                {selected.length > 0 ? (                    <motion.div                        role="toolbar"                        aria-label="Bulk actions"                        initial={{ opacity: 0, y: 12, scale: 0.98 }}                        animate={{ opacity: 1, y: 0, scale: 1 }}                        exit={{                            opacity: 0,                            y: 8,                            scale: 0.98,                            transition: exit.slow,                        }}                        transition={spring.slow}                        className="absolute inset-x-0 bottom-4 mx-auto flex w-fit items-center gap-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-popover"                    >                        <span className="px-2 text-13 font-medium tabular-nums">                            {selected.length} selected                        </span>                        <span                            aria-hidden="true"                            className="mx-0.5 h-5 w-px bg-border"                        />                        <Button                            type="button"                            variant="ghost"                            size="sm"                            onClick={() => {                                toast.add({                                    title: "Added to Friday's payment run",                                    description: `${selected.length} invoices scheduled for Oct 2.`,                                });                                setSelected([]);                            }}                        >                            <SendIcon                                data-icon="inline-start"                                aria-hidden="true"                            />                            Schedule                        </Button>                        <Tooltip>                            <TooltipTrigger                                render={                                    <Button                                        type="button"                                        variant="ghost"                                        size="icon-sm"                                        aria-label="Clear selection"                                        onClick={() => setSelected([])}                                    />                                }                            >                                <XIcon aria-hidden="true" />                            </TooltipTrigger>                            <TooltipContent>Clear selection</TooltipContent>                        </Tooltip>                    </motion.div>                ) : null}            </AnimatePresence>        </div>    );}
Passing the tiers to Motion
import { exit, spring } from "@oration/canon/lib/springs";import { AnimatePresence, motion } from "motion/react";<AnimatePresence>  {count > 0 ? (    <motion.div      initial={{ opacity: 0, y: 12, scale: 0.98 }}      animate={{ opacity: 1, y: 0, scale: 1 }}      // The exit tween goes inside the exit target. Passing spring.slow      // whole as `transition` does not apply its nested exit duration.      exit={{ opacity: 0, y: 8, scale: 0.98, transition: exit.slow }}      transition={spring.slow}    />  ) : null}</AnimatePresence>

exitFallbackMs(tier) turns a tier into a safety timer for portals that wait on onAnimationComplete before unmounting: the exit duration plus 100ms, so 160, 220 or 260ms. See Springs for the full API.

The 0.3s spring

Action bar, Segmented control, Icon swap and the record view tabs hard-code { type: "spring", duration: 0.3, bounce: 0 }, and DESIGN.md describes view tabs on that spring. It sits inside the 320ms ceiling but isn't one of the tiers. New work uses the tiers; the action bar's 140ms exit maps to exit.slow.

CSS timings for primitives#

Menus, tooltips, dialogs, sheets and collapsibles animate in CSS. globals.css sets their timings once, keyed on data-slot, so every instance agrees.

Open each surface

These are the real components. Watch the menu scale from 0.97 and close faster than it opened, the dialog grow from 0.96 and leave toward 0.98, and the sheet glide in on the drawer curve.

import { Button } from "@oration/canon/components/button";import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@oration/canon/components/collapsible";import {  Dialog,  DialogClose,  DialogContent,  DialogDescription,  DialogFooter,  DialogHeader,  DialogTitle,  DialogTrigger,} from "@oration/canon/components/dialog";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import {  Sheet,  SheetContent,  SheetDescription,  SheetHeader,  SheetTitle,  SheetTrigger,} from "@oration/canon/components/sheet";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { ChevronDownIcon, DownloadIcon } from "lucide-react";import { motion } from "motion/react";import * as React from "react";export function CssSurfaces() {    const [open, setOpen] = React.useState(false);    return (        <div className="flex w-full max-w-xl flex-col gap-4">            <div className="flex flex-wrap items-center gap-2">                <DropdownMenu>                    <DropdownMenuTrigger                        render={                            <Button type="button" variant="outline" size="sm" />                        }                    >                        Menu                        <ChevronDownIcon                            data-icon="inline-end"                            aria-hidden="true"                        />                    </DropdownMenuTrigger>                    <DropdownMenuContent className="w-48">                        <DropdownMenuItem                            onClick={() =>                                toast.add({ title: "Remittance resent" })                            }                        >                            Resend remittance                        </DropdownMenuItem>                        <DropdownMenuItem                            onClick={() =>                                toast.add({ title: "W-9 requested" })                            }                        >                            Request W-9                        </DropdownMenuItem>                    </DropdownMenuContent>                </DropdownMenu>                <Tooltip>                    <TooltipTrigger                        render={                            <Button                                type="button"                                variant="outline"                                size="icon-sm"                                aria-label="Export payment run"                                onClick={() =>                                    toast.add({ title: "Export started" })                                }                            />                        }                    >                        <DownloadIcon aria-hidden="true" />                    </TooltipTrigger>                    <TooltipContent>Export payment run</TooltipContent>                </Tooltip>                <Dialog>                    <DialogTrigger                        render={                            <Button type="button" variant="outline" size="sm" />                        }                    >                        Dialog                    </DialogTrigger>                    <DialogContent>                        <DialogHeader>                            <DialogTitle>Approve payment run</DialogTitle>                            <DialogDescription>                                212 invoices to 48 suppliers leave on Friday,                                October 2.                            </DialogDescription>                        </DialogHeader>                        <DialogFooter>                            <DialogClose                                render={                                    <Button type="button" variant="outline" />                                }                            >                                Cancel                            </DialogClose>                            <DialogClose                                render={                                    <Button                                        type="button"                                        onClick={() =>                                            toast.add({                                                type: "success",                                                title: "Run approved",                                            })                                        }                                    />                                }                            >                                Approve                            </DialogClose>                        </DialogFooter>                    </DialogContent>                </Dialog>                <Sheet>                    <SheetTrigger                        render={                            <Button type="button" variant="outline" size="sm" />                        }                    >                        Sheet                    </SheetTrigger>                    <SheetContent className="w-full sm:max-w-md">                        <SheetHeader>                            <SheetTitle>Northwind Freight</SheetTitle>                            <SheetDescription>                                Supplier since March 2024. Three invoices in                                Friday's run.                            </SheetDescription>                        </SheetHeader>                    </SheetContent>                </Sheet>            </div>            <Collapsible                open={open}                onOpenChange={setOpen}                className="rounded-xl bg-card shadow-border"            >                <CollapsibleTrigger className="flex h-10 w-full items-center justify-between gap-3 rounded-xl px-3 text-left text-13 font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/40">                    Remittance details                    <ChevronDownIcon                        aria-hidden="true"                        className={cn(                            "size-4 text-muted-foreground transition-transform duration-200 ease-out motion-reduce:transition-none",                            open && "rotate-180",                        )}                    />                </CollapsibleTrigger>                <CollapsibleContent>                    <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 border-t border-border px-3 py-3 text-13">                        <dt className="text-muted-foreground">Sent to</dt>                        <dd className="text-right">ap@northwindfreight.com</dd>                        <dt className="text-muted-foreground">Method</dt>                        <dd className="text-right">ACH, settles Oct 5</dd>                    </dl>                </CollapsibleContent>            </Collapsible>        </div>    );}
Primitive timings
SurfaceEnterExitMovement
Menus, popovers, selects, comboboxes, hover cards160ms110msScale from 0.97 at the trigger, with a fade.
Tooltips125ms125msFade and a small zoom after a 400ms delay. Moving between adjacent tooltips shows them instantly (data-instant).
Dialogs and alert dialogs200ms140msIn from 0.96, out toward 0.98. The scrim fades over 200ms.
Sheets320ms200msSlide from their edge on the drawer curve, cubic-bezier(0.32, 0.72, 0, 1).
Collapsible200ms200msHeight, on the house ease-out.
Primitive timings in globals.css
/* * Motion timing for primitives, kept unlayered so it outranks the per-component * tw-animate utility classes without editing every component. */:where(  [data-slot="dropdown-menu-content"],  [data-slot="dropdown-menu-sub-content"],  [data-slot="context-menu-content"],  [data-slot="context-menu-sub-content"],  [data-slot="menubar-content"],  [data-slot="popover-content"],  [data-slot="select-content"],  [data-slot="combobox-content"],  [data-slot="hover-card-content"]) {  --tw-animation-duration: 160ms;  --tw-ease: var(--ease-out);  --tw-enter-scale: 0.97;}:where(  [data-slot="dropdown-menu-content"],  [data-slot="dropdown-menu-sub-content"],  [data-slot="context-menu-content"],  [data-slot="menubar-content"],  [data-slot="popover-content"],  [data-slot="select-content"],  [data-slot="combobox-content"],  [data-slot="hover-card-content"])[data-closed] {  --tw-animation-duration: 110ms;}[data-slot="tooltip-content"] {  --tw-animation-duration: 125ms;  --tw-ease: var(--ease-out);}[data-slot="tooltip-content"][data-instant] {  animation: none;}:where([data-slot="dialog-content"], [data-slot="alert-dialog-content"]) {  --tw-animation-duration: 200ms;  --tw-ease: var(--ease-out);  --tw-enter-scale: 0.96;  --tw-exit-scale: 0.98;}:where(  [data-slot="dialog-content"],  [data-slot="alert-dialog-content"])[data-closed] {  --tw-animation-duration: 140ms;}:where(  [data-slot="dialog-overlay"],  [data-slot="alert-dialog-overlay"],  [data-slot="sheet-overlay"]) {  --tw-animation-duration: 200ms;  --tw-ease: var(--ease-out);}[data-slot="dialog-content"][data-no-motion],[data-slot="dialog-overlay"]:has(+ [data-no-motion]) {  animation: none;}[data-slot="sheet-content"] {  transition-property: transform, translate, opacity;  transition-duration: 320ms;  transition-timing-function: var(--ease-drawer);}[data-slot="sheet-content"][data-ending-style] {  transition-duration: 200ms;}[data-slot="collapsible-content"] {  overflow: hidden;  height: var(--collapsible-panel-height);  transition: height 200ms var(--ease-out);}[data-slot="collapsible-content"][data-starting-style],[data-slot="collapsible-content"][data-ending-style] {  height: 0;}

The components' own classes still say duration-100 and zoom-in-95. globals.css overrides them through data-slot, so a hand-built surface that copies the classes without the slot animates at 100ms from 0.95. Keep the slot, or use the component.

One curve#

Every transition eases out on cubic-bezier(0.23, 1, 0.32, 1). Sheets are the one exception, on the drawer curve. Linear is for loops.

The house curves

Ease out leaves fast and lands slowly, so a surface is nearly in place on the first frame. The drawer curve is gentler at the start, for a panel with a long way to travel.

Ease outcubic-bezier(0.23, 1, 0.32, 1)
Drawercubic-bezier(0.32, 0.72, 0, 1)
Linearloops only
Slowed to 800ms so the shape shows.
import { Button } from "@oration/canon/components/button";import { motion } from "motion/react";import * as React from "react";export function Curves() {    const [run, setRun] = React.useState(false);    const curves = [        {            name: "Ease out",            token: "--ease-out",            value: "cubic-bezier(0.23, 1, 0.32, 1)",            points: [0.23, 1, 0.32, 1],        },        {            name: "Drawer",            token: "--ease-drawer",            value: "cubic-bezier(0.32, 0.72, 0, 1)",            points: [0.32, 0.72, 0, 1],        },        {            name: "Linear",            token: "linear",            value: "loops only",            points: [0, 0, 1, 1],        },    ];    return (        <div className="flex w-full max-w-lg flex-col gap-4">            {curves.map((curve) => {                const [x1, y1, x2, y2] = curve.points;                return (                    <div key={curve.name} className="flex items-center gap-4">                        <svg                            viewBox="0 0 40 40"                            aria-hidden="true"                            className="size-10 shrink-0 rounded-md bg-muted/70 text-foreground"                        >                            <path                                d={`M4 36 C ${4 + (x1 ?? 0) * 32} ${36 - (y1 ?? 0) * 32}, ${4 + (x2 ?? 0) * 32} ${36 - (y2 ?? 0) * 32}, 36 4`}                                fill="none"                                stroke="currentColor"                                strokeWidth="1.75"                                strokeLinecap="round"                            />                        </svg>                        <div className="flex min-w-0 flex-1 flex-col gap-1.5">                            <div className="flex items-baseline justify-between gap-3">                                <span className="text-13 font-medium">                                    {curve.name}                                </span>                                <code className="truncate font-mono text-2xs text-muted-foreground">                                    {curve.value}                                </code>                            </div>                            <div className="relative h-4 rounded-full bg-muted">                                <span                                    className="absolute top-0.5 left-0.5 h-3 w-[calc(100%-1rem)] transition-transform duration-[800ms] motion-reduce:transition-none"                                    style={{                                        transitionTimingFunction:                                            curve.value.startsWith("cubic")                                                ? curve.value                                                : "linear",                                        transform: run                                            ? "translateX(100%)"                                            : "translateX(0)",                                    }}                                >                                    <span className="block size-3 rounded-full bg-foreground/80" />                                </span>                            </div>                        </div>                    </div>                );            })}            <div className="flex items-center gap-3">                <Button                    type="button"                    variant="outline"                    size="sm"                    onClick={() => setRun((value) => !value)}                >                    Run                </Button>                <span className="text-xs text-muted-foreground">                    Slowed to 800ms so the shape shows.                </span>            </div>        </div>    );}
Easing tokens
TokenValueUsed for
--ease-outcubic-bezier(0.23, 1, 0.32, 1)Everything: hovers, presses, popups, dialogs, disclosure, exit tweens. Tailwind's ease-out is remapped to it.
--ease-drawercubic-bezier(0.32, 0.72, 0, 1)Sheets and drawers sliding from an edge.
--ease-in-outcubic-bezier(0.77, 0, 0.175, 1)The pulse-soft loop only.
linearLoops: shimmer (1.6s), the marching edge (0.9s) and spinners.

--ease-standard is defined in globals.css but nothing uses it. Don't reach for it; ease out is the curve.

Hover and press#

Hovers change color over 150ms. Press scales a button to 0.96. Lists and menus share one highlight that glides between rows.

Press

Press and hold. The button shrinks to 0.96 and the fill darkens over 150ms. Under reduced motion the scale is skipped (motion-safe:active:scale-[0.96]).

import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { PlusIcon } from "lucide-react";export function Press() {    return (        <div className="flex flex-wrap items-center gap-2">            <Button                type="button"                onClick={() =>                    toast.add({                        type: "success",                        title: "Payment run approved",                    })                }            >                Approve run            </Button>            <Button                type="button"                variant="outline"                onClick={() => toast.add({ title: "Draft saved" })}            >                Save draft            </Button>            <Button                type="button"                variant="ghost"                onClick={() => toast.add({ title: "Run preview opened" })}            >                Preview            </Button>            <Tooltip>                <TooltipTrigger                    render={                        <Button                            type="button"                            variant="outline"                            size="icon"                            aria-label="Add invoice"                            onClick={() => toast.add({ title: "New invoice" })}                        />                    }                >                    <PlusIcon aria-hidden="true" />                </TooltipTrigger>                <TooltipContent>Add invoice</TooltipContent>            </Tooltip>        </div>    );}
Hover and press classes
// Hover and color changes are CSS, not MotionclassName="transition-colors duration-150 ease-out hover:bg-muted"// Press: Button does this for youclassName="transition-[background-color,border-color,color,box-shadow,scale,opacity]  duration-150 ease-out motion-safe:active:scale-[0.96]"

Fluid hover

Move the pointer down each list. Per-row hover blinks off and on between rows; the fluid highlight glides on spring.fast and fades in fresh at the nearest row each time the pointer enters.

Per-row hover

Fluid hover

import { FluidHoverHighlight } from "@oration/canon/components/fluid-hover";import { toast } from "@oration/canon/components/toast";import { useFluidHover } from "@oration/canon/hooks/use-fluid-hover";export function FluidList() {    const hover = useFluidHover<HTMLUListElement>({ axis: "y" });    const runs = [        {            name: "Friday, Oct 2",            detail: "212 invoices",            amount: "$1,284,310.42",        },        {            name: "Friday, Oct 9",            detail: "148 invoices",            amount: "$842,019.10",        },        {            name: "Friday, Oct 16",            detail: "96 invoices",            amount: "$511,640.00",        },        { name: "Friday, Oct 23", detail: "Draft", amount: "$0.00" },    ];    return (        <div className="grid w-full max-w-2xl gap-6 sm:grid-cols-2">            <div className="flex min-w-0 flex-col gap-2">                <p className="text-xs text-muted-foreground">Per-row hover</p>                <ul className="flex flex-col gap-px rounded-xl bg-card p-1.5 shadow-border">                    {runs.map((run) => (                        <li key={run.name}>                            <button                                type="button"                                onClick={() =>                                    toast.add({ title: `Opened ${run.name}` })                                }                                className="flex h-10 w-full items-center justify-between gap-3 rounded-lg px-2.5 text-left text-13 outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/40"                            >                                <span className="font-medium">{run.name}</span>                                <span className="text-muted-foreground tabular-nums">                                    {run.amount}                                </span>                            </button>                        </li>                    ))}                </ul>            </div>            <div className="flex min-w-0 flex-col gap-2">                <p className="text-xs text-muted-foreground">Fluid hover</p>                <div className="rounded-xl bg-card p-1.5 shadow-border">                    <ul                        ref={hover.ref}                        {...hover.handlers}                        aria-label="Payment runs"                        className="relative isolate flex flex-col gap-px"                    >                        <FluidHoverHighlight hover={hover} />                        {runs.map((run) => (                            <li key={run.name}>                                <button                                    type="button"                                    data-fluid-item=""                                    onClick={() =>                                        toast.add({                                            title: `Opened ${run.name}`,                                        })                                    }                                    className="flex h-10 w-full items-center justify-between gap-3 rounded-lg px-2.5 text-left text-13 outline-none focus-visible:ring-3 focus-visible:ring-ring/40"                                >                                    <span className="font-medium">                                        {run.name}                                    </span>                                    <span className="text-muted-foreground tabular-nums">                                        {run.amount}                                    </span>                                </button>                            </li>                        ))}                    </ul>                </div>            </div>        </div>    );}
  • useFluidHover measures items marked data-fluid-item and lights the one nearest the pointer, so gaps between rows never drop the highlight. A click in a gap goes to the lit row.
  • Pass axis y for lists and menus, x for strips and tabs, xy for grids. isItemQuiet yields to an item that already carries a stronger fill, such as the active nav item.
  • The container is relative isolate and FluidHoverHighlight is its first child. It is aria-hidden and ignores touch.
  • Under reduced motion the highlight jumps instead of travelling, and only its opacity changes.
Wiring a fluid hover
import { FluidHoverHighlight } from "@oration/canon/components/fluid-hover";import { useFluidHover } from "@oration/canon/hooks/use-fluid-hover";const hover = useFluidHover<HTMLUListElement>({ axis: "y" });<ul ref={hover.ref} {...hover.handlers} className="relative isolate flex flex-col">  <FluidHoverHighlight hover={hover} />  {rows.map((row) => (    <li key={row.id}>      <button type="button" data-fluid-item="">…</button>    </li>  ))}</ul>

What animates and what never does#

Motion is spent on changes a person caused or needs to notice. Content that was already there, and surfaces driven from the keyboard, hold still.

What animates
AnimatesNever animates
Surfaces opening and closing: menus, popovers, dialogs, sheets, floating bars.Lists, rows and cards that exist on page load. They render in place.
Rows added or removed by the person, in height and opacity.The command palette and other keyboard-first surfaces.
Highlights and indicators moving between items.J and K moves, arrow-key selection and focus changes.
Disclosure height, chevrons rotating, icons swapping.Layout properties: width, top, left, margin, padding.
Hover color, press scale, shadow on interactive cards.Entrances from scale 0, or any bounce larger than 0.12.
Work in progress: shimmer, the live pulse, the voice orb, meter fills.Decoration: nothing moves to attract attention.

Rows present on load stay still

The two invoices render in place, with AnimatePresence initial={false}. Add one and it opens on spring.moderate; remove one and it closes on exit.moderate, faster than it came.

  • INV-20931Northwind Freight$18,240.00
  • INV-20932Halcyon$9,612.50
import { Button } from "@oration/canon/components/button";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { exit, spring } from "@oration/canon/lib/springs";import { PlusIcon, XIcon } from "lucide-react";import { AnimatePresence, motion } from "motion/react";import * as React from "react";export function ListChanges() {    const pool = [        {            id: "INV-20940",            supplier: "Bellweather Logistics",            amount: "$3,402.18",        },        {            id: "INV-20941",            supplier: "Cobalt Office Supply",            amount: "$612.40",        },        { id: "INV-20942", supplier: "Juniper Print Co.", amount: "$1,980.00" },    ];    const [rows, setRows] = React.useState([        {            id: "INV-20931",            supplier: "Northwind Freight",            amount: "$18,240.00",        },        { id: "INV-20932", supplier: "Halcyon", amount: "$9,612.50" },    ]);    const next = pool.find((item) => !rows.some((row) => row.id === item.id));    return (        <div className="flex w-full max-w-md flex-col gap-3">            <ul className="flex flex-col overflow-hidden rounded-xl bg-card shadow-border">                <AnimatePresence initial={false}>                    {rows.map((row) => (                        <motion.li                            key={row.id}                            initial={{ height: 0, opacity: 0 }}                            animate={{ height: 40, opacity: 1 }}                            exit={{                                height: 0,                                opacity: 0,                                transition: exit.moderate,                            }}                            transition={spring.moderate}                            className="overflow-hidden border-b border-border last:border-b-0"                        >                            <div className="flex h-10 items-center gap-3 px-3 text-13">                                <span className="font-mono text-xs text-muted-foreground">                                    {row.id}                                </span>                                <span className="min-w-0 flex-1 truncate">                                    {row.supplier}                                </span>                                <span className="tabular-nums">                                    {row.amount}                                </span>                                <Tooltip>                                    <TooltipTrigger                                        render={                                            <Button                                                type="button"                                                variant="ghost"                                                size="icon-xs"                                                aria-label={`Remove ${row.id}`}                                                onClick={() =>                                                    setRows((list) =>                                                        list.filter(                                                            (item) =>                                                                item.id !==                                                                row.id,                                                        ),                                                    )                                                }                                                className="text-muted-foreground"                                            />                                        }                                    >                                        <XIcon aria-hidden="true" />                                    </TooltipTrigger>                                    <TooltipContent>                                        Remove from run                                    </TooltipContent>                                </Tooltip>                            </div>                        </motion.li>                    ))}                </AnimatePresence>            </ul>            <div>                <Button                    type="button"                    variant="outline"                    size="sm"                    disabled={!next}                    onClick={() => {                        if (next) setRows((list) => [...list, next]);                    }}                >                    <PlusIcon data-icon="inline-start" aria-hidden="true" />                    Add invoice                </Button>            </div>        </div>    );}

Keyboard surfaces don't animate

A person pressing J nine times wants to be on the ninth row now. Selection moves instantly; nothing eases. The command palette opens with data-no-motion for the same reason.

Northwind Freight
Halcyon
Orchard Street
Bellweather Logistics
Cobalt Office Supply

Focus the list, then press J and K or the arrow keys.

import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function KeyboardMoves() {    const suppliers = [        "Northwind Freight",        "Halcyon",        "Orchard Street",        "Bellweather Logistics",        "Cobalt Office Supply",    ];    const [active, setActive] = React.useState(0);    const id = React.useId();    return (        <div className="flex w-full max-w-xs flex-col gap-2">            <div                role="listbox"                tabIndex={0}                aria-label="Suppliers"                aria-activedescendant={`${id}-${active}`}                onKeyDown={(event) => {                    const down = event.key === "ArrowDown" || event.key === "j";                    const up = event.key === "ArrowUp" || event.key === "k";                    if (!down && !up) return;                    event.preventDefault();                    setActive((index) =>                        Math.max(                            0,                            Math.min(                                suppliers.length - 1,                                index + (down ? 1 : -1),                            ),                        ),                    );                }}                className="flex flex-col gap-px rounded-xl bg-card p-1.5 shadow-border outline-none focus-visible:ring-3 focus-visible:ring-ring/40"            >                {suppliers.map((supplier, index) => (                    // biome-ignore lint/a11y/useKeyWithClickEvents: the listbox handles keys                    <div                        key={supplier}                        id={`${id}-${index}`}                        role="option"                        tabIndex={-1}                        aria-selected={index === active}                        onClick={() => setActive(index)}                        className={cn(                            "flex h-8 cursor-default items-center rounded-lg px-2.5 text-13",                            index === active                                ? "bg-accent font-medium text-foreground"                                : "text-muted-foreground",                        )}                    >                        {supplier}                    </div>                ))}            </div>            <p className="text-xs text-muted-foreground">                Focus the list, then press J and K or the arrow keys.            </p>        </div>    );}
Request W-9 from Halcyon
Do. Enter from a 0.96 to 0.98 scale with a fade, on a tier with no bounce. It reads as the surface arriving.
Request W-9 from Halcyon
Don't. Grow from scale 0 on a bouncy spring. The surface seems to come from nowhere, overshoots and takes 600ms to settle.

Exceptions#

Three things are allowed to run longer or loop, because they show work in progress or a live voice.

Shimmer, meters, the orb and the live pulse

ShimmerMatching remittance email…
1.6s linear, stops under reduced motion.
Meter fill
38 of 48 W-9s on file
Width over 500ms ease-out.
Voice orbFollows the voice level. Holds still under reduced motion.
Live pulseRunningA labelled live state may pulse when motion is allowed.
import { Button } from "@oration/canon/components/button";import { Meter } from "@oration/canon/components/meter";import { StatusLabel } from "@oration/canon/components/status-dot";import { VoiceOrb } from "@oration/canon/components/voice/orb";import { motion } from "motion/react";import * as React from "react";export function Exceptions() {    const [resolved, setResolved] = React.useState(38);    return (        <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">            <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border">                <span className="text-13 font-medium">Shimmer</span>                <span className="text-shimmer text-13">                    Matching remittance email…                </span>                <div className="flex flex-col gap-2">                    <span className="skeleton-shimmer h-2 w-4/5 rounded-full" />                    <span className="skeleton-shimmer h-2 w-3/5 rounded-full" />                </div>                <span className="text-xs text-muted-foreground">                    1.6s linear, stops under reduced motion.                </span>            </div>            <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border">                <span className="text-13 font-medium">Meter fill</span>                <Meter                    value={resolved}                    max={48}                    label="Suppliers with a W-9 on file"                />                <div className="flex items-center justify-between gap-3">                    <span className="text-xs text-muted-foreground tabular-nums">                        {resolved} of 48 W-9s on file                    </span>                    <Button                        type="button"                        variant="outline"                        size="xs"                        onClick={() =>                            setResolved((value) =>                                value >= 48 ? 30 : value + 5,                            )                        }                    >                        Update                    </Button>                </div>                <span className="text-xs text-muted-foreground">                    Width over 500ms ease-out.                </span>            </div>            <div className="flex items-center gap-4 rounded-xl bg-card p-4 shadow-border">                <VoiceOrb state="speaking" size={48} label="Nora is speaking" />                <div className="flex flex-col gap-0.5">                    <span className="text-13 font-medium">Voice orb</span>                    <span className="text-xs text-muted-foreground">                        Follows the voice level. Holds still under reduced                        motion.                    </span>                </div>            </div>            <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border">                <span className="text-13 font-medium">Live pulse</span>                <StatusLabel tone="primary" pulse>                    Running                </StatusLabel>                <span className="text-xs text-muted-foreground">                    A labelled live state may pulse when motion is allowed.                </span>            </div>        </div>    );}
  • Shimmer. text-shimmer on an in-progress label and skeleton-shimmer on queued cells, 1.6s linear. It is the only gradient-clipped text in the system.
  • Voice orb. The only animated gradient. One animation frame loop follows the voice level, pauses offscreen and holds still under reduced motion. See Voice orb.
  • Meter fills. Width changes over 500ms ease-out, the one state change past 320ms; progress bars take 300ms. See Meter.
  • A labelled live state such as Running may pulse when motion is allowed (motion-safe:animate-ping on the dot).

Reduced motion#

Under prefers-reduced-motion: reduce, movement goes and opacity stays. Two mechanisms cover it: MotionConfig for Motion and one CSS block for everything else.

Full and reduced motion

Switch to reduced motion and open the panel. It fades in place: Motion skips the translate and scale and keeps the opacity.

import { Button } from "@oration/canon/components/button";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { exit, spring } from "@oration/canon/lib/springs";import { AnimatePresence, MotionConfig, motion } from "motion/react";import * as React from "react";export function ReducedMotion() {    const [mode, setMode] = React.useState<"never" | "always">("never");    const [open, setOpen] = React.useState(false);    return (        <MotionConfig reducedMotion={mode}>            <div className="flex w-full max-w-md flex-col gap-4">                <div className="flex flex-wrap items-center gap-2">                    <SegmentedControl<"never" | "always">                        label="Motion preference"                        value={mode}                        onValueChange={setMode}                        options={[                            { value: "never", label: "Full motion" },                            { value: "always", label: "Reduced motion" },                        ]}                    />                    <Button                        type="button"                        variant="outline"                        size="sm"                        onClick={() => setOpen((value) => !value)}                    >                        {open ? "Close panel" : "Open panel"}                    </Button>                </div>                <div className="h-28">                    <AnimatePresence>                        {open ? (                            <motion.div                                key="panel"                                initial={{ opacity: 0, y: 12, scale: 0.96 }}                                animate={{ opacity: 1, y: 0, scale: 1 }}                                exit={{                                    opacity: 0,                                    y: 8,                                    scale: 0.98,                                    transition: exit.slow,                                }}                                transition={spring.slow}                                className="flex flex-col gap-1 rounded-xl bg-popover p-4 shadow-lg ring-1 ring-foreground/10"                            >                                <span className="text-sm font-medium">                                    Payment run approved                                </span>                                <span className="text-13 text-muted-foreground">                                    {mode === "never"                                        ? "Rises 12px from a 0.96 scale while it fades in."                                        : "Fades in place. Translate and scale are skipped."}                                </span>                            </motion.div>                        ) : null}                    </AnimatePresence>                </div>            </div>        </MotionConfig>    );}
MotionConfig in the root layout
"use client";import { MotionConfig } from "motion/react";import type * as React from "react";export function MotionProvider({ children }: { children: React.ReactNode }) {    return <MotionConfig reducedMotion="user">{children}</MotionConfig>;}
The reduced-motion block in globals.css
@media (prefers-reduced-motion: reduce) {  .t-resize {    /* biome-ignore lint/complexity/noImportantStyles: snippet verbatim */    transition: none !important;  }}
What reduced motion changes
SurfaceUnder reduced motion
Motion componentsMotionConfig reducedMotion="user" in app/layout.tsx skips transform and layout animation and keeps opacity.
Menus, popovers, dialogsEnter and exit scale and translate are forced to zero; they fade.
SheetsOnly opacity transitions. No slide.
CollapsibleSnaps open and shut. No height transition.
ShimmerStops. The label and skeleton hold still.
Fluid hoverThe highlight jumps to the row and fades rather than gliding.
PressNo scale. motion-safe: keeps the color change and drops the shrink.
Voice orbHolds still; a ring steps with the level.
Smooth scrollscroll-behavior: auto.
  • In a Motion component, MotionConfig covers transforms. Read useReducedMotion() when you need to change values yourself, for example to drop a y offset from an initial you compute.
  • In CSS, use motion-safe: for movement and motion-reduce:transition-none on anything that rotates or slides by hand.
  • Reduced motion is not no feedback. Keep the fade and the color change so state still reads.

Every timing#

One table for review. If a value isn't here, it shouldn't be in the code.

Motion timings
WhatInOutCurve or tierSource
Hover and color150ms150msease outClasses
Press scale to 0.96150ms150msease outButton
Fluid hover highlight80ms60msspring.fast, exit.fastFluidHoverHighlight
Switch thumb, approval cards, card stack160ms120msspring.moderate, exit.moderatesprings.ts
Save bar, stacked dialog240ms160msspring.slow, exit.slowsprings.ts
Menus and popovers from 0.97160ms110msease outglobals.css
Tooltips125ms125msease outglobals.css
Dialogs from 0.96, to 0.98200ms140msease outglobals.css
Dialog and sheet scrims200ms200msease outglobals.css
Sheets320ms200msdrawer curveglobals.css
Collapsible height200ms200msease outglobals.css
Interactive card shadow150ms150msease outClasses
Progress fill300ms300msease outProgress
Meter fill500ms500msease outMeter
Shimmer1.6s loop–linearglobals.css
Marching edge0.9s loop–linearglobals.css
Command palette, J and K0ms0msnonedata-no-motion

Do and don't#

The mistakes that come up in review.

Do. Pass a tier: transition={spring.moderate} in and exit.moderate out.
Don't. Write a one-off { duration: 0.6, ease: "easeInOut" }. It drifts from every other component and leaves slowly.
Do. Leave faster than you arrived. A dismissed menu is gone in 110ms.
Don't. Reuse the enter spring for the exit, so a closing panel lingers after the person has moved on.
Do. Animate transform and opacity. Use the collapsible's height for disclosure.
Don't. Animate width, top or margin. Layout animation janks dense grids and fights the browser.
Do. Render the list that was there on load in place, and animate only the row someone added.
Don't. Stagger every row of a table in on load. It delays the data and repeats on every navigation.