Animation System
Scroll and intro animations are driven by declarative data-* attributes rather than
bespoke GSAP code written per section. To add a scroll effect to a section, add one of
the attributes below to a JSX element — don’t write a new useGSAP call for it.
Architecture
lib/gsap.ts— registers GSAP plugins once (useGSAP,ScrollTrigger,ScrollSmoother,SplitText) and re-exports them. Import GSAP from here, not directly fromgsap.SiteMotionProvider(components/animations/SiteMotionProvider.tsx) — a React context (useSiteMotion) holding shared state:headerRef(the header DOM node),smootherRef(the liveScrollSmootherinstance), and ascrollTo(target, duration?)helper that smooth-scrolls to an in-page anchor, briefly disabling the header’s scroll-driven animation while it does.SiteAnimations(components/animations/SiteAnimations.tsx) — mounted once bySiteShell. It owns theScrollSmootherwrapper/content DOM structure (#smooth-wrapper/#smooth-content) and contains the singleuseGSAPblock that scans its scope for everydata-*attribute below and wires up the matching effect. This runs once fonts are ready (gated ondocument.fonts.ready), and on cleanup it reverts allSplitTextinstances, kills allScrollTriggers, and removes the intro-animation-blockinggsap-intro-pendingclass.
data-* attribute reference
| Attribute | Where it goes | What it does |
|---|---|---|
data-page-intro-animation | Wraps a section that plays an entrance animation on load | Root marker; scoped children below animate once fonts are ready |
data-page-intro-animation-unmask-text | On a text element inside a data-page-intro-animation root | Splits the text into lines and reveals them with a masked upward reveal (SplitText, yPercent from -unmask-y-percent, default 120) |
data-page-intro-animation-move-up | On any element inside a data-page-intro-animation root | Fades/slides the element up on load (staggered) |
data-highlight-text-on-scroll | On a text block | Splits into words and fades each word in on scroll (scrubbed) as the block enters the viewport |
data-unmask-text-on-scroll | On a text block | Same masked-line reveal as the intro variant, but scroll-scrubbed instead of load-triggered |
data-move-up-on-scroll | On any element | Fades/slides the element up as it scrolls into view (scrubbed) |
data-parallax-image-on-scroll | Wraps an <img> | Parallax-shifts the image vertically as its container scrolls |
data-highlight-on-scroll | Wraps a set of a[href^="#"] links + matching headings | Marks the link matching the currently-in-view heading with data-highlight-on-scroll-active-link (for TOC-style active-state highlighting) |
data-pinned-side | Wraps content that should pin while a sibling column scrolls | Pins the element under the header while its data-pinned-side-container ancestor scrolls past; supports data-pinned-side-screen (sm/md/lg/xl/2xl) to only pin above a breakpoint |
data-pinned-side-container | The scroll-length ancestor for a data-pinned-side element | Defines how long the pin lasts |
Most attributes accept -start / -end scrub-position overrides (e.g.
data-move-up-on-scroll-start, data-move-up-on-scroll-end) and numeric tuning
attributes (e.g. -y-percent, -stagger, -duration) — read the relevant block in
SiteAnimations.tsx for the exact defaults before overriding one.
Example
<div data-move-up-on-scroll data-move-up-on-scroll-start="top 90%">
<h2 data-highlight-text-on-scroll>This fades in word by word on scroll.</h2>
</div>Adding a new scroll effect
Prefer adding a new data-* attribute consumed by the existing useGSAP block in
SiteAnimations.tsx over writing a bespoke effect elsewhere. This keeps every scroll
animation discoverable in one file and means new sections opt into effects by attribute,
not by importing animation code.
The intro-pending class
app/layout.tsx puts a gsap-intro-pending class on <html> from the very first paint:
<html lang="en" className={`${instrumentSans.variable} gsap-intro-pending`}>While present, this CSS rule in app/globals.css keeps intro-animation targets invisible
so they don’t flash in unanimated before GSAP is ready:
html.gsap-intro-pending [data-page-intro-animation-unmask-text],
html.gsap-intro-pending [data-page-intro-animation-move-up] {
opacity: 0;
}SiteAnimations removes the class itself once fonts are ready and the intro useGSAP
block runs (document.documentElement.classList.remove("gsap-intro-pending")). Don’t
remove this class from layout.tsx without also updating this CSS rule — doing so
would make intro-animated content flash visible before its animation plays.
Smooth scrolling and in-page anchors
SiteAnimations wires up ScrollSmoother and intercepts clicks on same-page anchor
links (a[href^="#"]) inside its scope, routing them through scrollTo instead of the
browser’s native jump. If you add a custom anchor link outside this flow, use the
useSmoothAnchorClick() hook (exported from SiteAnimations.tsx) as its onClick
handler to keep it consistent with the rest of the site.