Motion

Sixty-odd classes for movement, and one structural decision underneath all of them: almost the entire file sits inside @media (prefers-reduced-motion: no-preference). The motion is not declared and then switched off — for a reader who asked for calm, it is never declared at all.

When to use it

Motion earns its place when it explains something: where an element came from, that a value changed, that a press registered, which of two things is now on top. Every class here is meant to answer one of those.

It does not earn its place as polish. A page where six things animate on load is a page the reader has to wait for, and Deck's own source says so in as many words above the entrance block: use for content that appears, not for every section.

How the guard is built

This matters more than any individual class, and it is unusual enough to be worth stating plainly. Most frameworks declare their animations and then add a prefers-reduced-motion block that overrides them. Deck inverts it: the animations live inside a no-preference query.

@media (prefers-reduced-motion: no-preference) {
  .enter { animation: dk-fade var(--dur-3) var(--ease-out) both; }
  …everything else…
}

The practical difference is that there is nothing to override and nothing to leak. A reader with the setting on does not get a 0.01ms version of the animation — they get an element with no animation property, in its final state, immediately. No both fill-mode holding an opacity of zero, no half-applied transform.

The transition utilities are outside the guard, deliberately. .transition, .dur-*, .ease-* and .delay-* are declared unconditionally, because the global reset in src/02-reset.css already collapses every transition duration to .01ms under reduce. They are configuration rather than motion, and something has to remain to be configured.

Transitions, durations and easings

Five transition shorthands for the properties worth animating, plus the scales to tune them.

  • .transition — all, which is convenient and the least efficient. Prefer one of the specific ones.
  • .transition-colors — background, border, text, fill and stroke.
  • .transition-move — translate, scale and rotate, as individual properties so they compose.
  • .transition-size — inline-size and block-size, logical rather than width and height.
  • .transition-opacity — the cheapest thing you can animate.

.dur-1 through .dur-5 are 110, 190, 300, 420 and 620 milliseconds. .delay-1 to .delay-3 are 60, 140 and 260. Easings are .ease-out, .ease-in, .ease-linear, .ease-spring, .ease-bounce and .ease-overshoot — the last three are linear() functions with real overshoot rather than cubic approximations.

.no-motion is the escape hatch: transition: none !important and animation: none !important on anything that must stay still.

The duration and easing tokens behind these classes are not in Deck's documented token list. --dur-0, --dur-4, --dur-5, --dur-slow, --stagger-step, --travel and seven --ease-* values are declared on :root in src/16-motion.css, and tools/docs/extract.mjs only reads src/01-tokens.css. They are real, they work, and no generated table can show them. Recorded in FINDINGS.md.

Entrances and stagger

Seven one-shot animations for content arriving: .enter (fade), .enter-rise, .enter-drop, .enter-start, .enter-end, .enter-pop and .enter-blur. Distance comes from --travel, 12px by default, so one property retunes all of them.

.enter-start and .enter-end are the logical pair — they move in from the start or end edge and so reverse correctly under dir="rtl" without a rule anywhere.

Reload the page to see it again — entrances are one-shot
One
Two
Three
Four
Five
Six
<div class="stagger grid" style="--cols:3;gap:var(--space-3)"> <div class="card enter-rise">One</div> <div class="card enter-rise">Two</div> <div class="card enter-rise">Three</div> <div class="card enter-rise">Four</div> <div class="card enter-rise">Five</div> <div class="card enter-rise">Six</div> </div>
<div class="stagger grid" style="--cols:3;gap:var(--space-3)">
  <div class="card enter-rise">One</div>
  <div class="card enter-rise">Two</div>
  <div class="card enter-rise">Three</div>
  <div class="card enter-rise">Four</div>
  <div class="card enter-rise">Five</div>
  <div class="card enter-rise">Six</div>
</div>

.stagger on the parent delays each child by --stagger-step (45ms; .stagger-fast is 28, .stagger-slow is 80). Where sibling-index() is supported it is one line and has no ceiling. Where it is not, twelve :nth-child rules cover the first twelve — and the thirteenth onward hold the last step rather than snapping back to zero:

/* Past twelve everything holds the last step rather than snapping back
   to zero. Without sibling-index() that is as far as CSS can count. */
.stagger > :nth-child(n+13) { --n: 11; }

That one line is the difference between a long list degrading gracefully and its thirteenth item animating before its second. It is worth contrasting with .pile, which has the same sibling-index() fallback and does not have this line — so a ninth card stacks on the first.

Scroll-driven reveals

.reveal, .reveal-fade and .reveal-pop animate as the element enters the scrollport — with no IntersectionObserver and no scroll handler. The animation is tied to position with animation-timeline: view(), so the browser drives it on the compositor.

