Animate without a JavaScript library
You want things to move a little — a panel that slides, a row that fades in, a button that acknowledges a press — and you do not want 40 KB of animation library to get it. Deck's motion is CSS, it reads the same duration and easing tokens everywhere, and it stops for anyone who has asked their system to calm things down.
The short answer
Four families, and you can guess which you need from the name.
| You want | Use | Fires when |
|---|---|---|
| A property to change smoothly | .transition, .transition-colors |
Whenever the property changes |
| Something to arrive | .enter, .enter-rise, .enter-pop |
On load, once |
| Something to arrive on scroll | .reveal, .reveal-fade, .reveal-pop |
When it scrolls into view |
| A one-off reaction | Deck.play(node, 'shake') |
When you say so |
<div class="cluster">
<button class="btn" onclick="Deck.play(this, 'shake')">Shake</button>
<button class="btn" onclick="Deck.play(this, 'flash-good')">Flash</button>
<button class="btn" onclick="Deck.play(this, 'nudge')">Nudge</button>
<button class="btn btn-primary lift">Hover me</button>
</div>
<div class="cluster">
<button class="btn" onclick="Deck.play(this, 'shake')">Shake</button>
<button class="btn" onclick="Deck.play(this, 'flash-good')">Flash</button>
<button class="btn" onclick="Deck.play(this, 'nudge')">Nudge</button>
<button class="btn btn-primary lift">Hover me</button>
</div>
Reduced motion is the default, not an afterthought
Almost everything that moves in Deck is inside
@media (prefers-reduced-motion: no-preference). The animation is not
written and then switched off for some people; it is only written for people who have
not asked for less. So a reader with the setting on gets the end state instantly —
the panel is open, the row is visible, the card has arrived — rather than a broken
page with the entrance missing.
There is one deliberate exception: loading indicators keep moving. A frozen spinner reads as a hung request, which is worse than the motion it avoided.
You get all of this by using the classes. If you write your own animation, use the same guard:
@layer app.components {
@media (prefers-reduced-motion: no-preference) {
.invoice-row { transition: background-color var(--dur-2) var(--ease-out); }
}
}
From JavaScript, Deck.reduced() answers the same question, evaluated fresh
on each call so it follows a preference changed mid-session:
if (!Deck.reduced()) confetti();
.no-motion switches motion off for one subtree regardless of the system
setting — useful around a third-party embed that animates more than you would like.
Durations and easings
Seven durations and ten easing curves, and the point of using the tokens rather than
typing 200ms ease-out is that changing one value later changes the whole
product's feel rather than one rule.
| Token | Live | Declared value |
|---|---|---|
--dur-1 |
110ms |
|
--dur-2 |
190ms |
|
--dur-3 |
300ms |
|
--dur-4 |
420ms |
|
--dur-5 |
620ms |
|
--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-bounce |
linear(0, .004 2%, .016 4%, .062 8%, .137 12%, .5 24%, .68 28%, .824 32%, .93 36%, .995 40%, 1.02 44%, 1.01 52%, 1 60%) |
The rough rule: --dur-1 for a colour change, --dur-2 for
something moving a short distance, --dur-3 for a panel opening, and the
longer two for something crossing the screen. If an interaction feels sluggish it is
usually one step too long, not the easing.
Three of the easings are linear() springs rather than cubic béziers —
--ease-spring, --ease-bounce and
--ease-overshoot. They give the overshoot-and-settle feel that normally
needs a physics library, as a plain CSS value with no JavaScript and no per-frame cost.
<div class="cluster">
<span class="badge enter-pop">enter-pop</span>
<span class="badge enter-rise">enter-rise</span>
<span class="badge enter-blur">enter-blur</span>
<span class="badge enter-drop">enter-drop</span>
</div>
<div class="cluster">
<span class="badge enter-pop">enter-pop</span>
<span class="badge enter-rise">enter-rise</span>
<span class="badge enter-blur">enter-blur</span>
<span class="badge enter-drop">enter-drop</span>
</div>
Lists that arrive in sequence
Put .stagger on the container and the entrance classes on the children,
and each child starts a little after the one before it. The step is
--stagger-step, 45ms by default.
<ul class="stack stack-2 stagger">
<li class="enter-rise">First</li>
<li class="enter-rise">Second</li>
<li class="enter-rise">Third</li>
</ul>
Keep it under about eight items. A stagger across thirty rows means the last one
arrives more than a second after the first, and by then it reads as the page being
slow rather than the page being considered. .stagger-fast and
.stagger-slow change the step.
Revealing on scroll
.reveal, .reveal-fade and .reveal-pop animate
when the element scrolls into view. An IntersectionObserver in
deck.js adds .is-revealed once and then stops watching, so
the cost is one observer for the page rather than a scroll handler.
<section class="reveal">…</section>
<section class="reveal-fade reveal-late">…</section>
Two warnings. Do not put a reveal on anything above the fold — it will animate while the reader is already looking at it. And do not reveal content that matters: if JavaScript fails the observer never runs, so anything hidden pending reveal stays hidden. Use it for polish on a marketing page, never for the contents of an app.
Animating between two states of the page
Deck.transition() wraps a DOM change so the browser tweens between before
and after — a row leaving a table, a card expanding into a detail view — using the View
Transition API.
Deck.transition(() => row.remove());
await Deck.transition(() => list.prepend(newRow), { direction: 'forward' }).finished;
newRow.focus();
The useful part is the fallback. Where the API is missing, or the reader has
asked for reduced motion, the update runs immediately and the return value is a stub
with the same shape — { finished, ready }, both already resolved. So
await …finished is safe to write unconditionally and you never
feature-detect at the call site.
For cross-document transitions, the four .vt-* classes name elements that
should be treated as the same thing across two pages — a header that stays put while
the content changes. See the motion component
page for the full set.
One-shot reactions
Deck.play() adds an animation class, waits for it, and cleans up after
itself. It also removes the class and forces a reflow first, so playing the same
animation twice in a row actually plays twice — the thing that catches people writing
this by hand.
await Deck.play(field, 'shake');
field.focus();
Deck.play(card, 'flash-good');
It returns a promise resolving to the node, resolves immediately if the node is null, and has a two-second safety timeout so a class that triggers no animation — a typo, or a rule switched off under reduced motion — still resolves rather than leaving a promise pending forever.
Two rules of taste
- Motion should explain, not decorate. A panel that slides down from its trigger tells you where it came from. A card that spins on hover tells you nothing and costs attention. If you cannot say what a movement communicates, remove it.
-
Animate
transformandopacity. Those two run on the compositor. Animatingwidth,topormarginforces layout on every frame, which is how a smooth animation on your laptop becomes a stuttering one on a three-year-old phone. Deck's own animations usetranslate,scaleandopacityfor exactly this reason.