Motion

Every animation in pixilui uses the same small set of presets from motion-tokens.ts, and the same rules about when to use each one. Items never contain a literal duration, easing or spring number, so a change to a preset reaches the whole library.

Animations run on motion (motion/react) for springs and layout, and on CSS transitions for colour and opacity. Every animation has a reduced-motion branch.

The three springs#

Spring Feel Used for
snappy quick, a small settle (visual duration 0.26s, bounce 0.12) presses, toggles, switch thumbs, selection indicators
smooth calm, never overshoots (0.40s, bounce 0) panels, heights, anything shrinking
morph a little give (0.42s, bounce 0.16) shapes growing, widths following their content

The rule: growing uses morph, shrinking uses smooth. A narrowing width must never dip below its target and bounce back, so shrinking never overshoots. resizeSpring(previous, next) picks the right one.

The springs are defined by stiffness and damping, not a fixed duration. If a value changes again mid-flight (a label changes twice quickly), the animation keeps its velocity and heads for the new target instead of restarting.

ts
import { motionTokens, resizeSpring } from "@/components/pixilui/motion-tokens";

<motion.div animate={{ scale: pressed ? 0.97 : 1 }} transition={motionTokens.spring.snappy} />

Durations and easings#

For transitions that are not springs:

Duration ms For
instant 120 hover tints, an old face fading out
quick 150 press, close, text swap, tooltip appear
fast 250 open, icon swap, tabs
standard 240 the default UI transition
medium 350 panel close, toast close
slow 400 panel open, content reveal
considered 480 transitions that carry meaning
verySlow 500 emphasis, a success check, an entrance
Easing For
enter things arriving
smoothOut surfaces opening, closing, sliding, resizing
exit things leaving
move travel across the screen
swap icon and label swaps; never bounces
linear progress, shimmer, spinners
bounce entrances only

The same values exist as CSS variables and Tailwind utilities: duration-fast, ease-enter, ease-swap.

Staggers keep a sequence short: 16ms between characters, 35ms between items, 40ms between words, 80ms between lines, and the whole sequence under about 400ms.

The rules#

These are the behaviours you will see across the library, and the ones to keep if you change an item.

  • One material. When something changes shape (a button whose label changes, a card folding into a receipt), width, height and radius animate together. The content never scales. The old content fades out quickly with a 2px blur, and the new content arrives in reading order, 35ms apart.
  • Numbers roll, they do not swap. Digits are keyed by place and roll in the direction the value moved. A new digit column opens its width on the morph spring.
  • Only changed words move. When text updates, the words that changed rise 0.3em out of a 4px blur; the rest stay still.
  • Limits push back. Dragging past an end stretches at most 10px and returns. A note rises from the side it guards.
  • Results do not snap in. Anything that took work shows a short sequence first.
  • Agent runs read at human speed. Steps advance every 0.7 to 1s, text streams at about 50ms per word, and rows stagger 150 to 200ms.
  • Quiet success. A check scales in. No glow, no confetti, no looping decoration.
  • Things that move between containers travel. They animate from where they were to where they land, instead of disappearing and reappearing.

Reduced motion#

When the user asks for reduced motion, items swap movement for a short fade (reducedTransition, 150ms linear) or an instant change (instantTransition). Nothing travels, scales or bounces, and spinners hold still. Items read the setting with motion's useReducedMotion(); you do not need to wire anything.

Using the presets in your own code#

Import from the installed file and stay inside the presets, so your additions feel like the rest of the app:

tsx
import { motion, useReducedMotion } from "motion/react";
import { motionTokens, reducedTransition } from "@/components/pixilui/motion-tokens";

export function Reveal({ children }: { children: React.ReactNode }) {
  const reduce = useReducedMotion();
  return (
    <motion.div
      initial={{ opacity: 0, y: reduce ? 0 : motionTokens.distance.base }}
      animate={{ opacity: 1, y: 0 }}
      transition={reduce ? reducedTransition : motionTokens.spring.smooth}
    >
      {children}
    </motion.div>
  );
}