← Gallery

Animation Toolbox

Dependency-free motion techniques for client sites — every demo on this page is the implementation (view source). Verdicts follow NN/g research, web.dev performance guidance, and WCAG. Scroll slowly; most demos trigger as they enter the viewport.

House rules (apply to every technique)

1 · Scroll-reveal (fade-up + stagger)

DEFAULT ON — the workhorse. Cards, headings, grids. IntersectionObserver + class, animate once, 16–32px distance, stagger ≤ 5 items.

Card one

Fades up when 15% visible, 90ms before its neighbor.

Card two

Transform + opacity only, 600ms, ease-out-quint.

Card three

Unobserved after firing — never re-animates.

Don't reveal paragraphs of body text, the hours table, or the contact block.

Implementation
/* CSS — initial state only exists for JS + motion-ok users */
@media (prefers-reduced-motion: no-preference) {
  .js .reveal { opacity: 0; transform: translateY(24px);
    transition: opacity .6s ease, transform .6s cubic-bezier(.22,1,.36,1);
    transition-delay: calc(var(--i, 0) * 90ms); }
  .js .reveal.in-view { opacity: 1; transform: none; }
}
/* JS — put class "js" on <html> first so no-JS users see everything */
const io = new IntersectionObserver(entries => {
  for (const e of entries) if (e.isIntersecting) {
    e.target.classList.add('in-view'); io.unobserve(e.target);
  }
}, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' });
document.querySelectorAll('.reveal').forEach(el => io.observe(el));

2 · Hero entrance stagger

DEFAULT ON — pure CSS, one-time, done within 1s. The H1 is exempt (LCP) — animate eyebrow, subhead, CTA.

Est. 2019 · Your Town, TX

The headline never hides

…but the supporting line rises in 120ms later,

and the CTA lands last
Implementation
@media (prefers-reduced-motion: no-preference) {
  .hero .rise { animation: rise .7s cubic-bezier(.22,1,.36,1) both; }
  .hero .rise:nth-of-type(2) { animation-delay: .12s; }
  .hero .rise:nth-of-type(3) { animation-delay: .24s; }
  @keyframes rise { from { opacity: 0; transform: translateY(18px); } }
}
/* H1 gets NO .rise class — it must be visible at first paint. */

3 · Hover micro-interactions

DEFAULT ON — highest ROI. 150–250ms, @media (hover:hover) so touch never gets sticky states, every hover is also a :focus-visible state.

Card lift

-4px translate; shadow is a pseudo-element whose opacity transitions (composited, cheap).

Underline draw

Links like this one draw a 2px line left-to-right via background-size — multi-line safe.

Image zoom ≤ 1.08
Implementation
/* lift: shadow via pseudo-element opacity, not box-shadow animation */
.card::after { content:""; position:absolute; inset:0; border-radius:inherit;
  box-shadow: 0 14px 34px rgb(0 0 0 / .35); opacity: 0; transition: opacity .2s; }
@media (hover:hover) and (prefers-reduced-motion: no-preference) {
  .card:hover { transform: translateY(-4px); }
  .card:hover::after { opacity: 1; }
}
/* underline draw (multi-line safe) */
a { background-image: linear-gradient(currentColor, currentColor);
  background-size: 0% 2px; background-position: 0 100%; background-repeat: no-repeat;
  transition: background-size .25s ease; }
a:hover, a:focus-visible { background-size: 100% 2px; }
/* image zoom: wrapper overflow hidden; img transform scale(1.05), .5s */

4 · Marquee ribbon

OPTIONAL, MAX 1 — brand words, press logos, review snippets. Decorative content only, 25–40s per loop, pause on hover.

  • fresh daily
  • family owned
  • est. 2019
  • open late
  • walk-ins welcome
Implementation
/* two identical groups; -50% makes the seam invisible */
.marquee { overflow: hidden;
  mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); }
.marquee-track { display: flex; gap: 2.5rem; width: max-content; will-change: transform;
  animation: scroll-x 30s linear infinite; }
@keyframes scroll-x { to { transform: translateX(-50%); } }
.marquee:hover .marquee-track { animation-play-state: paused; }
/* duplicate group gets aria-hidden="true"; static row under reduce-motion */

