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.
- Container lands first. Full opacity, spring overshoot,
300ms. The frame appears with intent. - Content rows stagger in.
30msintervals, base reveal curve (no overshoot),150mseach. Evidence arrives. - 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.
/* 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.
Marquees, indeterminate progress. Nothing else.
Default. Anything entering, hovering, expanding.
Outgoing only — toasts dismissing, modals closing.
Round-trip transitions (drawer swipe, accordion).
Buttons that confirm a decisive action. Subtle overshoot.
Dramatic. Marketing only — almost never inside the product.
/* 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.
--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. /* 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.
<!-- 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 --> /* 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.
---
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> ---
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>