{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "page-loader",
  "title": "Page Loader",
  "description": "Website intro loader orchestrated with GSAP timelines, with stairs, odometer counter, greeting words, iris and blinds exits.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "https://trytweenly.vercel.app/r/tweenly-theme.json"
  ],
  "files": [
    {
      "path": "registry/new-york/page-loader/page-loader.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\"\nimport gsap from \"gsap\"\nimport { useGSAP } from \"@gsap/react\"\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\n\ngsap.registerPlugin(useGSAP)\n\nexport type PageLoaderVariant = \"stairs\" | \"counter\" | \"words\" | \"iris\" | \"blinds\"\nexport type PageLoaderEase = \"power4\" | \"expo\" | \"circ\" | \"sine\"\n\nexport interface PageLoaderProps {\n  /** Visual style of the loader and its exit. Default: \"stairs\" */\n  variant?: PageLoaderVariant\n  /** Page content, rendered underneath and revealed when loading finishes. */\n  children?: React.ReactNode\n  /** Length of the simulated load, in seconds. Default: 2.4 */\n  duration?: number\n  /** Controlled progress, 0 to 100. When set, the loader follows it and exits at 100. */\n  progress?: number\n  /** Words flashed in sequence by the \"words\" variant. Default: [\"Hello\", \"Bonjour\", \"Ciao\", \"Hola\", \"Hallo\", \"Olá\", \"こんにちは\"] */\n  words?: string[]\n  /** Number of columns (\"stairs\") or bars (\"blinds\"). Default: 5 */\n  columns?: number\n  /** Loader background (any CSS color). Inverts the theme by default. Default: \"var(--foreground)\" */\n  background?: string\n  /** Text color. Default: \"var(--background)\" */\n  color?: string\n  /** Accent used for progress marks. Default: \"#ff4d12\" */\n  accent?: string\n  /** GSAP ease family used for the intro and exit. Default: \"power4\" */\n  ease?: PageLoaderEase\n  /** Length of the exit animation, in seconds. Default: 1 */\n  exitDuration?: number\n  /** Small label shown while loading. Default: \"Loading\" */\n  label?: string\n  /** Show the percentage counter. Default: true */\n  showCounter?: boolean\n  /** Prevent page scrolling while loading (fixed position only). Default: true */\n  lockScroll?: boolean\n  /** \"fixed\" covers the viewport; \"absolute\" covers the nearest positioned parent. Default: \"fixed\" */\n  position?: \"fixed\" | \"absolute\"\n  /** Called once the exit animation has finished. */\n  onComplete?: () => void\n  /** Additional classes for the overlay. */\n  className?: string\n}\n\nconst DEFAULT_WORDS = [\"Hello\", \"Bonjour\", \"Ciao\", \"Hola\", \"Hallo\", \"Olá\", \"こんにちは\"]\nconst TICKS = 60\n// Staged checkpoints read as \"real\" loading rather than a linear timer: [progress, share of time]\nconst STAGES: [number, number][] = [\n  [22, 0.3],\n  [58, 0.3],\n  [83, 0.2],\n  [100, 0.2],\n]\n\nconst pad = (v: number) => Math.floor(v).toString().padStart(2, \"0\")\n\n/** Rectangle with a circular hole, as an even-odd polygon (clip-path can't subtract a circle). */\nfunction holePolygon(w: number, h: number, cx: number, cy: number, r: number, steps = 72) {\n  const pts: string[] = []\n  for (let i = 0; i <= steps; i++) {\n    const a = (i / steps) * Math.PI * 2\n    pts.push(`${(cx + Math.cos(a) * r).toFixed(1)}px ${(cy + Math.sin(a) * r).toFixed(1)}px`)\n  }\n  return `polygon(evenodd, 0px 0px, ${w}px 0px, ${w}px ${h}px, 0px ${h}px, 0px 0px, ${pts.join(\", \")}, 0px 0px)`\n}\n\n/** Overflow mask whose content rises in on intro and out on exit. */\nfunction Mask({ children, className }: { children: React.ReactNode; className?: string }) {\n  return (\n    <span className={cn(\"block overflow-hidden\", className)}>\n      <span data-pl-rise className=\"block\">\n        {children}\n      </span>\n    </span>\n  )\n}\n\nexport function PageLoader({\n  variant = \"stairs\",\n  children,\n  duration = 2.4,\n  progress,\n  words = DEFAULT_WORDS,\n  columns = 5,\n  background = \"var(--foreground)\",\n  color = \"var(--background)\",\n  accent = \"#ff4d12\",\n  ease = \"power4\",\n  exitDuration = 1,\n  label = \"Loading\",\n  showCounter = true,\n  lockScroll = true,\n  position = \"fixed\",\n  onComplete,\n  className,\n}: PageLoaderProps) {\n  const reduced = useReducedMotion()\n  const [done, setDone] = useState(false)\n  const rootRef = useRef<HTMLDivElement>(null)\n  const onCompleteRef = useRef(onComplete)\n  const progressRef = useRef(progress)\n  const loadToRef = useRef<((target: number) => void) | null>(null)\n  const controlled = progress !== undefined\n  const count = Math.max(1, Math.round(columns))\n  const wordList = words.length ? words : DEFAULT_WORDS\n\n  // Declared before useGSAP so the latest values are in place when the timeline is built\n  useLayoutEffect(() => {\n    onCompleteRef.current = onComplete\n    progressRef.current = progress\n  }, [onComplete, progress])\n\n  useGSAP(\n    (_ctx, contextSafe) => {\n      const root = rootRef.current\n      if (!root || !contextSafe) return\n      const q = gsap.utils.selector(root)\n      const inOut = `${ease}.inOut`\n      const out = `${ease}.out`\n      const exitD = reduced ? 0.35 : Math.max(0.2, exitDuration)\n      const proxy = { v: 0 }\n\n      const counters = q(\"[data-pl-count]\")\n      const lines = q(\"[data-pl-line]\")\n      const ticks = q(\"[data-pl-tick]\")\n      const strips = q(\"[data-pl-strip]\")\n      const digits = [-1, -1, -1]\n      let lit = -1\n\n      const render = () => {\n        const v = proxy.v\n        const text = pad(v)\n        for (const el of counters) el.textContent = text\n        if (lines.length) gsap.set(lines, { scaleX: v / 100 })\n        if (ticks.length) {\n          const n = Math.round((v / 100) * TICKS)\n          if (n !== lit) {\n            gsap.set(ticks, { opacity: (i: number) => (i < n ? 1 : 0) })\n            lit = n\n          }\n        }\n        if (strips.length) {\n          const f = Math.floor(v)\n          const next = [f >= 100 ? 1 : 0, Math.floor(f / 10) % 10, f % 10]\n          strips.forEach((strip, i) => {\n            if (next[i] === digits[i]) return\n            digits[i] = next[i]\n            // hundreds strip has two cells, the others ten\n            gsap.to(strip, { yPercent: -next[i] * (i === 0 ? 50 : 10), duration: 0.6, ease: \"expo.out\", overwrite: true })\n          })\n        }\n      }\n      render()\n\n      // ---------- exit ----------\n      const exit = gsap.timeline({\n        paused: true,\n        onComplete: () => {\n          setDone(true)\n          onCompleteRef.current?.()\n        },\n      })\n      const rise = q(\"[data-pl-rise]\")\n\n      if (reduced) {\n        exit.to(root, { opacity: 0, duration: exitD, ease: \"power1.out\" })\n      } else if (variant === \"stairs\") {\n        const cols = q(\"[data-pl-col]\")\n        exit\n          .set(cols, { willChange: \"transform\" })\n          .to(rise, { yPercent: -110, duration: 0.6, ease: \"power3.in\", stagger: 0.05 })\n          .to(cols, { yPercent: -100, duration: exitD, ease: inOut, stagger: exitD * 0.09 }, \"-=0.2\")\n      } else if (variant === \"counter\") {\n        const panel = q(\"[data-pl-panel]\")[0]\n        const edge = { l: 100, r: 100 }\n        const clip = () => {\n          panel.style.clipPath = `polygon(0% 0%, 100% 0%, 100% ${edge.r}%, 0% ${edge.l}%)`\n        }\n        exit\n          .to(rise, { yPercent: -110, duration: 0.55, ease: \"power3.in\", stagger: 0.06 })\n          .to(q(\"[data-pl-track]\"), { scaleX: 0, transformOrigin: \"100% 50%\", duration: 0.5, ease: \"power3.in\" }, \"<\")\n          .to(edge, { r: 0, duration: exitD, ease: inOut, onUpdate: clip }, \"-=0.15\")\n          .to(edge, { l: 0, duration: exitD, ease: inOut, onUpdate: clip }, `<${exitD * 0.2}`)\n      } else if (variant === \"words\") {\n        const panel = q(\"[data-pl-panel]\")\n        exit\n          .to(q(\"[data-pl-content]\"), { opacity: 0, y: -24, duration: 0.4, ease: \"power2.in\" })\n          .set(panel, { willChange: \"transform\" })\n          .to(panel, { yPercent: -100, duration: exitD, ease: inOut }, \"-=0.05\")\n          .to(q(\"[data-pl-curve]\"), { scaleY: 0, duration: exitD, ease: inOut }, \"<\")\n      } else if (variant === \"iris\") {\n        const panel = q(\"[data-pl-panel]\")[0]\n        const size = { w: 0, h: 0, max: 0 }\n        const hole = { r: 0 }\n        exit\n          .to(q(\"[data-pl-dial]\"), { scale: 0.86, opacity: 0, duration: 0.5, ease: \"power3.in\" })\n          .to(rise, { yPercent: -110, duration: 0.45, ease: \"power3.in\" }, \"<\")\n          .call(() => {\n            size.w = panel.offsetWidth\n            size.h = panel.offsetHeight\n            size.max = Math.hypot(size.w, size.h) / 2 + 2\n          })\n          .to(hole, {\n            r: () => size.max,\n            duration: exitD,\n            ease: inOut,\n            onUpdate: () => {\n              panel.style.clipPath = holePolygon(size.w, size.h, size.w / 2, size.h / 2, hole.r)\n            },\n          })\n      } else {\n        const bars = q(\"[data-pl-bar]\")\n        exit\n          .to(q(\"[data-pl-track]\"), { scaleX: 0, transformOrigin: \"100% 50%\", duration: 0.5, ease: \"power3.in\" })\n          .to(rise, { yPercent: -110, duration: 0.45, ease: \"power3.in\", stagger: 0.04 }, \"<\")\n          .set(bars, { willChange: \"transform\" })\n          .to(\n            bars,\n            {\n              scaleY: 0,\n              transformOrigin: (i: number) => (i % 2 ? \"50% 100%\" : \"50% 0%\"),\n              duration: exitD,\n              ease: inOut,\n              stagger: { each: exitD * 0.08, from: \"center\" },\n            },\n            \"-=0.15\",\n          )\n      }\n\n      let exiting = false\n      const startExit = contextSafe(() => {\n        if (exiting) return\n        exiting = true\n        gsap.delayedCall(reduced ? 0 : 0.3, () => exit.play())\n      })\n\n      // ---------- intro ----------\n      if (!reduced) {\n        gsap.from(rise, { yPercent: 110, duration: 0.9, ease: out, stagger: 0.08, delay: 0.1 })\n        gsap.from(q(\"[data-pl-dial]\"), { scale: 0.9, opacity: 0, duration: 1, ease: out })\n      }\n\n      // ---------- words ----------\n      const wordEls = q(\"[data-pl-word]\")\n      if (wordEls.length) {\n        gsap.set(wordEls, { opacity: 0, yPercent: 40 })\n        const per = Math.max(0.16, (reduced ? 0.4 : duration) / wordEls.length)\n        const seq = gsap.timeline({ repeat: controlled ? -1 : 0 })\n        wordEls.forEach((el, i) => {\n          seq.to(el, { opacity: 1, yPercent: 0, duration: 0.16, ease: \"power2.out\" }, i * per)\n          if (i < wordEls.length - 1 || controlled) {\n            seq.to(el, { opacity: 0, yPercent: -40, duration: 0.14, ease: \"power2.in\" }, (i + 1) * per - 0.14)\n          }\n        })\n      }\n\n      // ---------- load ----------\n      if (controlled) {\n        loadToRef.current = contextSafe((target: number) => {\n          const t = Math.min(Math.max(target, 0), 100)\n          gsap.to(proxy, {\n            v: t,\n            duration: 0.6,\n            ease: \"power2.out\",\n            overwrite: true,\n            onUpdate: render,\n            onComplete: () => {\n              if (t >= 100) startExit()\n            },\n          })\n        })\n        loadToRef.current(progressRef.current ?? 0)\n      } else {\n        const total = reduced ? 0.4 : Math.max(0.2, duration)\n        const load = gsap.timeline({ onComplete: startExit })\n        for (const [v, share] of STAGES) load.to(proxy, { v, duration: total * share, ease: \"power2.out\", onUpdate: render })\n      }\n\n      return () => {\n        loadToRef.current = null\n      }\n    },\n    {\n      scope: rootRef,\n      dependencies: [variant, duration, controlled, count, ease, exitDuration, reduced, wordList.join(\"|\"), showCounter],\n      revertOnUpdate: true,\n    },\n  )\n\n  // Controlled progress updates\n  useEffect(() => {\n    if (progress !== undefined) loadToRef.current?.(progress)\n  }, [progress])\n\n  useEffect(() => {\n    if (!lockScroll || position !== \"fixed\" || done) return\n    const prev = document.documentElement.style.overflow\n    document.documentElement.style.overflow = \"hidden\"\n    return () => {\n      document.documentElement.style.overflow = prev\n    }\n  }, [lockScroll, position, done])\n\n  const mono = \"font-mono text-[11px] uppercase tracking-[0.2em]\"\n  const display = \"font-[family-name:var(--font-display)] font-semibold tabular-nums\"\n  const faint = `color-mix(in oklab, ${color} 14%, transparent)`\n  const Label = (\n    <Mask>\n      <span className={cn(\"flex items-center gap-2 opacity-60\", mono)}>\n        <span className=\"size-1.5 rounded-full\" style={{ background: accent }} />\n        {label}\n      </span>\n    </Mask>\n  )\n\n  let body: React.ReactNode\n  if (variant === \"stairs\") {\n    body = (\n      <>\n        <div className=\"absolute inset-0 flex\">\n          {Array.from({ length: count }, (_, i) => (\n            <div key={i} data-pl-col className=\"-mr-px h-full flex-1\" style={{ background }} />\n          ))}\n        </div>\n        <div className=\"absolute inset-0 flex flex-col justify-between p-6 sm:p-10\">\n          {Label}\n          {showCounter && (\n            <Mask className=\"pb-[0.04em]\">\n              <span className={cn(\"flex items-start leading-[0.8]\", display)}>\n                <span data-pl-count className=\"text-[clamp(5rem,26cqw,18rem)] tracking-[-0.02em]\">\n                  00\n                </span>\n                <span className=\"mt-[0.2em] text-[clamp(1.25rem,4cqw,3rem)]\" style={{ color: accent }}>\n                  %\n                </span>\n              </span>\n            </Mask>\n          )}\n        </div>\n      </>\n    )\n  } else if (variant === \"counter\") {\n    body = (\n      <div\n        data-pl-panel\n        className=\"absolute inset-0 flex flex-col justify-between p-6 sm:p-10\"\n        style={{ background, clipPath: \"polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)\" }}\n      >\n        {Label}\n        <div className=\"flex items-end justify-between gap-6\">\n          <div data-pl-track className=\"mb-[1.2cqh] h-px w-[28%] overflow-hidden\" style={{ background: faint }}>\n            <div data-pl-line className=\"h-full origin-left\" style={{ background: accent, transform: \"scaleX(0)\" }} />\n          </div>\n          {showCounter && (\n            <Mask>\n              <div className={cn(\"flex items-start leading-none\", display)} aria-hidden>\n                {[2, 10, 10].map((cells, i) => (\n                  <span key={i} className=\"block h-[1em] overflow-hidden text-[clamp(5rem,30cqw,20rem)] tracking-normal\">\n                    <span data-pl-strip className=\"flex flex-col\">\n                      {Array.from({ length: cells }, (_, d) => (\n                        <span key={d} className=\"block h-[1em] w-[0.5em] text-center leading-none\">\n                          {i === 0 ? (d ? \"1\" : \"\") : d}\n                        </span>\n                      ))}\n                    </span>\n                  </span>\n                ))}\n                <span className=\"mt-[0.15em] text-[clamp(1.25rem,5cqw,3.5rem)] leading-none\" style={{ color: accent }}>\n                  %\n                </span>\n              </div>\n            </Mask>\n          )}\n        </div>\n      </div>\n    )\n  } else if (variant === \"words\") {\n    body = (\n      <div data-pl-panel className=\"absolute inset-0\" style={{ background }}>\n        <div data-pl-content className=\"absolute inset-0 grid place-items-center\">\n          <p className=\"flex items-center gap-[0.35em] text-[clamp(2rem,7cqw,4.5rem)] font-medium tracking-tight\">\n            <span className=\"size-[0.2em] shrink-0 rounded-full\" style={{ background: accent }} />\n            <span className=\"grid\">\n              {wordList.map((w, i) => (\n                <span key={i} data-pl-word className=\"whitespace-nowrap opacity-0 [grid-area:1/1]\">\n                  {w}\n                </span>\n              ))}\n            </span>\n          </p>\n          {showCounter && (\n            <span className={cn(\"absolute bottom-6 right-6 opacity-50 sm:bottom-10 sm:right-10\", mono)}>\n              <span data-pl-count>00</span>%\n            </span>\n          )}\n        </div>\n        <div\n          data-pl-curve\n          aria-hidden\n          className=\"absolute left-[-25%] top-[calc(100%-1px)] h-[22cqh] w-[150%] origin-top rounded-b-[100%]\"\n          style={{ background }}\n        />\n      </div>\n    )\n  } else if (variant === \"iris\") {\n    body = (\n      <div data-pl-panel className=\"absolute inset-0 grid place-items-center\" style={{ background }}>\n        <div className=\"flex flex-col items-center gap-8\">\n          <div data-pl-dial className=\"relative size-[min(36cqw,36cqh)] min-h-28 min-w-28\">\n            <div className=\"absolute inset-[3.5%] rounded-full border\" style={{ borderColor: faint }} />\n            {Array.from({ length: TICKS }, (_, i) => (\n              <div key={i} className=\"absolute inset-0\" style={{ transform: `rotate(${(i / TICKS) * 360}deg)` }}>\n                <div\n                  data-pl-tick\n                  className=\"absolute left-1/2 top-0 h-[7%] w-px -translate-x-1/2 opacity-0\"\n                  style={{ background: accent }}\n                />\n              </div>\n            ))}\n            {showCounter && (\n              <span\n                className={cn(\n                  \"absolute inset-0 grid place-items-center text-[clamp(2.5rem,10cqmin,5.5rem)] leading-none\",\n                  display,\n                )}\n              >\n                <span>\n                  <span data-pl-count>00</span>\n                  <span className=\"text-[0.4em] opacity-50\">%</span>\n                </span>\n              </span>\n            )}\n          </div>\n          {Label}\n        </div>\n      </div>\n    )\n  } else {\n    body = (\n      <>\n        <div className=\"absolute inset-0 flex flex-col\">\n          {Array.from({ length: count }, (_, i) => (\n            <div key={i} data-pl-bar className=\"-mb-px w-full flex-1\" style={{ background }} />\n          ))}\n        </div>\n        <div className=\"absolute inset-x-[8%] top-1/2 -translate-y-1/2\">\n          <div className=\"mb-4 flex items-end justify-between\">\n            {Label}\n            {showCounter && (\n              <Mask>\n                <span className={cn(\"block text-[clamp(1.75rem,6cqw,3.5rem)] leading-none\", display)}>\n                  <span data-pl-count>00</span>\n                  <span className=\"text-[0.5em] opacity-50\">%</span>\n                </span>\n              </Mask>\n            )}\n          </div>\n          <div data-pl-track className=\"h-px w-full overflow-hidden\" style={{ background: faint }}>\n            <div data-pl-line className=\"h-full origin-left\" style={{ background: accent, transform: \"scaleX(0)\" }} />\n          </div>\n        </div>\n      </>\n    )\n  }\n\n  return (\n    <>\n      {children}\n      {!done && (\n        <div\n          ref={rootRef}\n          role=\"progressbar\"\n          aria-label={label}\n          aria-valuemin={0}\n          aria-valuemax={100}\n          className={cn(\"inset-0 z-[9998] overflow-hidden [container-type:size]\", position, className)}\n          style={{ color }}\n        >\n          {body}\n        </div>\n      )}\n    </>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/page-loader",
  "type": "registry:component"
}