Motion
WIPHow motion works in DrumKit: what we animate, with what, and why. The three animation layers, the micro-interaction idiom, overlay enter and exit, and the reduced-motion rule.
The three layers
DrumKit animates in three places, listed in order of preference. Use CSS for anything CSS can do: it is cheap, SSR-safe, runs on the compositor, and ships no JavaScript. Drop to the next layer only when the one above genuinely cannot express the job.
1. Tailwind transitions
2. tailwindcss-animate keyframes
isEntering and isExiting, which hold the element mounted until the exit finishes.3. motion (v12)
Standing rule: exactly one JavaScript animation library
DrumKit uses motion. Do not add a second: no GSAP, no react-spring, no anime.js. If motion can’t do something, that’s a design conversation, not a dependency conversation.
Recipes
Every common motion job and the exact treatment it gets. If the change you’re making is in this table, the decision is already made.
You’re animating | Recipe | Layer |
|---|---|---|
| Hover, press, or focus feedback | transition duration-100 ease-linear | CSS |
| Popover, dropdown, or menu opening | animate-in fade-in duration-150 ease-out | CSS + React Aria |
| Modal opening | animate-in fade-in zoom-in-95 duration-300 ease-out | CSS + React Aria |
| An overlay closing | animate-out fade-out ease-in, 200ms modal / 100ms menu | CSS + React Aria |
| A loading or progress state | the component’s own prop, never hand-rolled | component-owned |
| Expand / collapse to auto height | motion.div height spring (see below) | motion |
| A continuous loop | an --animate-* token (approval required) | CSS keyframes |
| Reorder, gesture, spring, or stagger | motion + useReducedMotion() | motion |
Playground
Motion is hard to argue about in words and obvious in a second of movement. Set a duration, pick an easing, choose what moves, and the panel replays with exactly those classes. When a combination lands on a shipped recipe, it says so.
Duration
What moves
Easing
Panel
Whatever this panel is, it arrives like this.
animate-in fade-in zoom-in-95 duration-300 ease-outThat is the modal opening recipe. Use the recipe rather than these numbers.
The panel above runs the exact classes printed below it.
Micro-interactions
The dominant idiom for interactive elements (buttons, chips, nav items), applied at 137 call sites across the kit.
transition duration-100 ease-linearduration-100 ease-linear
The house idiom
duration-400 ease-in-out
Four times too long
Hover both. The slow one feels broken, not smooth.
For designers. 100ms with linear easing reads as near-instant. There is no perceptible curve at that length; the transition exists to take the hard edge off a color or border change, not to be seen as movement. Don’t spec an easing curve for hover and press states; nobody will see it. Spec the property that changes and the end state.
For engineers. Apply the full idiom, not part of it. Tailwind’s bare transition utility falls back to Tailwind’s own default duration and timing function, which is not DrumKit’s idiom. Write all three classes.
Overlays: enter and exit
React Aria overlays (Modal, Popover, Tooltip, Menu, and the components built on them) expose entering and exiting states and hold the element in the DOM until the exit animation finishes. The kit styles them through render props (isEntering / isExiting) driving tailwindcss-animate keyframes.
300 / 200
150 / 100
150 / 150
Live from the kit. Open each one and watch it leave: the exit is always quicker than the entrance, but the exact pace is per family.
Family | Enter | Exit |
|---|---|---|
| Modal (panel and backdrop) | fade + zoom-in-95, 300ms ease-out | 200ms ease-in |
| Menu, dropdown, select | fade + slide, 150ms ease-out | 100ms ease-in |
| Tooltip | fade + zoom-in-95, 150ms ease-out | 150ms ease-in |
// modal.tsx — the kit’s overlay pattern: React Aria render-prop
// states driving tailwindcss-animate keyframes.
<AriaModalOverlay className={({ isEntering, isExiting }) => cx(
"fixed inset-0 flex items-center justify-center bg-overlay/70",
isEntering && "animate-in fade-in duration-300 ease-out",
isExiting && "animate-out fade-out duration-200 ease-in",
)}>Exits run faster than entries on purpose: leaving should feel lighter than arriving. The ratio is roughly two-thirds for modals and two-thirds again for menus, but the absolute numbers are per family rather than global, so read them off the table rather than assuming one exit duration. Tooltips are the exception that proves the point: they ship no duration class at all, so both directions fall back to the tailwindcss-animate default, which measures 150ms.
One known trade-off: keyframe animations are not interruptible. Open and close an overlay quickly and the animation jumps to its end state before the next one begins. CSS transitions interrupt cleanly; if you build an overlay treatment on transitions instead, declare the transition on the default state (the entering state exists for a single frame only) and put any exit-specific transition inside the exiting state.
Loading and progress
Loading motion is component-owned: you switch a prop, the component animates. Never hand-roll a spinner next to a component that already knows how to load.
Live from the kit: the spinner, the pending state, and the label handling all ship with the component. The third is a standalone LoadingIndicator.
Buttons load themselves
isLoading swaps in the spinner and marks the button isPending, which blocks the press and announces the wait. It does not disable the button: a disabled control drops out of the tab order and announces nothing.Loading has its own resting color
data-loading: background styles ship with the six solid and outline colors. The three link colors carry none.Keep the label, or don’t
showTextWhileLoading keeps the label beside the spinner. Without it the label is hidden and the spinner centers.Standalone waiting
LoadingIndicator (application/loading-indicator) is the component for a wait that isn’t attached to a control.Known progress is information
The spin lives inside
animate-spin internally, which is the point: product code never types it.Expand and collapse
Animating to an unknown height is the classic CSS trap; height: auto can’t transition. It is layout animation, which makes it a motion job, and the kit’s FAQ accordions ship the pattern:
A settle that a fixed duration cannot produce. The panel arrives at its height and relaxes into it, which reads as the content pushing the layout rather than the layout being redrawn. That is the whole reason this one animation is worth JavaScript.
Your OS reduce-motion setting is currently off.
Spring settles with a little life; tween arrives flat; reduced skips the movement and keeps the state change.
// faq-accordion-01.tsx — the kit’s expand/collapse pattern:
// height-to-auto is layout animation, which CSS can't express,
// so this is a motion job.
import { motion } from "motion/react";
<motion.div
className="overflow-hidden"
initial={false}
animate={{
height: isOpen ? "auto" : 0,
opacity: isOpen ? 1 : 0,
}}
transition={{ type: "spring", damping: 24, stiffness: 240 }}
>
{answer}
</motion.div>The spring (damping 24, stiffness 240) gives the settle a slight life that a fixed duration wouldn’t; the disclosure chevron rotates alongside it with a plain 150ms ease-out transition. Reuse this pattern for any expand-to-content surface rather than inventing a height hack.
Named animations
2 tokenstheme.css defines exactly two animation tokens, with their keyframes. Both are looping: the case where a keyframe animation is the right tool rather than a transition. Adding a third requires approval, because theme.css is shared surface.
Token | Value | Keyframes |
|---|---|---|
--animate-marquee | 60s linear infinite | translateX(0) to translateX(-100%) |
--animate-caret-blink | 1s infinite | opacity 1 through 50%, then 0 |
--animate-marquee, at its real 60s pace. A full pass takes a minute: continuous motion should never be in a hurry.
Type here
--animate-caret-blink, 1s infinite: opacity holds through the first half of the cycle, then snaps off.
Both demos above carry motion-reduce:animate-none, which is the gate every looping animation needs: a continuous marquee is exactly what a vestibular-sensitive person needs stopped. The vendored kit carries that variant on its own loops, the loading-indicator spinners and the empty-state and social-proof marquees, so these two demos reflect the kit’s looping animations rather than a standard it has yet to meet.
Reduced motion
The standard: every animation gets a reduced-motion path. What reduced means here: remove movement, keep state changes legible. A modal that slides and fades should still fade. Position, scale, parallax, and looping motion come out; opacity and color changes stay; they carry meaning and they don’t trigger vestibular symptoms.
Three surfaces, three mechanisms:
Tailwind utilities. The motion-safe: and motion-reduce: variants: motion-reduce:transition-none for the common case.
Keyframes and tailwindcss-animate. Gate with motion-reduce:animate-none. Continuous animations are the highest-risk case in the system: a looping marquee is precisely what a vestibular-sensitive user needs stopped.
motion. Reduced motion is not automatic. Use useReducedMotion() and fall back to opacity-only or to no animation. Parallax and scroll-linked motion should be disabled outright, not softened.
Test it the same way every time: turn the OS-level reduce-motion setting on and use the feature.
Honest status
The docs site’s smooth scrolling is gated on prefers-reduced-motion and is the precedent to match. The vendored kit carries motion-reduce: variants only on its CSS loops (the loading-indicator spinners and the empty-state and social-proof marquees), and its own motion usages (carousels, parallax, the accordion above) do not yet call useReducedMotion(). Closing that gap is part of the motion-tokens work below; new work is held to the standard now.
Specifying motion
WIPUntil tokens land, specs written as raw numbers drift out of sync with the code. Spec motion by naming the state change ('the menu opens', not 'the menu animates in'), the properties that change, which layer it belongs to, and what happens under reduced motion. If you need a duration that isn’t already in the tokens table below, say so explicitly and say why; those cases are the evidence that will shape the scale.
Rule: only opacity and transform animate
Opacity and transform run on the compositor. Anything else (width, height, top, color-heavy repaints) is expensive and needs flagging and justification. The one sanctioned exception is the expand/collapse height spring above, which exists precisely because height can’t be faked with a transform.
Motion tokens
DrumKit does not yet have a motion token scale: there are no duration or easing tokens in theme.css, and components use Tailwind’s transition utilities directly. A formal scale is planned as Tailwind v4 theme variables (--duration-* and --ease-* inside @theme) so they generate real utilities rather than raw numbers scattered through components. The durations already in use are the evidence the scale will formalise:
Duration | Easing | Where |
|---|---|---|
| 100ms | linear | micro-interactions: hover, press, focus (137 call sites) |
| 150ms | ease-out | popover and dropdown enter; the disclosure chevron |
| 200ms | ease-in | overlay exits |
| 300ms | ease-out | modal enter |
The four easings
Tailwind ships four timing functions and DrumKit uses all four. The curve is the relationship between time (left to right) and distance covered (bottom to top): a straight line covers ground at a constant rate, and a curve that starts steep covers most of the distance early, then eases into place.
ease-linear
linear
Micro-interactions, where no curve is perceptible anyway
ease-in
cubic-bezier(0.4, 0, 1, 1)
Exits. Gathers speed as it leaves
ease-out
cubic-bezier(0, 0, 0.2, 1)
Entrances. Arrives fast, settles soft
ease-in-out
cubic-bezier(0.4, 0, 0.2, 1)
Longer moves that both start and end on screen
The same distance and the same 600ms in all four. Only the curve changes.
Until then
Use Tailwind’s transition utilities directly, follow the recipes on this page, and don’t add durations or easings to theme.css; theme.css changes require explicit approval.
Review checklist
Run every piece of motion through these before it ships.
- Is it in the recipes table? Then use the recipe.
- Could this be CSS? If yes, it’s CSS.
- If it’s
motion, which case is it: layout, gesture, spring, or orchestration? - No second animation library.
- Loading states use the component’s own prop, never a hand-rolled spinner.
- Overlay enter and exit driven by React Aria’s states, with exits faster than entries.
- Only opacity and transform animate, unless flagged and justified.
- Reduced-motion path exists, and has been tested with the OS setting on.
- No new theme.css values without approval.
Reference
Related pages.
- DrumKit Interaction (the states these transitions animate between)
- DrumKit Accessibility (the wider reduced-motion and vestibular standards)