Website motion
Two effects, deliberately. Motion is where a site starts looking machine-made: applied to every card and paragraph it reads as a template, and it is the single easiest way to lose content to an accessibility bug nobody notices. This skill ships the two that earn their place, and — more importantly — the contract that makes them safe.
Only add this when the page is actually long. On a three-section site a reveal is noise. The count-up needs numbers a visitor should stop and read (attendee counts, years running, projects shipped), not decoration.
Prerequisite: website-qa green first. This adds a test file to that suite
and changes how the a11y gate runs.
The class names below are examples, not the scaffold's
.section-title,.section-subtitle,.hero-stat-numberand.heroappear zero times innew-website's Astro templates. They come from a real site this was extracted from. Nothing here works until you point every selector at your own markup — the CSS, the two scripts, and theCONFIGblock at the top oftemplates/motion.spec.ts. The spec skips with a stated reason when a selector is left empty, so an unconfigured copy reports "not applicable" instead of failing in a way that looks like a product bug.
0. The one-line check that is not motion
Before adding anything, measure the hero:
const h = document.querySelector('.hero'); // your hero's selector
({ vh: innerHeight,
heroHeight: h?.getBoundingClientRect().height ?? null,
nextSectionVisible: h?.nextElementSibling
? innerHeight - h.nextElementSibling.getBoundingClientRect().top : null })
If min-height: 100vh is binding — the hero's own content is shorter than the
viewport — the next section starts exactly at the fold and the page reads as a
splash screen. Trim the floor until the next section peeks over the edge.
Do not copy a number from another site. The right value is a function of the
hero's content height. If the content already exceeds the viewport, changing
min-height does nothing at all on that screen size, and the real lever is
cutting a paragraph out of the hero. Measure, then decide.
1. The contract
Everything below exists to keep one promise: no effect may leave content unreachable. Three rules, in priority order.
-
Hidden state is added by JS, never authored in HTML. A visitor with no JS, or one landing mid-page, must see the finished page. The script adds the hiding class itself, and only to elements currently below the fold.
-
prefers-reduced-motion: reduceends with everything visible and nothing moving — including animations that predate this skill. The Astro scaffold shipshtml { scroll-behavior: smooth }with no override, so this is a step you must actually perform, not a property you inherit:/* AFTER the rules it overrides — see §6, this is the whole trap */ @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } /* plus any ambient animation this site runs */ } -
Print is a third state. It never scrolls, so
IntersectionObservernever fires and a revealed heading prints blank.
2. Scroll reveal — section headings only
@media (prefers-reduced-motion: no-preference) {
/* No transition on the hidden state. `reveal` is added after first paint, so a
transition here makes an already-drawn heading fade OUT for half a second
before it can fade in — visible to anyone scrolling straight after load.
With the transition on the visible state, hiding snaps and only the reveal
animates. */
.reveal { opacity: 0; transform: translateY(12px); }
.reveal.is-visible {
opacity: 1; transform: none;
transition: opacity 0.55s ease-out, transform 0.55s ease-out;
}
/* Same specificity as the rule above and LATER in the sheet, so the shorthand
there cannot reset the delay to zero. */
.reveal-delay.is-visible { transition-delay: 0.09s; }
}
/* Printing never scrolls, so the observer never fires. */
@media print { .reveal { opacity: 1 !important; transform: none !important; } }
(function () {
if (!('IntersectionObserver' in window)) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
function partnerOf(heading) {
var next = heading.nextElementSibling;
return next && next.matches('.section-subtitle') ? next : null;
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
io.unobserve(entry.target);
entry.target.classList.add('is-visible');
var partner = partnerOf(entry.target);
if (partner) partner.classList.add('is-visible');
});
}, { threshold: 0.15 });
document.querySelectorAll('.section-title').forEach(function (heading) {
// Anything already on screen is left alone — hiding it now would make it
// flash out and fade back in on load.
if (heading.getBoundingClientRect().top < window.innerHeight) return;
heading.classList.add('reveal');
var partner = partnerOf(heading);
if (partner) partner.classList.add('reveal', 'reveal-delay');
io.observe(heading);
});
})();
Headings only. The subtitle rides along one beat behind so the pair reads as one movement — that is what "staggered" should mean here. Applying this to cards, list items and paragraphs is the AI-generated-site tell.
3. Hero stat count-up
(function () {
var nums = document.querySelectorAll('.hero-stat-number'); // your selector
if (!nums.length || !('IntersectionObserver' in window)) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
function countUp(el) {
// Writing textContent flattens child markup permanently — a styled
// <span>+</span> inside the number would not survive the animation.
if (el.children.length) return;
var finalText = el.textContent.trim();
// Digits, an optional group separator ("," in en, "." in de), an optional
// trailing symbol. Everything else is left exactly as authored: these are
// published claims, and "4.9" must never animate up to 49.
var parts = finalText.match(/^(\d{1,3}(?:([.,])\d{3})*|\d+)(\D*)$/);
if (!parts) return;
var target = parseInt(parts[1].replace(/\D/g, ''), 10);
var groupSep = parts[2] || '';
var suffix = parts[3] || '';
// Mirror the AUTHORED grouping rather than a runtime locale. The frozen
// width below is measured from the authored string, so rendering "1,200"
// where the page says "1.200" — or grouping a number the page left
// ungrouped — is a different width, which is the exact row-shove the
// freeze exists to prevent.
function render(v) {
var digits = String(v);
return (groupSep ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, groupSep) : digits) + suffix;
}
// Freeze the box first. "0+" is far narrower than "1,200+", and the stat is
// usually sized by its number rather than its label, so an unfrozen count
// shoves the whole row sideways every frame.
// Remember any inline min-width already on the element — blanking it on
// settle would quietly undo a later styling change someone else made.
var priorMinWidth = el.style.minWidth;
el.style.minWidth = el.getBoundingClientRect().width + 'px';
var done = false;
function settle() {
done = true;
el.textContent = finalText;
el.style.minWidth = priorMinWidth;
}
// A print fired mid-count would capture an intermediate figure — a number
// the site does not claim. Contract rule 3 covers the reveal; this is the
// count-up's half of it.
window.addEventListener('beforeprint', settle);
var started = performance.now();
function frame(now) {
if (done) return;
var t = Math.min((now - started) / 1400, 1);
el.textContent = render(Math.round(target * (1 - Math.pow(1 - t, 3))));
if (t < 1) requestAnimationFrame(frame);
else settle();
}
requestAnimationFrame(frame);
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
io.unobserve(entry.target);
countUp(entry.target);
});
}, { threshold: 0.6 });
nums.forEach(function (n) { io.observe(n); });
})();
The final values stay in the HTML, so no-JS visitors and crawlers see the real numbers and the animation only ever replaces them temporarily.
min-width only works on a block box. If the stat number computes to
display: inline, the freeze is a silent no-op and the row still jumps. Check
the computed value, or set display: inline-block.
Run both scripts after the DOM is parsed — defer, a module, or at the end
of <body>. In a synchronous <head> script the query returns nothing and both
effects are silently dead.
4. The a11y gate has to change, or it gets quietly weaker
This is the part that is easy to miss, and it is the reason this skill exists
rather than a snippet in website-design-system.
A reveal leaves headings at opacity: 0 while axe runs, and axe skips elements
it considers invisible. Their colour contrast silently stops being checked. The
suite stays green and the gate is weaker than it was the day before.
Force reduced motion in a11y.spec.ts so axe always sees the finished page. The
snippet below assumes that file's for (const path of PAGES) shape — adjust it to
whatever loop or fixture your suite actually uses. The emulateMedia call before
goto, and the canary under it, are the load-bearing parts:
for (const path of PAGES) {
test(`a11y: ${path}`, async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(path, { waitUntil: 'load' });
// …unchanged
test.use({ reducedMotion: 'reduce' })may not be a substitute. Measured once, on Playwright 1.60, against a different repo's config — not this toolkit's, whose lockfile currently resolves 1.64.1. There, thetest.useform was accepted without complaint while the page'smatchMediastill reportedno-preference;page.emulateMediaandbrowser.newContextboth worked in the same run. That contradicts how the option is documented, and the mechanism was never established, so treat it as one measurement rather than a rule about Playwright.emulateMediais used here because it is the form that was actually observed to work.
Whichever form you use, prove it reached the browser, because the failure is silent — and a per-test call is one forgotten line away from a page that quietly keeps the weakened gate:
// Canary. Without it, a gate that does nothing looks exactly like a gate.
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches),
'reduced motion never reached the browser — the a11y gate is not deterministic')
.toBe(true);
5. motion.spec.ts — the test that makes the contract real
Copy this skill's templates/motion.spec.ts into the site's tests/. It lives
here rather than in the new-website Astro overlay on purpose: that overlay is
copied into every scaffold, and this spec asserts an effect a fresh site does
not have — it would fail on day one of every new project.
Each row below exists because of a specific way the contract breaks. The right-hand column is what the assertion is for, not a claim about how often it has fired:
| assertion | the bug it catches |
|---|---|
| No heading is invisible after a full scroll-through | the whole failure mode this skill guards against |
| Nothing above the fold is hidden at load | a heading that visibly flashes out and fades back in |
Reveal targets are visible under media: print | the print override being deleted or mis-ordered |
Under reduced motion nothing is hidden, scroll-behavior is auto, nothing is animating, and the .reveal rule is force-applied to prove the CSS itself yields | an ambient animation ignoring the preference; smooth scrolling left on; and an override placed before the rule it overrides — same specificity, so the original wins and the fix is a silent no-op |
| Stat numbers settle on their published values | someone editing the figures in HTML |
The forced-class step in row 4 is not belt-and-braces. Under reduced motion the reveal's JS bails, so the hiding class is never added and the stylesheet is never exercised — without forcing it on, that row would pass with the CSS override broken.
A "nothing animates" test must cover the page's other animations too — an
ambient hero glow, scroll-behavior: smooth. Asserting only your own effect
makes the test's name a lie.
The count-up assertion is a content snapshot, not an animation test. The final frame restores the authored string, so it passes with the animation dead. Say so in the comment rather than implying coverage it does not have; sampling a 1.4s rAF animation from the test side is timing-dependent and not worth a flaky gate.
6. Gotchas that cost real debugging time
- A reduced-motion override must come AFTER the rule it overrides. Same specificity means later wins. Placed up beside the original declaration it reads more naturally and does nothing.
- A marquee-style strip cannot be hovered or clicked by Playwright. It waits
for the element to stop moving and a looping element never does. Drive the
mouse by coordinate (
page.mouse.move) instead. - Sampling a looping animation flakes at the wrap. A single before/after pair measures one window against the loop period — a 1s sample of a 90s loop lands on the restart, where position jumps forward, about one run in ninety. Sample several intervals and take a majority; only one of them can contain the wrap.
- Adding a heading changes heading order. A new section heading between the
last content heading and the footer's can skip a level.
heading-orderis an axe best-practice rule, so a WCAG-tagged gate will not catch it — check it by hand once, or accept that it is ungated and say so. document.getAnimations()takes no arguments. It is already document-wide, pseudo-elements included; the{ subtree: true }option belongs toElement.getAnimations(). Passing it is ignored at runtime and fails a strict TypeScript check — a copied test that will not compile.- Animation names are Chromium-shaped.
animationName/transitionPropertylive onCSSAnimation/CSSTransition. An engine returning plainAnimationobjects still gets caught, but every entry readsunnamed, somotionExceptionscannot filter individual ones. - What this spec does NOT cover. It runs at one viewport, so wrapping and
section placement on mobile are unchecked — the below-fold set differs there.
And the toolkit's
template-tests.ymltests only the Astro starter and two other skills, never this template, so CI does not exercise it at all; it was verified by running it against a real site instead. - A preview pane may report
visibilityState: "hidden". Where it does,IntersectionObservernever fires, CSS transitions freeze at their start value, and deep-scroll screenshots come back blank. None of this reproduces a real browser. Verify motion with Playwright, and uselocator.screenshot()— it captures a single element and sidesteps the blank-capture problem.
Boundaries
- Owns motion only. Layout, images and theme tokens stay in
website-design-system; the QA suite stays inwebsite-qa. - Never a default.
new-websitedoes not run this; a human asks for it.