Animation & Motion

Hero headline animation

The hero headline entrance is a one-shot GSAP choreography that plays on page load: ink words hinge up like letterpress panels, the accent phrase swings open like a door, the highlighted word ignites into colour, and a gold rule draws beneath — all reading as a single composed move. It is shared verbatim by both hero sections and is deliberately separate from the site-wide scroll-in reveals: that system is a DIY IntersectionObserver + CSS cascade, while this one is the only place the site reaches for GSAP's heavier toolkit (gsap + SplitText + CustomEase).

The whole effect is decoupled by class so an editor can compose it inside the Sanity headline without touching code: one class drives motion, another drives colour. When motion isn't wanted — toggle off, reduced-motion, no-JS, or draft preview — the server-rendered headline and gold rule render statically and nothing is lost.

The mental model

One module, src/lib/heroHeadlineAnim.ts, exports initHeroHeadlines(). Both hero components import it and call it from their inline <script>; the module guards itself with a module-level started flag so the work runs exactly once even though it's imported twice:

let started = false;

export function initHeroHeadlines(): void {
  if (started) return;
  started = true;
  // ...finds every .hero-headline whose wrapper is data-heading-anim="on"
}

It collects every .hero-headline on the page, then keeps only those whose .hero-copy wrapper carries data-heading-anim="on":

const headlines = Array.from(
  document.querySelectorAll<HTMLElement>('.hero-headline'),
).filter((h1) => h1.closest('[data-heading-anim="on"]')); // toggle off -> skip

Everything else — the SplitText line/word split, the per-word transforms, the timeline — happens per headline inside animateHeadline().


The three beats (plus the rule)

The timeline runs four steps. SplitText first cuts the H1 into lines,words (lines so a word never breaks mid-character when the headline wraps), tagging each word .hero-word. Words are then classified by their authoring class — motion keys off .heading-accent, colour keys off .ignite-*:

const accentWords = words.filter((w) => w.closest('.heading-accent'));
const inkWords = words.filter((w) => !w.closest('.heading-accent'));
const igniteWords = words.filter((w) => w.closest(igniteSelector));

Beat 1 — ink words hinge up. The non-accent ("ink") words start tipped back and below their resting line (rotationX: -100, yPercent: 60, hinged from their bottom edge) and rise into place, staggered left-to-right. They ride a bespoke CustomEase called heroSettle that overshoots a hair past flat then seats — a letterpress-panel feel rather than a flat fade.

Beat 2 — accent door-unfold. The .heading-accent words start folded shut around their left edge (rotationY: -92, transformOrigin: '0% 50%') and swing open left-to-right with power3.out, overlapping the tail of beat 1 ('-=0.3') so the phrase arrives as the ink finishes.

Beat 3 — colour ignite. Each .ignite-<tone> word is set to ink colour up front, then tweened to its own resting colour after the unfold lands. The target colour is captured live from getComputedStyle before the override, so theme overrides and per-word tones are respected:

const igniteTargets = igniteWords.map((w) => getComputedStyle(w).color);
// ...
tl.to(igniteWords, {
  color: (i: number) => igniteTargets[i],
  duration: 0.4,
  ease: 'power1.inOut',
}, '>-.7');

Beat 4 — gold rule draws. The server-rendered .hero-rule sibling starts collapsed (scaleX: 0) and tweens to full width, synced 1:1 to the previous beat with the '<' position parameter (the ignite when present, otherwise the unfold).

On completion the timeline clearProps's the words back to pixel-sharp resting state. Timing and easing for every beat are inline in the timeline steps in heroHeadlineAnim.ts — that file is the single place to tune the motion.


The markup contract

Each hero wraps its headline, gold rule, and supporting copy in one .hero-copy block. The animation toggle lives on that wrapper; the H1 and rule live inside it:

<div class="hero-copy ..." data-heading-anim={animate ? 'on' : 'off'}>
  {title && (
    <Fragment>
      <h1 class="hero-headline ..." set:html={headingHtml} />
      <span class="hero-rule" aria-hidden="true"></span>
    </Fragment>
  )}
  <!-- supporting copy / chips / CTA -->
</div>

The three required hooks are:

ElementHookRole
.hero-copydata-heading-anim="on" | "off"Gates the whole animation; the JS skips when off.
<h1>.hero-headlineThe element SplitText cuts into words.
<span>.hero-ruleServer-rendered gold bar; GSAP finds it as a sibling inside .hero-copy.

Render the toggle by value, never by presence

Write data-heading-anim={animate ? 'on' : 'off'}, not data-heading-anim={animate}. Astro renders data-x={false} as the literal string data-x="false" — the attribute is still present, so a presence-based selector would match it and animate a headline that was supposed to be static. The JS specifically queries [data-heading-anim="on"] for this reason.

The toggle value comes from the Sanity "Animate heading" boolean (headingAnimation, on by default on both hero schemas). The components combine it with the draft and reduced-motion gates:

const forceAnim = Astro.url.searchParams.has('heroanim');
const animate = headingAnimation !== false && (!draft || forceAnim);

Authoring contract (inside the Sanity headline)

The headline is authored in Sanity and rendered with set:html. Motion and colour are decoupled by class, so an editor composes both without code:

ClassEffect
heading-accentWraps the accent phrase → gets the door-unfold. The CSS sets white-space: nowrap, so keep trailing punctuation inside it — a lone period must not orphan onto its own line.
ignite-ink / ignite-forest / ignite-goldMarks word(s) for the colour ignite and sets their resting colour from the theme var. The colour beat fires only after the unfold completes.

A real example, exactly as authored:

Marketing &amp; web <span class="heading-accent">built for <span class="ignite-forest">you</span>.</span>

The effect degrades gracefully: no heading-accent → the whole headline just hinges with no unfold; no ignite-* → the colour beat is skipped. Because these classes are either runtime-injected (.hero-word) or set:html-authored (.heading-accent, .ignite-*) and the rule is server-rendered, their CSS must be plain (unscoped) in global.css — an Astro scoped component rule would silently miss every runtime-created node.


The reduced-motion and fallback gates

Motion is wrapped in gsap.matchMedia('(prefers-reduced-motion: no-preference)'), and the actual word measurement waits for the self-hosted display font to settle so SplitText measures the right line breaks:

const mm = gsap.matchMedia();
mm.add('(prefers-reduced-motion: no-preference)', () => {
  const cleanups: Array<() => void> = [];
  document.fonts.ready.then(() => {
    headlines.forEach((h1) => cleanups.push(animateHeadline(h1, settle)));
  });
  return () => cleanups.forEach((fn) => fn());
});

When the toggle is off, reduced-motion is requested, or there's no JS, nothing runs and the SSR headline stays fully visible. The static appearance is owned entirely by CSS in global.css: the .ignite-<tone> rules set each word's resting colour, and .hero-copy .hero-rule paints the gold bar as a static full-width gradient. The collapse-then-draw behaviour is itself gated behind both .js and prefers-reduced-motion: no-preference, so the rule only ever starts hidden when GSAP is actually going to draw it:

@media (prefers-reduced-motion: no-preference) {
  .js [data-heading-anim='on'] .hero-rule {
    transform: scaleX(0);
    /* ...GSAP draws it in */
  }
}

Don't clearProps the gold rule

On timeline complete the words are clearProps'd back to resting state, but the rule's transform/opacity are deliberately left at their tween end-values. If you clearProps the rule, the anti-flash CSS (transform: scaleX(0) above) re-collapses it the instant the inline style is removed, and the bar vanishes. Only willChange is cleared on the rule.


The draft-mode footgun (load-bearing)

The animation is off by default in draft/preview mode, and this is not optional polish — removing the guard floods the console and breaks the Presentation overlay. Both HeroSection.astro and HeroExplorerSection.astro carry the identical guard:

const draft = isDraftMode(Astro.cookies);
const forceAnim = Astro.url.searchParams.has('heroanim');
const animate = headingAnimation !== false && (!draft || forceAnim);
const headingHtml = animate && draft && title ? stegaClean(title) : title;

SplitText shreds stega; stega breaks the decoder

In draft preview the headline string carries an invisible stega payload (the click-to-edit source tags). SplitText cuts the H1 into per-word spans, fragmenting that payload across nodes — which crashes @sanity/visual-editing's stega decoder with thousands of Failed to decode stega errors. So by default, draft = static headline (stega intact → the headline stays click-to-editable). See Visual editing & draft mode for the broader stega-equality rule.

To tune the animation against draft content, append ?heroanim to the preview URL. The component then runs stegaClean(title) before set:html — so there is nothing left to fragment — and animates. The trade-off in that mode: the headline itself is no longer click-to-editable (its stega was stripped); every other field stays editable. Don't remove either the guard or the clean — either one alone reintroduces the error flood.

Tuning the animation's code (durations and eases in heroHeadlineAnim.ts) needs none of this: just view the site at its plain URL with no preview cookie, where it animates against published content.


How to add an ignite tone

The colour tones live in two places that must mirror each other:

  1. The IGNITE_TONES array in heroHeadlineAnim.ts — currently ['ink', 'forest', 'gold']. This is what builds the .ignite-<tone> selector the JS classifies words by.
  2. A matching .hero-headline .ignite-<tone> rule in global.css that sets the word's resting (and final) colour from a theme var.
// heroHeadlineAnim.ts — MUST mirror the .ignite-<tone> rules in global.css.
const IGNITE_TONES = ['ink', 'forest', 'gold'];
/* global.css */
.hero-headline .ignite-forest {
  color: var(--color-forest);
}

Add the tone to both, using a brand token from Brand tokens for the colour. If the two drift, a word authored with the new class either won't be classified for the ignite beat (missing from the array) or won't have a resting colour (missing from CSS).

Why two sources of truth here

The CSS value doubles as the static fallback colour and the animation's live target — the JS reads it back via getComputedStyle before overriding to ink, so a single declaration serves both the no-motion path and the ignite tween. The array is the runtime's only way to know which classes count as ignite words. Neither can be derived from the other, so they're kept in sync by convention.


Where this lives

  • website/src/lib/heroHeadlineAnim.ts — the shared GSAP module: initHeroHeadlines(), the heroSettle CustomEase, word classification, the four-beat timeline, and IGNITE_TONES.
  • website/src/components/sections/HeroSection.astro — the standard hero; renders .hero-copy / .hero-headline / .hero-rule, computes the animate gate, and calls initHeroHeadlines().
  • website/src/components/sections/HeroExplorerSection.astro — the explorer hero; same headline contract and draft guard, plus its own GSAP backdrop crossfade.
  • website/src/styles/global.css — unscoped CSS for .hero-word, .heading-accent, .ignite-*, and the .hero-rule static-bar / collapse rules (search "Hero headline entrance (GSAP)").
  • website/CLAUDE.md — the canonical prose for this system under "Hero headline animation (GSAP)".
  • Motion durations and easing are inline in the timeline steps in heroHeadlineAnim.ts; see also Tunables & specs. For the unrelated CSS reveal cascade, see Scroll-in reveals.
Previous
Scroll-in reveals