.reveal-late shifts the range so the animation starts further into the viewport. Where animation-timeline is unsupported, a @supports not block sets the elements to opacity: 0 and waits for deck.js to add .is-revealed.

That fallback fails closed. The elements start invisible, and only become visible when the script marks them. If deck.js does not load — a blocked CDN, a script error earlier on the page — the content stays at opacity: 0 permanently on any browser without animation-timeline. Do not put anything on .reveal that the page cannot do without.

Two related classes use a scroll timeline rather than a view timeline. .scroll-progress is a 3px bar fixed to the top that fills as the document scrolls — genuinely useful on a long report. .shrink-on-scroll condenses a sticky header over the first 120px by animating --control-h-lg, which is a neat trick: it animates a custom property and lets everything sized from that property follow.

Attention and micro-interaction

.shake, .flash, .flash-good, .pulse, .ping and .nudge are for drawing the eye. .lift, .press, .sweep, .icon-follow and .ripple are responses to a pointer.

All four respond to something the reader did
A sweeping underline
Hover to lift
<span class="cluster"> <button type="button" class="btn press">Press me</button> <a href="#" class="sweep">A sweeping underline</a> <button type="button" class="btn btn-ghost"> Continue <svg class="icon icon-follow" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#arrow-right"></use></svg> </button> </span> <div class="card lift" style="max-inline-size:16rem">Hover to lift</div>
<span class="cluster">
  <button type="button" class="btn press">Press me</button>
  <a href="#" class="sweep">A sweeping underline</a>
  <button type="button" class="btn btn-ghost">
    Continue
    <svg class="icon icon-follow" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#arrow-right"></use></svg>
  </button>
</span>
<div class="card lift" style="max-inline-size:16rem">Hover to lift</div>

The two infinite ones deserve caution. .pulse runs for two seconds repeatedly and .nudge for 1.6 — both forever. Motion in the corner of the eye that never stops is genuinely difficult for some readers, and .ping::after is one of the seven selectors exempted from the reduced-motion freeze in src/02-reset.css, so it keeps moving slowly rather than stopping.

.shake has an interesting companion: .was-shaken is a marker deck.js writes so an invalid field shakes once rather than on every re-render. It is a JavaScript runtime mark, and it is classified as such in tools/docs/api-decisions.txt rather than being part of the public API.

View transitions

The most consequential thing in the file, and it does nothing until you opt in. Add one at-rule to your own CSS:

@view-transition { navigation: auto; }

and full page loads in an ordinary multi-page application cross-fade — no router, no client-side framework, no JavaScript. Deck styles what the browser generates: the root transition, a direction-aware variant for back navigation (html[data-vt="back"], which deck.js sets on popstate), and shorter durations for chrome that should sit still while the content changes.

.vt-header, .vt-main and .vt-tabbar name the regions. .vt-hold takes its name from --vt, so you can give the same name to a row in a list and to the heading on the page it links to, and the browser tweens one into the other.

The reduced-motion guard for this is separate and sits at the end of the file, because view-transition pseudo-elements cannot be inside the no-preference block that wraps everything else. It sets every group, old and new to animation-duration: 1ms !important, so the navigation still happens and the animation does not.

Classes

These are a dozen unrelated utility groups in src/16-motion.css rather than one component root, so there is no completeness check on this page.

