---
title: Fashion Preloader
description: Tilted photo cards open one by one, a brand name rises and a counter runs to 100.
category: Loaders
tier: free
url: https://ui.dimaac.com/components/fashion-preloader
registry: https://ui.dimaac.com/r/fashion-preloader.json
---

# Fashion Preloader

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.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/fashion-preloader.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/fashion-preloader.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/fashion-preloader.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/fashion-preloader`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

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.

## Usage

```tsx
import { FashionPreloader } from "@/components/dimaac/fashion-preloader";

<FashionPreloader images={images} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` (required) | `string[]` | - | The photos, one per card (they stack in this order). Four to eight look best. |
| `brand` | `string` | `"DiMaac"` | The big word in the middle. |
| `duration` | `number` | `3` | How long the counter takes to run from 0 to 100, in seconds. The whole intro lasts about 3 seconds longer. |
| `tilt` | `number` | `8` | How far the cards lean, in degrees. 8 is the original look. 0 stacks them straight. |
| `counter` | `boolean` | `true` | Show the 000 to 100 counter next to the brand. |
| `background` | `string` | `"#14161c"` | The curtain colour. |
| `color` | `string` | `"#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. |
| `className` | `string` | `""` | Extra classes for the curtain, e.g. the font. Give the brand a font here, it inherits it. |

## Credits

- Orange visor portrait: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License (free for personal and commercial use)
- Grey suit portrait: Axel Photography, Lummi (https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn), Lummi License (free for personal and commercial use)
- Chunky knit portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License (free for personal and commercial use)
- Woven corset portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-portrait-rjvlr), Lummi License (free for personal and commercial use)
- Gold visor portrait: West Kast, Lummi (https://www.lummi.ai/photo/futuristic-gold-profile-jnio5), Lummi License (free for personal and commercial use)
- Mid-air suit portrait: Axel Photography, Lummi (https://www.lummi.ai/photo/modern-fashion-pose-zsdav), Lummi License (free for personal and commercial use)

## Source

### fashion-preloader.tsx → `components/dimaac/fashion-preloader.tsx`

```tsx
"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>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useRef, useState } from "react";
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";
import { FashionPreloader, type FashionPreloaderProps } from "@/components/dimaac/fashion-preloader";

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

// A studio landing page. The preloader covers it, then lifts, and the big word rises into place.
// Photos: Lummi (Lummi License: free for personal and commercial use). See the credits for the photographers.

const photos = [
  "fashion/orange-visor-4x5.jpg",
  "fashion/grey-suit-stride-4x5.jpg",
  "fashion/chunky-knit-4x5.jpg",
  "fashion/woven-corset-4x5.jpg",
  "fashion/gold-visor-4x5.jpg",
  "fashion/mid-air-suit-4x5.jpg",
].map(media);

const titles = ["Orange visor", "Grey suit", "Chunky knit", "Woven corset", "Gold visor", "Mid-air suit"];

// The page under the curtain. It waits hidden, then animates in when the preloader says "reveal".
function Landing({ brand, revealed, onReplay, canReplay }: { brand: string; revealed: boolean; onReplay: () => void; canReplay: boolean }) {
  const pageRef = useRef<HTMLDivElement>(null);
  const headingRef = useRef<HTMLHeadingElement>(null);

  useGSAP(
    () => {
      const heading = headingRef.current;
      if (!heading) return;
      const fromBelow = { yPercent: 100, rotation: 10, transformOrigin: "0% 100%" };
      const mm = gsap.matchMedia();
      // Reduced motion leaves plain text: no split, no masks, nothing to clip
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const split = SplitText.create(heading, { 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" });
        if (!revealed) {
          gsap.set(split.chars, fromBelow);
          gsap.set("[data-fade]", { opacity: 0, y: 14 });
        } else {
          gsap.fromTo(split.chars, fromBelow, { yPercent: 0, rotation: 0, duration: 1, ease: "power3.out", stagger: 0.04 });
          gsap.fromTo("[data-fade]", { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.9, ease: "power3.out", stagger: 0.08, delay: 0.35 });
        }
        return () => split.revert();
      });
      return () => mm.revert();
    },
    { scope: pageRef, dependencies: [revealed, brand], revertOnUpdate: true },
  );

  return (
    <div ref={pageRef} className="absolute inset-0 flex flex-col overflow-hidden bg-[#e8e2d6] text-[#14161c]">
      <div className="relative flex flex-1 items-center justify-center pt-10">
        <h1
          ref={headingRef}
          className="leading-[0.85] font-medium tracking-[-0.045em] whitespace-nowrap uppercase"
          style={{ fontSize: `clamp(1.5rem, min(22vw, ${130 / Math.max(brand.length, 1)}vw), 26rem)` }}
        >
          {brand}
        </h1>
      </div>

      <footer data-fade className="relative z-10 flex items-center justify-center gap-2 px-6 pt-4 pb-24 md:gap-3 md:px-10 md:pt-6 md:pb-20">
        {photos.map((photo, i) => (
          <figure key={photo} className="w-[13%] max-w-[120px] min-w-[44px]">
            <img src={photo} alt={titles[i]} draggable={false} className="aspect-[4/5] w-full rounded-[3px] object-cover" />
            <figcaption className="mt-2 text-[10px] leading-none font-medium tracking-[0.06em] uppercase opacity-60 md:text-[11px]">No. {String(i + 1).padStart(2, "0")}</figcaption>
          </figure>
        ))}
      </footer>

      <button
        type="button"
        onClick={onReplay}
        tabIndex={canReplay ? 0 : -1}
        className={`absolute bottom-6 left-1/2 z-10 -translate-x-1/2 cursor-pointer rounded-full border border-[#14161c]/30 bg-[#e8e2d6] px-4 py-2 text-[12px] font-medium tracking-[0.04em] uppercase transition-opacity duration-300 hover:bg-[#14161c] hover:text-[#e8e2d6] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#14161c] ${canReplay ? "opacity-100" : "pointer-events-none opacity-0"}`}
      >
        Replay
      </button>
    </div>
  );
}

export default function Demo(props: Partial<FashionPreloaderProps>) {
  // A new key builds both the curtain and the page again, which is how you replay it
  const [run, setRun] = useState(0);
  const [revealed, setRevealed] = useState(false);
  const [done, setDone] = useState(false);
  const brand = props.brand ?? "DiMaac";

  const replay = () => {
    setRevealed(false);
    setDone(false);
    setRun((n) => n + 1);
  };

  return (
    <div className="relative h-full w-full overflow-hidden bg-[#e8e2d6]" style={{ fontFamily: '"Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif', fontWeight: 500 }}>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500&display=swap" precedence="default" />
      <Landing key={`page-${run}`} brand={brand} revealed={revealed} onReplay={replay} canReplay={done} />
      <FashionPreloader
        key={`loader-${run}`}
        images={photos}
        {...props}
        className={`font-medium tracking-[-0.04em] ${props.className ?? ""}`}
        onReveal={() => {
          setRevealed(true);
          props.onReveal?.();
        }}
        onComplete={() => {
          setDone(true);
          props.onComplete?.();
        }}
      />
    </div>
  );
}
```

## Agent prompt

```text
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
```
