Docs
Motion
The springs, curves and durations every component uses, and what never animates.
Every Eased component moves with the same small vocabulary: three springs, four curves and five durations. Each one exists twice, as a CSS variable in the stylesheet and as a Motion object in components/eased/lib/motion.ts. Both are sampled from the same physics, so a CSS transition and a Motion animation with the same name feel identical.
Springs
| Spring | Motion | CSS | Use it for |
|---|---|---|---|
snappy | { type: "spring", stiffness: 520, damping: 38 } | ease-spring-snappy, 350ms | Presses, toggles, thumbs, indicators, text swaps, rolling digits and the focus ring |
smooth | { type: "spring", visualDuration: 0.4, bounce: 0 } | ease-spring-smooth, 450ms | Height and layout changes, panels, and toast and tooltip position. No overshoot. |
bouncy | { type: "spring", stiffness: 400, damping: 26 } | ease-spring-bouncy, 600ms | Shape morphs and playful reveals only. Never on an exit. |
Use a spring in CSS
.thumb { transition: translate var(--eased-duration-spring-snappy) var(--eased-spring-snappy);}In CSS, the springs are linear() curves, so they work in a plain transition. Pair each spring with its duration variable.
Use a spring in Motion
import { motion } from "motion/react";import { snappy } from "@/components/eased/lib/motion";<motion.span layoutId="indicator" transition={snappy} />Curves and durations
| Name | Value | Use it for |
|---|---|---|
ease-enter | cubic-bezier(0.16, 1, 0.3, 1) | Things that appear |
ease-standard | cubic-bezier(0.22, 1, 0.36, 1) | Colour and opacity while something stays on screen |
ease-exit | cubic-bezier(0.7, 0, 0.84, 0) | Things that leave |
--eased-ease-in-out | cubic-bezier(0.65, 0, 0.35, 1) | Movement that starts and ends on screen |
--eased-duration-instant | 100ms | Press-down and the reduced-motion fade |
--eased-duration-fast | 150ms | Hover and colour changes |
--eased-duration-standard | 200ms | Popovers and tooltips |
--eased-duration-slow | 300ms | Dialogs |
--eased-duration-sheet | 500ms | Sheets and drawers |
Interaction feedback never takes longer than 200ms. The enter curve, cubic-bezier(0.16, 1, 0.3, 1), is the curve in the Eased logo.
Enter and exit
import { enter, exit } from "@/components/eased/lib/motion";<motion.div initial={{ opacity: 0, y: 6, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1, transition: enter }} exit={{ opacity: 0, y: 3, scale: 0.98, transition: exit }}/>Use the enter and exit pairs from motion.ts. An element that appears is something to look at; an element that leaves is in the way. So exits:
- take at most three quarters of the enter time
- use an ease-in curve
- never overshoot
Anything that opens from a trigger grows from it. Set its transform-origin to the trigger, and enter from 6px toward it at 97% scale. Nothing scales up from zero.
Swap a label in place
import { AnimatePresence, motion } from "motion/react";import { swap, swapIn, swapOut, swapShown } from "@/components/eased/lib/motion";<AnimatePresence mode="popLayout" initial={false}> <motion.span key={label} initial={swapIn} animate={swapShown} exit={swapOut} transition={swap}> {label} </motion.span></AnimatePresence>Use the swap when a label or icon changes in place, such as Save to Saved, or a copy icon to a check. The old text moves up 3px and the new text rises from 4px below, each with a soft blur, so the eye reads one change instead of a flicker. The blur is tweened separately from the spring, so it clears before the movement settles. Text Swap and Button use this pattern.
Press
Class in press.ts | Scale | Use it for |
|---|---|---|
pressIcon | 0.96 | Icon buttons up to 48px |
press | 0.97 | Text buttons |
pressWide | 0.985 | Bars and cards wider than 220px |
Buttons press down in 100ms and spring back on snappy. The scale depends on width, so a wide bar doesn't dip further than a small icon. Triggers that open a popover or menu never scale; they press by colour instead.
Stagger
stagger key | Step |
|---|---|
char | 16ms per character |
item | 35ms per item |
word | 40ms per word |
line | 80ms per line |
Lists that enter together step through stagger in motion.ts. Keep the whole stagger under 400ms. It runs once when the list mounts, never on a re-render.
Reduced motion
import { reducedFade, snappy, useSafeReducedMotion } from "@/components/eased/lib/motion";const reduced = useSafeReducedMotion();<motion.div layout transition={reduced ? reducedFade : snappy} />Every animated component has a reduced branch. When the visitor's system asks for reduced motion, movement becomes a 100ms opacity fade (reducedFade) or nothing, and looping animations stop.
Read the setting with useSafeReducedMotion() from motion.ts, not Motion's useReducedMotion. It returns false on the server and during hydration, then the real value, so reduced-motion visitors don't get a hydration mismatch.
In CSS, the reduced: variant applies under prefers-reduced-motion: reduce, for example reduced:transition-none.
What never animates
- Animate only
transform,opacity,clip-path,filterand colours. Never animatewidth,height,top,left,margin,paddingorbox-shadowframe by frame, and never usetransition: all. - Don't animate keyboard-driven, high-frequency actions: opening a command menu, adding or removing list items, opening a context menu or typing.
- Hover changes only colour or shadow, by one step. It never changes size, weight or position.
- Looping motion pauses off-screen and under reduced motion.
- Set
will-changeonly during a gesture, and remove it after.