{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "loader",
  "name": "Loader",
  "title": "Loader",
  "type": "registry:ui",
  "description": "A cinematic loader with a traveling numeric counter and a smooth staggered curtain reveal.",
  "summary": "A large counter glides across a dark canvas, settles at loading milestones, and reaches 100 before vertical panels lift to reveal your content. Use the automatic timeline or provide real loading progress. Tune the duration, panel count, stagger, and colors.",
  "category": "loaders",
  "tags": [
    "loader",
    "loading",
    "progress",
    "counter",
    "reveal",
    "curtain",
    "gsap"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-03",
  "updatedDate": "2026-10-03",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "A responsive counter travels from left to right without clipping",
    "Milestone pauses and coordinated staggered panel motion",
    "Automatic sequence or externally controlled loading progress",
    "Replay by remounting with a new key",
    "Scoped GSAP cleanup and reduced-motion support"
  ],
  "anatomy": [
    "<Loader> scoped loading container",
    "Counter rail with proportional movement",
    "Vertical panels that lift in sequence",
    "Content remains inert until the reveal completes"
  ],
  "physics": {
    "engine": "GSAP",
    "description": "A sequenced GSAP timeline eases the counter through loading milestones, then lifts panels with transform-based motion. External progress updates ease from the current position without restarting the sequence.",
    "parameters": [
      {
        "label": "Counter",
        "value": "2.2s · power2.inOut"
      },
      {
        "label": "Panel reveal",
        "value": "0.85s · power3.inOut"
      },
      {
        "label": "Stagger",
        "value": "0.09s"
      }
    ]
  },
  "accessibility": {
    "role": "progressbar / status",
    "aria": "A named progressbar reports the displayed percentage. A polite status announces content readiness. Covered content is inert and hidden from assistive technology until completion.",
    "reducedMotion": "Skips counter movement and panel motion. Automatic mode reveals immediately; controlled mode waits for progress to reach 100."
  },
  "guidelines": {
    "recommended": [
      "Page introductions and content reveals",
      "Real loading flows with measurable progress"
    ],
    "bestPractices": [
      "Pass actual progress when waiting for assets or data.",
      "Use a new React key to start another loading cycle.",
      "Keep onComplete stable when possible and use children for your own content.",
      "Set the container height with className to suit the layout."
    ]
  },
  "props": [
    {
      "name": "children",
      "type": "ReactNode",
      "description": "Content revealed beneath the loader."
    },
    {
      "name": "progress",
      "type": "number",
      "description": "Optional actual loading progress, clamped to 0–100. Reaching 100 completes the reveal; omit for the automatic sequence."
    },
    {
      "name": "duration",
      "type": "number",
      "defaultValue": "2.2",
      "description": "Automatic counter duration in seconds."
    },
    {
      "name": "revealDuration",
      "type": "number",
      "defaultValue": "0.85",
      "description": "Duration in seconds for each panel to lift."
    },
    {
      "name": "stagger",
      "type": "number",
      "defaultValue": "0.09",
      "description": "Delay in seconds between adjacent panels."
    },
    {
      "name": "columns",
      "type": "number",
      "defaultValue": "5",
      "description": "Number of reveal panels, clamped to 2–12."
    },
    {
      "name": "background",
      "type": "string",
      "defaultValue": "\"#191919\"",
      "description": "CSS color of the loading panels."
    },
    {
      "name": "foreground",
      "type": "string",
      "defaultValue": "\"#f2f0e9\"",
      "description": "CSS color of the counter."
    },
    {
      "name": "label",
      "type": "string",
      "defaultValue": "\"Loading\"",
      "description": "Accessible loading label."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Container classes. Set min-height or h-screen for the intended layout."
    },
    {
      "name": "onComplete",
      "type": "() => void",
      "description": "Called once after all panels finish revealing. Remount with a new key to replay."
    }
  ],
  "usage": "import { Loader } from \"@/components/ui/loader\";\n\nexport function LoaderDemo() {\n  return (\n    <Loader className=\"h-[480px]\">\n      <h1>Content is ready</h1>\n    </Loader>\n  );\n}",
  "files": [
    {
      "name": "loader.tsx",
      "path": "registry/ui/loader.tsx",
      "code": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface LoaderProps {\n  children?: ReactNode;\n  progress?: number;\n  duration?: number;\n  revealDuration?: number;\n  stagger?: number;\n  columns?: number;\n  background?: string;\n  foreground?: string;\n  label?: string;\n  className?: string;\n  onComplete?: () => void;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nexport function Loader({\n  children,\n  progress,\n  duration = 2.2,\n  revealDuration = 0.85,\n  stagger = 0.09,\n  columns = 5,\n  background = \"#191919\",\n  foreground = \"#f2f0e9\",\n  label = \"Loading\",\n  className,\n  onComplete,\n}: LoaderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const overlayRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const counterRef = useRef<HTMLDivElement>(null);\n  const numberRef = useRef<HTMLSpanElement>(null);\n  const controllerRef = useRef<((value: number) => void) | null>(null);\n  const callbackRef = useRef(onComplete);\n  const progressRef = useRef(progress);\n  const finishedRef = useRef(false);\n  const [complete, setComplete] = useState(false);\n  const automatic = progress === undefined;\n  const panelCount = Math.round(bounded(columns, 5, 2, 12));\n  const loadingDuration = bounded(duration, 2.2, 0.3, 30);\n  const exitDuration = bounded(revealDuration, 0.85, 0.2, 3);\n  const panelStagger = bounded(stagger, 0.09, 0, 0.3);\n\n  useEffect(() => {\n    callbackRef.current = onComplete;\n    progressRef.current = progress;\n  }, [onComplete, progress]);\n\n  useGSAP(\n    () => {\n      const media = gsap.matchMedia();\n      finishedRef.current = false;\n      setComplete(false);\n\n      media.add(\n        {\n          reduceMotion: \"(prefers-reduced-motion: reduce)\",\n          animate: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          const overlay = overlayRef.current;\n          const content = contentRef.current;\n          const counter = counterRef.current;\n          const number = numberRef.current;\n          if (!overlay || !content || !counter || !number) return;\n\n          const panels = gsap.utils.toArray<HTMLElement>(\n            \"[data-loader-panel]\",\n            rootRef.current,\n          );\n          const reduced = Boolean(context.conditions?.reduceMotion);\n          const state = { value: 0 };\n          let revealing = false;\n          let progressTween: gsap.core.Tween | null = null;\n          const moveCounter = gsap.quickSetter(counter, \"xPercent\");\n          const alignNumber = gsap.quickSetter(number, \"xPercent\");\n\n          const renderProgress = () => {\n            const value = Math.round(state.value);\n            number.textContent = String(value).padStart(2, \"0\");\n            overlay.setAttribute(\"aria-valuenow\", String(value));\n            moveCounter(reduced ? 0 : state.value);\n            alignNumber(reduced ? 0 : -state.value);\n          };\n\n          const finish = () => {\n            if (finishedRef.current) return;\n            finishedRef.current = true;\n            setComplete(true);\n            callbackRef.current?.();\n          };\n\n          const reveal = context.add(\"reveal\", () => {\n            if (revealing || finishedRef.current) return;\n            revealing = true;\n            if (reduced) {\n              gsap.set(overlay, { autoAlpha: 0 });\n              gsap.set(content, { y: 0 });\n              finish();\n              return;\n            }\n\n            gsap\n              .timeline({\n                defaults: { ease: \"power3.inOut\" },\n                onComplete: finish,\n              })\n              .to(counter, { autoAlpha: 0, y: -16, duration: 0.18 })\n              .to(\n                panels,\n                { yPercent: -100, duration: exitDuration, stagger: panelStagger },\n                \">+=0.12\",\n              )\n              .to(\n                content,\n                { y: 0, duration: exitDuration, ease: \"power3.out\" },\n                \"<+=0.12\",\n              )\n              .set(overlay, { autoAlpha: 0 });\n          }) as () => void;\n\n          gsap.set(overlay, { autoAlpha: finishedRef.current ? 0 : 1 });\n          gsap.set(content, { y: reduced || finishedRef.current ? 0 : 24 });\n          gsap.set(counter, { autoAlpha: 1, y: 0 });\n          gsap.set(panels, { yPercent: 0 });\n          renderProgress();\n\n          const updateProgress = context.add(\"updateProgress\", (value: number) => {\n            if (revealing || finishedRef.current) return;\n            const target = bounded(value, 0, 0, 100);\n            progressTween?.kill();\n            if (reduced) {\n              state.value = target;\n              renderProgress();\n              if (target === 100) reveal();\n              return;\n            }\n            progressTween = gsap.to(state, {\n              value: target,\n              duration: 0.45,\n              ease: \"power2.out\",\n              onUpdate: renderProgress,\n              onComplete: () => {\n                if (target === 100) reveal();\n              },\n            });\n          }) as (value: number) => void;\n\n          controllerRef.current = updateProgress;\n          if (!finishedRef.current) {\n            if (automatic) {\n              if (reduced) updateProgress(100);\n              else {\n                gsap\n                  .timeline({\n                    defaults: { ease: \"power2.inOut\" },\n                    onUpdate: renderProgress,\n                    onComplete: reveal,\n                  })\n                  .to(state, { value: 16, duration: loadingDuration * 0.14 })\n                  .to(state, { value: 16, duration: loadingDuration * 0.07 })\n                  .to(state, { value: 40, duration: loadingDuration * 0.18 })\n                  .to(state, { value: 40, duration: loadingDuration * 0.09 })\n                  .to(state, { value: 61, duration: loadingDuration * 0.16 })\n                  .to(state, { value: 61, duration: loadingDuration * 0.08 })\n                  .to(state, { value: 100, duration: loadingDuration * 0.2 })\n                  .to(state, { value: 100, duration: loadingDuration * 0.08 });\n              }\n            } else updateProgress(progressRef.current ?? 0);\n          }\n\n          return () => {\n            progressTween?.kill();\n            controllerRef.current = null;\n          };\n        },\n        rootRef,\n      );\n\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        automatic,\n        panelCount,\n        loadingDuration,\n        exitDuration,\n        panelStagger,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  useGSAP(\n    () => {\n      if (progress !== undefined) controllerRef.current?.(progress);\n    },\n    { scope: rootRef, dependencies: [progress] },\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      aria-busy={!complete}\n      className={cn(\n        \"relative isolate min-h-80 w-full overflow-hidden [container-type:inline-size]\",\n        className,\n      )}\n    >\n      <div\n        ref={contentRef}\n        inert={!complete}\n        aria-hidden={!complete}\n        className=\"relative min-h-[inherit]\"\n      >\n        {children}\n      </div>\n      <div\n        ref={overlayRef}\n        role=\"progressbar\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={0}\n        aria-hidden={complete}\n        className={cn(\n          \"absolute inset-0 z-20 overflow-hidden\",\n          complete && \"pointer-events-none\",\n        )}\n        style={{ color: foreground }}\n      >\n        <div aria-hidden=\"true\" className=\"absolute inset-0 flex\">\n          {Array.from({ length: panelCount }, (_, index) => (\n            <div\n              key={index}\n              data-loader-panel\n              className=\"h-full min-w-0 flex-1 will-change-transform\"\n              style={{\n                backgroundColor: background,\n                marginRight: index < panelCount - 1 ? -1 : 0,\n              }}\n            />\n          ))}\n        </div>\n        <div aria-hidden=\"true\" className=\"absolute inset-x-[6%] top-[7%]\">\n          <div ref={counterRef} className=\"w-full will-change-transform\">\n            <span\n              ref={numberRef}\n              className=\"inline-block text-[clamp(3.5rem,13cqw,10rem)] leading-none font-semibold tracking-[-0.075em] tabular-nums will-change-transform\"\n            >\n              00\n            </span>\n          </div>\n        </div>\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {complete ? \"Content ready\" : label}\n      </span>\n    </div>\n  );\n}\n\nexport default Loader;\n",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface LoaderProps {\n  children?: ReactNode;\n  progress?: number;\n  duration?: number;\n  revealDuration?: number;\n  stagger?: number;\n  columns?: number;\n  background?: string;\n  foreground?: string;\n  label?: string;\n  className?: string;\n  onComplete?: () => void;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nexport function Loader({\n  children,\n  progress,\n  duration = 2.2,\n  revealDuration = 0.85,\n  stagger = 0.09,\n  columns = 5,\n  background = \"#191919\",\n  foreground = \"#f2f0e9\",\n  label = \"Loading\",\n  className,\n  onComplete,\n}: LoaderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const overlayRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const counterRef = useRef<HTMLDivElement>(null);\n  const numberRef = useRef<HTMLSpanElement>(null);\n  const controllerRef = useRef<((value: number) => void) | null>(null);\n  const callbackRef = useRef(onComplete);\n  const progressRef = useRef(progress);\n  const finishedRef = useRef(false);\n  const [complete, setComplete] = useState(false);\n  const automatic = progress === undefined;\n  const panelCount = Math.round(bounded(columns, 5, 2, 12));\n  const loadingDuration = bounded(duration, 2.2, 0.3, 30);\n  const exitDuration = bounded(revealDuration, 0.85, 0.2, 3);\n  const panelStagger = bounded(stagger, 0.09, 0, 0.3);\n\n  useEffect(() => {\n    callbackRef.current = onComplete;\n    progressRef.current = progress;\n  }, [onComplete, progress]);\n\n  useGSAP(\n    () => {\n      const media = gsap.matchMedia();\n      finishedRef.current = false;\n      setComplete(false);\n\n      media.add(\n        {\n          reduceMotion: \"(prefers-reduced-motion: reduce)\",\n          animate: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          const overlay = overlayRef.current;\n          const content = contentRef.current;\n          const counter = counterRef.current;\n          const number = numberRef.current;\n          if (!overlay || !content || !counter || !number) return;\n\n          const panels = gsap.utils.toArray<HTMLElement>(\n            \"[data-loader-panel]\",\n            rootRef.current,\n          );\n          const reduced = Boolean(context.conditions?.reduceMotion);\n          const state = { value: 0 };\n          let revealing = false;\n          let progressTween: gsap.core.Tween | null = null;\n          const moveCounter = gsap.quickSetter(counter, \"xPercent\");\n          const alignNumber = gsap.quickSetter(number, \"xPercent\");\n\n          const renderProgress = () => {\n            const value = Math.round(state.value);\n            number.textContent = String(value).padStart(2, \"0\");\n            overlay.setAttribute(\"aria-valuenow\", String(value));\n            moveCounter(reduced ? 0 : state.value);\n            alignNumber(reduced ? 0 : -state.value);\n          };\n\n          const finish = () => {\n            if (finishedRef.current) return;\n            finishedRef.current = true;\n            setComplete(true);\n            callbackRef.current?.();\n          };\n\n          const reveal = context.add(\"reveal\", () => {\n            if (revealing || finishedRef.current) return;\n            revealing = true;\n            if (reduced) {\n              gsap.set(overlay, { autoAlpha: 0 });\n              gsap.set(content, { y: 0 });\n              finish();\n              return;\n            }\n\n            gsap\n              .timeline({\n                defaults: { ease: \"power3.inOut\" },\n                onComplete: finish,\n              })\n              .to(counter, { autoAlpha: 0, y: -16, duration: 0.18 })\n              .to(\n                panels,\n                { yPercent: -100, duration: exitDuration, stagger: panelStagger },\n                \">+=0.12\",\n              )\n              .to(\n                content,\n                { y: 0, duration: exitDuration, ease: \"power3.out\" },\n                \"<+=0.12\",\n              )\n              .set(overlay, { autoAlpha: 0 });\n          }) as () => void;\n\n          gsap.set(overlay, { autoAlpha: finishedRef.current ? 0 : 1 });\n          gsap.set(content, { y: reduced || finishedRef.current ? 0 : 24 });\n          gsap.set(counter, { autoAlpha: 1, y: 0 });\n          gsap.set(panels, { yPercent: 0 });\n          renderProgress();\n\n          const updateProgress = context.add(\"updateProgress\", (value: number) => {\n            if (revealing || finishedRef.current) return;\n            const target = bounded(value, 0, 0, 100);\n            progressTween?.kill();\n            if (reduced) {\n              state.value = target;\n              renderProgress();\n              if (target === 100) reveal();\n              return;\n            }\n            progressTween = gsap.to(state, {\n              value: target,\n              duration: 0.45,\n              ease: \"power2.out\",\n              onUpdate: renderProgress,\n              onComplete: () => {\n                if (target === 100) reveal();\n              },\n            });\n          }) as (value: number) => void;\n\n          controllerRef.current = updateProgress;\n          if (!finishedRef.current) {\n            if (automatic) {\n              if (reduced) updateProgress(100);\n              else {\n                gsap\n                  .timeline({\n                    defaults: { ease: \"power2.inOut\" },\n                    onUpdate: renderProgress,\n                    onComplete: reveal,\n                  })\n                  .to(state, { value: 16, duration: loadingDuration * 0.14 })\n                  .to(state, { value: 16, duration: loadingDuration * 0.07 })\n                  .to(state, { value: 40, duration: loadingDuration * 0.18 })\n                  .to(state, { value: 40, duration: loadingDuration * 0.09 })\n                  .to(state, { value: 61, duration: loadingDuration * 0.16 })\n                  .to(state, { value: 61, duration: loadingDuration * 0.08 })\n                  .to(state, { value: 100, duration: loadingDuration * 0.2 })\n                  .to(state, { value: 100, duration: loadingDuration * 0.08 });\n              }\n            } else updateProgress(progressRef.current ?? 0);\n          }\n\n          return () => {\n            progressTween?.kill();\n            controllerRef.current = null;\n          };\n        },\n        rootRef,\n      );\n\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        automatic,\n        panelCount,\n        loadingDuration,\n        exitDuration,\n        panelStagger,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  useGSAP(\n    () => {\n      if (progress !== undefined) controllerRef.current?.(progress);\n    },\n    { scope: rootRef, dependencies: [progress] },\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      aria-busy={!complete}\n      className={cn(\n        \"relative isolate min-h-80 w-full overflow-hidden [container-type:inline-size]\",\n        className,\n      )}\n    >\n      <div\n        ref={contentRef}\n        inert={!complete}\n        aria-hidden={!complete}\n        className=\"relative min-h-[inherit]\"\n      >\n        {children}\n      </div>\n      <div\n        ref={overlayRef}\n        role=\"progressbar\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={0}\n        aria-hidden={complete}\n        className={cn(\n          \"absolute inset-0 z-20 overflow-hidden\",\n          complete && \"pointer-events-none\",\n        )}\n        style={{ color: foreground }}\n      >\n        <div aria-hidden=\"true\" className=\"absolute inset-0 flex\">\n          {Array.from({ length: panelCount }, (_, index) => (\n            <div\n              key={index}\n              data-loader-panel\n              className=\"h-full min-w-0 flex-1 will-change-transform\"\n              style={{\n                backgroundColor: background,\n                marginRight: index < panelCount - 1 ? -1 : 0,\n              }}\n            />\n          ))}\n        </div>\n        <div aria-hidden=\"true\" className=\"absolute inset-x-[6%] top-[7%]\">\n          <div ref={counterRef} className=\"w-full will-change-transform\">\n            <span\n              ref={numberRef}\n              className=\"inline-block text-[clamp(3.5rem,13cqw,10rem)] leading-none font-semibold tracking-[-0.075em] tabular-nums will-change-transform\"\n            >\n              00\n            </span>\n          </div>\n        </div>\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {complete ? \"Content ready\" : label}\n      </span>\n    </div>\n  );\n}\n\nexport default Loader;\n",
      "type": "registry:ui",
      "target": "@ui/loader.tsx"
    }
  ]
}
