Motion

The easing set, the duration knob, and how reduced motion is honoured without disabling feedback.

Easings

Three curves for three jobs. A fourth would only give the next component something new to be inconsistent with.

app/globals.css
@theme {
  --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
  --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
TokenUtilityUse
--ease-smoothease-smoothThe default. Hovers, colour changes, anything under 200ms.
--ease-emphasizedease-emphasizedThings entering the page. Decelerates hard so the element settles rather than slides.
--ease-bounceease-bounceOvershoots. For a deliberate moment of delight — a toggle, a success mark — and nothing else.

Presets

Three named animations, each already carrying its duration and curve, so an entrance never has to be hand-timed at a call site.

app/globals.css
@theme {
  --motion: 1;
  --animate-fade-in:  fade-in  calc(0.3s * var(--motion)) var(--ease-smooth) both;
  --animate-fade-up:  fade-up  calc(0.4s * var(--motion)) var(--ease-emphasized) both;
  --animate-scale-in: scale-in calc(0.2s * var(--motion)) var(--ease-smooth) both;
}

/* Used as a utility, like any other theme value. */
<div className="animate-scale-in" />
  • animate-fade-in — opacity only, 300ms. For content swapping in place.
  • animate-fade-up — opacity and an 8px rise, 400ms. For something arriving.
  • animate-scale-in — from 96%, 200ms. For overlays, which should feel like they came from the trigger.
  • All three use both, so the element holds its start state before the animation and its end state after. Without it a delayed entrance flashes at full opacity first.

The duration knob

Every preset multiplies its duration by --motion, so the whole library's pace is one variable.

app/globals.css
/* Everything at half speed, for a demo or a recording. */
:root { --motion: 2; }

/* Or off, without any layout moving. */
:root { --motion: 0.001; }

--shadow-strength works the same way for elevation. Both exist so a global change is an edit in one place rather than a search across every component.

Not a substitute for reduced motion

Turning --motion down is a design choice you make for everyone. Honouring prefers-reduced-motion is a per-user requirement, and it needs the media query below.

Reduced motion

Resolve to the end state. Never replay the same animation faster, and never remove the feedback along with the movement.

app/globals.css
@media (prefers-reduced-motion: reduce) {
  /* Land on the end state — do not replay it faster. */
  .js [data-reveal="out"] {
    opacity: 1;
    transform: none;
  }

  .js [data-reveal] {
    transition: none;
  }
}
  • Movement goes, meaning stays. A menu still opens and a toast still appears — they simply arrive rather than travel. Suppressing the state change too would be a worse bug than the animation.
  • Colour and opacity transitions are usually fine to keep. What the setting is about is vestibular triggers: transforms, parallax, large or looping movement.
  • Check it in the client too, when JavaScript drives the timing. A CSS media query cannot help a staggered sequence built from timers — read the same query with matchMedia and skip to the finished state.

When to animate

Motion here is feedback, not decoration. Three questions settle almost every case.

  • Did something change state? Animate the change so the eye can follow it. If nothing changed, there is nothing to animate.
  • Would you notice it twice? An entrance you see on every page load becomes a delay. It runs once and never re-plays — that is why the scroll reveal here does not re-hide when an element leaves the viewport.
  • Is it under 400ms? Past that the interface starts waiting on itself. If a longer duration is the only way the motion reads, the motion is doing too much.