Performance

Download only what a page uses, animate only what the GPU can move cheaply, and adapt to the device.

What it is

Three things decide whether motion feels smooth. Bytes: Three.js and Rive are large, so this lab loads them with next/dynamic only when a demo nears the screen. Frame budget: at 60 frames a second each frame has about 16 ms; animating transform and opacity stays on the GPU, while layout properties make the browser recalculate the page every frame. The device: no WebGL, dense screens, few cores, or a preference for reduced motion all call for less.

When to use it

  • Lazy-load any library a page doesn't need on first paint
  • Animate transform and opacity; avoid width, height, top, left
  • Pause what's off screen; respect reduced motion
Official docs ↗

1. What lazy loading saved

Before this phase, every page in the lab downloaded Three.js and Rive, because Next.js prefetches the code of every page linked in the sidebar and those pages imported them directly. Now each heavy demo is a separate chunk, loaded when it nears the viewport.

PageBeforeAfter
Home page598 KB227 KB
A GSAP page598 KB227 KB
A Rive page598 KB292 KB + Rive runtime (WASM)
A Three.js page598 KB470–540 KB

What remains everywhere: about 140 KB of Next.js and React, and 79 KB of GSAP. GSAP stays eager on purpose: it's small, and lazy-loading it would make animations start late on the pages built around them.

2. Watch it happen on this page

JavaScript…
WASM, .riv, .glb…
Requests…

3. The frame budget: transform vs layout

x / y (transform) · – fps

left / top (layout) · – fps

On a fast machine both may hold 60 fps; on a phone, or with many more dots, the layout version drops first. Turn on Show FPS for the whole page.

4. This device

Checking this device…

Show code · src/components/Deferred.tsx
"use client";

import { type ReactNode, useEffect, useRef, useState } from "react";
import { cx } from "./ui";

// A placeholder the same size as the demo it stands in for, so nothing jumps
// when the real thing arrives (no layout shift).
export function DemoPlaceholder({ label, className = "h-95" }: { label: string; className?: string }) {
  return (
    <div className={cx("mb-3 grid place-items-center rounded-xl border border-dashed border-line bg-bg text-sm text-muted", className)}>
      Loading {label}…
    </div>
  );
}

// Renders its children only once they come near the viewport. Combined with
// next/dynamic, a heavy library (Three.js, Rive) is downloaded only when its
// demo is about to be seen, not when the page loads, and not when another
// page prefetches this one.
export function WhenVisible({ placeholder, children, margin = "300px" }: { placeholder: ReactNode; children: ReactNode; margin?: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [show, setShow] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el || show) return;
    const observer = new IntersectionObserver(([entry]) => entry.isIntersecting && setShow(true), { rootMargin: margin });
    observer.observe(el);
    return () => observer.disconnect();
  }, [show, margin]);
  return show ? <>{children}</> : <div ref={ref}>{placeholder}</div>;
}
Show code · src/app/three-basics/lazy.tsx
"use client";

import dynamic from "next/dynamic";
import type { ComponentProps } from "react";
import { DemoPlaceholder, WhenVisible } from "@/components/Deferred";

// The heavy demo code (and the library it imports) is split into its own
// chunk: downloaded only when the demo nears the viewport, never by a
// prefetch of this page. ssr: false because it only runs in the browser.

const SceneChunk = dynamic(() => import("./Scene").then((m) => m.Scene), {
  ssr: false,
  loading: () => <DemoPlaceholder label="3D scene" className="h-95" />,
});

export function Scene(props: ComponentProps<typeof SceneChunk>) {
  return (
    <WhenVisible placeholder={<DemoPlaceholder label="3D scene" className="h-95" />}>
      <SceneChunk {...props} />
    </WhenVisible>
  );
}
Show code · src/app/performance/FrameBudget.tsx
"use client";

import { useRef, useState } from "react";
import { useMotion } from "@/components/motion/MotionProvider";
import { Button, Stage } from "@/components/ui";
import { gsap, useGSAP } from "@/lib/gsap";

const DOTS = 300;
type Mode = "transform" | "layout";

// The same animation two ways. Animating `x` (a transform) lets the browser
// move already-painted layers on the GPU. Animating `left`/`top` changes
// layout, so every frame the browser recalculates positions and repaints.
// The FPS counters show what each costs on this device.
function Field({ mode, running }: { mode: Mode; running: boolean }) {
  const stage = useRef<HTMLDivElement>(null);
  const [fps, setFps] = useState<number | null>(null);

  useGSAP(() => {
    if (!running) return;
    // Random spots anywhere in the field (re-picked on every repeat).
    const w = () => gsap.utils.random(0, (stage.current?.clientWidth ?? 300) - 10);
    const h = () => gsap.utils.random(0, (stage.current?.clientHeight ?? 200) - 10);
    const props = mode === "transform" ? { x: w, y: h } : { left: w, top: h };
    gsap.to(".dot", { ...props, duration: 1, repeat: -1, repeatRefresh: true, ease: "sine.inOut", stagger: { each: 0.004 } });

    // Count frames for this field while it runs.
    let frames = 0;
    let last = performance.now();
    const tick = () => {
      frames++;
      const now = performance.now();
      if (now - last >= 500) {
        setFps(Math.round((frames * 1000) / (now - last)));
        frames = 0;
        last = now;
      }
    };
    gsap.ticker.add(tick);
    return () => gsap.ticker.remove(tick);
  }, { scope: stage, dependencies: [mode, running], revertOnUpdate: true });

  return (
    <div>
      <p className="mb-1 text-sm"><code>{mode === "transform" ? "x / y (transform)" : "left / top (layout)"}</code> · <strong className="tabular-nums">{running ? (fps ?? "…") : "–"} fps</strong></p>
      <Stage ref={stage} className="h-52">
        {Array.from({ length: DOTS }, (_, i) => (
          <span key={i} className="dot absolute top-0 left-0 size-2 rounded-full bg-accent" style={{ opacity: 0.35 + (i % 7) / 10 }} />
        ))}
      </Stage>
    </div>
  );
}

export function FrameBudget() {
  const { reduced } = useMotion();
  const [running, setRunning] = useState(false);
  return (
    <>
      <div className="grid gap-3 md:grid-cols-2">
        <Field mode="transform" running={running} />
        <Field mode="layout" running={running} />
      </div>
      <Button onClick={() => setRunning(!running)}>{running ? "Stop" : `Animate ${DOTS * 2} dots`}</Button>
      {reduced && <p className="text-sm text-muted">Reduced motion is on: nothing moves until you press the button.</p>}
    </>
  );
}