← Components

Image Loader

A counter climbs to 100 while photos open in a stack and the last one fills the screen.

Loaders · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

3.2 s
Your code
<ImageLoader images={images} />

Changes show live and stay in the address, so a link keeps them.

Code

"use client";

import { useRef, type CSSProperties } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A full-screen intro loader. A big counter climbs from 0 to 100 while photos clip open one on top
// of the other in a tilted stack. When the counter reaches 100 the last photo opens as a window,
// grows to fill the whole box and a headline slides up over it.
//
// The whole thing is one GSAP timeline. Read it from top to bottom: every line says when something
// happens (the last number is the time in seconds, or a label like "reveal").

export type ImageLoaderProps = {
  /** The photos. All but the last one open in the stack, the last one grows into the full-screen hero. Two or more look best. */
  images: string[];
  /** How long the counter takes to climb from 0 to 100, in seconds. The reveal after it adds about two more. */
  duration?: number;
  /** Show the rolling counter (and the label above it). Turn it off for a counter-free photo intro. */
  counterDigits?: boolean;
  /** Where each photo starts opening from: "center" splits open from the middle line, "bottom" wipes up from its bottom edge. */
  clipFrom?: "bottom" | "center";
  /** The headline that slides up over the hero photo. */
  heroTitle?: string;
  /** Describes the hero photo for screen readers. Empty means the photo is decoration. */
  heroAlt?: string;
  /** The small word in the top corner while it loads. Empty hides it. */
  label?: string;
  /** Called once the headline has landed (or straight away when the visitor prefers reduced motion). */
  onComplete?: () => void;
  /** Extra classes for the outer box. Give it a size (it fills its parent). Add `bg-...!` to change the paper colour. */
  className?: string;
};

// How far each photo in the stack leans, in degrees. Used in turn, so any number of photos works.
const tilts = [-6, 4.5, -3.5, 5.5, -4.5, 2.5];

// Digits for the rolling columns. Each column is a tall strip of numbers that slides up behind a
// one-digit window. The ones column rolls through 20 digits, the tens through 10, the hundreds once.
const hundreds = [0, 1];
const tens = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
const ones = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];

// The photo frame is 34 by 44 (portrait). On a narrow box it shrinks so it never touches the sides.
const FRAME_RATIO = 44 / 34;
const frameWidth = (width: number, height: number) => Math.min(height * 0.34, width * 0.72);

// A clip path that shows the rectangle between two corners. We use polygon() rather than inset()
// because a polygon always has eight distinct numbers to tween, and the browser never rewrites it
// into a shorter form (it does that for inset(), which breaks the tween halfway).
const rect = (left: number, top: number, right: number, bottom: number, unit: "px" | "%") =>
  `polygon(${left}${unit} ${top}${unit}, ${right}${unit} ${top}${unit}, ${right}${unit} ${bottom}${unit}, ${left}${unit} ${bottom}${unit})`;

const photoOpen = rect(0, 0, 100, 100, "%");

