{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loader-iris-ring",
  "type": "registry:block",
  "title": "Loader Iris Ring",
  "description": "A page preloader built around a ring gauge: the arc fills in uneven steps while images flash through its lens and four corner counters sync and go live. Then an iris opens from the lens and the ring flies into the logo in your nav.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "@number-flow/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/loader-iris-ring.tsx",
      "type": "registry:component",
      "target": "components/tweenui/loader-iris-ring.tsx",
      "content": "'use client';\n\nimport {\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type CSSProperties,\n  type ReactNode,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport NumberFlow from '@number-flow/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** The percentages the gauge stops on: uneven, like a real load. */\nconst STEPS = [0, 7, 18, 26, 41, 53, 62, 78, 86, 94, 100];\nconst STEP = 0.24;\n\nexport interface LoaderModule {\n  label: string;\n  count: number;\n}\n\ntype Status = 'waiting' | 'syncing' | 'live';\n\nexport interface LoaderIrisRingProps extends ComponentPropsWithoutRef<'section'> {\n  /** Counters in the four corners; each one syncs, counts up and goes live in turn. */\n  modules?: LoaderModule[];\n  /** Images flashed through the lens while the gauge fills. */\n  frames?: string[];\n  /** Words for a module's three states. */\n  status?: Record<Status, string>;\n  /** Caption under the percentage, while loading and once done. */\n  caption?: { loading: string; done: string };\n  /** Cover the whole viewport and lock page scroll, instead of filling this section. */\n  fullscreen?: boolean;\n  /** Fires as the iris opens and the page settles in. */\n  onComplete?: () => void;\n  /**\n   * What the iris opens onto. Defaults to a sample page. Mark an element with\n   * `data-loader-target` (the exported `IrisRingMark` fits exactly) and the ring flies into it.\n   */\n  children?: ReactNode;\n}\n\nconst unsplash = (id: string) =>\n  `https://images.unsplash.com/${id}?w=480&h=480&fit=crop&auto=format&q=75`;\n\nconst INK = '#e8f3f2';\nconst LIME = '#c6f56f';\n\nconst DEFAULT_MODULES: LoaderModule[] = [\n  { label: 'Components', count: 20 },\n  { label: 'Blocks', count: 27 },\n  { label: 'Test suites', count: 55 },\n  { label: 'Motion-safe', count: 47 },\n];\n\nconst DEFAULT_FRAMES = [\n  'photo-1461749280684-dccba630e2f6',\n  'photo-1518770660439-4636190af475',\n  'photo-1460925895917-afdab827c52f',\n  'photo-1550751827-4bd374c3f58b',\n  'photo-1498050108023-c5249f4df085',\n  'photo-1526374965328-7f61d4dc18c5',\n  'photo-1551288049-bebda4e38f71',\n  'photo-1555066931-4365d14bab8c',\n].map(unsplash);\n\nconst DEFAULT_STATUS: Record<Status, string> = {\n  waiting: 'Queued',\n  syncing: 'Tweening',\n  live: 'Ready',\n};\n\nconst DEFAULT_CAPTION = { loading: 'Warming up timelines', done: 'Ready to tween' };\n\n/**\n * The landing spot for the ring: a 32-unit circle of radius 12.5 with the dot on top,\n * the same proportions the flight is solved for. The ring takes on the mark's `color`\n * and the dot's fill on the way in, so it lands matching either theme.\n */\nexport const IrisRingMark = ({ className, ...props }: ComponentPropsWithoutRef<'svg'>) => {\n  return (\n    <svg\n      viewBox=\"0 0 32 32\"\n      aria-hidden=\"true\"\n      data-loader-target=\"\"\n      className={cn('size-7 shrink-0 text-[#03110f]', className)}\n      {...props}\n    >\n      <circle cx=\"16\" cy=\"16\" r=\"12.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" />\n      <circle cx=\"16\" cy=\"3.5\" r=\"3.5\" fill=\"#045f64\" data-loader-target-dot=\"\" />\n    </svg>\n  );\n};\n\nconst SamplePage = () => {\n  return (\n    <div className=\"flex h-full w-full flex-col bg-white text-[#03110f]\">\n      <header className=\"flex items-center justify-between px-5 py-5 sm:px-8\">\n        <span className=\"flex items-center gap-2.5\">\n          <IrisRingMark />\n          <span className=\"text-lg font-semibold tracking-tight\">Tween UI</span>\n        </span>\n        <span className=\"font-mono text-[11px] tracking-[0.08em] text-[#03110f]/55 uppercase max-sm:hidden\">\n          Components · Blocks · Docs\n        </span>\n      </header>\n      <div className=\"grid flex-1 place-items-center px-5 pb-16 text-center sm:px-8\">\n        <div className=\"grid justify-items-center gap-4\">\n          <p className=\"font-mono text-[11px] tracking-[0.08em] text-[#045f64] uppercase\">\n            Loader Iris Ring\n          </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-[40ch] text-[15px] leading-relaxed text-balance text-[#03110f]/60\">\n            Pass your own page as children. The ring lands on any mark with{' '}\n            <code className=\"font-mono text-[13px]\">data-loader-target</code>.\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nconst LoaderIrisRing = ({\n  modules = DEFAULT_MODULES,\n  frames = DEFAULT_FRAMES,\n  status = DEFAULT_STATUS,\n  caption = DEFAULT_CAPTION,\n  fullscreen = false,\n  onComplete,\n  children,\n  className,\n  ...props\n}: LoaderIrisRingProps) => {\n  const [done, setDone] = useState(false);\n  const [percent, setPercent] = useState(0);\n  const [counts, setCounts] = useState(() => modules.map(() => 0));\n  const [states, setStates] = useState<Status[]>(() => modules.map(() => 'waiting'));\n  const [isDone, setIsDone] = useState(false);\n  const rootRef = useRef<HTMLElement>(null);\n  const pageRef = useRef<HTMLDivElement>(null);\n  const onCompleteRef = useRef(onComplete);\n  onCompleteRef.current = onComplete;\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const page = pageRef.current;\n      if (!root || prefersReducedMotion()) {\n        onCompleteRef.current?.();\n        const id = requestAnimationFrame(() => setDone(true));\n        return () => cancelAnimationFrame(id);\n      }\n\n      const q = <T extends Element = HTMLElement>(selector: string) =>\n        root.querySelector<T>(`[data-loader] ${selector}`);\n      const veil = q('[data-loader-veil]');\n      const content = q('[data-loader-content]');\n      const ring = q('[data-loader-ring]');\n      const arc = q<SVGCircleElement>('[data-loader-arc]');\n      const track = q<SVGCircleElement>('[data-loader-track]');\n      const hand = q('[data-loader-hand]');\n      const dot = q('[data-loader-dot]');\n      const lens = q('[data-loader-lens]');\n      const readout = q('[data-loader-readout]');\n      const moduleEls = gsap.utils.toArray<HTMLElement>('[data-loader] [data-loader-module]', root);\n      const frameEls = gsap.utils.toArray<HTMLElement>('[data-loader] [data-loader-frame]', root);\n      const mark = page?.querySelector<HTMLElement>('[data-loader-target]') ?? null;\n\n      if (fullscreen) {\n        window.scrollTo(0, 0);\n        document.documentElement.style.overflow = 'hidden';\n      }\n      const unlock = () => {\n        if (fullscreen) document.documentElement.style.overflow = '';\n      };\n\n      const iris = { r: 0 };\n      const setIris = () => {\n        if (!veil) return;\n        const mask = `radial-gradient(circle at 50% 50%, transparent ${iris.r}px, #000 ${iris.r + 1.5}px)`;\n        veil.style.maskImage = mask;\n        veil.style.setProperty('-webkit-mask-image', mask);\n      };\n\n      if (mark) gsap.set(mark, { opacity: 0 });\n      const origin = fullscreen ? `50% ${window.innerHeight / 2}px` : '50% 50%';\n      if (page) gsap.set(page, { scale: 1.12, transformOrigin: origin });\n\n      const tl = gsap.timeline({\n        onComplete: () => {\n          unlock();\n          setDone(true);\n        },\n      });\n\n      tl.fromTo(\n        ring,\n        { scale: 0.7, opacity: 0, rotation: -40 },\n        { scale: 1, opacity: 1, rotation: 0, duration: 1.4, ease: 'expo.out' },\n        0\n      )\n        .fromTo(lens, { scale: 0.4 }, { scale: 1, duration: 1.2, ease: 'expo.out' }, 0.1)\n        .to(readout, { opacity: 1, duration: 0.6 }, 0.3);\n\n      frameEls.forEach((frame, i) => {\n        const at = 0.35 + i * 0.3;\n        tl.fromTo(\n          frame,\n          { opacity: 0, scale: 1.25 },\n          { opacity: 1, scale: 1, duration: 0.5, ease: 'power3.out' },\n          at\n        );\n        if (i > 0) tl.set(frameEls[i - 1], { opacity: 0 }, at + 0.18);\n      });\n\n      // One continuous sweep through the uneven steps: the pace changes from step to step\n      // but never stops, and the readout ticks over as the arc passes each step.\n      const gauge = { p: 0 };\n      const along = gsap.utils.interpolate(STEPS);\n      const last = STEPS.length - 1;\n      let shown = 0;\n      tl.to(\n        gauge,\n        {\n          p: 1,\n          duration: last * STEP,\n          ease: 'sine.inOut',\n          onUpdate: () => {\n            const value = along(gauge.p);\n            arc?.setAttribute('stroke-dashoffset', String(1 - value / 100));\n            if (hand) gsap.set(hand, { rotation: value * 3.6 });\n            const step = Math.min(last, Math.floor(gauge.p * last + 1e-6));\n            if (step !== shown) {\n              shown = step;\n              setPercent(STEPS[step]);\n            }\n          },\n        },\n        0.4\n      );\n\n      const setModule = (i: number, next: Status) =>\n        setStates((prev) => prev.map((state, j) => (j === i ? next : state)));\n\n      moduleEls.forEach((module, i) => {\n        const at = 0.45 + i * 0.5;\n        tl.fromTo(\n          module,\n          { opacity: 0, y: 14 },\n          { opacity: 1, y: 0, duration: 0.6, ease: 'expo.out' },\n          at\n        )\n          .call(\n            () => {\n              setModule(i, 'syncing');\n              setCounts((prev) => prev.map((count, j) => (j === i ? modules[i].count : count)));\n            },\n            [],\n            at + 0.2\n          )\n          .call(() => setModule(i, 'live'), [], at + 1.1);\n      });\n\n      const end = 0.4 + (STEPS.length - 1) * STEP;\n      tl.call(() => setIsDone(true), [], end + 0.1)\n        .fromTo(\n          dot,\n          { scale: 1 },\n          { scale: 1.8, duration: 0.25, yoyo: true, repeat: 1, ease: 'power2.out' },\n          end + 0.1\n        )\n        .add('open', end + 0.65)\n        .call(\n          () => {\n            iris.r = lens ? lens.getBoundingClientRect().width / 2 : 0;\n            setIris();\n          },\n          [],\n          'open'\n        )\n        .to(frameEls, { opacity: 0, duration: 0.3, ease: 'power2.out' }, 'open')\n        .to(lens, { opacity: 0, duration: 0.3 }, 'open')\n        .to(\n          iris,\n          {\n            r: () => {\n              const box = root.getBoundingClientRect();\n              const [w, h] = fullscreen\n                ? [window.innerWidth, window.innerHeight]\n                : [box.width, box.height];\n              return Math.hypot(w, h) / 2 + 40;\n            },\n            duration: 1.5,\n            ease: 'expo.inOut',\n            onUpdate: setIris,\n          },\n          'open+=0.15'\n        )\n        .to(\n          moduleEls,\n          { opacity: 0, y: -12, duration: 0.5, ease: 'power2.in', stagger: 0.04 },\n          'open+=0.1'\n        )\n        .to(readout, { opacity: 0, y: 16, duration: 0.5, ease: 'power3.in' }, 'open+=0.1');\n\n      if (ring && arc && mark) {\n        // Where the mark will sit once the page has settled to scale 1\n        const landing = () => {\n          const scale = page ? Number(gsap.getProperty(page, 'scale')) : 1;\n          if (page) gsap.set(page, { scale: 1 });\n          const to = mark.getBoundingClientRect();\n          if (page) gsap.set(page, { scale });\n          return to;\n        };\n        const flight = () => {\n          const from = ring.getBoundingClientRect();\n          const to = landing();\n          const scale = (to.width * (25 / 32)) / (from.width * 0.96);\n          const markDot = mark.querySelector('[data-loader-target-dot]');\n          return {\n            x: to.left + to.width / 2 - (from.left + from.width / 2),\n            y: to.top + to.height / 2 - (from.top + from.height / 2),\n            scale,\n            stroke: (to.width * (2 / 32)) / ((from.width * scale) / 100),\n            dot: (to.width * (7 / 32)) / ((dot?.offsetWidth || 14) * scale),\n            // The ring lands in the mark's own colours, so it never vanishes on a light page\n            color: getComputedStyle(mark).color,\n            dotColor: markDot ? getComputedStyle(markDot).fill : LIME,\n          };\n        };\n        let path = {\n          x: 0,\n          y: 0,\n          scale: 1,\n          stroke: 0.7,\n          dot: 1,\n          color: INK,\n          dotColor: LIME,\n        };\n        tl.call(() => void (path = flight()), [], 'open+=0.2')\n          .to(ring, { x: () => path.x, duration: 1.3, ease: 'power3.inOut' }, 'open+=0.25')\n          .to(ring, { y: () => path.y, duration: 1.3, ease: 'expo.inOut' }, 'open+=0.25')\n          .to(\n            ring,\n            { scale: () => path.scale, rotation: 0, duration: 1.3, ease: 'expo.inOut' },\n            'open+=0.25'\n          )\n          .to(\n            arc,\n            {\n              attr: { 'stroke-width': () => path.stroke, stroke: () => path.color },\n              duration: 1.3,\n              ease: 'expo.inOut',\n            },\n            'open+=0.25'\n          )\n          .to(track, { opacity: 0, duration: 0.4 }, 'open+=0.25')\n          .to(hand, { rotation: 360, duration: 1.3, ease: 'expo.inOut' }, 'open+=0.25')\n          .to(\n            dot,\n            {\n              scale: () => path.dot,\n              backgroundColor: () => path.dotColor,\n              boxShadow: '0 0 0 rgb(198 245 111 / 0)',\n              duration: 1.3,\n              ease: 'expo.inOut',\n            },\n            'open+=0.25'\n          )\n          .call(\n            () => {\n              gsap.set(ring, { opacity: 0 });\n              gsap.set(mark, { opacity: 1 });\n              gsap.fromTo(\n                mark,\n                { scale: 1.25 },\n                { scale: 1, duration: 0.6, ease: 'back.out(3)', transformOrigin: '50% 50%' }\n              );\n            },\n            [],\n            'open+=1.55'\n          )\n          .set(content, { opacity: 0 }, 'open+=1.6');\n      } else {\n        tl.to(\n          ring,\n          { opacity: 0, scale: 1.25, duration: 0.9, ease: 'power3.in' },\n          'open+=0.15'\n        ).set(content, { opacity: 0 }, 'open+=1.1');\n      }\n\n      if (page) {\n        tl.to(\n          page,\n          { scale: 1, duration: 1.9, ease: 'expo.out', clearProps: 'transform,transformOrigin' },\n          'open+=0.2'\n        );\n      }\n\n      tl.call(() => onCompleteRef.current?.(), [], 'open+=0.75');\n\n      return () => {\n        tl.kill();\n        if (page) gsap.set(page, { clearProps: 'transform,transformOrigin' });\n        if (mark) gsap.set(mark, { clearProps: 'opacity,transform' });\n        unlock();\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',\n        className\n      )}\n      {...props}\n    >\n      <div ref={pageRef} className={cn('w-full', !fullscreen && 'absolute inset-0')}>\n        {children ?? <SamplePage />}\n      </div>\n\n      {!done && (\n        <div\n          aria-hidden=\"true\"\n          data-loader=\"\"\n          className={cn(\n            'inset-0 [--ring:min(62cqw,24rem,52cqh)] motion-reduce:hidden',\n            fullscreen ? 'fixed z-[90]' : 'absolute z-10'\n          )}\n          style={{ containerType: 'size' } as CSSProperties}\n        >\n          <div className=\"absolute inset-0 overflow-hidden bg-[#03110f]\" data-loader-veil=\"\">\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          <div className=\"absolute inset-0\" data-loader-content=\"\">\n            <div className=\"absolute inset-0 grid grid-cols-2 content-between px-5 py-8 sm:px-8 @min-[48rem]:py-10\">\n              {modules.map((module, i) => (\n                <div\n                  key={module.label}\n                  className={cn('opacity-0', i % 2 === 1 && 'justify-self-end text-right')}\n                  data-loader-module=\"\"\n                >\n                  <p className=\"font-mono text-[11px] tracking-[0.08em] text-[#9fd4d6]/70 uppercase\">\n                    {module.label}\n                  </p>\n                  <p className=\"mt-2 text-[clamp(1.75rem,3cqw,2.75rem)] leading-none font-bold tracking-[-0.03em] text-[#e8f3f2]\">\n                    <NumberFlow\n                      value={counts[i] ?? 0}\n                      transformTiming={{ duration: 900, easing: 'ease-out' }}\n                      spinTiming={{ duration: 900, easing: 'ease-out' }}\n                    />\n                  </p>\n                  <p\n                    className={cn(\n                      'mt-2 font-mono text-[0.6875rem] tracking-[0.06em] uppercase transition-colors duration-300 motion-reduce:transition-none',\n                      states[i] === 'live' ? 'text-[#c6f56f]' : 'text-[#9fd4d6]/40'\n                    )}\n                  >\n                    {status[states[i] ?? 'waiting']}\n                  </p>\n                </div>\n              ))}\n            </div>\n\n            <div className=\"absolute top-1/2 left-1/2 size-(--ring) -translate-x-1/2 -translate-y-1/2\">\n              <div className=\"relative size-full opacity-0\" data-loader-ring=\"\">\n                <svg\n                  viewBox=\"0 0 100 100\"\n                  className=\"absolute inset-0 size-full -rotate-90 overflow-visible\"\n                >\n                  <circle\n                    cx=\"50\"\n                    cy=\"50\"\n                    r=\"48\"\n                    fill=\"none\"\n                    stroke=\"rgb(159 212 214 / 0.24)\"\n                    strokeWidth=\"0.3\"\n                    data-loader-track=\"\"\n                  />\n                  <circle\n                    cx=\"50\"\n                    cy=\"50\"\n                    r=\"48\"\n                    fill=\"none\"\n                    stroke=\"#e8f3f2\"\n                    strokeWidth=\"0.7\"\n                    pathLength={1}\n                    strokeDasharray=\"1\"\n                    strokeDashoffset=\"1\"\n                    data-loader-arc=\"\"\n                  />\n                </svg>\n                <div className=\"absolute inset-0\" data-loader-hand=\"\">\n                  <span\n                    className=\"absolute top-[2%] left-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#c6f56f] shadow-[0_0_24px_rgb(198_245_111/0.7)]\"\n                    data-loader-dot=\"\"\n                  />\n                </div>\n                <div\n                  className=\"absolute inset-[13%] overflow-hidden rounded-full bg-[#071d1b]\"\n                  data-loader-lens=\"\"\n                >\n                  {frames.map((frame) => (\n                    <img\n                      key={frame}\n                      src={frame}\n                      alt=\"\"\n                      loading=\"eager\"\n                      className=\"absolute inset-0 size-full object-cover opacity-0\"\n                      data-loader-frame=\"\"\n                    />\n                  ))}\n                </div>\n              </div>\n            </div>\n\n            <div\n              className=\"absolute inset-x-0 top-[calc(50%+var(--ring)/2+1.25rem)] flex flex-col items-center gap-2 opacity-0\"\n              data-loader-readout=\"\"\n            >\n              <span className=\"flex items-baseline font-mono text-[clamp(2rem,4cqw,3.25rem)] leading-none text-[#e8f3f2]\">\n                <NumberFlow\n                  value={percent}\n                  format={{ useGrouping: false }}\n                  transformTiming={{ duration: 300, easing: 'ease-out' }}\n                  spinTiming={{ duration: 300, easing: 'ease-out' }}\n                />\n                <span className=\"ml-1 text-[#c6f56f]\">%</span>\n              </span>\n              <span className=\"font-mono text-[11px] tracking-[0.08em] text-[#9fd4d6]/70 uppercase\">\n                {isDone ? caption.done : caption.loading}\n              </span>\n            </div>\n          </div>\n        </div>\n      )}\n    </section>\n  );\n};\n\nexport default LoaderIrisRing;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/loader-iris-ring\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping.\nPut `IrisRingMark` (or any element with `data-loader-target`) in your nav and the ring lands on it. Without a target the ring swells and fades out instead."
}