Motion

WIP

How 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.

CSS

1. Tailwind transitions

Hover, press, focus, and every other interactive state change. The default layer, and the one most motion belongs in.
CSS + React Aria

2. tailwindcss-animate keyframes

Overlay enter and exit, driven by React Aria’s isEntering and isExiting, which hold the element mounted until the exit finishes.
JavaScript

3. motion (v12)

Layout animation, gestures, springs, and orchestration. The only JavaScript animation library in DrumKit, and the last resort.

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 feedbacktransition duration-100 ease-linearCSS
Popover, dropdown, or menu openinganimate-in fade-in duration-150 ease-outCSS + React Aria
Modal openinganimate-in fade-in zoom-in-95 duration-300 ease-outCSS + React Aria
An overlay closinganimate-out fade-out ease-in, 200ms modal / 100ms menuCSS + React Aria
A loading or progress statethe component’s own prop, never hand-rolledcomponent-owned
Expand / collapse to auto heightmotion.div height spring (see below)motion
A continuous loopan --animate-* token (approval required)CSS keyframes
Reorder, gesture, spring, or staggermotion + 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

300ms

What moves

Easing

Panel

Whatever this panel is, it arrives like this.

animate-in fade-in zoom-in-95 duration-300 ease-out

That 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-linear
Hover me

duration-100 ease-linear

The house idiom

Hover me

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-out200ms ease-in
Menu, dropdown, selectfade + slide, 150ms ease-out100ms ease-in
Tooltipfade + zoom-in-95, 150ms ease-out150ms 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

The 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

A progress indicator or progress steps component. Determinate progress tells someone how long is left; it isn’t decoration.

The spin lives inside

All of these use 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 tokens

theme.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-marquee60s linear infinitetranslateX(0) to translateX(-100%)
--animate-caret-blink1s infiniteopacity 1 through 50%, then 0
Continuous motionNever in a hurryAlways pausableGated on reduced motion

--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

WIP

Until 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
100mslinearmicro-interactions: hover, press, focus (137 call sites)
150msease-outpopover and dropdown enter; the disclosure chevron
200msease-inoverlay exits
300msease-outmodal 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.