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:
Components mark reveal units. An element that should enter as one unit carries
data-reveal(the default: rise + fade), ordata-reveal="left"/data-reveal="right"for the lateral entrances used by side-by-side columns.SectionHead.astrocarries it on its root<header>, so every consuming section's heading is automatically its own unit; card grids put it on each cell.The section toggle arms them.
AnimatedSection.astrorenders 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.astrowraps every section uniformly, so there are no short-circuit branches; a section likemarqueeSectionthat contains nodata-revealjust gets an inert wrapper.The observer cascades them.
Animations.astroruns oneIntersectionObserverper 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 staggeredtransition-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 order —
IntersectionObserverentry order isn't guaranteed to be DOM order, so it sorts viacompareDocumentPositionso 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_MSas an inline style, capped atREVEAL_MAX_DELAY_MSso a dense section can't push its last unit absurdly late. A unit's owndata-reveal-delayis 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:
| Where | Behavior |
|---|---|
| Sticky scroll | Image 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. |
| About | Portrait from the left, bio from the right, at lg+. |
| Content + image | Each column from its own side (follows Image position) at lg+. |
| Pricing tiers | All 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/early → data-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-revealon an element a background-hairline trick depends on. The Receipts section's reveal unit is an inner div so itsgap-pxgrid 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-revealoff 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.
| Knob | Source of truth | Controls |
|---|---|---|
| Trigger line | TRIGGER_FROM_BOTTOM in animation-config.ts | Default fire line as a fraction of viewport height from the bottom. Larger = fires later. |
| Trigger tiers | REVEAL_TRIGGERS (same file) | Named alternate lines opted into via data-reveal-trigger. early exists for tall sections. |
| Cascade step | REVEAL_STAGGER_MS (same file) | Delay between sibling units in one batch. |
| Cascade cap | REVEAL_MAX_DELAY_MS (same file) | Ceiling on accumulated stagger per batch. |
| Hero base delay | HERO_REVEAL_BASE_DELAY_MS (same file) | Extra base delay on the heroes' supporting copy/CTA. |
| Duration / Easing | --reveal-duration / --reveal-ease in global.css :root | Transition 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
- Studio schema — add the
animateboolean to the section's Styles group (copy it from any section schema;initialValue: true).SectionBasealready declaresanimate?: booleanandSectionRendererwraps every section, so there are no renderer, type, or query changes. - Mark reveal units in the component per the placement rules above —
<SectionHead>for a free heading unit, onedata-revealper conceptual block / grid cell, wrapped in a plain<div>if the element has its own transitions. - 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
| File | Role |
|---|---|
website/src/lib/animation-config.ts | Cascade knobs (TRIGGER_FROM_BOTTOM, REVEAL_TRIGGERS, REVEAL_STAGGER_MS, REVEAL_MAX_DELAY_MS, HERO_REVEAL_BASE_DELAY_MS). Dependency-free. |
website/src/components/Animations.astro | Production runtime: one IO per trigger line, batch stagger, per-section index reset, delay clearing, .heading-underline draw. |
website/src/components/AnimatedSection.astro | The [data-section-anim] toggle wrapper (animate !== false). |
website/src/components/AnimDebug.astro | Dev-only debug overlay + trigger lines — fully deletable. |
website/src/components/SectionRenderer.astro | Wraps 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.ts | SectionBase.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.