export function ImageLoader({
  images,
  duration = 3.2,
  counterDigits = true,
  clipFrom = "center",
  heroTitle = "Welcome in",
  heroAlt = "",
  label = "Loading",
  onComplete,
  className = "",
}: ImageLoaderProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  // Always the latest callback, so a new function from the parent doesn't restart the loader
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  const stack = images.slice(0, -1);
  const hero = images[images.length - 1];
  const words = heroTitle.split(" ").filter(Boolean);
  // Photos start closed: a thin line across the middle, or a line along the bottom edge
  const photoClosed = clipFrom === "bottom" ? rect(0, 100, 100, 100, "%") : rect(0, 50, 100, 50, "%");

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root || !hero) return;
      const q = gsap.utils.selector(root);
      const photos = q(".il-photo");
      const photoImages = q(".il-photo img");
      const heroBox = q(".il-hero")[0];
      const heroImage = q(".il-hero img")[0];
      const shade = q(".il-shade")[0];
      const title = q(".il-title")[0];
      const titleWords = q(".il-word");
      const digits = q(".il-digit");
      const labelText = q(".il-label");
      if (!heroBox || !heroImage || !shade || !title) return;

      const mm = gsap.matchMedia();

      // Reduced motion: no counter, no stack. The hero is simply there with its headline.
      mm.add("(prefers-reduced-motion: reduce)", () => {
        gsap.set(photos, { autoAlpha: 0 });
        gsap.set(heroBox, { clipPath: "none" });
        gsap.set([...digits, ...labelText], { autoAlpha: 0 });
        gsap.set(shade, { opacity: 1 });
        gsap.set(title, { visibility: "visible" });
        gsap.set(titleWords, { yPercent: 0 });
        onCompleteRef.current?.();
      });

      mm.add("(prefers-reduced-motion: no-preference)", () => {
        // The hero starts as a window the size of one photo frame. Clip paths only tween smoothly
        // between values in the same unit, so the frame is measured in pixels here.
        const width = root.clientWidth;
        const height = root.clientHeight;
        const frame = frameWidth(width, height);
        const left = (width - frame) / 2;
        const top = (height - frame * FRAME_RATIO) / 2;
        const windowOpen = rect(left, top, width - left, height - top, "px");
        const windowClosed = clipFrom === "bottom" ? rect(left, height - top, width - left, height - top, "px") : rect(left, height / 2, width - left, height / 2, "px");
        const fullBox = rect(0, 0, width, height, "px");

        gsap.set(title, { visibility: "visible" });
        gsap.set(titleWords, { yPercent: 130 });

        // The photos should be ready before anything moves, or they'd pop in half way through
        const ready = Promise.race([
          Promise.all(q("img").map((img) => (img as HTMLImageElement).decode().catch(() => undefined))),
          new Promise((resolve) => setTimeout(resolve, 4000)),
        ]);
        let cancelled = false;

        const tl = gsap.timeline({
          paused: true,
          onComplete: () => {
            // Done: let the hero follow the box when it is resized
            gsap.set(heroBox, { clipPath: "none" });
            onCompleteRef.current?.();
          },
        });

        // The counter climbs for `duration` seconds. Everything below hangs off that one number.
        const climb = Math.max(duration, 0.8);
        // The photos open one after another, spread over two thirds of the climb
        const spread = { amount: climb * 0.66, ease: "power1.out" };

        if (photos.length > 0) {
          tl.fromTo(photos, { clipPath: photoClosed }, { clipPath: photoOpen, duration: 0.8, ease: "power4.out", stagger: spread }, 0)
            // Each photo also settles from slightly zoomed in, as it opens
            .fromTo(photoImages, { scale: 1.3 }, { scale: 1, duration: 1, ease: "power3.out", stagger: spread }, 0);
        }

        // Rolling counter: ones and tens spin for the whole climb (starting slow, ending fast),
        // the hundreds ticks over to 1 in the last moment
        const rolling = digits.length > 0;
        if (rolling) {
          tl.to(q(".il-ones span"), { yPercent: -2000, duration: climb, ease: "power1.in" }, 0)
            .to(q(".il-tens span"), { yPercent: -1000, duration: climb, ease: "power1.in" }, 0)
            .to(q(".il-hundreds span"), { yPercent: -100, duration: 0.4, ease: "power2.in" }, climb - 0.4);
        }

        // Just before 100 the hero opens as a thin window in the middle of the stack
        tl.fromTo(heroBox, { clipPath: windowClosed }, { clipPath: windowOpen, duration: 0.8, ease: "power4.out" }, climb - 0.8);

        // The reveal. "reveal" is the moment the counter hits 100.
        tl.addLabel("reveal", climb)
          // The window grows to the whole box...
          .to(heroBox, { clipPath: fullBox, duration: 1.2, ease: "power2.inOut" }, "reveal")
          // ...while the photo inside it settles from a zoom
          .fromTo(heroImage, { scale: 1.25 }, { scale: 1, duration: 1.6, ease: "power2.out" }, "reveal");
        if (rolling) {
          // The digits slide up and out one after another, and the label fades
          tl.to(digits, { yPercent: -100, duration: 0.5, ease: "power2.in", stagger: 0.04 }, "reveal+=0.2").to(labelText, { opacity: 0, duration: 0.4 }, "reveal+=0.2");
        }
        // A soft shade under the headline so white text reads on any photo
        tl.to(shade, { opacity: 1, duration: 1 }, "reveal+=0.4")
          // The headline slides up out of its mask, one word after another
          .to(titleWords, { yPercent: 0, duration: 1, ease: "power4.out", stagger: 0.06 }, "reveal+=0.8");

        ready.then(() => {
          if (!cancelled) tl.play();
        });

        return () => {
          cancelled = true;
          tl.kill();
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [images.join("|"), duration, counterDigits, clipFrom, heroTitle, label], revertOnUpdate: true },
  );

  if (!hero) return null;

  return (
    <div
      ref={rootRef}
      className={`relative h-full w-full overflow-hidden bg-[#f4f4ef] text-[#0b1220] dark:bg-[#0c0f14] dark:text-[#f4f4ef] ${className}`}
      // A size container, so everything below can be sized in cqh/cqw (a share of THIS box,
      // not of the browser window). That is what lets the loader sit inside any container.
      style={{ containerType: "size", "--il-frame": "min(34cqh, 72cqw)" } as CSSProperties}
    >
      {counterDigits && (
        <>
          {label && (
            <p role="status" className="il-label absolute top-[4cqh] left-[4cqw] m-0 text-[2.4cqh] leading-none font-medium text-[#8a93a3]">
              {label}
            </p>
          )}
          <div
            aria-hidden="true"
            className="absolute bottom-[4cqh] left-[4cqw] flex h-[1em] overflow-hidden pr-[0.1em] text-[15cqh] leading-none font-semibold tracking-[-0.05em] tabular-nums"
          >
            {[
              { name: "il-hundreds", list: hundreds },
              { name: "il-tens", list: tens },
              { name: "il-ones", list: ones },
            ].map(({ name, list }) => (
              // One column. The extra padding and negative margin stop the digit's edges being clipped.
              <div key={name} className={`il-digit ${name} -mr-[0.1em] h-[1em] overflow-hidden pr-[0.1em]`}>
                {list.map((digit, index) => (
                  <span key={index} className="block h-[1em]">
                    {digit}
                  </span>
                ))}
              </div>
            ))}
          </div>
        </>
      )}

      {stack.map((src, index) => (
        <div
          key={`${index}-${src}`}
          aria-hidden="true"
          className="il-photo absolute inset-0 m-auto"
          style={{ width: "var(--il-frame)", aspectRatio: "34 / 44", rotate: `${tilts[index % tilts.length]}deg`, clipPath: photoClosed }}
        >
          <img src={src} alt="" draggable={false} className="block h-full w-full object-cover" />
        </div>
      ))}

      {/* The hero photo always fills the box. Its clip path is the window that opens and grows. */}
      <div className="il-hero absolute inset-0" style={{ clipPath: rect(50, 50, 50, 50, "%") }}>
        <img src={hero} alt={heroAlt} draggable={false} className="absolute inset-0 h-full w-full object-cover" />
        <div className="il-shade absolute inset-0 opacity-0" style={{ background: "linear-gradient(transparent 40%, rgba(11, 18, 32, 0.6))" }} />
      </div>

      <h1
        className="il-title absolute bottom-[6cqh] left-[4cqw] m-0 max-w-[6em] text-[min(11cqh,15cqw)] leading-[1.05] font-semibold tracking-[-0.045em] text-white"
        style={{ visibility: "hidden" }}
      >
        {words.map((word, index) => (
          // Each word sits in its own mask (overflow hidden), so it slides up out of nothing
          <span key={index}>
            <span className="-mx-[0.1em] -mt-[0.06em] -mb-[0.14em] inline-block overflow-hidden px-[0.1em] pt-[0.06em] pb-[0.14em] align-top">
              <span className="il-word inline-block">{word}</span>
            </span>
            {index < words.length - 1 ? " " : null}
          </span>
        ))}
      </h1>
    </div>
  );
}

