{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"fashion-preloader","type":"registry:component","title":"Fashion Preloader","description":"Tilted photo cards open one by one, a brand name rises and a counter runs to 100.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/fashion-preloader/fashion-preloader.tsx","type":"registry:component","target":"components/dimaac/fashion-preloader.tsx","content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { SplitText } from \"gsap/SplitText\";\nimport { useGSAP } from \"@gsap/react\";\n\nif (typeof window !== \"undefined\") gsap.registerPlugin(SplitText, useGSAP);\n\n// A full-box loading curtain. Tilted photo cards open up one after another, the brand name rises\n// letter by letter with a counter beside it, then the cards shrink away and the curtain lifts to\n// show whatever sits underneath. Put it inside a `relative` box that is as big as the page you reveal.\n\nexport type FashionPreloaderProps = {\n  /** The photos, one per card (they stack in this order). Four to eight look best. */\n  images: string[];\n  /** The big word in the middle. */\n  brand?: string;\n  /** How long the counter takes to run from 0 to 100, in seconds. The whole intro lasts about 3 seconds longer. */\n  duration?: number;\n  /** How far the cards lean, in degrees. 8 is the original look. 0 stacks them straight. */\n  tilt?: number;\n  /** Show the 000 to 100 counter next to the brand. */\n  counter?: boolean;\n  /** The curtain colour. */\n  background?: string;\n  /** The colour of the brand name and counter. */\n  color?: string;\n  /** Called when the curtain starts to lift. Use it to start the entrance of the page underneath. */\n  onReveal?: () => void;\n  /** Called when the curtain has gone. */\n  onComplete?: () => void;\n  /** Extra classes for the curtain, e.g. the font. Give the brand a font here, it inherits it. */\n  className?: string;\n};\n\n// Each card leans by this share of `tilt`, so the pile fans out in both directions.\n// At tilt 8 these are the angles 8, -3, -10, 10, -7, 5.\nconst LEAN = [1, -0.375, -1.25, 1.25, -0.875, 0.625];\n\n// Cards start as a small square in the middle of the photo and open up to the full card\nconst CLOSED = \"polygon(20% 20%, 80% 20%, 80% 80%, 20% 80%)\";\nconst OPEN = \"polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)\";\n// The curtain lifts by pulling its bottom edge up to the top\nconst CURTAIN_UP = \"polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)\";\n\nconst RISE = { yPercent: 100, rotation: 10, transformOrigin: \"0% 100%\" };\n\nexport function FashionPreloader({\n  images,\n  brand = \"DiMaac\",\n  duration = 3,\n  tilt = 8,\n  counter = true,\n  background = \"#14161c\",\n  color = \"#e8e2d6\",\n  onReveal,\n  onComplete,\n  className = \"\",\n}: FashionPreloaderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const brandRef = useRef<HTMLDivElement>(null);\n  const titleRef = useRef<HTMLDivElement>(null);\n  const countRef = useRef<HTMLParagraphElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  // The callbacks are read when they fire, so passing a new function never restarts the intro\n  const onRevealRef = useRef(onReveal);\n  onRevealRef.current = onReveal;\n  const onCompleteRef = useRef(onComplete);\n  onCompleteRef.current = onComplete;\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const brandBox = brandRef.current;\n      const title = titleRef.current;\n      const count = countRef.current;\n      const cards = cardRefs.current.slice(0, images.length).filter((card): card is HTMLDivElement => card !== null);\n      if (!root || !brandBox || !title) return;\n\n      const angle = (i: number) => (LEAN[i % LEAN.length] ?? 0) * tilt;\n      const finish = () => {\n        root.setAttribute(\"aria-hidden\", \"true\");\n        gsap.set(root, { display: \"none\" });\n        onCompleteRef.current?.();\n      };\n\n      const mm = gsap.matchMedia();\n\n      // Reduced motion: no flying cards. The loader shows its finished frame for a moment,\n      // fades out, and the page underneath takes over.\n      mm.add(\"(prefers-reduced-motion: reduce)\", () => {\n        gsap.set(cards, { visibility: \"visible\", rotation: angle, xPercent: -50, yPercent: -50, clipPath: OPEN });\n        gsap.set(brandBox, { visibility: \"visible\" });\n        if (count) count.textContent = \"100\";\n        const hold = gsap.timeline({ delay: Math.min(duration, 1.2) });\n        hold.to(root, { opacity: 0, duration: 0.4, ease: \"power1.out\" });\n        hold.call(() => onRevealRef.current?.(), [], 0);\n        hold.call(finish);\n      });\n\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        // The letters sit in masks, so each one rises out of a clip and nothing shows below\n        const split = SplitText.create(title, { type: \"chars\", mask: \"chars\" });\n        // The tight letter spacing makes glyphs overhang their box, so pad the masks to keep the edges\n        gsap.set(split.masks, { paddingInline: \"0.08em\", marginInline: \"-0.08em\" });\n\n        gsap.set(cards, { xPercent: -50, yPercent: -50, scale: 0, rotation: angle, clipPath: CLOSED, visibility: \"visible\" });\n        gsap.set(split.chars, RISE);\n        if (count) gsap.set(count, { yPercent: 100 });\n\n        // When every moment happens, in seconds. Cards open first, the counter starts a little later,\n        // and the exit begins just after the counter reaches 100.\n        const cardsOpenEnd = 1 + (cards.length - 1) * 0.2;\n        const counterStart = 0.85;\n        const exitAt = Math.max(counterStart + duration + 0.4, cardsOpenEnd + 0.25);\n        const curtainAt = exitAt + 1.1;\n\n        const tl = gsap.timeline({ paused: true, delay: 0.3 });\n        tl.to(cards, { scale: 1, clipPath: OPEN, duration: 1, ease: \"power3.inOut\", stagger: 0.2 }, 0);\n\n        tl.set(brandBox, { visibility: \"visible\" }, 0.35);\n        tl.to(split.chars, { yPercent: 0, rotation: 0, duration: 1, ease: \"power3.out\", stagger: 0.04 }, 0.35);\n        if (count) {\n          tl.to(count, { yPercent: 0, duration: 1, ease: \"power3.out\" }, 0.35);\n          const progress = { value: 0 };\n          tl.to(\n            progress,\n            {\n              value: 100,\n              duration,\n              ease: \"power2.inOut\",\n              onUpdate: () => {\n                count.textContent = String(Math.round(progress.value)).padStart(3, \"0\");\n              },\n            },\n            counterStart,\n          );\n          tl.to(count, { yPercent: -100, duration: 0.75, ease: \"power3.in\" }, exitAt);\n        }\n\n        tl.to(split.chars, { yPercent: -100, rotation: -10, duration: 0.75, ease: \"power3.in\", stagger: 0.04 }, exitAt);\n        tl.to(cards, { scale: 0, clipPath: CLOSED, duration: 1, ease: \"power3.inOut\", stagger: -0.075 }, exitAt + 0.25);\n        tl.to(root, { clipPath: CURTAIN_UP, duration: 1, ease: \"power3.inOut\" }, curtainAt);\n        tl.call(() => onRevealRef.current?.(), [], curtainAt + 0.3);\n        tl.call(finish, [], curtainAt + 1);\n\n        // Start once the photos have decoded (or after a moment), so a card never opens empty\n        let cancelled = false;\n        const photos = Array.from(root.querySelectorAll(\"img\"));\n        const ready = Promise.all(photos.map((image) => image.decode().catch(() => undefined)));\n        const timeout = new Promise((resolve) => window.setTimeout(resolve, 1500));\n        void Promise.race([ready, timeout]).then(() => {\n          if (!cancelled) tl.play();\n        });\n\n        return () => {\n          cancelled = true;\n          tl.kill();\n          split.revert();\n          root.removeAttribute(\"aria-hidden\");\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: rootRef, dependencies: [images.join(\"|\"), brand, duration, tilt, counter], revertOnUpdate: true },\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"status\"\n      aria-label={`Loading ${brand}`}\n      className={`absolute inset-0 z-50 overflow-hidden leading-[0.85] uppercase ${className}`}\n      style={{ background, color, clipPath: OPEN }}\n    >\n      {images.map((src, index) => (\n        <div\n          key={`${index}-${src}`}\n          ref={(card) => {\n            cardRefs.current[index] = card;\n          }}\n          aria-hidden=\"true\"\n          className=\"absolute top-1/2 left-1/2 aspect-[5/6] w-[clamp(160px,17vw,300px)]\"\n          style={{ visibility: \"hidden\", willChange: \"transform, clip-path\" }}\n        >\n          <img src={src} alt=\"\" className=\"block h-full w-full object-cover\" draggable={false} loading=\"eager\" decoding=\"async\" />\n        </div>\n      ))}\n\n      <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\" }}>\n        <div ref={titleRef} className=\"whitespace-nowrap\" style={{ fontSize: `clamp(1.5rem, min(10vw, ${55 / Math.max(brand.length, 1)}vw), 15rem)` }}>\n          {brand}\n        </div>\n        {counter ? (\n          <div className=\"absolute top-[-1.5rem] left-[calc(100%+1.5rem)] overflow-hidden text-[clamp(1rem,1.5vw,2rem)]\">\n            <p ref={countRef} className=\"tabular-nums\">\n              000\n            </p>\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n"}],"categories":["loaders","gsap","splittext","loader","preloader","intro","images"],"docs":"import { FashionPreloader } from \"@/components/dimaac/fashion-preloader\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/fashion-preloader","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/fashion-preloader","credits":[{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Orange visor portrait","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"Axel Photography","url":"https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn","what":"Grey suit portrait","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo","what":"Chunky knit portrait","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-portrait-rjvlr","what":"Woven corset portrait","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"West Kast","url":"https://www.lummi.ai/photo/futuristic-gold-profile-jnio5","what":"Gold visor portrait","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"Axel Photography","url":"https://www.lummi.ai/photo/modern-fashion-pose-zsdav","what":"Mid-air suit portrait","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"}]}}