Classes, generated from the stylesheet source
Class Layer What it does Source
.transition deck.motion Sets transition. 16-motion.css:49
.transition-colors deck.motion Sets transition. 16-motion.css:50
.transition-move deck.motion Sets transition. 16-motion.css:55
.transition-opacity deck.motion Sets transition. 16-motion.css:60
.transition-size deck.motion Sets transition. 16-motion.css:58
.dur-1 deck.motion Sets transition-duration, animation-duration. 16-motion.css:62
.dur-2 deck.motion Sets transition-duration, animation-duration. 16-motion.css:63
.dur-3 deck.motion Sets transition-duration, animation-duration. 16-motion.css:64
.dur-4 deck.motion Sets transition-duration, animation-duration. 16-motion.css:65
.dur-5 deck.motion Sets transition-duration, animation-duration. 16-motion.css:66
.ease-in deck.motion Sets transition-timing-function, animation-timing-function. 16-motion.css:69
.ease-out deck.motion Sets transition-timing-function, animation-timing-function. 16-motion.css:68
.ease-linear deck.motion Sets transition-timing-function, animation-timing-function. 16-motion.css:73
.ease-spring deck.motion Sets transition-timing-function, animation-timing-function. 16-motion.css:70
.ease-bounce deck.motion Sets transition-timing-function, animation-timing-function. 16-motion.css:71
.ease-overshoot deck.motion Sets transition-timing-function, animation-timing-function. 16-motion.css:72
.delay-1 deck.motion Sets transition-delay, animation-delay. 16-motion.css:75
.delay-2 deck.motion Sets transition-delay, animation-delay. 16-motion.css:76
.delay-3 deck.motion Sets transition-delay, animation-delay. 16-motion.css:77
.no-motion deck.motion Sets transition, animation. 16-motion.css:78
.will-move deck.motion No declarations. 16-motion.css:82
.is-animating deck.motion No declarations. 16-motion.css:82
.enter deck.motion One-shot, on load. Use for content that appears, not for every section. 16-motion.css:89
.enter-blur deck.motion Sets animation. 16-motion.css:95
.enter-drop deck.motion Sets animation. 16-motion.css:91
.enter-end deck.motion Sets animation. 16-motion.css:93
.enter-pop deck.motion Sets animation. 16-motion.css:94
.enter-rise deck.motion Sets animation. 16-motion.css:90
.enter-start deck.motion Sets animation. 16-motion.css:92
.stagger deck.motion Past twelve everything holds the last step rather than snapping back to zero. Without sibling-index() that is as far as CSS can count. .pile in 21-space3d.css has the same fallback and is MISSING this line, so its ninth card stacks on top of the first. 16-motion.css:112
.stagger-fast deck.motion Sets --stagger-step. 16-motion.css:134
.stagger-slow deck.motion Sets --stagger-step. 16-motion.css:135
.reveal deck.motion Sets animation, animation-timeline, animation-range. 16-motion.css:143
.reveal-fade deck.motion Sets animation-name. 16-motion.css:148
.reveal-late deck.motion Sets animation-range. 16-motion.css:150
.reveal-pop deck.motion Sets animation-name. 16-motion.css:149
.is-revealed deck.motion No declarations. 16-motion.css:178
.scroll-progress deck.motion A progress bar that fills as the page scrolls — good for long reports 16-motion.css:153
.shrink-on-scroll deck.motion Sticky header that condenses once you scroll past the top 16-motion.css:167
.shake deck.motion Sets animation. 16-motion.css:184
.was-shaken deck.motion No declarations. 16-motion.css:192
.flash deck.motion Sets animation. 16-motion.css:196
.flash-good deck.motion Sets animation. 16-motion.css:201
.pulse deck.motion Sets animation. 16-motion.css:207
.ping deck.motion Ping ring for a live indicator — a dot with a halo going out 16-motion.css:211
.nudge deck.motion Sets animation. 16-motion.css:226
.lift deck.motion Sets transition. 16-motion.css:230
.press deck.motion Sets transition. 16-motion.css:234
.sweep deck.motion Underline that draws in from the start edge 16-motion.css:238
.icon-follow deck.motion Sets transition. 16-motion.css:254
.ripple deck.motion Ripple. deck.js sets --rx/--ry from the pointer position. 16-motion.css:257
.ripple-ink deck.motion No declarations. 16-motion.css:258
.expand deck.motion Real height:auto transitions, no measuring in JS. Needs interpolate-size, which tokens opts into on :root. 16-motion.css:279
.is-open deck.components No declarations. 11-combobox.css:73
.fade-swap deck.motion Anything that goes display:none can still animate out. 16-motion.css:291
.marquee deck.motion For a status strip: recalls, open campaigns, parts on backorder. 16-motion.css:301
.marquee-track deck.motion Sets display, gap, flex, padding-inline-end, and 2 more. 16-motion.css:308
.tick deck.motion Sets transition. 16-motion.css:320
.is-up deck.motion No declarations. 16-motion.css:321
.is-down deck.motion No declarations. 16-motion.css:322
.vt-hold deck.motion Elements that persist across the navigation. Give the same view-transition-name to the matching element on both pages and the browser tweens between them — a row in a list morphing into a detail header. 16-motion.css:358
.vt-header deck.motion Sets view-transition-name. 16-motion.css:359
.vt-main deck.motion Sets view-transition-name. 16-motion.css:360
.vt-tabbar deck.motion Sets view-transition-name. 16-motion.css:361

Tokens

Design tokens this component reads
Token Live Declared value
--dur-1 110ms
--dur-2 190ms
--dur-3 300ms
--ease-out cubic-bezier(.22, 1, .36, 1)
--ease-spring linear(0, .18 6%, .53 14%, .89 24%, 1.04 32%, 1.06 40%, 1)
--ease-in-out cubic-bezier(.65, 0, .35, 1)
--brand-500 oklch(60.8% var(--chroma-brand) var(--hue-brand))
--good-500 oklch(62% var(--chroma-status) var(--hue-good))
--z-toast — 500

Thirteen further tokens live on :root in src/16-motion.css and cannot appear here — see Transitions, durations and easings for the list and the reason.

