Animation & Motion

Scroll-in reveals

Scroll-in reveals are the site's "as you scroll, content arrives" choreography: a heading rises in as one unit, the row of cards under it ticks in one after another, and side-by-side columns slide in from their own sides — all driven by a single IntersectionObserver and plain CSS transitions, with no animation library.

There is deliberately one reveal style, tuned in code. Editors get exactly one control per section — an Animate on scroll boolean — and everything else (timing, distance, direction, stagger) is set-and-forget so every page moves the same way. This page documents the codebase-specific contract: what a reveal unit is, how the animate toggle arms it, how the cascade staggers, and the placement traps that bite when you mark up a new section. The GSAP hero headline entrance is a separate system — see Hero headline animation.

The mental model: three layers

The system has three moving parts, each in its own file:

  1. Components mark reveal units. An element that should enter as one unit carries data-reveal (the default: rise + fade), or data-reveal="left" / data-reveal="right" for the lateral entrances used by side-by-side columns. SectionHead.astro carries it on its root <header>, so every consuming section's heading is automatically its own unit; card grids put it on each cell.

  2. The section toggle arms them. AnimatedSection.astro renders a <div data-section-anim> wrapper around the section when the Studio toggle is on (or unset). The hidden-state CSS only matches [data-reveal] inside a [data-section-anim] ancestor — toggle off, and the units render statically at zero cost. SectionRenderer.astro wraps every section uniformly, so there are no short-circuit branches; a section like marqueeSection that contains no data-reveal just gets an inert wrapper.

  3. The observer cascades them. Animations.astro runs one IntersectionObserver per distinct trigger line over every armed unit. Units whose tops cross the line in the same observer callback form a batch — sorted into DOM order, grouped by their containing section, and revealed with a staggered transition-delay.

{animate !== false ? (
  <div data-section-anim>
    <slot />
  </div>
) : (
  <slot />
)}

That animate !== false is the whole gate (next section). The runtime also doubles as the "drawn-in underline" trigger: it flips any .heading-underline element to .is-drawn (the underline draw is pure CSS — a background-size transition — the observer only flips the class).


The animate toggle: undefined means ON

animate is a plain boolean on every section schema (Studio: "Animate on scroll", in the Styles group, initialValue: true). The website checks animate !== false, so the field is on by default — including for sections authored before it existed, where the value is unset.

SectionBase in src/sanity/types.ts declares it, and the GROQ ... spread in projections.ts carries it through — no query changes are ever needed to add animation to a section.

interface SectionBase {
  // Studio "Animate on scroll" toggle. undefined = on (sections animate by
  // default); only an explicit false disables the reveal cascade.
  animate?: boolean;
}

Render the wrapper attribute by presence, never =false

Booleans carry no stega, so unlike string fields this needs no stegaClean. But the wrapper must be emitted by presence only — never data-section-anim={false}. Astro renders data-x={false} as the literal string "false" (the attribute is still present), and the CSS presence selector [data-section-anim] would still match it, arming a section the editor explicitly turned off. AnimatedSection sidesteps this by conditionally rendering two different wrappers instead of toggling an attribute value.

The heroes carry two booleans: animate (the supporting copy / CTA cascade below the headline) and a separate headingAnimation ("Animate heading") that governs the GSAP headline — a different system. marqueeSection has no animate field at all; the marquee never reveals in by design.

The blog and portfolio index grids are always-on: those index pages stamp a bare data-section-anim on the grid <section> and PostGrid.astro wraps each card, so the cascade runs with no Sanity toggle.


Batch staggering and delay clearing

When several units cross the trigger line in one observer callback, they form a cascade batch. The runtime in Animations.astro:

  • Sorts the batch into DOM orderIntersectionObserver entry order isn't guaranteed to be DOM order, so it sorts via compareDocumentPosition so the cascade always reads top-left → bottom-right.
  • Groups by containing section (closest('[data-section-anim]')) and resets the stagger index per section, so two sections entering at once each run their own independent cascade.
  • Stamps transition-delay = i × REVEAL_STAGGER_MS as an inline style, capped at REVEAL_MAX_DELAY_MS so a dense section can't push its last unit absurdly late. A unit's own data-reveal-delay is added on top as a base.

Units that enter later fire alone with no delay — so a tall grid cascades row by row as you scroll, and a fast scroll never leaves content queued behind a long delay chain. Each unit reveals once.

The inline transition-delay is cleared on transitionend

The cascade slot is written as an inline transition-delay. If it stayed, it would bleed into any later transition on the same element (hover, open/close, etc.) — a settled card would lag by its old cascade beat the next time anything animated it. The runtime registers a one-shot transitionend listener that resets el.style.transitionDelay = '' after the reveal lands.

One subtlety in the stagger override parse: an ancestor data-reveal-stagger of "0" is a meaningful value (fire the group in sync), so the code uses an explicit Number.isFinite check rather than Number(attr) || default, which would wrongly treat 0 as falsy and fall back to the default step.

Unfired units are never unobserved — only firing units are. A display:none unit (e.g. a desktop-only figure) reports no intersection now but re-enters observation naturally when a breakpoint flip makes it visible.


