{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kinetic-type-ring",
  "type": "registry:block",
  "title": "Kinetic Type Ring",
  "description": "A phrase wrapped once around a ring in real 3D. A falling dot pulls the letters out of a blur, then drag or scroll spins the ring.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/kinetic-type-ring.tsx",
      "type": "registry:component",
      "target": "components/tweenui/kinetic-type-ring.tsx",
      "content": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/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/** Letter colour at the front of the ring and at the back, lerped by depth. */\nconst FRONT: [number, number, number] = [239, 235, 227];\nconst BACK: [number, number, number] = [58, 58, 58];\n\nconst DEFAULT_TEXT = 'KINETIC TYPE · 3D · SOUND · LOOPS · SPRINGS · EASING · ';\n\n/** Perspective distance as a multiple of the ring radius. */\nconst DEPTH = 2.5;\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\nconst shade = (t: number) =>\n  `rgb(${lerp(BACK[0], FRONT[0], t) | 0},${lerp(BACK[1], FRONT[1], t) | 0},${lerp(BACK[2], FRONT[2], t) | 0})`;\n\ninterface Char {\n  el: HTMLSpanElement;\n  glyph: HTMLSpanElement;\n  /** Position around the ring, in degrees. */\n  angle: number;\n  width: number;\n}\n\nexport interface KineticTypeRingProps extends ComponentPropsWithoutRef<'section'> {\n  /**\n   * The phrase wrapped around the ring. It is repeated verbatim, so end it with\n   * the same separator it uses inside — otherwise the loop reads with a seam.\n   */\n  text?: string;\n  /** Idle spin in degrees per second. The front of the text flows leftwards. */\n  speed?: number;\n  /** Resting camera tilt in degrees. Negative looks down on the ring. */\n  tilt?: number;\n}\n\nexport default function KineticTypeRing({\n  text = DEFAULT_TEXT,\n  speed = 14,\n  tilt = -16,\n  className,\n  ...props\n}: KineticTypeRingProps) {\n  const stageRef = useRef<HTMLDivElement>(null);\n  const sceneRef = useRef<HTMLDivElement>(null);\n  const tiltRef = useRef<HTMLDivElement>(null);\n  const spinRef = useRef<HTMLDivElement>(null);\n  const dotWrapRef = useRef<HTMLDivElement>(null);\n  const dotRef = useRef<HTMLDivElement>(null);\n\n  useGSAP(\n    () => {\n      const stage = stageRef.current;\n      const scene = sceneRef.current;\n      const spin = spinRef.current;\n      const tiltEl = tiltRef.current;\n      const dotWrap = dotWrapRef.current;\n      const dot = dotRef.current;\n      if (!stage || !scene || !spin || !tiltEl || !dotWrap || !dot) return;\n\n      const reduced = prefersReducedMotion();\n\n      const state = {\n        rotation: 0,\n        /** Negative so the text facing us flows leftwards. */\n        baseSpeed: -Math.abs(speed),\n        /** Extra velocity from a drag or a wheel, eased back to zero each frame. */\n        boost: 0,\n        tilt,\n        radius: 0,\n      };\n\n      // One span per character: `layout` owns the outer span's transform, which\n      // puts it on the ring, and GSAP owns the inner glyph's. Nothing writes to\n      // both, so a re-solve mid-intro cannot wipe a tween and vice versa.\n      spin.replaceChildren();\n      const chars: Char[] = [...text].map((character) => {\n        const el = document.createElement('span');\n        el.className =\n          'absolute top-0 left-0 leading-none font-extrabold tracking-tight whitespace-pre transform-3d will-change-[transform,color] motion-reduce:will-change-auto';\n        const glyph = document.createElement('span');\n        glyph.className =\n          'inline-block will-change-[transform,opacity,filter] motion-reduce:will-change-auto';\n        glyph.textContent = character;\n        el.appendChild(glyph);\n        spin.appendChild(el);\n        return { el, glyph, angle: 0, width: 0 };\n      });\n\n      /**\n       * How far the ring reaches on screen, per unit of radius. Points at the\n       * side sit at z = 0 and are not magnified; the ones between the side and\n       * the front are, so the widest part of the silhouette is a little past\n       * the radius. The tallest part is the front of the ring, lifted by the\n       * tilt and magnified most of all.\n       */\n      const tiltRad = (Math.abs(tilt) * Math.PI) / 180;\n      const spread = 1.12;\n      const rise =\n        Math.sin(tiltRad) * (DEPTH / (DEPTH - Math.cos(tiltRad))) + 0.08 * (DEPTH / (DEPTH - 1));\n\n      /**\n       * Solve the type size so the phrase wraps the ring exactly once and the\n       * whole ring fits the stage, then give each glyph an arc proportional to\n       * its real width — so \"I\" and \"W\" sit evenly rather than on a fixed grid.\n       */\n      const layout = () => {\n        const w = stage.clientWidth;\n        const h = stage.clientHeight;\n        if (!w || !h) return false;\n\n        chars.forEach((c) => (c.el.style.fontSize = '100px'));\n        const unitWidth = chars.reduce((sum, c) => sum + c.el.offsetWidth, 0) / 100;\n        // jsdom and a stage that is not displayed both measure zero.\n        if (!unitWidth) return false;\n\n        // The type scales with the ring, so the cap height does too.\n        const fontPerRadius = (2 * Math.PI) / unitWidth;\n        const halfHeight = rise + 0.62 * fontPerRadius * (DEPTH / (DEPTH - 1));\n\n        // 0.97 leaves a little air between the ring and the edge of the stage.\n        const targetR = Math.min((w * 0.97) / (2 * spread), (h * 0.97) / (2 * halfHeight), 720);\n\n        let total = 0;\n        chars.forEach((c) => {\n          c.el.style.fontSize = `${targetR * fontPerRadius}px`;\n          c.width = c.el.offsetWidth;\n          total += c.width;\n        });\n        if (!total) return false;\n\n        // Re-derive from the measured widths so the loop closes exactly.\n        const radius = total / (2 * Math.PI);\n        state.radius = radius;\n\n        let acc = 0;\n        chars.forEach((c) => {\n          c.angle = ((acc + c.width / 2) / total) * 360;\n          acc += c.width;\n          place(c);\n        });\n\n        scene.style.perspective = `${radius * DEPTH}px`;\n        const dotSize = Math.max(20, Math.min(radius * 0.16, 96));\n        dot.style.width = `${dotSize}px`;\n        dot.style.height = `${dotSize}px`;\n        return true;\n      };\n\n      /**\n       * Put a letter on the ring. `rotateY` has to come before `translateZ` so\n       * the letter travels out along its own axis rather than the stage's —\n       * which is why this is a string rather than a GSAP transform.\n       */\n      const place = (c: Char) => {\n        c.el.style.transform = `translate(-50%, -50%) rotateY(${c.angle}deg) translateZ(${state.radius}px)`;\n      };\n\n      /** Paint every glyph by how far round the ring it currently sits. */\n      const paint = () => {\n        const rad = Math.PI / 180;\n        for (const c of chars) {\n          const depth = (Math.cos((c.angle + state.rotation) * rad) + 1) / 2;\n          c.el.style.color = shade(Math.pow(depth, 1.6));\n        }\n      };\n\n      const applyTransforms = () => {\n        spin.style.transform = `rotateY(${state.rotation}deg)`;\n        tiltEl.style.transform = `rotateX(${state.tilt}deg)`;\n        // Counter-tilted so the dot keeps facing the camera.\n        dotWrap.style.transform = `rotateX(${-state.tilt}deg)`;\n      };\n\n      /**\n       * The dot reads the ring's momentum. Scrolling, dragging or arrowing adds\n       * boost, and the dot swells or shrinks with it — a scroll forward grows\n       * it, a scroll back shrinks it — easing home to its resting size as the\n       * boost decays.\n       */\n      const swell = () => {\n        if (!settled) return;\n        const push = gsap.utils.clamp(-1, 1, state.boost / 700);\n        gsap.set(dot, { scale: 1 - push * 0.35 });\n      };\n\n      const render = (_time: number, delta: number) => {\n        const sec = delta / 1000;\n        state.boost *= Math.pow(0.04, sec);\n        state.rotation += (state.baseSpeed + state.boost) * sec;\n        applyTransforms();\n        paint();\n        swell();\n      };\n\n      const kick = (amount: number) => {\n        state.boost = gsap.utils.clamp(-900, 900, state.boost + amount);\n      };\n\n      let intro: gsap.core.Timeline | null = null;\n      /** The dot only starts responding once it has landed and stopped squashing. */\n      let settled = false;\n      let tiltTo: ((value: number) => void) | null = null;\n      let running = false;\n\n      /**\n       * The ring cannot be built until the stage has a box to measure, and the\n       * first paint often beats that. So nothing starts until a layout lands —\n       * whether that is now or on the first callback from the observer below.\n       */\n      const begin = () => {\n        if (running || !layout()) return;\n        running = true;\n        paint();\n        gsap.ticker.add(render);\n\n        const fall = 0.9;\n        /** Where the ring will have turned to by the time the dot lands. */\n        const restRotation = state.baseSpeed * fall;\n        /** 0 for the letter facing the camera on impact, 1 for the far side. */\n        const wave = (angle: number) =>\n          Math.abs(((((angle + restRotation) % 360) + 540) % 360) - 180) / 180;\n        // Blur is a length, so it has to scale with the type or it reads as a\n        // smudge on a phone and a whisper on a hero.\n        const haze = gsap.utils.clamp(6, 26, state.radius * 0.045);\n\n        gsap.set(dot, { xPercent: -50, yPercent: -50, transformOrigin: '50% 100%' });\n\n        intro = gsap.timeline();\n        intro\n          // The dot falls in, stretching as it picks up speed.\n          .fromTo(\n            dot,\n            { y: -(stage.clientHeight / 2 + dot.offsetHeight * 2), scaleX: 0.75, scaleY: 1.35 },\n            { y: 0, duration: fall, ease: 'power3.in' },\n            0\n          )\n          // Squash on impact, then spring back round.\n          .to(dot, { scaleX: 1.4, scaleY: 0.6, duration: 0.1, ease: 'power2.out' }, fall)\n          .to(\n            dot,\n            { scaleX: 1, scaleY: 1, duration: 1.1, ease: 'elastic.out(1.1, 0.3)' },\n            fall + 0.1\n          )\n          .set(dot, { transformOrigin: '50% 50%' })\n          // The letters start as soft, oversized blurs. The impact pulls them\n          // into focus, the sweep rolling round from the one nearest the\n          // camera to the far side of the ring.\n          .from(\n            chars.map((c) => c.glyph),\n            {\n              opacity: 0,\n              filter: `blur(${haze}px)`,\n              scale: 1.3,\n              duration: 1.2,\n              ease: 'power2.out',\n              stagger: (i: number) => wave(chars[i].angle) * 0.5,\n              // A filter left at 0 still costs a compositing layer per letter.\n              clearProps: 'filter',\n            },\n            fall\n          )\n          // The camera settles while the ring takes a kick that decays into the idle spin.\n          .from(state, { tilt: tilt - 26, duration: 2.2, ease: 'power3.out' }, fall)\n          .call(() => (state.boost = -380), undefined, fall)\n          // Created on completion so it cannot overwrite the intro's tilt tween.\n          .eventCallback('onComplete', () => {\n            settled = true;\n            tiltTo = gsap.quickTo(state, 'tilt', { duration: 1.2, ease: 'power3.out' });\n          });\n      };\n\n      applyTransforms();\n\n      // Re-solves on any box change, so the ring fits a resized window, a\n      // drawer opening beside it or a container that grows with its content.\n      const observer = new ResizeObserver(() => {\n        if (reduced || running) {\n          if (layout()) paint();\n          return;\n        }\n        begin();\n      });\n      observer.observe(stage);\n\n      // Finished state: the ring is laid out and shaded, nothing moves.\n      if (reduced) {\n        gsap.set(dot, { xPercent: -50, yPercent: -50 });\n        if (layout()) paint();\n        return () => {\n          observer.disconnect();\n          spin.replaceChildren();\n        };\n      }\n\n      begin();\n\n      let dragging = false;\n      let lastX = 0;\n\n      const onPointerDown = (event: PointerEvent) => {\n        dragging = true;\n        lastX = event.clientX;\n        stage.setPointerCapture(event.pointerId);\n      };\n      const onPointerMove = (event: PointerEvent) => {\n        if (dragging) {\n          kick((event.clientX - lastX) * 6);\n          lastX = event.clientX;\n        }\n        const { top, height } = stage.getBoundingClientRect();\n        tiltTo?.(tilt - ((event.clientY - top) / height - 0.5) * 22);\n      };\n      const endDrag = () => (dragging = false);\n      const onWheel = (event: WheelEvent) => kick(-event.deltaY * 0.8);\n      const onKeyDown = (event: KeyboardEvent) => {\n        if (event.key === 'ArrowLeft') kick(220);\n        else if (event.key === 'ArrowRight') kick(-220);\n        else return;\n        event.preventDefault();\n      };\n\n      stage.addEventListener('pointerdown', onPointerDown);\n      stage.addEventListener('pointermove', onPointerMove);\n      stage.addEventListener('pointerup', endDrag);\n      stage.addEventListener('pointercancel', endDrag);\n      stage.addEventListener('wheel', onWheel, { passive: true });\n      stage.addEventListener('keydown', onKeyDown);\n\n      return () => {\n        observer.disconnect();\n        gsap.ticker.remove(render);\n        intro?.kill();\n        stage.removeEventListener('pointerdown', onPointerDown);\n        stage.removeEventListener('pointermove', onPointerMove);\n        stage.removeEventListener('pointerup', endDrag);\n        stage.removeEventListener('pointercancel', endDrag);\n        stage.removeEventListener('wheel', onWheel);\n        stage.removeEventListener('keydown', onKeyDown);\n        spin.replaceChildren();\n      };\n    },\n    { scope: stageRef, dependencies: [text, speed, tilt] }\n  );\n\n  return (\n    <section\n      data-kinetic-type-ring\n      className={cn(\n        'relative h-full w-full overflow-hidden bg-[#141414] text-[#efebe3]',\n        className\n      )}\n      {...props}\n    >\n      {/* The ring is decoration built from one span per letter; this is the text itself. */}\n      <p className=\"sr-only\">{text}</p>\n\n      <div\n        ref={stageRef}\n        tabIndex={0}\n        role=\"group\"\n        aria-label=\"Kinetic type ring. Drag, scroll, or use the left and right arrow keys to spin it.\"\n        className=\"relative aspect-[16/9] h-full max-h-svh min-h-[280px] w-full cursor-grab touch-pan-y select-none focus-visible:ring-2 focus-visible:ring-[#c6f56f] focus-visible:outline-none focus-visible:ring-inset active:cursor-grabbing\"\n      >\n        <div\n          ref={sceneRef}\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 flex items-center justify-center\"\n        >\n          <div ref={tiltRef} className=\"relative h-0 w-0 transform-3d\">\n            <div ref={spinRef} className=\"relative h-0 w-0 transform-3d\" />\n            <div ref={dotWrapRef} className=\"absolute top-0 left-0 transform-3d\">\n              <div ref={dotRef} className=\"absolute size-14 rounded-full bg-white\" />\n            </div>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/kinetic-type-ring\nThe type size is solved from the stage, so the phrase wraps the ring exactly once at any width. Give the section its own height with `className` when the default clamp does not suit the page."
}