{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"film-split-loader","type":"registry:component","title":"Film Split Loader","description":"Loader words spread apart and a tilted card grows between them into the full-screen picture.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/film-split-loader/film-split-loader.tsx","type":"registry:component","target":"components/dimaac/film-split-loader.tsx","content":"\"use client\";\n\nimport { useRef, type ReactNode } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A loader that opens like a film title. Two words sit in the middle of the screen, then they\n// spread apart and a small tilted card pops up between them. The card straightens, grows to fill\n// the whole box and shows the picture, and the title rises word by word on top of it.\n//\n// Colours come from CSS variables so the host page can theme it (see className):\n//   --film-bg  the loader background        --film-fg  the loader words\n//   --film-on  text and header on the image\n\nexport type FilmSplitLoaderProps = {\n  /** The two loader words: one goes left of the card, one goes right. */\n  words?: [string, string];\n  /** The picture the card grows into. */\n  src: string;\n  /** Alt text for the picture. Leave empty if it is only decoration. */\n  alt?: string;\n  /** How much the card is tilted while it pops up, in degrees. It straightens as it grows. */\n  rotate?: number;\n  /** How long the whole opening takes, in seconds (from the first word to the full-screen picture). */\n  duration?: number;\n  /** How far apart the two words spread, as a percentage of the box width. On narrow boxes it grows so the card always fits. */\n  gap?: number;\n  /** The big line that rises over the picture once it is full screen. */\n  title?: string;\n  /** The smaller line under the title. */\n  subtitle?: string;\n  /** Your own header or navigation, shown over the picture. It fades in while the card grows. */\n  children?: ReactNode;\n  /** Called when the title has finished rising. */\n  onComplete?: () => void;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n};\n\n// How big the card is at the start, as a share of the box width, for a few box widths\nfunction cardSize(boxWidth: number) {\n  if (boxWidth <= 480) return { w: 0.35, h: 0.5 };\n  if (boxWidth <= 768) return { w: 0.3, h: 0.4 };\n  if (boxWidth <= 1024) return { w: 0.18, h: 0.25 };\n  return { w: 0.1, h: 0.15 };\n}\n\n// Each word sits in its own clipped box so it can slide up from below the line\nfunction RiseWords({ text, className }: { text: string; className: string }) {\n  return (\n    <p className={`overflow-hidden ${className}`}>\n      {text.split(\" \").map((word, index, all) => (\n        <span key={`${index}-${word}`} data-rise className=\"inline-block\">\n          {word}\n          {index < all.length - 1 ? \" \" : \"\"}\n        </span>\n      ))}\n    </p>\n  );\n}\n\nexport function FilmSplitLoader({\n  words = [\"Di\", \"Maac\"],\n  src,\n  alt = \"\",\n  rotate = -8,\n  duration = 2.4,\n  gap = 20,\n  title,\n  subtitle,\n  children,\n  onComplete,\n  className = \"\",\n}: FilmSplitLoaderProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const loaderRef = useRef<HTMLDivElement>(null);\n  const wordsRef = useRef<HTMLDivElement>(null);\n  const cardRef = useRef<HTMLDivElement>(null);\n  const imageRef = useRef<HTMLImageElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  // Read when the timeline finishes, so a new callback doesn't replay the opening\n  const onCompleteRef = useRef(onComplete);\n  onCompleteRef.current = onComplete;\n\n  useGSAP(\n    () => {\n      const box = boxRef.current;\n      const loader = loaderRef.current;\n      const heading = wordsRef.current;\n      const card = cardRef.current;\n      const image = imageRef.current;\n      const content = contentRef.current;\n      if (!box || !loader || !heading || !card || !image || !content) return;\n      const riseWords = gsap.utils.toArray<HTMLElement>(\"[data-rise]\", box);\n\n      const mm = gsap.matchMedia();\n\n      // Reduced motion: no opening. The picture is already full screen and the text is in place.\n      mm.add(\"(prefers-reduced-motion: reduce)\", () => {\n        gsap.set(loader, { autoAlpha: 0 });\n        gsap.set(card, { autoAlpha: 1, width: \"100%\", height: \"100%\" });\n        gsap.set(content, { opacity: 1 });\n        onCompleteRef.current?.();\n      });\n\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        const boxWidth = box.clientWidth;\n        const size = cardSize(boxWidth);\n        const cardWidth = boxWidth * size.w;\n        // The words must spread wide enough for the card to fit between them\n        const spread = Math.max((boxWidth * gap) / 100, cardWidth * 1.6);\n\n        gsap.set(loader, { autoAlpha: 1 });\n        gsap.set(heading, { opacity: 0, columnGap: 8 });\n        gsap.set(card, { autoAlpha: 1, width: cardWidth, height: boxWidth * size.h, rotation: rotate, scale: 0 });\n        gsap.set(image, { scale: 1.5 });\n        gsap.set(content, { opacity: 0 });\n        // Start from a clean transform, so a replay never stacks on where the last run ended\n        gsap.set(riseWords, { clearProps: \"transform\" });\n        gsap.set(riseWords, { yPercent: 120 });\n\n        // Part one: words appear, spread apart, the card pops up, grows and straightens.\n        // It is built in \"natural\" seconds and then stretched to the duration you asked for.\n        const opening = gsap.timeline({ defaults: { ease: \"power2.inOut\" } });\n        opening\n          .to(heading, { opacity: 1, duration: 0.5, ease: \"power2.out\" })\n          .to(heading, { columnGap: spread, duration: 0.8 }, 0.4)\n          .to(card, { scale: 1, duration: 0.8 }, 0.4)\n          .to(card, { rotation: 0, width: \"100%\", height: \"100%\", duration: 1.5 }, 1.2)\n          .to(image, { scale: 1, duration: 1.5 }, 1.5)\n          .to(content, { opacity: 1, duration: 0.6, ease: \"power2.out\" }, 2.1)\n          .set(loader, { autoAlpha: 0 });\n        opening.duration(Math.max(0.2, duration));\n\n        // Part two: the title rises word by word\n        const tl = gsap.timeline({ paused: true, onComplete: () => onCompleteRef.current?.() });\n        tl.add(opening).to(riseWords, { yPercent: 0, duration: 0.5, stagger: 0.03, ease: \"power3.out\" }, \"-=0.2\");\n\n        // Wait for the picture so the card never grows empty (but never wait longer than 1.5 s)\n        let cancelled = false;\n        const ready = Promise.race([image.decode().catch(() => undefined), new Promise((resolve) => window.setTimeout(resolve, 1500))]);\n        void ready.then(() => {\n          if (!cancelled) tl.play();\n        });\n\n        return () => {\n          cancelled = true;\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: boxRef, dependencies: [rotate, duration, gap, words[0], words[1], title, subtitle], revertOnUpdate: true },\n  );\n\n  return (\n    <div\n      ref={boxRef}\n      className={`relative h-full w-full overflow-hidden bg-[var(--film-bg,#faf0ed)] text-[var(--film-fg,#2a1f1c)] ${className}`}\n    >\n      {/* The loader: the two words, centred, with the background */}\n      <div ref={loaderRef} aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center bg-[var(--film-bg,#faf0ed)]\">\n        <div ref={wordsRef} className=\"flex items-center justify-center gap-2 text-[clamp(1.2rem,3vw,1.6rem)] font-light tracking-wide uppercase opacity-0\">\n          <span>{words[0]}</span>\n          <span>{words[1]}</span>\n        </div>\n      </div>\n\n      {/* The card that grows into the picture */}\n      <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n        <div ref={cardRef} className=\"invisible relative shrink-0 overflow-hidden bg-[#1a1411]\">\n          <img ref={imageRef} src={src} alt={alt} className=\"absolute inset-0 block h-full w-full object-cover\" draggable={false} decoding=\"async\" />\n          <div className=\"absolute inset-0 bg-gradient-to-b from-black/50 to-black/35\" />\n        </div>\n      </div>\n\n      {/* What sits on the picture: your header, then the title */}\n      <div ref={contentRef} className=\"absolute inset-0 flex flex-col justify-between text-[var(--film-on,#faf0ed)] opacity-0\">\n        <div>{children}</div>\n        {title || subtitle ? (\n          <div className=\"mb-[12vh] flex flex-col items-center gap-1.5 px-4 text-center font-extralight\">\n            {title ? <RiseWords text={title} className=\"text-[clamp(1.5rem,6vw,3rem)] leading-tight\" /> : null}\n            {subtitle ? <RiseWords text={subtitle} className=\"text-[clamp(0.9rem,2.5vw,1.5rem)]\" /> : null}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n"}],"categories":["loaders","gsap","loader","preloader","intro","image"],"docs":"import { FilmSplitLoader } from \"@/components/dimaac/film-split-loader\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/film-split-loader","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/film-split-loader","credits":[{"by":"Siren Media","url":"https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl","what":"Photo","source":"Lummi","licence":"Lummi License"}]}}