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.

The four kinds of motion in Deck
You wantUseFires 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
Press them. The first three are one-shots; the last is a hover transition
<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.

Design tokens this component reads
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.

The entrance set, played on load
enter-pop enter-rise enter-blur enter-drop
<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 transform and opacity. Those two run on the compositor. Animating width, top or margin forces 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 use translate, scale and opacity for exactly this reason.