Foundations / Motion

Motion

Durations, eases, and the three primitives every animated surface in the system uses.

Section: Foundations

Principles

Motion supports comprehension, not delight.

Every animation answers a question the user already asked. Where did that menu come from? What just changed? Did my click register? If an animation does not answer one of those, it is taking attention that belongs to the work.

Reduced-motion is a contract.

When the OS or the user says "less motion," we honor it without exception. The @media (prefers-reduced-motion: reduce) rule collapses every --ds-motion-* token to 0.01ms and rewires the semantic transitions to none. Component code never has to write a second @media block.

Every animation declares its purpose via data-motion.

full is the default. subtle keeps opacity but drops transforms, for users who want some feedback but not movement. none is silence. Set it on a single element, or on <html> to override the global preference.

Proof Arrival — the ypai motion signature

The whole system runs on one paired curve set. Base reveal animates the everyday — rows entering, hovers landing, focus rings tightening. The overshoot is reserved for a single moment: a container arriving. A dialog opens, a toast spawns, a panel reveals. Anything else uses the base.

  1. Container lands first. Full opacity, spring overshoot, 300ms. The frame appears with intent.
  2. Content rows stagger in. 30ms intervals, base reveal curve (no overshoot), 150ms each. Evidence arrives.
  3. Primary CTA fades in last. A short trailing delay so the eye reaches it after the data. Action presents itself.

cubic-bezier(0.16, 1, 0.3, 1) base + cubic-bezier(0.34, 1.56, 0.64, 1) overshoot. We adopted Linear's foundation because it's correct, and added a single reserved overshoot moment because data presentations should arrive with the confidence of a printed proof.

Hover on the trigger does not overshoot — only the click's arrival does. That asymmetry is the whole personality.

proof-arrival.css | css
/* Base reveal — anything entering, hovering, expanding. */
.thing {
  transition: var(--ds-transition-reveal);
  /* opacity + transform, 200ms, cubic-bezier(0.16, 1, 0.3, 1) */
}

/* Container arrival — Dialog open, Toast spawn, panel reveal. */
.thing--arrives {
  transition: var(--ds-transition-arrive);
  /* opacity + transform, 300ms, cubic-bezier(0.34, 1.56, 0.64, 1) */
}

Curve playground (reference)

Six eases, each animating a box left-to-right on a 2s loop. Match the curve to the use case — almost everything in the product uses --ds-ease-out (which is the signature base curve). The overshoot here, --ds-ease-spring, is the same bezier as the signature arrival curve.

--ds-ease-linear linear

Marquees, indeterminate progress. Nothing else.

--ds-ease-out cubic-bezier(0.16, 1, 0.3, 1)

Default. Anything entering, hovering, expanding.

--ds-ease-in cubic-bezier(0.7, 0, 0.84, 0)

Outgoing only — toasts dismissing, modals closing.

--ds-ease-in-out cubic-bezier(0.65, 0, 0.35, 1)

Round-trip transitions (drawer swipe, accordion).

--ds-ease-spring cubic-bezier(0.34, 1.56, 0.64, 1)

Buttons that confirm a decisive action. Subtle overshoot.

--ds-ease-bounce cubic-bezier(0.68, -0.55, 0.265, 1.55)

Dramatic. Marketing only — almost never inside the product.

transition.css | css
/* Default UI transition — anything entering, hovering, expanding. */
.thing {
  transition:
    opacity   var(--ds-motion-fast) var(--ds-ease-out),
    transform var(--ds-motion-base) var(--ds-ease-out);
}

Durations

Five steps. If a value isn't on this scale, you almost certainly don't need that value — pick the nearest step.

TokenValueDemoUse
--ds-motion-instant 50ms Tap-feedback. Anything that should feel like the pixel reacted, not animated.
--ds-motion-fast 120ms Hover, focus ring, tooltips. Below the threshold of perceived motion.
--ds-motion-base 240ms Default UI transition. Dropdowns, expanding panels, color shifts.
--ds-motion-slow 480ms Page transitions, scroll reveal. Long enough to communicate hierarchy.
--ds-motion-glacial 800ms Hero entrance, marquee. Use sparingly — anything longer is a parallax sin.
animation.css | css
/* Long-form entrance: page transition, hero, dialog open. */
.dialog {
  animation: enter var(--ds-motion-slow) var(--ds-ease-out) both;
}

@keyframes enter {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0);   }
}

Reduced-motion demo

Toggle the level below. It sets <html data-motion=...> for the page. The three reveals re-fire so you can compare. The OS-level prefers-reduced-motion still takes precedence — if your OS asks for reduced motion, every option below renders identically.

Reveal #1 — up

Fades in from below, 24px of travel.

Reveal #2 — left, 120ms delay

Travels horizontally with a small offset.

Reveal #3 — up, 240ms delay

The shortest hop. Tail of a stagger.

data-motion.html | html
<!-- Set on <html> to switch the whole document. -->
<html data-motion="full">    <!-- default: opacity + transform -->
<html data-motion="subtle">  <!-- opacity only, no transform -->
<html data-motion="none">    <!-- mirrors prefers-reduced-motion -->
reduced-motion.css | css
/* OS-level preference always wins. Components honour both signals. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Primitives

Three Astro components, all reading the tokens above: <Reveal>, <Stagger>, <PageTransition>. The Stagger below demonstrates sequential delay across six children.

Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Reveal.astro | astro
---
import Reveal from "@/components/ui/Reveal.astro";
---
<Reveal direction="up" delay={120} distance={24}>
  <p>Fades in from below when scrolled into view.</p>
</Reveal>
Stagger.astro | astro
---
import Stagger from "@/components/ui/Stagger.astro";
import Reveal  from "@/components/ui/Reveal.astro";
---
<Stagger step={60}>
  {items.map((item) => (
    <Reveal direction="up" distance={20}>
      <Card>{item.title}</Card>
    </Reveal>
  ))}
</Stagger>