Getting Started
Scroll Animations
Text Animations
Hover & Cursor
Sliders & Carousels
Galleries & Grids
Menus & Navigation
Preloaders & Intros
Page Transitions
3D & WebGL
Tween components share one motion vocabulary. When you build your own sections next to them, use the same curves, durations and sequencing rules so the page reads as one piece of work instead of a collage of demos.
Rule of thumb: one set of eases, one timing scale, one timeline per scene. Everything below is a way of keeping to those three.
Pick one strong in-out curve and use it for every large move: preloaders, page transitions, menus and panels. A steep start and a long, soft landing reads as deliberate and calm.
import { gsap } from "gsap"
import { CustomEase } from "gsap/CustomEase"
if (typeof window !== "undefined") {
gsap.registerPlugin(CustomEase)
}
// Register once, at module level, with a name that is unlikely to collide.
CustomEase.create("tween-hop", "0.9, 0, 0.1, 1")
CustomEase.create("tween-glide", "0.8, 0, 0.2, 1")The CSS and Web Animations twin of tween-hop is cubic-bezier(0.87, 0, 0.13, 1). Use it for hover fills, clip-path transitions and anything you write in plain CSS so both layers move the same way.
Give custom eases unique names. Many snippets on the web register a curve called hop, and two different hop curves on the same page silently overwrite each other.
| Job | Ease | Typical duration |
|---|---|---|
| Text lines rising from a mask | power3.out or power4.out | 0.75–1 s |
| Panels, menus, overlays | power3.inOut or power4.inOut | 1–1.25 s |
| Long decelerations | expo.out | 1–1.5 s |
| Fades, progress bars | power2.inOut | 0.3–0.6 s |
| Big wipes, shared elements | your signature curve | 1–1.75 s |
| Scrubbed scroll, marquees | "none" | set by scroll |
Keep elastic and back.out for physics toys. On editorial layouts they look like a bounce bug.
A scrubbed tween should use ease: "none" so it tracks the scrollbar exactly. When a scrubbed value needs to feel softer, shape the progress yourself instead of easing the tween:
const smoothstep = (p: number) => p * p * (3 - 2 * p)
ScrollTrigger.create({
trigger: section,
scrub: true,
onUpdate: (self) => {
gsap.set(image, { scale: 1.2 - 0.2 * smoothstep(self.progress) })
},
})Or put the eased tween inside a scrubbed timeline: the timeline stays linear, the child keeps its own curve.
| Element | Duration | Stagger |
|---|---|---|
| Text lines | 1 s | 0.1 s |
| Words | 0.75–1 s | 0.05–0.075 s |
| Characters | 0.5–1 s | 0.015–0.05 s |
| Random "flicker" reveal | 0.05 s per step | { amount: 0.5, from: "random" } |
| UI entrances | 0.6–1 s | 0.05–0.1 s |
| Hover micro-feedback | 0.25 s | — |
| Hover state change | 0.5 s | — |
| Hover expansion | 0.75 s | — |
| Hero zooms | 1.5–2.5 s | — |
| Page transitions | ≤ 1.2 s felt in total | — |
| Preloader to readable hero | ≤ 2.5 s | — |
Two numbers matter more than the rest:
Treat every section as a 100svh scene that does one thing: a statement, a reveal, a stack, a gallery. Bookend each pinned section with a calm intro and outro so the user always has a resting state before the next effect starts.
Write a scene as a single timeline with labels rather than a pile of independent tweens with delays. Labels make retiming a one-line change:
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 1 } })
tl.addLabel("frame")
.to(frame, { clipPath: "inset(0% 0% 0% 0%)", ease: "tween-hop" }, "frame")
.addLabel("copy", "frame+=0.6")
.to(lines, { yPercent: 0, stagger: 0.1 }, "copy")
.to(cta, { autoAlpha: 1, duration: 0.6 }, "copy+=0.3")When one sequence has to wait for another (a preloader handing off to a hero, a transition cover handing off to a route change), expose the moment as a promise or a timeline callback. Do not guess with a hard-coded delay; the next person to change the preloader length will break the hero.
function playIntro(): Promise<void> {
return new Promise((resolve) => {
gsap
.timeline({ onComplete: resolve })
.to(overlay, { clipPath: "inset(0% 0% 100% 0%)", ease: "tween-hop" })
})
}
await playIntro()
revealHero()The same rule applies to page transitions: navigate only when the cover is fully closed, from the cover tween's onComplete, then reset scroll and call ScrollTrigger.refresh() before uncovering.
Enable hover and pointer effects only after a section's intro has finished. A hover that fires halfway through an entrance fights the timeline and leaves elements in mixed states.
Most Tween headings use one pattern: split into lines, mask each line, and slide the lines up from below. See Text Reveal Scroll and Block Text Reveal for complete components.
"use client"
import * as React from "react"
import { gsap } from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { SplitText } from "gsap/SplitText"
import { usePreviewScroller } from "@/registry/lib/preview-scroller"
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, SplitText)
}
export function LineReveal({
children,
scroller,
}: {
children: React.ReactNode
scroller?: HTMLElement | string | null
}) {
const ref = React.useRef<HTMLHeadingElement>(null)
const ambientScroller = usePreviewScroller()
const activeScroller = scroller ?? ambientScroller
React.useEffect(() => {
const el = ref.current
if (!el) return
const reduceMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
if (reduceMotion) return // the heading is already in its final state
let split: SplitText | null = null
let cancelled = false
// Split after fonts load so line breaks match the final layout.
document.fonts.ready.then(() => {
if (cancelled) return
split = SplitText.create(el, {
type: "lines",
mask: "lines",
autoSplit: true,
onSplit(self) {
// Returning the tween lets SplitText kill it on re-split.
return gsap.from(self.lines, {
yPercent: 100,
duration: 1,
stagger: 0.1,
ease: "power4.out",
scrollTrigger: {
scroller: activeScroller ?? undefined,
trigger: el,
start: "top 80%",
once: true,
},
})
},
})
})
return () => {
cancelled = true
split?.revert()
}
}, [activeScroller])
return <h2 ref={ref}>{children}</h2>
}Notes on the pattern:
onSplit. With autoSplit, SplitText re-splits on resize. Returning the animation lets it kill the old one; otherwise triggers stack up on every resize.mask: "lines" or "words" for headlines and body copy, mask: "chars" for short brand words, and exit through the same mask with yPercent: -100.text-indent lands on every line. Move it to the first line as padding-left and set the element's indent to 0, the way TextRevealScroll does."words,chars", rotate characters from rotationX: -90 with transformOrigin: "50% 50% -50px", and give the parent perspective: 700px.revert() on unmount. It restores the original text node for screen readers and for React.Motion lands best on a layout that is built for it.
clamp(4rem, 12vw, 18rem) against 10–16 px labels. Tight leading (0.8–0.95) and slightly negative tracking suit display type; medium-weight sentence case reads calmer than heavy all-caps.#0f0f0f), a warm off-white second surface, and a single accent. Keep the UI monochrome and let the imagery carry the colour.mix-blend-mode: difference for fixed navigation over mixed backgrounds.Next: Scroll and Smooth Scroll covers how these timelines connect to the scrollbar.