5 · Parallax

OFF BY DEFAULT — one banner max, ≤10% drift, never on text. Ship it only as an @supports (animation-timeline: view()) enhancement: Chrome/Safari get drift, Firefox gets a static image, reduce-motion users get nothing moving. Top vestibular trigger — when in doubt, skip.

drifts ±6% in Chrome/Safari · static in Firefox
Implementation
.banner { overflow: hidden; }
.banner img { position: absolute; inset: -15% 0; object-fit: cover; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .banner img { animation: para linear both; animation-timeline: view(); }
    @keyframes para { from { transform: translateY(-6%); } to { transform: translateY(6%); } }
  }
}
/* NEVER background-attachment: fixed (broken on iOS) or JS multi-layer scenes */

6 · Animated counters

OPTIONAL — one stats row max (members, years, rating). Final value lives in the HTML; JS counts up once when visible. Never for prices.

500+
Happy clients
12
Years open
4.9
Google rating
Implementation
/* HTML already contains the final value (SEO, no-JS, zero CLS) */
<div class="stat-num" data-target="500" data-suffix="+">500+</div>
/* tabular numerals stop width jitter */
.stat-num { font-variant-numeric: tabular-nums; }
/* JS: IO once → rAF count, easeOutCubic, 1.4s; skip entirely under reduce */
const eased = 1 - Math.pow(1 - p, 3);
el.textContent = (target * eased).toFixed(dec) + suffix;

7 · Sticky nav + scroll progress

STICKY NAV ON (all our pages already do it) · PROGRESS BAR OPTIONAL — the thin accent line at the top of this page is animation-timeline: scroll(), invisible in Firefox. Scroll-tied feedback is fine under reduce (it's position, not autonomous motion). Never scroll-snap the document.

Implementation
/* scrolled-state without scroll listeners: sentinel + IO */
<div class="nav-sentinel"></div>  /* sits above the sticky header */
new IntersectionObserver(([e]) =>
  header.classList.toggle('scrolled', !e.isIntersecting)
).observe(document.querySelector('.nav-sentinel'));

/* progress bar — pure CSS enhancement */
@supports (animation-timeline: scroll()) {
  .progress { position: fixed; top:0; left:0; height:3px; width:100%;
    transform-origin: 0 50%; animation: grow linear; animation-timeline: scroll(root); }
  @keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}

8 · Text effects

OFF UNLESS THE BRAND CALLS FOR IT — max one, hero only. Words, never letters. No typewriters, ever.

Gradient shimmer — runs 3 times, then rests.

Implementation
/* split into WORD spans (aria-hidden), full text in aria-label on the parent */
<h1 aria-label="Great hair isn't luck">
  <span aria-hidden="true" style="--w:0">Great</span> …
</h1>
span { display: inline-block;
  animation: word-rise .6s cubic-bezier(.22,1,.36,1) both;
  animation-delay: calc(var(--w) * 70ms); }
/* shimmer: background-clip:text + background-position sweep, 3 iterations NOT infinite */

9 · Ambient motion

OPTIONAL, ONE ZONE MAX — texture, not spectacle. Blur is applied statically; only transform animates, 18–25s. First thing to cut under reduce-motion.

Two drifting blobs · 20s + 24s alternate

Implementation
.blob { position:absolute; border-radius:50%; filter: blur(70px); /* static! */
  opacity:.5; pointer-events:none; }
@media (prefers-reduced-motion: no-preference) {
  .blob-a { animation: drift 20s ease-in-out infinite alternate; }
  @keyframes drift { to { transform: translate(12%, 18%) scale(1.15); } }
}
/* steam: 3 SVG paths, translateY+opacity keyframes, delays 0/1.1s/2.2s */

10 · Video

ON WHEN FOOTAGE EXISTS — full placement patterns (hero band, inline card, click-to-play testimonials) live in the media toolbox. Core rules: autoplay muted loop playsinline + poster, no audio track at all, ≤10s loop ≤4MB, scrim overlay for text contrast, reduce-motion users get the poster.

Anti-pattern reference and per-category applicability matrix: see STANDARDS.md · Media placement: toolbox/media.html