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-sizeandblock-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.
<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.
<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.
| 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
| 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,.marqueeand.pingnever 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-liveor focus movement — the animation is the sighted half only. -
.revealhides content behind a script on older browsers. See above. Never on anything essential. -
.marqueeis 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.
bothfill-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.
<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-preferencewrapper 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::afterand.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
.revealon 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
.transitionwhere a specific one will do.allanimates properties you did not intend, including ones added later. -
Not infinite animation for something that is not urgent.
.pulseon 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.