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
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
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
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-selectedoraria-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 outspring.moderate160ms in, 120ms outspring.slow240ms in, 160ms out, bounce 0.12import { 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> );}| Tier | Enter | Bounce | Exit tween | Used for |
|---|---|---|---|---|
spring.fast | 80ms | 0 | 60ms | Small, frequent movement: the fluid hover highlight, fades, chips, checkmarks. |
spring.moderate | 160ms | 0 | 120ms | Short travel that must land exactly: dropdowns, tab indicators, drawers, merged selection fills, the switch thumb. The most used tier. |
spring.slow | 240ms | 0.12 | 160ms | Larger 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> );}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> );}| Surface | Enter | Exit | Movement |
|---|---|---|---|
| Menus, popovers, selects, comboboxes, hover cards | 160ms | 110ms | Scale from 0.97 at the trigger, with a fade. |
| Tooltips | 125ms | 125ms | Fade and a small zoom after a 400ms delay. Moving between adjacent tooltips shows them instantly (data-instant). |
| Dialogs and alert dialogs | 200ms | 140ms | In from 0.96, out toward 0.98. The scrim fades over 200ms. |
| Sheets | 320ms | 200ms | Slide from their edge on the drawer curve, cubic-bezier(0.32, 0.72, 0, 1). |
| Collapsible | 200ms | 200ms | Height, on the house ease-out. |
/* * 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.
cubic-bezier(0.23, 1, 0.32, 1)cubic-bezier(0.32, 0.72, 0, 1)loops onlyimport { 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> );}| Token | Value | Used for |
|---|---|---|
--ease-out | cubic-bezier(0.23, 1, 0.32, 1) | Everything: hovers, presses, popups, dialogs, disclosure, exit tweens. Tailwind's ease-out is remapped to it. |
--ease-drawer | cubic-bezier(0.32, 0.72, 0, 1) | Sheets and drawers sliding from an edge. |
--ease-in-out | cubic-bezier(0.77, 0, 0.175, 1) | The pulse-soft loop only. |
linear | Loops: 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 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-itemand 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
axisyfor lists and menus,xfor strips and tabs,xyfor grids.isItemQuietyields to an item that already carries a stronger fill, such as the active nav item. - The container is
relative isolateandFluidHoverHighlightis its first child. It isaria-hiddenand ignores touch. - Under reduced motion the highlight jumps instead of travelling, and only its opacity changes.
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.
| Animates | Never 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.
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> );}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
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-shimmeron an in-progress label andskeleton-shimmeron 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-pingon 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> );}"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>;}@media (prefers-reduced-motion: reduce) { .t-resize { /* biome-ignore lint/complexity/noImportantStyles: snippet verbatim */ transition: none !important; }}| Surface | Under reduced motion |
|---|---|
| Motion components | MotionConfig reducedMotion="user" in app/layout.tsx skips transform and layout animation and keeps opacity. |
| Menus, popovers, dialogs | Enter and exit scale and translate are forced to zero; they fade. |
| Sheets | Only opacity transitions. No slide. |
| Collapsible | Snaps open and shut. No height transition. |
| Shimmer | Stops. The label and skeleton hold still. |
| Fluid hover | The highlight jumps to the row and fades rather than gliding. |
| Press | No scale. motion-safe: keeps the color change and drops the shrink. |
| Voice orb | Holds still; a ring steps with the level. |
| Smooth scroll | scroll-behavior: auto. |
- In a Motion component, MotionConfig covers transforms. Read
useReducedMotion()when you need to change values yourself, for example to drop ayoffset from aninitialyou compute. - In CSS, use
motion-safe:for movement andmotion-reduce:transition-noneon 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.
| What | In | Out | Curve or tier | Source |
|---|---|---|---|---|
| Hover and color | 150ms | 150ms | ease out | Classes |
| Press scale to 0.96 | 150ms | 150ms | ease out | Button |
| Fluid hover highlight | 80ms | 60ms | spring.fast, exit.fast | FluidHoverHighlight |
| Switch thumb, approval cards, card stack | 160ms | 120ms | spring.moderate, exit.moderate | springs.ts |
| Save bar, stacked dialog | 240ms | 160ms | spring.slow, exit.slow | springs.ts |
| Menus and popovers from 0.97 | 160ms | 110ms | ease out | globals.css |
| Tooltips | 125ms | 125ms | ease out | globals.css |
| Dialogs from 0.96, to 0.98 | 200ms | 140ms | ease out | globals.css |
| Dialog and sheet scrims | 200ms | 200ms | ease out | globals.css |
| Sheets | 320ms | 200ms | drawer curve | globals.css |
| Collapsible height | 200ms | 200ms | ease out | globals.css |
| Interactive card shadow | 150ms | 150ms | ease out | Classes |
| Progress fill | 300ms | 300ms | ease out | Progress |
| Meter fill | 500ms | 500ms | ease out | Meter |
| Shimmer | 1.6s loop | – | linear | globals.css |
| Marching edge | 0.9s loop | – | linear | globals.css |
| Command palette, J and K | 0ms | 0ms | none | data-no-motion |
Do and don't#
The mistakes that come up in review.
transition={spring.moderate} in and exit.moderate out.{ duration: 0.6, ease: "easeInOut" }. It drifts from every other component and leaves slowly.transform and opacity. Use the collapsible's height for disclosure.width, top or margin. Layout animation janks dense grids and fights the browser.