Getting Started
Scroll Animations
Text Animations
Hover & Cursor
Sliders & Carousels
Galleries & Grids
Menus & Navigation
Preloaders & Intros
Page Transitions
3D & WebGL
Rich motion is only worth shipping if it holds its frame rate on an ordinary phone and never locks anyone out. These are the rules Tween components follow, and the ones to keep when you adapt them.
Motion code copied from demos tends to fail in the same places. Check for these first:
prefers-reduced-motion handling at all.<button>, Escape key, focus management or scroll lock.width, height, top, left or gap; creating new tweens every frame; global selectors inside React components.user-select: none on text, empty alt on content images, and multi-megabyte unoptimised images and models.Check the preference inside the effect and show the final composition when motion is reduced. The reduced version may use short fades (≤ 0.3 s), but no counters, no image cycling and no idle canvas animation.
React.useEffect(() => {
const root = ref.current
if (!root) return
const reduceMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
if (reduceMotion) {
gsap.set(root.querySelectorAll("[data-line]"), { yPercent: 0 })
return
}
// ...full animation
}, [])Make sure the reduced branch still lands on a complete, readable layout. A section that was meant to reveal content on scroll must not leave it hidden.
Each effect returns a cleanup that undoes all of its work. Tween components use plain useEffect with either a scoped gsap.context or explicit kills:
React.useEffect(() => {
const root = containerRef.current
if (!root) return
const ctx = gsap.context(() => {
// tweens and ScrollTriggers created here are recorded
}, root)
const onResize = () => ScrollTrigger.refresh()
window.addEventListener("resize", onResize)
return () => {
window.removeEventListener("resize", onResize)
ctx.revert() // kills tweens, triggers and pins, restores inline styles
}
}, [activeScroller])The cleanup has to cover every kind of side effect: tweens and timelines, ScrollTriggers, SplitText instances (split.revert()), event listeners, gsap.ticker callbacks, requestAnimationFrame loops, observers, and WebGL resources.
Scope selectors to the component root (root.querySelectorAll(...), or the gsap.context scope) so two copies of the same component on one page never animate each other's elements.
Attach pointer, wheel and touch listeners to the component's own element, not to window or document, and remove them in cleanup. That keeps a hover effect from reacting while the pointer is over a different section, and it makes the component safe to render twice.
{ passive: true } unless the component genuinely owns the gesture (a draggable slider, for example) and has to call preventDefault().touchmove for a decorative effect. Use CSS touch-action to declare which gestures the element handles.Pointer-follow and hover effects run only on devices that can hover with a precise pointer. On touch devices, show a static or auto-playing version, never a blank space.
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matchesEvery interactive row, card or link is a real <a href> or <button>, and keyboard focus runs the same enter and leave code as the pointer:
React.useEffect(() => {
const root = listRef.current
if (!root) return
const items = Array.from(root.querySelectorAll<HTMLElement>("[data-item]"))
const cleanups = items.map((item) => {
const enter = () =>
gsap.to(item, { x: 16, duration: 0.5, ease: "power2.out" })
const leave = () =>
gsap.to(item, { x: 0, duration: 0.5, ease: "power2.out" })
item.addEventListener("pointerenter", enter)
item.addEventListener("pointerleave", leave)
item.addEventListener("focusin", enter)
item.addEventListener("focusout", leave)
return () => {
item.removeEventListener("pointerenter", enter)
item.removeEventListener("pointerleave", leave)
item.removeEventListener("focusin", enter)
item.removeEventListener("focusout", leave)
gsap.killTweensOf(item)
}
})
return () => cleanups.forEach((fn) => fn())
}, [])See Hover Preview List and Image Trail Cursor for complete hover components.
transform, opacity and clip-path. Use masks sparingly. Do not tween top, left, width or height; move or scale the element instead, or use the Flip plugin to animate between layouts.gsap.quickTo or gsap.quickSetter and call it on each move. Never create a new tween per pointermove.will-change: transform only to elements that are actually animating, and only for as long as they animate.A follower written as x += (target - x) * 0.1 moves twice as fast at 120 Hz as at 60 Hz. Scale the factor by elapsed time:
const rate = 8 // higher is snappier
let x = 0
let targetX = 0
const tick = (_time: number, deltaMs: number) => {
const k = 1 - Math.exp(-rate * (deltaMs / 1000))
x += (targetX - x) * k
gsap.set(follower, { x })
}
gsap.ticker.add(tick)
// cleanup: gsap.ticker.remove(tick)Treat every canvas as a resource with a lifecycle:
IntersectionObserver when the canvas leaves the screen, and on visibilitychange when the tab is hidden. When resuming, account for the paused time so animations do not jump.Math.min(window.devicePixelRatio, 2), or 1.5 on coarse pointers.try/catch, and keep a real <img alt="..."> underneath so the content survives without WebGL.React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
let visible = false
let frame = 0
const loop = () => {
render()
frame = requestAnimationFrame(loop)
}
const start = () => {
if (!frame && visible && !document.hidden)
frame = requestAnimationFrame(loop)
}
const stop = () => {
cancelAnimationFrame(frame)
frame = 0
}
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting
visible ? start() : stop()
})
io.observe(canvas)
const onVisibility = () => (document.hidden ? stop() : start())
document.addEventListener("visibilitychange", onVisibility)
return () => {
stop()
io.disconnect()
document.removeEventListener("visibilitychange", onVisibility)
disposeScene()
}
}, [])Canvas components such as Fractal Glass Hero, Fluid Cursor Canvas and Ripple Displacement Slider are good candidates for this treatment when you place several on one page.
document.fonts.ready and the hero image's decode()), with a 2.5 s ceiling and an error path.sessionStorage).next/image with priority) so it is the page's largest contentful paint.See Loader Reveal Hero.
<button> with aria-expanded and aria-controls, and its label switches between "Menu" and "Close".inert. Mark the menu itself inert while closed.See Fullscreen Overlay Menu and Push Over Menu.
<main> an id and tabindex="-1", and move focus to the new page's <h1> after the transition.aria-live="polite" region.aria-hidden and inert.<h1> per page.aria-label that carries the real string.user-select: none on text. Give content images real alt text, and mark decorative clones and canvases aria-hidden.next/font, not a CSS @import.next/image with AVIF or WebP, sized to the largest on-screen size at 2× pixel density.playsinline, with a poster frame.Per page, on a mid-range phone over 4G:
| Metric | Target |
|---|---|
| Largest Contentful Paint | ≤ 2.5 s |
| Cumulative Layout Shift | ≤ 0.05 |
| Interaction to Next Paint | ≤ 200 ms |
| Motion JavaScript before WebGL chunks, gzip | ≤ 120 KB |
| WebGL code | loaded on demand |
Back to Motion Principles.