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.
will-change only on marquee tracks and parallax layers.@media (prefers-reduced-motion: no-preference). Reduce users get fully visible static content — never opacity: 0 outside the guard.DEFAULT ON — the workhorse. Cards, headings, grids. IntersectionObserver + class, animate once, 16–32px distance, stagger ≤ 5 items.
Fades up when 15% visible, 90ms before its neighbor.
Transform + opacity only, 600ms, ease-out-quint.
Unobserved after firing — never re-animates.
Don't reveal paragraphs of body text, the hours table, or the contact block.
/* 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));DEFAULT ON — pure CSS, one-time, done within 1s. The H1 is exempt (LCP) — animate eyebrow, subhead, CTA.
Est. 2019 · Your Town, TX
…but the supporting line rises in 120ms later,
and the CTA lands last
@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. */DEFAULT ON — highest ROI. 150–250ms, @media (hover:hover) so touch never gets sticky states, every hover is also a :focus-visible state.
-4px translate; shadow is a pseudo-element whose opacity transitions (composited, cheap).
Links like this one draw a 2px line left-to-right via background-size — multi-line safe.
/* 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 */OPTIONAL, MAX 1 — brand words, press logos, review snippets. Decorative content only, 25–40s per loop, pause on hover.
/* 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 */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.
.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 */OPTIONAL — one stats row max (members, years, rating). Final value lives in the HTML; JS counts up once when visible. Never for prices.
/* 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;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.
/* 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); } }
}OFF UNLESS THE BRAND CALLS FOR IT — max one, hero only. Words, never letters. No typewriters, ever.
Gradient shimmer — runs 3 times, then rests.
/* 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 */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
.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 */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