{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "circular-text",
  "type": "registry:ui",
  "title": "Circular Text",
  "description": "A rotating ring of type around a centre slot — the classic “available for work” badge.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/circular-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type CircularTextDirection = \"clockwise\" | \"counterclockwise\";\nexport type CircularTextHover = \"none\" | \"pause\" | \"accelerate\";\n\nexport interface CircularTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** Text set around the circle (repeated to fill it). */\n  text?: string;\n  /** Inserted between repetitions; drawn in the accent colour. */\n  separator?: string;\n  /** Times the text repeats around the circle. Estimated from the circumference when omitted. */\n  repeat?: number;\n  /** Outer diameter in px. */\n  size?: number;\n  /** Font size of the ring text in px. */\n  fontSize?: number;\n  /** Rotation speed in degrees per second (0 = still). */\n  speed?: number;\n  direction?: CircularTextDirection;\n  /** What hovering (or keyboard focus on a link) does to the rotation. */\n  hover?: CircularTextHover;\n  /** Draw a hairline ring between the text and the centre slot. */\n  ring?: boolean;\n  /** Separator and ring colour (any CSS colour). */\n  accent?: string;\n  /** Render as a link. */\n  href?: string;\n  target?: string;\n  rel?: string;\n  /** Accessible name. Defaults to `text`. */\n  label?: string;\n  /** Classes for the ring text (font, case, weight). */\n  textClassName?: string;\n  /** Content of the centre slot, e.g. an arrow icon. */\n  children?: React.ReactNode;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/** Rough advance of one glyph relative to the font size; textLength corrects the rest. */\nconst GLYPH_ADVANCE = 0.66;\nconst HOVER_RATE: Record<CircularTextHover, number> = { none: 1, pause: 0, accelerate: 3.5 };\n\n/**\n * Text on a circular SVG textPath that turns slowly around a centre slot. The text is repeated\n * to fill the circumference and justified with `textLength`, so the seam always closes cleanly.\n * Rotation runs on the Web Animations API, eases its speed on hover, and pauses offscreen.\n */\nexport function CircularText({\n  text = \"Available for work\",\n  separator = \" • \",\n  repeat,\n  size = 168,\n  fontSize = 11,\n  speed = 24,\n  direction = \"clockwise\",\n  hover = \"accelerate\",\n  ring = true,\n  accent = \"#FF7448\",\n  href,\n  target,\n  rel,\n  label,\n  textClassName = \"font-mono uppercase\",\n  children,\n  className = \"\",\n  style,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: CircularTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const spinRef = React.useRef<SVGSVGElement>(null);\n  const engagedRef = React.useRef<((engaged: boolean) => void) | null>(null);\n  const pathId = `ct-${React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n\n  // Geometry: the baseline sits on a circle; glyphs grow outward from it.\n  const half = size / 2;\n  const r = Math.max(8, half - fontSize * 0.8 - 2);\n  const circumference = 2 * Math.PI * r;\n  const ringRadius = Math.max(4, r - fontSize * 0.75);\n  const slot = Math.max(0, ringRadius * 2 - 10);\n  const unit = `${text}${separator}`;\n  const count =\n    repeat ?? Math.max(1, Math.round(circumference / Math.max(1, Array.from(unit).length * fontSize * GLYPH_ADVANCE)));\n  const path = `M ${half} ${half - r} a ${r} ${r} 0 1 1 0 ${2 * r} a ${r} ${r} 0 1 1 0 ${-2 * r}`;\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const spinner = spinRef.current;\n    if (!root || !spinner || reduceMotion || speed <= 0 || typeof spinner.animate !== \"function\") return;\n\n    const turn = direction === \"counterclockwise\" ? [\"rotate(0deg)\", \"rotate(-360deg)\"] : [\"rotate(0deg)\", \"rotate(360deg)\"];\n    const animation = spinner.animate(\n      [{ transform: turn[0] }, { transform: turn[1] }],\n      { duration: (360 / speed) * 1000, iterations: Infinity, easing: \"linear\" },\n    );\n\n    let inView = true;\n    let rate = 1;\n    let targetRate = 1;\n    let frame = 0;\n\n    // Ease the playback rate towards its target instead of jumping (setting it keeps currentTime).\n    const ease = () => {\n      frame = 0;\n      rate += (targetRate - rate) * 0.12;\n      if (Math.abs(targetRate - rate) < 0.01) rate = targetRate;\n      animation.playbackRate = rate;\n      if (rate !== targetRate) frame = requestAnimationFrame(ease);\n    };\n    engagedRef.current = (engaged: boolean) => {\n      targetRate = engaged ? HOVER_RATE[hover] ?? 1 : 1;\n      if (!frame) frame = requestAnimationFrame(ease);\n    };\n\n    const sync = () => {\n      if (inView && !document.hidden) animation.play();\n      else animation.pause();\n    };\n    const observer = new IntersectionObserver((entries) => {\n      inView = entries.some((entry) => entry.isIntersecting);\n      sync();\n    });\n    observer.observe(root);\n    document.addEventListener(\"visibilitychange\", sync);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      engagedRef.current = null;\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      animation.cancel();\n    };\n  }, [reduceMotion, speed, direction, hover]);\n\n  const handlePointerEnter = (event: React.PointerEvent<HTMLElement>) => {\n    onPointerEnter?.(event);\n    if (event.pointerType === \"mouse\") engagedRef.current?.(true);\n  };\n  const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {\n    onPointerLeave?.(event);\n    engagedRef.current?.(false);\n  };\n  const handleFocus = (event: React.FocusEvent<HTMLElement>) => {\n    onFocus?.(event);\n    if (event.currentTarget.matches(\":focus-visible\")) engagedRef.current?.(true);\n  };\n  const handleBlur = (event: React.FocusEvent<HTMLElement>) => {\n    onBlur?.(event);\n    engagedRef.current?.(false);\n  };\n\n  const name = label ?? text;\n  const Tag = (href ? \"a\" : \"div\") as React.ElementType;\n  const linkProps = href ? { href, target, rel, \"aria-label\": name } : {};\n\n  return (\n    <Tag\n      ref={rootRef}\n      className={`relative inline-flex shrink-0 items-center justify-center rounded-full text-zinc-900 outline-none dark:text-zinc-100 ${\n        href\n          ? \"focus-visible:ring-2 focus-visible:ring-[var(--ct-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950\"\n          : \"\"\n      } ${className}`}\n      style={{ [\"--ct-accent\" as string]: accent, width: size, height: size, ...style }}\n      onPointerEnter={handlePointerEnter}\n      onPointerLeave={handlePointerLeave}\n      onFocus={handleFocus}\n      onBlur={handleBlur}\n      {...linkProps}\n      {...props}\n    >\n      {href ? null : <span className=\"sr-only\">{name}</span>}\n      <svg\n        ref={spinRef}\n        aria-hidden=\"true\"\n        viewBox={`0 0 ${size} ${size}`}\n        width={size}\n        height={size}\n        className=\"pointer-events-none absolute inset-0 overflow-visible\"\n      >\n        <defs>\n          <path id={pathId} d={path} fill=\"none\" />\n        </defs>\n        <text\n          className={`fill-current ${textClassName}`}\n          style={{ fontSize, whiteSpace: \"pre\" }}\n        >\n          <textPath href={`#${pathId}`} textLength={circumference} lengthAdjust=\"spacing\">\n            {Array.from({ length: count }, (_, i) => (\n              <React.Fragment key={i}>\n                <tspan>{text}</tspan>\n                <tspan fill=\"var(--ct-accent)\">{separator}</tspan>\n              </React.Fragment>\n            ))}\n          </textPath>\n        </text>\n      </svg>\n      {ring ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute rounded-full border border-[var(--ct-accent)] opacity-40\"\n          style={{ width: ringRadius * 2, height: ringRadius * 2 }}\n        />\n      ) : null}\n      {children !== undefined && children !== null ? (\n        <span className=\"relative flex items-center justify-center rounded-full\" style={{ width: slot, height: slot }}>\n          {children}\n        </span>\n      ) : null}\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/circular-text"
}