The component. Installs to components/dimaac/image-loader.tsx.

Install

With the shadcn CLI

npx shadcn@latest add https://ui.dimaac.com/r/image-loader.json

It adds components/dimaac/image-loader.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/image-loader.

By hand

npm install gsap @gsap/react
  1. Install the packages it uses: gsap, @gsap/react.
  2. Copy the component file below into components/dimaac/image-loader.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { ImageLoader } from "@/components/dimaac/image-loader" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
images (required)string[]-The photos. All but the last one open in the stack, the last one grows into the full-screen hero. Two or more look best.
durationnumber3.2How long the counter takes to climb from 0 to 100, in seconds. The reveal after it adds about two more.
counterDigitsbooleantrueShow the rolling counter (and the label above it). Turn it off for a counter-free photo intro.
clipFrom"bottom" | "center""center"Where each photo starts opening from: "center" splits open from the middle line, "bottom" wipes up from its bottom edge.
heroTitlestring"Welcome in"The headline that slides up over the hero photo.
heroAltstring""Describes the hero photo for screen readers. Empty means the photo is decoration.
labelstring"Loading"The small word in the top corner while it loads. Empty hides it.
onComplete() => void-Called once the headline has landed (or straight away when the visitor prefers reduced motion).
classNamestring""Extra classes for the outer box. Give it a size (it fills its parent). Add `bg-...!` to change the paper colour.

Use with AI

Agent prompt

Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.

Prompt
Add the Image Loader component (<ImageLoader />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/image-loader
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/image-loader.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/image-loader.json

Then use it like this:
import { ImageLoader } from "@/components/dimaac/image-loader";
<ImageLoader images={images} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/image-loader.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt

Files agents read

llms.txt
Every component and how to install it
image-loader.md
This page as Markdown: install, props and the full source
image-loader.json
The shadcn registry item

About

A full-screen intro loader. A big rolling counter climbs from 0 to 100 while photos clip open one after another in a tilted stack. At 100 the last photo opens as a window in the middle, grows to fill the whole box and a headline slides up over it, word by word. It is one GSAP timeline with a single duration to tune, sizes itself from its own box (so it fits any container, not just the window), waits for the photos to decode before it starts, and skips straight to the finished hero when the visitor prefers reduced motion. Built for the last chapter of the GSAP crash course.

Installs to
components/dimaac/image-loader.tsx
Uses
gsap, @gsap/react
Tags
gsap, loader, intro, counter, clip-path, images

Photo by rena, SHIHO, Viri Gutiérrez, Siren Media, Jupiter et Passagère® and Dona Mara on Lummi (Lummi License). Hero photo by Siren Media on Lummi (Lummi License).