Skip to content

Next.js

In Next.js, GSAP and Lenis are installed as npm packages and dynamically imported on the client. The lib is initialised once from a small client component mounted in your root layout.

Terminal window
npm install alrdy-animate@alpha gsap lenis

gsap and lenis are declared as peer dependencies — they live in your project's node_modules, not bundled into alrdy-animate. This keeps the GSAP version under your control (e.g. for licence compliance with Club plugins, though Webflow's GSAP acquisition has made all Club plugins free).

Create a client component (e.g. app/_components/AlrdyInit.tsx for App Router or components/AlrdyInit.tsx for Pages Router):

'use client'
import { useEffect } from 'react'
import 'alrdy-animate/style'
export function AlrdyInit() {
useEffect(() => {
let cancelled = false
Promise.all([
import('gsap').then(m => { (window as any).gsap = m.gsap }),
import('gsap/ScrollTrigger').then(m => { (window as any).ScrollTrigger = m.ScrollTrigger }),
import('gsap/CustomEase').then(m => { (window as any).CustomEase = m.CustomEase }),
// Add only the plugins your app actually uses:
// import('gsap/SplitText').then(m => { (window as any).SplitText = m.SplitText }),
// import('gsap/Flip').then(m => { (window as any).Flip = m.Flip }),
// import('gsap/Draggable').then(m => { (window as any).Draggable = m.Draggable }),
// import('gsap/InertiaPlugin').then(m => { (window as any).InertiaPlugin = m.InertiaPlugin }),
import('lenis').then(m => { (window as any).Lenis = m.default }),
]).then(async () => {
if (cancelled) return
const { init, destroy } = await import('alrdy-animate')
await init({ debug: process.env.NODE_ENV !== 'production' })
;(window as any).__alrdyDestroy = destroy
})
return () => {
cancelled = true
;(window as any).__alrdyDestroy?.()
}
}, [])
return null
}

The import 'alrdy-animate/style' line is required. It carries the FOUC guard ([aa-animate]:not([aa-ready]) { visibility: hidden }) — without it, every animated element flashes briefly in its final state before init applies the from-states. It also supplies the layout rules for the hover heads ([aa-hover-bg] for block/curve, [aa-hover-underline] for the underline bars including its --aa-hover-underline-offset / --aa-hover-underline-thickness variables, and the :where([aa-hover~="text"]) nowrap rule for the text head) — drop the import and these effects render as static unstyled spans.

app/layout.tsx
import { AlrdyInit } from './_components/AlrdyInit'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AlrdyInit />
{children}
</body>
</html>
)
}
pages/_app.tsx
import type { AppProps } from 'next/app'
import { AlrdyInit } from '@/components/AlrdyInit'
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<AlrdyInit />
<Component {...pageProps} />
</>
)
}
Section titled “Slow-network + init fallback (recommended)”

AlrdyInit runs init() from inside a useEffectafter dynamically importing GSAP, Lenis, and the lib on the client. Until all of that lands and init() runs, the FOUC guard from alrdy-animate/style holds every [aa-animate] element at visibility: hidden. On a slow connection that's a visible delay; if a chunk fails to load or init() throws, the hero stays hidden indefinitely while the rest of the SSR'd page renders.

The slow-network + init-timeout fallback covers exactly this case, and it's worth wiring up anywhere you ship alrdy-animate/style. The one Next.js-specific rule: the fallback timer must be inline in the document <head> — not inside AlrdyInit. A timer that lives in the bundle can't fire until the bundle loads, which is the very thing it's there to beat.

See Using it in a bundled app for the App Router (app/layout.tsx) and Pages Router (_document.tsx) placement: an inline <style> carrying the fallback keyframe + reveal rules (these no longer ship in dist — only the FOUC guard and lcp floor do), a <script dangerouslySetInnerHTML> timer in the <head>, and the matching <noscript> reveal for the JS-disabled case.

alrdy-animate/jsx provides ambient TypeScript types so your editor autocompletes aa-animate, aa-trigger, etc. on every JSX intrinsic element. Add this anywhere that's compiled — typically at the top of your root layout or a dedicated types/global.d.ts:

types/global.d.ts
import 'alrdy-animate/jsx'

Then in your components:

<h1 aa-animate="text-slide-up" aa-split="lines mask" aa-trigger="load">
Headline
</h1>

Which aa-trigger value to pick depends on whether you're running visual page transitions:

SetupRecommended triggerWhy
No visual transition (simple SPA route changes)aa-trigger="load"Fires on every init() cycle — every <Link> click and back/forward nav animates the hero. load-once would skip on subsequent inits.
Visual page transition (recipe)aa-trigger="load-once event:enter"First cold visit fires via load-once; subsequent navigations wait for an aa:trigger event:enter dispatched from inside the enter timeline — keeps the content reveal in sync with the transition's visual peak instead of firing the instant init() runs (which is mid-transition, behind the overlay).

See aa-trigger for the full comparison of triggers.

Using with AI assistants (Claude Code, Cursor, etc.)

Section titled “Using with AI assistants (Claude Code, Cursor, etc.)”

alrdy-animate ships an AGENTS.md at the package root so AI coding assistants can learn the attribute syntax, trigger system, and feature plugins without web-fetching the docs. After npm install alrdy-animate, reference it from your project's own AI-instruction file:

<!-- AGENTS.md or CLAUDE.md at your repo root -->
@node_modules/alrdy-animate/AGENTS.md

The JSDoc on alrdy-animate/jsx (covered above) handles per-attribute hover docs in your editor; AGENTS.md is the broader reference — feature table with plugin requirements, lifecycle API, common recipes (including the Barba/page-transition wiring). Both stay version-pinned to the installed package, so upgrades automatically refresh the agent's knowledge.

For per-component custom GSAP, use a useGSAP hook (from @gsap/react) or a useEffect with manual cleanup. The lib's init() is mounted once at the app root and doesn't interfere with component-level GSAP — they run on separate sets of elements (aa-* for the lib; whatever selectors you target for your own).

'use client'
import { useEffect, useRef } from 'react'
export function HeroParallax() {
const ref = useRef<HTMLImageElement>(null)
useEffect(() => {
const gsap = (window as any).gsap
const ScrollTrigger = (window as any).ScrollTrigger
if (!gsap || !ScrollTrigger || !ref.current) return
const tween = gsap.to(ref.current, {
scale: 1.15,
ease: 'none',
scrollTrigger: { trigger: ref.current, scrub: 0.5 },
})
return () => {
tween.scrollTrigger?.kill()
tween.kill()
}
}, [])
return <img ref={ref} src="/hero.jpg" alt="" />
}

For better ergonomics, install @gsap/react and use the useGSAP hook — it auto-cleans tweens and ScrollTriggers when the component unmounts. Same lib, just nicer.

If your component-level GSAP needs the lib's named eases (smooth, osmo, ...) or wants to match its detected motion / viewport state, await ready() first and read from options:

'use client'
import { useEffect } from 'react'
import { ready, options } from 'alrdy-animate'
export function NavScrollEffect() {
useEffect(() => {
let cancelled = false
ready().then(() => {
if (cancelled) return
const { reducedMotion, duration } = options
const gsap = (window as any).gsap
gsap.to('.site-nav', {
ease: 'smooth', // CustomEase registered by init()
duration: reducedMotion ? 0.01 : duration,
})
})
return () => { cancelled = true }
}, [])
return null
}

ready() resolves immediately if init() has already completed; options is a live readonly snapshot (reducedMotion, optimizeMobile, breakpoints, duration, ease, distance, ...). See Custom GSAP scripts alongside v8 in AGENTS.md for the full interop pattern.

In App Router, the same AlrdyInit instance survives every navigation — only the route's content swaps in. New aa-* elements on the new route don't get scanned unless you re-run init(). The pattern: subscribe to usePathname() changes and destroy({ keepGlobals: true }) + init() on each change.

'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import 'alrdy-animate/style'
export function AlrdyInit() {
const pathname = usePathname()
useEffect(() => {
let cancelled = false
const run = async () => {
// First-mount: load peer deps once. Subsequent navigations skip this.
if (!(window as any).gsap) {
await Promise.all([
import('gsap').then(m => { (window as any).gsap = m.gsap }),
import('gsap/ScrollTrigger').then(m => { (window as any).ScrollTrigger = m.ScrollTrigger }),
import('gsap/CustomEase').then(m => { (window as any).CustomEase = m.CustomEase }),
import('lenis').then(m => { (window as any).Lenis = m.default }),
])
}
if (cancelled) return
const { init, destroy } = await import('alrdy-animate')
// Tear down per-route state (matchMedia contexts, ScrollTriggers,
// feature inits) but keep Lenis alive so scroll velocity survives
// the swap. Then re-init against the fresh DOM.
destroy({ keepGlobals: true })
await init({ debug: process.env.NODE_ENV !== 'production' })
}
run()
return () => { cancelled = true }
}, [pathname])
return null
}

keepGlobals: true leaves Lenis, the body scroll-state observer, and the scroll-target click handler alive across the cycle. init() re-scans the DOM on every cycle and re-attaches scroll-target / scroll-state listeners to the new route's elements — clicks on [aa-scroll-target] links on the new route work without further wiring.

For elements that should animate every time the user lands on the route (cold load, forward <Link> click, browser back/forward), use aa-trigger="load" (every init). Use aa-trigger="load-once" only when you want first-init-only behaviour — usually irrelevant in App Router setups.

For visual page transitions where the leaving and incoming pages both animate (Osmo stacked-cards, curtain wipes, shared elements, anything GSAP-driven), see the dedicated Next.js page transitions recipe. It covers:

  • The leave-before-push / enter-after-push state machine — Next's router.push is atomic (no Barba sync mode), so the timeline has to split at the "deck fully covering" boundary.
  • The snapshot + handoff pattern — clone the leaving <main> into an overlay slot for the leave (because the old DOM is about to unmount), then animate the real new <main> through the enter (because by then it's mounted).
  • Wiring init({ root: newMain }) and destroy({ keepGlobals: true }) into a pathname-driven useEffect.
  • Using <Link> for viewport-based RSC prefetch so the click→commit window is zero.
  • Dispatching aa:trigger events from inside the enter timeline for perfectly-timed content reveals.

A runnable copy lives at examples/nextjs-transition/ in the repo.

If you don't need a visual transition — just want alrdy-animate to re-scan the new route after navigation — the simpler SPA route changes pattern above is enough.