Directional, group, and per-element triggers

Directional entrances (code-fixed)

Lateral entrances are determined in component code, not by an editor field:

WhereBehavior
Sticky scrollImage column and content card converge in sync from their own sides, following the Image-position setting — lg+ only. Below lg, each stacked content+image group rises as its own unit.
AboutPortrait from the left, bio from the right, at lg+.
Content + imageEach column from its own side (follows Image position) at lg+.
Pricing tiersAll tiers fire in sync, one stagger beat after the heading — first from the left, last from the right, middle rises — at md+ via the data-reveal-md opt-in. A lone tier just rises.

Directional transforms gate at lg (or md via the data-reveal-md attribute, used by pricing tiers); below the gate everything just rises — a lateral slide on a stacked phone layout reads as noise. The directions that follow imagePosition are stega-cleaned in the consuming sections (load-bearing — see the stega note).

Two refinements keep converging pairs clean. To sync a pair, put data-reveal-stagger="0" on their common ancestor — a trailing column sliding under a settled one reads as a layering jump. For an opaque card converging over something darker (the sticky-scroll image card over the ink content band), the card slides in opaque via data-reveal-no-fade so its band background occludes the dark layer, and the image inside fades separately via data-reveal-no-move so it materializes in place rather than double-translating.

Group triggers

A container marked data-reveal data-reveal-no-fade data-reveal-no-move fires like any unit but has no visual of its own — the no-fade + no-move pair zeroes out its hidden state. Its data-reveal-with-parent descendants then co-fire off its box rather than keying off their own. The sticky-scroll [data-sticky-root] is the group trigger for its figure column, content card, and image group.

This is the only reliable way to sync side-by-side units whose top edges differ: the sticky content card's lg:-my-12 lifts its top ~48px above the figure column's, so per-element triggers would fire them ~48px of scroll apart (visible on a slow scroll). One trigger = one scroll position for the whole converge. data-reveal-with-parent units are deliberately excluded from the runtime's top-level query — they co-fire instead of self-keying, which is opt-in (not "any nested unit") because some reveal units legitimately nest inside others and must stay independent.

Per-element trigger lines

A unit can fire on a named alternate line via data-reveal-trigger="<name>", where the name resolves through REVEAL_TRIGGERS in animation-config.ts. The runtime keeps one observer per distinct line, created on demand and cached by its fraction; a unit with no (or an unknown) trigger uses the default TRIGGER_FROM_BOTTOM. The line is implemented as a rootMargin that shrinks the root's bottom edge upward, firing when an element's top crosses it.

Only the sticky-scroll section uses this today: its root carries early (fires as the tall section enters, shrinking the pre-reveal blank on short viewports), and it's the one editor-selectable reveal-timing control — the schema's revealTrigger field ("Reveal timing": Early / Default) maps unset/earlydata-reveal-trigger="early" and explicit default → the site-wide line. On a group trigger the attribute goes on the root only; the with-parent children inherit its timing. Don't add a revealTrigger field elsewhere without a real need — the rest stay code-fixed.

Breakpoint-scoped units

data-reveal-only="lg" participates only at lg+; data-reveal-only="max-lg" only below lg. Outside its window the unit's hidden state and transition are stripped (it renders static). The observer still marks it visible regardless of breakpoint — harmless. Sticky-scroll uses both: its two columns are lg-only, and each stacked content+image article is a max-lg-only rise unit, so mobile reveals per group while the ink card band stays static furniture.


Placement traps

These are the footguns when marking up a new section.

Never put data-reveal on an element with its own transition-* utilities

The reveal CSS sets transition: opacity, transform at specificity 0,0,2,0, which clobbers Tailwind hover-lift, color, and <details> open/close transitions on the same element. Wrap the element in a plain <div data-reveal> and pass class="h-full" to the card so grid cells still stretch. WorkCard, PostCard, ServiceCard, TestimonialCard, and FaqItem all do this.

Other traps:

  • Units must be block-level. Transforms don't apply to non-replaced inline elements — but grid/flex items are fine automatically.
  • Don't put data-reveal on an element a background-hairline trick depends on. The Receipts section's reveal unit is an inner div so its gap-px grid backdrop can't show through an opacity-0 cell.
  • One conceptual block = one data-reveal; a grid of cards = one per cell. Use <SectionHead> and the heading unit comes free.
  • Keep data-reveal off anything inside the marquee — it stays static by design.

A note on a transient side effect: while a unit transitions, its transform makes it a containing block for position: fixed descendants (pasted form/iframe embeds). It lasts one reveal and ends at transform: none. Sticky elements are safe — transform transitions on or above lg:sticky elements don't disturb sticky positioning, and the settled state carries no transform. There is also no permanent will-change on purpose: dozens of units are observed at once, and permanent hints would force that many composite layers; modern engines promote layers during the transition automatically.


Knob homes

Each knob has exactly one home.

