← Components

Fashion Preloader

Tilted photo cards open one by one, a brand name rises and a counter runs to 100.

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

Customize

3.0 s
8 deg
#14161c
#e8e2d6
Your code
<FashionPreloader images={images} />

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

Code

"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";

if (typeof window !== "undefined") gsap.registerPlugin(SplitText, useGSAP);

// A full-box loading curtain. Tilted photo cards open up one after another, the brand name rises
// letter by letter with a counter beside it, then the cards shrink away and the curtain lifts to
// show whatever sits underneath. Put it inside a `relative` box that is as big as the page you reveal.

export type FashionPreloaderProps = {
  /** The photos, one per card (they stack in this order). Four to eight look best. */
  images: string[];
  /** The big word in the middle. */
  brand?: string;
  /** How long the counter takes to run from 0 to 100, in seconds. The whole intro lasts about 3 seconds longer. */
  duration?: number;
  /** How far the cards lean, in degrees. 8 is the original look. 0 stacks them straight. */
  tilt?: number;
  /** Show the 000 to 100 counter next to the brand. */
  counter?: boolean;
  /** The curtain colour. */
  background?: string;
  /** The colour of the brand name and counter. */
  color?: string;
  /** Called when the curtain starts to lift. Use it to start the entrance of the page underneath. */
  onReveal?: () => void;
  /** Called when the curtain has gone. */
  onComplete?: () => void;
  /** Extra classes for the curtain, e.g. the font. Give the brand a font here, it inherits it. */
  className?: string;
};

// Each card leans by this share of `tilt`, so the pile fans out in both directions.
// At tilt 8 these are the angles 8, -3, -10, 10, -7, 5.
const LEAN = [1, -0.375, -1.25, 1.25, -0.875, 0.625];

// Cards start as a small square in the middle of the photo and open up to the full card
const CLOSED = "polygon(20% 20%, 80% 20%, 80% 80%, 20% 80%)";
const OPEN = "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)";
// The curtain lifts by pulling its bottom edge up to the top
const CURTAIN_UP = "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)";

const RISE = { yPercent: 100, rotation: 10, transformOrigin: "0% 100%" };

