{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-loader","type":"registry:component","title":"Image Loader","description":"A counter climbs to 100 while photos open in a stack and the last one fills the screen.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/image-loader/image-loader.tsx","type":"registry:component","target":"components/dimaac/image-loader.tsx","content":"\"use client\";\n\nimport { useRef, type CSSProperties } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A full-screen intro loader. A big counter climbs from 0 to 100 while photos clip open one on top\n// of the other in a tilted stack. When the counter reaches 100 the last photo opens as a window,\n// grows to fill the whole box and a headline slides up over it.\n//\n// The whole thing is one GSAP timeline. Read it from top to bottom: every line says when something\n// happens (the last number is the time in seconds, or a label like \"reveal\").\n\nexport type ImageLoaderProps = {\n  /** 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. */\n  images: string[];\n  /** How long the counter takes to climb from 0 to 100, in seconds. The reveal after it adds about two more. */\n  duration?: number;\n  /** Show the rolling counter (and the label above it). Turn it off for a counter-free photo intro. */\n  counterDigits?: boolean;\n  /** Where each photo starts opening from: \"center\" splits open from the middle line, \"bottom\" wipes up from its bottom edge. */\n  clipFrom?: \"bottom\" | \"center\";\n  /** The headline that slides up over the hero photo. */\n  heroTitle?: string;\n  /** Describes the hero photo for screen readers. Empty means the photo is decoration. */\n  heroAlt?: string;\n  /** The small word in the top corner while it loads. Empty hides it. */\n  label?: string;\n  /** Called once the headline has landed (or straight away when the visitor prefers reduced motion). */\n  onComplete?: () => void;\n  /** Extra classes for the outer box. Give it a size (it fills its parent). Add `bg-...!` to change the paper colour. */\n  className?: string;\n};\n\n// How far each photo in the stack leans, in degrees. Used in turn, so any number of photos works.\nconst tilts = [-6, 4.5, -3.5, 5.5, -4.5, 2.5];\n\n// Digits for the rolling columns. Each column is a tall strip of numbers that slides up behind a\n// one-digit window. The ones column rolls through 20 digits, the tens through 10, the hundreds once.\nconst hundreds = [0, 1];\nconst tens = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];\nconst ones = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];\n\n// The photo frame is 34 by 44 (portrait). On a narrow box it shrinks so it never touches the sides.\nconst FRAME_RATIO = 44 / 34;\nconst frameWidth = (width: number, height: number) => Math.min(height * 0.34, width * 0.72);\n\n// A clip path that shows the rectangle between two corners. We use polygon() rather than inset()\n// because a polygon always has eight distinct numbers to tween, and the browser never rewrites it\n// into a shorter form (it does that for inset(), which breaks the tween halfway).\nconst rect = (left: number, top: number, right: number, bottom: number, unit: \"px\" | \"%\") =>\n  `polygon(${left}${unit} ${top}${unit}, ${right}${unit} ${top}${unit}, ${right}${unit} ${bottom}${unit}, ${left}${unit} ${bottom}${unit})`;\n\nconst photoOpen = rect(0, 0, 100, 100, \"%\");\n\nexport function ImageLoader({\n  images,\n  duration = 3.2,\n  counterDigits = true,\n  clipFrom = \"center\",\n  heroTitle = \"Welcome in\",\n  heroAlt = \"\",\n  label = \"Loading\",\n  onComplete,\n  className = \"\",\n}: ImageLoaderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  // Always the latest callback, so a new function from the parent doesn't restart the loader\n  const onCompleteRef = useRef(onComplete);\n  onCompleteRef.current = onComplete;\n\n  const stack = images.slice(0, -1);\n  const hero = images[images.length - 1];\n  const words = heroTitle.split(\" \").filter(Boolean);\n  // Photos start closed: a thin line across the middle, or a line along the bottom edge\n  const photoClosed = clipFrom === \"bottom\" ? rect(0, 100, 100, 100, \"%\") : rect(0, 50, 100, 50, \"%\");\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      if (!root || !hero) return;\n      const q = gsap.utils.selector(root);\n      const photos = q(\".il-photo\");\n      const photoImages = q(\".il-photo img\");\n      const heroBox = q(\".il-hero\")[0];\n      const heroImage = q(\".il-hero img\")[0];\n      const shade = q(\".il-shade\")[0];\n      const title = q(\".il-title\")[0];\n      const titleWords = q(\".il-word\");\n      const digits = q(\".il-digit\");\n      const labelText = q(\".il-label\");\n      if (!heroBox || !heroImage || !shade || !title) return;\n\n      const mm = gsap.matchMedia();\n\n      // Reduced motion: no counter, no stack. The hero is simply there with its headline.\n      mm.add(\"(prefers-reduced-motion: reduce)\", () => {\n        gsap.set(photos, { autoAlpha: 0 });\n        gsap.set(heroBox, { clipPath: \"none\" });\n        gsap.set([...digits, ...labelText], { autoAlpha: 0 });\n        gsap.set(shade, { opacity: 1 });\n        gsap.set(title, { visibility: \"visible\" });\n        gsap.set(titleWords, { yPercent: 0 });\n        onCompleteRef.current?.();\n      });\n\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        // The hero starts as a window the size of one photo frame. Clip paths only tween smoothly\n        // between values in the same unit, so the frame is measured in pixels here.\n        const width = root.clientWidth;\n        const height = root.clientHeight;\n        const frame = frameWidth(width, height);\n        const left = (width - frame) / 2;\n        const top = (height - frame * FRAME_RATIO) / 2;\n        const windowOpen = rect(left, top, width - left, height - top, \"px\");\n        const windowClosed = clipFrom === \"bottom\" ? rect(left, height - top, width - left, height - top, \"px\") : rect(left, height / 2, width - left, height / 2, \"px\");\n        const fullBox = rect(0, 0, width, height, \"px\");\n\n        gsap.set(title, { visibility: \"visible\" });\n        gsap.set(titleWords, { yPercent: 130 });\n\n        // The photos should be ready before anything moves, or they'd pop in half way through\n        const ready = Promise.race([\n          Promise.all(q(\"img\").map((img) => (img as HTMLImageElement).decode().catch(() => undefined))),\n          new Promise((resolve) => setTimeout(resolve, 4000)),\n        ]);\n        let cancelled = false;\n\n        const tl = gsap.timeline({\n          paused: true,\n          onComplete: () => {\n            // Done: let the hero follow the box when it is resized\n            gsap.set(heroBox, { clipPath: \"none\" });\n            onCompleteRef.current?.();\n          },\n        });\n\n        // The counter climbs for `duration` seconds. Everything below hangs off that one number.\n        const climb = Math.max(duration, 0.8);\n        // The photos open one after another, spread over two thirds of the climb\n        const spread = { amount: climb * 0.66, ease: \"power1.out\" };\n\n        if (photos.length > 0) {\n          tl.fromTo(photos, { clipPath: photoClosed }, { clipPath: photoOpen, duration: 0.8, ease: \"power4.out\", stagger: spread }, 0)\n            // Each photo also settles from slightly zoomed in, as it opens\n            .fromTo(photoImages, { scale: 1.3 }, { scale: 1, duration: 1, ease: \"power3.out\", stagger: spread }, 0);\n        }\n\n        // Rolling counter: ones and tens spin for the whole climb (starting slow, ending fast),\n        // the hundreds ticks over to 1 in the last moment\n        const rolling = digits.length > 0;\n        if (rolling) {\n          tl.to(q(\".il-ones span\"), { yPercent: -2000, duration: climb, ease: \"power1.in\" }, 0)\n            .to(q(\".il-tens span\"), { yPercent: -1000, duration: climb, ease: \"power1.in\" }, 0)\n            .to(q(\".il-hundreds span\"), { yPercent: -100, duration: 0.4, ease: \"power2.in\" }, climb - 0.4);\n        }\n\n        // Just before 100 the hero opens as a thin window in the middle of the stack\n        tl.fromTo(heroBox, { clipPath: windowClosed }, { clipPath: windowOpen, duration: 0.8, ease: \"power4.out\" }, climb - 0.8);\n\n        // The reveal. \"reveal\" is the moment the counter hits 100.\n        tl.addLabel(\"reveal\", climb)\n          // The window grows to the whole box...\n          .to(heroBox, { clipPath: fullBox, duration: 1.2, ease: \"power2.inOut\" }, \"reveal\")\n          // ...while the photo inside it settles from a zoom\n          .fromTo(heroImage, { scale: 1.25 }, { scale: 1, duration: 1.6, ease: \"power2.out\" }, \"reveal\");\n        if (rolling) {\n          // The digits slide up and out one after another, and the label fades\n          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\");\n        }\n        // A soft shade under the headline so white text reads on any photo\n        tl.to(shade, { opacity: 1, duration: 1 }, \"reveal+=0.4\")\n          // The headline slides up out of its mask, one word after another\n          .to(titleWords, { yPercent: 0, duration: 1, ease: \"power4.out\", stagger: 0.06 }, \"reveal+=0.8\");\n\n        ready.then(() => {\n          if (!cancelled) tl.play();\n        });\n\n        return () => {\n          cancelled = true;\n          tl.kill();\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: rootRef, dependencies: [images.join(\"|\"), duration, counterDigits, clipFrom, heroTitle, label], revertOnUpdate: true },\n  );\n\n  if (!hero) return null;\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative h-full w-full overflow-hidden bg-[#f4f4ef] text-[#0b1220] dark:bg-[#0c0f14] dark:text-[#f4f4ef] ${className}`}\n      // A size container, so everything below can be sized in cqh/cqw (a share of THIS box,\n      // not of the browser window). That is what lets the loader sit inside any container.\n      style={{ containerType: \"size\", \"--il-frame\": \"min(34cqh, 72cqw)\" } as CSSProperties}\n    >\n      {counterDigits && (\n        <>\n          {label && (\n            <p role=\"status\" className=\"il-label absolute top-[4cqh] left-[4cqw] m-0 text-[2.4cqh] leading-none font-medium text-[#8a93a3]\">\n              {label}\n            </p>\n          )}\n          <div\n            aria-hidden=\"true\"\n            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\"\n          >\n            {[\n              { name: \"il-hundreds\", list: hundreds },\n              { name: \"il-tens\", list: tens },\n              { name: \"il-ones\", list: ones },\n            ].map(({ name, list }) => (\n              // One column. The extra padding and negative margin stop the digit's edges being clipped.\n              <div key={name} className={`il-digit ${name} -mr-[0.1em] h-[1em] overflow-hidden pr-[0.1em]`}>\n                {list.map((digit, index) => (\n                  <span key={index} className=\"block h-[1em]\">\n                    {digit}\n                  </span>\n                ))}\n              </div>\n            ))}\n          </div>\n        </>\n      )}\n\n      {stack.map((src, index) => (\n        <div\n          key={`${index}-${src}`}\n          aria-hidden=\"true\"\n          className=\"il-photo absolute inset-0 m-auto\"\n          style={{ width: \"var(--il-frame)\", aspectRatio: \"34 / 44\", rotate: `${tilts[index % tilts.length]}deg`, clipPath: photoClosed }}\n        >\n          <img src={src} alt=\"\" draggable={false} className=\"block h-full w-full object-cover\" />\n        </div>\n      ))}\n\n      {/* The hero photo always fills the box. Its clip path is the window that opens and grows. */}\n      <div className=\"il-hero absolute inset-0\" style={{ clipPath: rect(50, 50, 50, 50, \"%\") }}>\n        <img src={hero} alt={heroAlt} draggable={false} className=\"absolute inset-0 h-full w-full object-cover\" />\n        <div className=\"il-shade absolute inset-0 opacity-0\" style={{ background: \"linear-gradient(transparent 40%, rgba(11, 18, 32, 0.6))\" }} />\n      </div>\n\n      <h1\n        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\"\n        style={{ visibility: \"hidden\" }}\n      >\n        {words.map((word, index) => (\n          // Each word sits in its own mask (overflow hidden), so it slides up out of nothing\n          <span key={index}>\n            <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\">\n              <span className=\"il-word inline-block\">{word}</span>\n            </span>\n            {index < words.length - 1 ? \" \" : null}\n          </span>\n        ))}\n      </h1>\n    </div>\n  );\n}\n"}],"categories":["loaders","gsap","loader","intro","counter","clip-path","images"],"docs":"import { ImageLoader } from \"@/components/dimaac/image-loader\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/image-loader","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/image-loader","credits":[{"by":"rena","url":"https://www.lummi.ai/photo/modern-architectural-design-aozka","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/minimalist-rustic-interior-qppyl","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Siren Media","url":"https://www.lummi.ai/photo/minimalist-stucco-building-gurez","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Jupiter et Passagère®","url":"https://www.lummi.ai/photo/modern-minimalist-building-kumrj","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Dona Mara","url":"https://www.lummi.ai/photo/abstract-sculptural-objects-flp-3","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Siren Media","url":"https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl","what":"Hero photo","source":"Lummi","licence":"Lummi License"}]}}