Accessibility

  • The guard is structural, and you can undo it. Everything Deck ships respects prefers-reduced-motion. Motion you add in your own layer does not unless you wrap it the same way — and a single unguarded animation beside a guarded one is what the reader will notice.
  • Infinite motion is the hardest kind. .pulse, .nudge, .marquee and .ping never stop. Use them where something genuinely needs attention until it is dealt with, and not as texture.
  • Motion is not an announcement. A shaken field, a flashed row and a ticking number are all silent. If the change matters, it needs aria-live or focus movement — the animation is the sighted half only.
  • .reveal hides content behind a script on older browsers. See above. Never on anything essential.
  • .marquee is a moving target. Text that slides cannot be read by someone who reads slowly, and it cannot be clicked reliably. It pauses on hover, which does not help a keyboard user. Prefer almost anything else.
  • Entrance animations delay content. both fill-mode means the element is invisible until its animation begins. With a stagger of 45ms, the twentieth item appears nearly a second after the first — and a screen reader may reach it before it is visible.
  • View transitions can disorient. A cross-fade between pages is gentle; a directional slide is not, for everyone. The 1ms guard covers the setting, and the setting is not universally enabled by people who would benefit from it.

Right to left

Mostly correct by construction. .enter-start and .enter-end are named and built logically, so they enter from the correct edge in either direction with no rule. .transition-size animates inline-size and block-size rather than width and height.

Three things are physical and are corrected in src/19-logical.css: .scroll-progress's transform-origin, the .icon-follow direction, and the marquee keyframes — a ticker that scrolls left in an English page has to scroll right in an Arabic one, so the keyframes are flipped rather than the element.

.enter-start comes in from the right here, and from the left in English
الأول
الثاني
الثالث
<div dir="rtl" class="stagger stack stack-2" style="max-inline-size:20rem"> <div class="card enter-start">الأول</div> <div class="card enter-start">الثاني</div> <div class="card enter-start">الثالث</div> </div>
<div dir="rtl" class="stagger stack stack-2" style="max-inline-size:20rem">
  <div class="card enter-start">الأول</div>
  <div class="card enter-start">الثاني</div>
  <div class="card enter-start">الثالث</div>
</div>

Reduced motion

This whole page is that topic, so the summary is short. Three mechanisms are in play:

  • The no-preference wrapper around everything from entrances to the ticker — the motion is not declared at all.
  • The global reset in src/02-reset.css, which collapses every remaining transition and animation to .01ms. This is what catches the transition utilities, which are outside the wrapper.
  • Seven named exemptions, including .ping::after and .marquee-track, which slow to 2.4 seconds instead of stopping — on the grounds that a frozen loading indicator reads as a broken page.

.marquee-track being in the exemption list is worth a second look: a ticker is not a loading indicator, and a reader who asked for no motion arguably should not get a slower ticker. It is defensible if the ticker carries live data, and not if it carries marketing copy.

Printing

Nothing in src/16-motion.css has a print rule, in either of Deck's two print layers. Animations do not run when printing, so most of this is moot — an element prints in whatever state it had reached.

Two exceptions are worth planning for. .scroll-progress is position: fixed at the top of the viewport, so it prints as a coloured bar across the top of the first page. And anything with an entrance that has not run — .reveal below the fold on a browser using the JavaScript fallback — is at opacity: 0 and prints blank.

@media print {
  .scroll-progress, .marquee { display: none; }
  /* Anything waiting on a reveal should simply be visible on paper */
  .reveal, .reveal-fade, .reveal-pop { opacity: 1 !important; animation: none !important; }
}

Overriding it

/* One element, no layer needed */
<div class="enter-rise" style="--travel:24px"> … </div>

@layer app.components {
  /* A calmer stagger across the app */
  .stagger { --stagger-step: 30ms; }

  /* Your own motion, guarded the way Deck guards its own */
  @media (prefers-reduced-motion: no-preference) {
    .my-thing { animation: my-keyframes var(--dur-3) var(--ease-out) both; }
  }
}

The second block is the pattern worth copying. Wrapping in no-preference rather than overriding in reduce means there is never a partially-applied state to reason about.

When not to use it

  • Not on everything that appears. Entrances are for content the reader is waiting for, not for every section of a page they scrolled to.
  • Not .reveal on essential content. It fails closed where the script does not load.
  • Not .marquee, more or less ever. Moving text is hard to read, hard to click and impossible to pause from the keyboard.
  • Not .transition where a specific one will do. all animates properties you did not intend, including ones added later.
  • Not infinite animation for something that is not urgent. .pulse on a decorative element is movement with no message.
  • Not as feedback on its own. A shake says "wrong" to people watching it. The error message says what is wrong to everybody.