KnobSource of truthControls
Trigger lineTRIGGER_FROM_BOTTOM in animation-config.tsDefault fire line as a fraction of viewport height from the bottom. Larger = fires later.
Trigger tiersREVEAL_TRIGGERS (same file)Named alternate lines opted into via data-reveal-trigger. early exists for tall sections.
Cascade stepREVEAL_STAGGER_MS (same file)Delay between sibling units in one batch.
Cascade capREVEAL_MAX_DELAY_MS (same file)Ceiling on accumulated stagger per batch.
Hero base delayHERO_REVEAL_BASE_DELAY_MS (same file)Extra base delay on the heroes' supporting copy/CTA.
Duration / Easing--reveal-duration / --reveal-ease in global.css :rootTransition length and timing curve.
Rise / Lateral distance--reveal-rise / --reveal-shift (same block)Vertical and horizontal travel.

Current values: TRIGGER_FROM_BOTTOM = 0.1445, REVEAL_TRIGGERS = { early: 0.05 }, REVEAL_STAGGER_MS = 120, REVEAL_MAX_DELAY_MS = 600, HERO_REVEAL_BASE_DELAY_MS = 0; visuals are --reveal-duration: 550ms, --reveal-rise: 24px, --reveal-shift: 40px.

animation-config.ts must stay dependency-free

src/lib/animation-config.ts has zero imports by design — it's imported from browser <script> blocks (Animations.astro and AnimDebug.astro), so a single server-only import would drag the Workers runtime into the client bundle. Keep it plain constants only.

Cascade timing lives in animation-config.ts because it's data the JS needs; duration/easing/distances live as CSS custom props in global.css because they're CSS values. Per-markup overrides exist for outliers — use sparingly: data-reveal-stagger="<ms>" on an ancestor (the logo wall sets 60 for its dense 6-up rows) and data-reveal-delay="<ms>" on a unit (the heroes stamp HERO_REVEAL_BASE_DELAY_MS; pricing tiers stamp one stagger beat so the synced row lands after the heading). Every code-only knob is registered in tunables.md.


No-JS and reduced-motion fallbacks

The hidden-state CSS is gated on a .js class added synchronously by an inline <script> in <head>. Without JS, .js [data-section-anim] [data-reveal] never matches, so everything renders at full opacity — no flash of hidden content.

prefers-reduced-motion: reduce strips transitions and transforms via @media with !important, and the runtime mirrors this by marking every unit visible immediately. Crucially, in reduced-motion mode the runtime marks all units — including nested ones, not just the observed top-level ones — so its internal state matches what the CSS forces. The drawn-in underlines are flipped to .is-drawn straight away in this path too. See the accessibility page for the broader motion policy.


AnimDebug is deletable dev tooling

AnimDebug.astro renders an editor toggle (bottom-right) that outlines every armed reveal unit and draws a magenta line per trigger tier (the default line plus one for each REVEAL_TRIGGERS entry, e.g. early), so you can see exactly where each unit fires while tuning. It reads TRIGGER_FROM_BOTTOM from the same config the runtime uses, so the indicator stays in sync.

Production reveals do not depend on it. To remove it entirely: delete AnimDebug.astro, then drop the import AnimDebug … line and the <AnimDebug /> render in Layout.astro. Its debug-only CSS ships inside the component's own <style is:global> block, so deleting the file removes the styles too; a leftover animDebug=1 in localStorage is harmless. (Its localStorage read is itself wrapped in a try/catch — access can throw in private mode, where it falls back to off.)


Wiring a new section

  1. Studio schema — add the animate boolean to the section's Styles group (copy it from any section schema; initialValue: true). SectionBase already declares animate?: boolean and SectionRenderer wraps every section, so there are no renderer, type, or query changes.
  2. Mark reveal units in the component per the placement rules above — <SectionHead> for a free heading unit, one data-reveal per conceptual block / grid cell, wrapped in a plain <div> if the element has its own transitions.
  3. That's it — no per-section JS. The observer discovers armed units at page load.

See Adding a section for the full section-creation checklist.


Where this lives

FileRole
website/src/lib/animation-config.tsCascade knobs (TRIGGER_FROM_BOTTOM, REVEAL_TRIGGERS, REVEAL_STAGGER_MS, REVEAL_MAX_DELAY_MS, HERO_REVEAL_BASE_DELAY_MS). Dependency-free.
website/src/components/Animations.astroProduction runtime: one IO per trigger line, batch stagger, per-section index reset, delay clearing, .heading-underline draw.
website/src/components/AnimatedSection.astroThe [data-section-anim] toggle wrapper (animate !== false).
website/src/components/AnimDebug.astroDev-only debug overlay + trigger lines — fully deletable.
website/src/components/SectionRenderer.astroWraps every section in AnimatedSection.
website/src/styles/global.css:root reveal vars + the [data-section-anim] [data-reveal] hidden states, directional / no-fade / no-move / breakpoint-scoped variants, and reduced-motion overrides.
website/src/sanity/types.tsSectionBase.animate?: boolean.
README.md (repo root)The operator's manual: how it works, tuning, wiring, debug-tool removal.
website/CLAUDE.md → "Scroll-in reveals" / studio/CLAUDE.md → "Per-section animations"The website-side contract and the schema-side animate field.

For the distinct GSAP headline entrance, see Hero headline animation; for the full registry of code-only knobs, Tunables & specs.

Previous
Components