{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loader-line-fold",
  "type": "registry:block",
  "title": "Loader Line Fold",
  "description": "A page preloader: the wordmark rises letter by letter while a hairline fills with a counter riding its tip. Once the page is ready the line travels to the middle, folds into a single point, and the page opens out of that point to every edge.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/loader-line-fold.tsx",
      "type": "registry:component",
      "target": "components/tweenui/loader-line-fold.tsx",
      "content": "'use client';\n\nimport { useEffect, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { SplitText } from 'gsap/SplitText';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, SplitText);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst ease = {\n  out: CustomEase.create('llf-out', '0.22,1,0.36,1'),\n  expo: CustomEase.create('llf-expo', '0.16,1,0.3,1'),\n  inOut: CustomEase.create('llf-in-out', '0.76,0,0.24,1'),\n};\n\nconst ASSET_TIMEOUT = 6000;\n\n/** Fonts plus every image in the content, capped so a slow network never holds the page. */\nconst assetsReady = (content: HTMLElement | null) => {\n  const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n  const images = Array.from(content?.querySelectorAll('img') ?? []).map((img) =>\n    typeof img.decode === 'function' ? img.decode().catch(() => undefined) : undefined\n  );\n  return Promise.race([\n    Promise.all([fonts, ...images]),\n    new Promise((resolve) => setTimeout(resolve, ASSET_TIMEOUT)),\n  ]);\n};\n\nconst GRAIN =\n  \"url(\\\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\\\")\";\n\nconst label = 'font-mono text-[11px] font-medium tracking-[0.08em] uppercase';\n\nexport interface LoaderLineFoldProps extends ComponentPropsWithoutRef<'section'> {\n  /** The word that rises letter by letter. */\n  wordmark?: string;\n  /** Left of the meta row, before the live clock. */\n  location?: string;\n  /** IANA time zone for the clock. Defaults to the visitor's own. */\n  timeZone?: string;\n  /** Right of the meta row. */\n  credit?: string;\n  /** Seconds the counter takes to reach 90%, however fast the assets load. */\n  minDuration?: number;\n  /** Resolves when the page is ready. Defaults to fonts plus every image in `children`. */\n  ready?: () => Promise<unknown>;\n  /** Cover the whole viewport and lock page scroll, instead of filling this section. */\n  fullscreen?: boolean;\n  /** Fires as the page starts opening out of the centre point. */\n  onComplete?: () => void;\n  /** What the loader opens onto. Defaults to a sample hero. */\n  children?: ReactNode;\n}\n\nconst Clock = ({ timeZone }: { timeZone?: string }) => {\n  const [time, setTime] = useState('--:--');\n\n  useEffect(() => {\n    const format = new Intl.DateTimeFormat('en-GB', {\n      timeZone,\n      hour: '2-digit',\n      minute: '2-digit',\n      hourCycle: 'h23',\n    });\n    let timer = 0;\n    const tick = () => {\n      setTime(format.format(new Date()));\n      timer = window.setTimeout(tick, 1000 - (Date.now() % 1000) + 5);\n    };\n    tick();\n    return () => window.clearTimeout(timer);\n  }, [timeZone]);\n\n  return <time className=\"tabular-nums\">{time}</time>;\n};\n\nconst SampleHero = () => {\n  return (\n    <div className=\"grid h-full w-full place-items-center bg-white px-5 text-center sm:px-8\">\n      <div className=\"grid justify-items-center gap-4\">\n        <p className={cn(label, 'text-[#045f64]')}>Loader Line Fold</p>\n        <h2 className=\"max-w-[14ch] text-[clamp(2.25rem,6vw,4.5rem)] leading-[0.95] font-semibold tracking-[-0.04em] text-[#03110f]\">\n          Your awesome hero here\n        </h2>\n        <p className=\"max-w-[38ch] text-[15px] leading-relaxed text-balance text-[#03110f]/60\">\n          Pass your own hero as children and the page opens onto it.\n        </p>\n      </div>\n    </div>\n  );\n};\n\nconst LoaderLineFold = ({\n  wordmark = 'Tween UI',\n  location = 'Local time',\n  timeZone,\n  credit,\n  minDuration = 1.6,\n  ready,\n  fullscreen = false,\n  onComplete,\n  children,\n  className,\n  ...props\n}: LoaderLineFoldProps) => {\n  const [done, setDone] = useState(false);\n  const rootRef = useRef<HTMLElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const wordRef = useRef<HTMLParagraphElement>(null);\n  const lineRef = useRef<HTMLDivElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const fillRef = useRef<HTMLDivElement>(null);\n  const countRef = useRef<HTMLSpanElement>(null);\n  const metaRef = useRef<HTMLDivElement>(null);\n  const onCompleteRef = useRef(onComplete);\n  onCompleteRef.current = onComplete;\n  const readyRef = useRef(ready);\n  readyRef.current = ready;\n\n  useGSAP(\n    () => {\n      const content = contentRef.current;\n      const word = wordRef.current;\n      const panel = panelRef.current;\n      const line = lineRef.current;\n      const track = trackRef.current;\n      const fill = fillRef.current;\n      const count = countRef.current;\n      const meta = metaRef.current;\n      if (\n        !word ||\n        !panel ||\n        !line ||\n        !track ||\n        !fill ||\n        !count ||\n        !meta ||\n        prefersReducedMotion()\n      ) {\n        onCompleteRef.current?.();\n        const id = requestAnimationFrame(() => setDone(true));\n        return () => cancelAnimationFrame(id);\n      }\n\n      const lock = (on: boolean) => {\n        if (fullscreen) document.documentElement.style.overflow = on ? 'hidden' : '';\n      };\n      lock(true);\n\n      const split = SplitText.create(word, { type: 'chars', mask: 'chars', aria: 'none' });\n      // Tight tracking lets curves and diagonals overhang their letter boxes: give each mask\n      // room on every side and cancel it with negative margins so the spacing stays the same.\n      (split.masks as HTMLElement[]).forEach((mask) => {\n        mask.style.cssText += ';padding:0.08em 0.1em;margin:-0.08em -0.1em';\n      });\n\n      const progress = { value: 0 };\n      const render = () => {\n        const p = progress.value / 100;\n        fill.style.transform = `scaleX(${p})`;\n        count.textContent = `${String(Math.round(progress.value)).padStart(3, '0')}%`;\n        // The counter rides the tip of the line, never past the left edge\n        count.style.left = `${Math.max(count.offsetWidth, p * line.offsetWidth)}px`;\n      };\n      render();\n\n      gsap.set(split.chars, { yPercent: 110 });\n      gsap.set(word, { opacity: 1 });\n      if (content) gsap.set(content, { scale: 1.25, filter: 'brightness(0.2)' });\n\n      const intro = gsap\n        .timeline({ defaults: { ease: ease.out } })\n        .fromTo(split.chars, { yPercent: 110 }, { yPercent: 0, duration: 1.1, stagger: 0.06 }, 0.15)\n        .fromTo([meta, count], { autoAlpha: 0, y: 12 }, { autoAlpha: 1, y: 0, duration: 0.8 }, 0.35)\n        .fromTo(\n          track,\n          { scaleX: 0 },\n          { scaleX: 1, duration: 1, ease: ease.inOut, transformOrigin: 'left center' },\n          0.2\n        );\n\n      const counting = gsap.to(progress, {\n        value: 90,\n        duration: minDuration,\n        delay: 0.4,\n        ease: 'power2.inOut',\n        onUpdate: render,\n      });\n\n      // A centred rectangular hole in the sheet (evenodd polygon): 0 = closed, 1 = past the edges\n      const hole = { w: 0, h: 0 };\n      const cut = () => {\n        const [l, r] = [50 - 51 * hole.w, 50 + 51 * hole.w];\n        const [t, b] = [50 - 51 * hole.h, 50 + 51 * hole.h];\n        panel.style.clipPath = `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${l}% ${t}%, ${r}% ${t}%, ${r}% ${b}%, ${l}% ${b}%, ${l}% ${t}%)`;\n      };\n\n      let outro: gsap.core.Timeline | null = null;\n      const exit = () => {\n        const stage = panel.getBoundingClientRect();\n        const lineBox = line.getBoundingClientRect();\n        const toMiddle = stage.top + stage.height / 2 - (lineBox.top + lineBox.height / 2);\n        outro = gsap\n          .timeline({\n            onComplete: () => {\n              lock(false);\n              setDone(true);\n            },\n          })\n          .to(split.chars, { yPercent: -110, duration: 0.7, stagger: 0.04, ease: ease.inOut }, 0)\n          .to([meta, count], { autoAlpha: 0, y: 10, duration: 0.4, ease: 'power2.in' }, 0)\n          .to(track, { autoAlpha: 0, duration: 0.4 }, 0.1)\n          .to(line, { y: toMiddle, duration: 0.9, ease: ease.inOut }, 0.15)\n          // The line folds into the centre point...\n          .to(fill, { scaleX: 0, transformOrigin: '50% 50%', duration: 0.6, ease: ease.inOut }, 1)\n          // ...and the page opens out of that point to every edge, width leading height\n          .add('open', 1.25)\n          .to(hole, { w: 1, duration: 1.3, ease: ease.inOut, onUpdate: cut }, 'open')\n          .to(hole, { h: 1, duration: 1.3, ease: ease.inOut, onUpdate: cut }, 'open+=0.1')\n          .call(() => onCompleteRef.current?.(), undefined, 'open');\n        if (content) {\n          outro.to(\n            content,\n            {\n              scale: 1,\n              filter: 'brightness(1)',\n              duration: 2.4,\n              ease: ease.expo,\n              clearProps: 'transform,filter',\n            },\n            'open'\n          );\n        }\n      };\n\n      let finish: gsap.core.Tween | null = null;\n      let cancelled = false;\n      const waiting = readyRef.current?.() ?? assetsReady(content);\n      Promise.all([waiting, counting.then()]).then(() => {\n        if (cancelled) return;\n        finish = gsap.to(progress, {\n          value: 100,\n          duration: 0.45,\n          ease: 'power2.out',\n          onUpdate: render,\n          onComplete: exit,\n        });\n      });\n\n      return () => {\n        cancelled = true;\n        [intro, counting, finish, outro].forEach((tween) => tween?.kill());\n        split.revert();\n        if (content) gsap.set(content, { clearProps: 'transform,filter' });\n        lock(false);\n      };\n    },\n    { scope: rootRef }\n  );\n\n  return (\n    <section\n      ref={rootRef}\n      aria-busy={!done}\n      className={cn(\n        'relative isolate w-full bg-white text-[#03110f]',\n        !fullscreen && 'h-full min-h-[600px] overflow-hidden max-sm:min-h-[540px]',\n        className\n      )}\n      {...props}\n    >\n      <div ref={contentRef} className={cn('w-full', !fullscreen && 'absolute inset-0')}>\n        {children ?? <SampleHero />}\n      </div>\n\n      {!done && (\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            '@container inset-0 motion-reduce:hidden',\n            fullscreen ? 'fixed z-[60]' : 'absolute z-10'\n          )}\n        >\n          {/* One dark sheet; the exit cuts a hole in it that grows out from the centre */}\n          <div ref={panelRef} className=\"absolute inset-0 overflow-hidden bg-[#03110f]\">\n            <span className=\"pointer-events-none absolute top-full left-1/2 h-[90%] w-[140%] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,rgb(4_95_100/0.55)_0%,rgb(4_95_100/0.18)_50%,rgb(4_95_100/0)_100%)]\" />\n            <div\n              className=\"pointer-events-none absolute inset-0 bg-size-[128px_128px] bg-repeat opacity-12 mix-blend-screen\"\n              style={{ backgroundImage: GRAIN }}\n            />\n          </div>\n\n          <div className=\"absolute inset-0 grid place-items-center\">\n            <p\n              ref={wordRef}\n              className=\"pb-[0.04em] text-[min(17cqw,224px)] leading-[0.85] font-semibold tracking-[-0.05em] whitespace-nowrap text-[#e8f3f2] uppercase opacity-0 @3xl:text-[min(14cqw,224px)]\"\n            >\n              {wordmark}\n            </p>\n          </div>\n\n          <div className=\"absolute inset-x-0 bottom-0 px-5 pb-8 sm:px-8 @5xl:px-12 @5xl:pb-12\">\n            <div ref={lineRef} className=\"relative h-px w-full\">\n              <div ref={trackRef} className=\"absolute inset-0 bg-[#9fd4d6]/16\" />\n              <div\n                ref={fillRef}\n                className=\"absolute inset-0 origin-left bg-[#c6f56f]\"\n                style={{ transform: 'scaleX(0)' }}\n              />\n              <span\n                ref={countRef}\n                className={cn(\n                  label,\n                  'absolute bottom-3 left-0 -translate-x-full text-[#c6f56f] tabular-nums opacity-0'\n                )}\n              >\n                000%\n              </span>\n            </div>\n            <div\n              ref={metaRef}\n              className={cn(label, 'mt-4 flex justify-between gap-6 text-[#9fd4d6]/60 opacity-0')}\n            >\n              <span>\n                {location} {'//'} <Clock timeZone={timeZone} />\n              </span>\n              <span suppressHydrationWarning>\n                {credit ?? `${wordmark} ©${new Date().getFullYear()}`}\n              </span>\n            </div>\n          </div>\n        </div>\n      )}\n    </section>\n  );\n};\n\nexport default LoaderLineFold;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/loader-line-fold\nGSAP plugins (CustomEase, SplitText) are registered inside the component, so there is nothing to set up.\nWrap the hero it should open onto, or pass `fullscreen` to cover the viewport on first load. The counter holds at 90% until fonts and every image inside have decoded."
}