export function FashionPreloader({
  images,
  brand = "DiMaac",
  duration = 3,
  tilt = 8,
  counter = true,
  background = "#14161c",
  color = "#e8e2d6",
  onReveal,
  onComplete,
  className = "",
}: FashionPreloaderProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const brandRef = useRef<HTMLDivElement>(null);
  const titleRef = useRef<HTMLDivElement>(null);
  const countRef = useRef<HTMLParagraphElement>(null);
  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
  // The callbacks are read when they fire, so passing a new function never restarts the intro
  const onRevealRef = useRef(onReveal);
  onRevealRef.current = onReveal;
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  useGSAP(
    () => {
      const root = rootRef.current;
      const brandBox = brandRef.current;
      const title = titleRef.current;
      const count = countRef.current;
      const cards = cardRefs.current.slice(0, images.length).filter((card): card is HTMLDivElement => card !== null);
      if (!root || !brandBox || !title) return;

      const angle = (i: number) => (LEAN[i % LEAN.length] ?? 0) * tilt;
      const finish = () => {
        root.setAttribute("aria-hidden", "true");
        gsap.set(root, { display: "none" });
        onCompleteRef.current?.();
      };

      const mm = gsap.matchMedia();

      // Reduced motion: no flying cards. The loader shows its finished frame for a moment,
      // fades out, and the page underneath takes over.
      mm.add("(prefers-reduced-motion: reduce)", () => {
        gsap.set(cards, { visibility: "visible", rotation: angle, xPercent: -50, yPercent: -50, clipPath: OPEN });
        gsap.set(brandBox, { visibility: "visible" });
        if (count) count.textContent = "100";
        const hold = gsap.timeline({ delay: Math.min(duration, 1.2) });
        hold.to(root, { opacity: 0, duration: 0.4, ease: "power1.out" });
        hold.call(() => onRevealRef.current?.(), [], 0);
        hold.call(finish);
      });

      mm.add("(prefers-reduced-motion: no-preference)", () => {
        // The letters sit in masks, so each one rises out of a clip and nothing shows below
        const split = SplitText.create(title, { type: "chars", mask: "chars" });
        // The tight letter spacing makes glyphs overhang their box, so pad the masks to keep the edges
        gsap.set(split.masks, { paddingInline: "0.08em", marginInline: "-0.08em" });

        gsap.set(cards, { xPercent: -50, yPercent: -50, scale: 0, rotation: angle, clipPath: CLOSED, visibility: "visible" });
        gsap.set(split.chars, RISE);
        if (count) gsap.set(count, { yPercent: 100 });

        // When every moment happens, in seconds. Cards open first, the counter starts a little later,
        // and the exit begins just after the counter reaches 100.
        const cardsOpenEnd = 1 + (cards.length - 1) * 0.2;
        const counterStart = 0.85;
        const exitAt = Math.max(counterStart + duration + 0.4, cardsOpenEnd + 0.25);
        const curtainAt = exitAt + 1.1;

        const tl = gsap.timeline({ paused: true, delay: 0.3 });
        tl.to(cards, { scale: 1, clipPath: OPEN, duration: 1, ease: "power3.inOut", stagger: 0.2 }, 0);

        tl.set(brandBox, { visibility: "visible" }, 0.35);
        tl.to(split.chars, { yPercent: 0, rotation: 0, duration: 1, ease: "power3.out", stagger: 0.04 }, 0.35);
        if (count) {
          tl.to(count, { yPercent: 0, duration: 1, ease: "power3.out" }, 0.35);
          const progress = { value: 0 };
          tl.to(
            progress,
            {
              value: 100,
              duration,
              ease: "power2.inOut",
              onUpdate: () => {
                count.textContent = String(Math.round(progress.value)).padStart(3, "0");
              },
            },
            counterStart,
          );
          tl.to(count, { yPercent: -100, duration: 0.75, ease: "power3.in" }, exitAt);
        }

        tl.to(split.chars, { yPercent: -100, rotation: -10, duration: 0.75, ease: "power3.in", stagger: 0.04 }, exitAt);
        tl.to(cards, { scale: 0, clipPath: CLOSED, duration: 1, ease: "power3.inOut", stagger: -0.075 }, exitAt + 0.25);
        tl.to(root, { clipPath: CURTAIN_UP, duration: 1, ease: "power3.inOut" }, curtainAt);
        tl.call(() => onRevealRef.current?.(), [], curtainAt + 0.3);
        tl.call(finish, [], curtainAt + 1);

        // Start once the photos have decoded (or after a moment), so a card never opens empty
        let cancelled = false;
        const photos = Array.from(root.querySelectorAll("img"));
        const ready = Promise.all(photos.map((image) => image.decode().catch(() => undefined)));
        const timeout = new Promise((resolve) => window.setTimeout(resolve, 1500));
        void Promise.race([ready, timeout]).then(() => {
          if (!cancelled) tl.play();
        });

        return () => {
          cancelled = true;
          tl.kill();
          split.revert();
          root.removeAttribute("aria-hidden");
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [images.join("|"), brand, duration, tilt, counter], revertOnUpdate: true },
  );

  return (
    <div
      ref={rootRef}
      role="status"
      aria-label={`Loading ${brand}`}
      className={`absolute inset-0 z-50 overflow-hidden leading-[0.85] uppercase ${className}`}
      style={{ background, color, clipPath: OPEN }}
    >
      {images.map((src, index) => (
        <div
          key={`${index}-${src}`}
          ref={(card) => {
            cardRefs.current[index] = card;
          }}
          aria-hidden="true"
          className="absolute top-1/2 left-1/2 aspect-[5/6] w-[clamp(160px,17vw,300px)]"
          style={{ visibility: "hidden", willChange: "transform, clip-path" }}
        >
          <img src={src} alt="" className="block h-full w-full object-cover" draggable={false} loading="eager" decoding="async" />
        </div>
      ))}

      <div ref={brandRef} aria-hidden="true" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" style={{ visibility: "hidden" }}>
        <div ref={titleRef} className="whitespace-nowrap" style={{ fontSize: `clamp(1.5rem, min(10vw, ${55 / Math.max(brand.length, 1)}vw), 15rem)` }}>
          {brand}
        </div>
        {counter ? (
          <div className="absolute top-[-1.5rem] left-[calc(100%+1.5rem)] overflow-hidden text-[clamp(1rem,1.5vw,2rem)]">
            <p ref={countRef} className="tabular-nums">
              000
            </p>
          </div>
        ) : null}
      </div>
    </div>
  );
}

The component. Installs to components/dimaac/fashion-preloader.tsx.

Install

With the shadcn CLI

npx shadcn@latest add https://ui.dimaac.com/r/fashion-preloader.json

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

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/fashion-preloader.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 { FashionPreloader } from "@/components/dimaac/fashion-preloader" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
images (required)string[]-The photos, one per card (they stack in this order). Four to eight look best.
brandstring"DiMaac"The big word in the middle.
durationnumber3How long the counter takes to run from 0 to 100, in seconds. The whole intro lasts about 3 seconds longer.
tiltnumber8How far the cards lean, in degrees. 8 is the original look. 0 stacks them straight.
counterbooleantrueShow the 000 to 100 counter next to the brand.
backgroundstring"#14161c"The curtain colour.
colorstring"#e8e2d6"The colour of the brand name and counter.
onReveal() => void-Called when the curtain starts to lift. Use it to start the entrance of the page underneath.
onComplete() => void-Called when the curtain has gone.
classNamestring""Extra classes for the curtain, e.g. the font. Give the brand a font here, it inherits it.

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 Fashion Preloader component (<FashionPreloader />) from DiMaac UI to this project.

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

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

Then use it like this:
import { FashionPreloader } from "@/components/dimaac/fashion-preloader";
<FashionPreloader images={images} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/fashion-preloader.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
fashion-preloader.md
This page as Markdown: install, props and the full source
fashion-preloader.json
The shadcn registry item

About

A full-page loading curtain for fashion, studio and portfolio sites. Tilted photo cards open from a small square one after another while the brand name rises letter by letter and a counter runs from 000 to 100. Then the letters lift away, the cards shrink back and the curtain pulls up to reveal the page, with onReveal and onComplete callbacks so the page underneath can animate in on cue. The counter time, the lean of the cards, the word and the colours are props. It waits for the photos to decode before it starts, cleans up its timeline and split text on unmount, and with reduced motion it shows one still frame and fades out. Remount it with a React key to play it again. Rebuilt from the YouTube Fashion preloader, with licence-clear photos.

Installs to
components/dimaac/fashion-preloader.tsx
Uses
gsap, @gsap/react
Tags
gsap, splittext, loader, preloader, intro, images

Orange visor portrait by West Kast on Lummi (Lummi License (free for personal and commercial use)). Grey suit portrait by Axel Photography on Lummi (Lummi License (free for personal and commercial use)). Chunky knit portrait by Semihan Dosunmu on Lummi (Lummi License (free for personal and commercial use)). Woven corset portrait by Semihan Dosunmu on Lummi (Lummi License (free for personal and commercial use)). Gold visor portrait by West Kast on Lummi (Lummi License (free for personal and commercial use)). Mid-air suit portrait by Axel Photography on Lummi (Lummi License (free for personal and commercial use)).