{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-prism",
  "type": "registry:ui",
  "title": "Text Prism",
  "description": "Words printed on the faces of a 3D prism that tumbles to the next word, with the width reserved for the longest.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/text-prism.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nfunction subscribeVisibility(callback: () => void) {\n  document.addEventListener(\"visibilitychange\", callback);\n  return () => document.removeEventListener(\"visibilitychange\", callback);\n}\n\nexport type TextPrismShape = \"triangle\" | \"square\";\nexport type TextPrismAxis = \"x\" | \"y\";\nexport type TextPrismVariant = \"accent\" | \"plain\" | \"solid\";\n\nexport interface TextPrismProps extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  /** Words placed on the prism faces, shown in order. */\n  words: string[];\n  /** Time each word stays in front, in ms (includes the turn). */\n  interval?: number;\n  /** Length of one turn in ms. */\n  duration?: number;\n  /** Triangular (3 faces) or square (4 faces) prism. */\n  shape?: TextPrismShape;\n  /** `x` tumbles vertically (next word rolls up from below); `y` spins sideways. */\n  axis?: TextPrismAxis;\n  /** CSS perspective in px — lower is more dramatic. */\n  perspective?: number;\n  /** `accent` colours the words, `plain` inherits the text colour, `solid` puts them on accent faces. */\n  variant?: TextPrismVariant;\n  /** Hold the current word while a mouse is over it. */\n  pauseOnHover?: boolean;\n  /** Stop after this many passes through the list, on the last word. 0 = loop forever. */\n  loops?: number;\n  /** Show a small pause / play button while the prism is looping. */\n  showPauseButton?: boolean;\n  /** Word colour, solid face colour and focus ring (any CSS colour). */\n  accent?: string;\n  /** Screen-reader text read once instead of the rotating words. Defaults to the words joined by commas. */\n  srLabel?: string;\n}\n\nconst mod = (n: number, m: number) => ((n % m) + m) % m;\nconst OVERSHOOT = \"cubic-bezier(0.34, 1.32, 0.5, 1)\";\n\n/**\n * Words printed on the faces of a CSS 3D prism that tumbles to the next word. Faces sit at\n * translateZ(r) around the axis, where r is derived from the measured line height (or the reserved\n * width for the y axis), and the faces are refilled as they rotate out of view, so any number of\n * words works with three or four faces. The visible prism is aria-hidden; screen readers get the\n * full list once. Reduced motion swaps the rotation for a crossfade.\n */\nexport function TextPrism({\n  words,\n  interval = 2400,\n  duration = 700,\n  shape = \"triangle\",\n  axis = \"x\",\n  perspective = 600,\n  variant = \"accent\",\n  pauseOnHover = true,\n  loops = 0,\n  showPauseButton = true,\n  accent = \"#FF7448\",\n  srLabel,\n  className = \"\",\n  style,\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: TextPrismProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const documentHidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);\n  const stageRef = React.useRef<HTMLSpanElement>(null);\n\n  const [step, setStep] = React.useState(0);\n  const [hovered, setHovered] = React.useState(false);\n  const [userPaused, setUserPaused] = React.useState(false);\n  const [inView, setInView] = React.useState(true);\n\n  const count = words.length;\n  const faces = shape === \"square\" ? 4 : 3;\n  const angle = 360 / faces;\n  const finished = loops > 0 && step >= loops * count - 1;\n  const running = count > 1 && !finished && !userPaused && !(pauseOnHover && hovered) && inView && !documentHidden;\n\n  React.useEffect(() => {\n    if (!running) return;\n    const timer = window.setTimeout(() => setStep((s) => s + 1), Math.max(interval, duration + 100));\n    return () => window.clearTimeout(timer);\n  }, [running, step, interval, duration]);\n\n  // Pause offscreen; publish the measured size so the face radius matches the real line box.\n  React.useEffect(() => {\n    const stage = stageRef.current;\n    if (!stage) return;\n    const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));\n    io.observe(stage);\n    const ro = new ResizeObserver(() => {\n      stage.style.setProperty(\"--tp-h\", `${Math.round(stage.offsetHeight * 100) / 100}px`);\n      stage.style.setProperty(\"--tp-w\", `${Math.round(stage.offsetWidth * 100) / 100}px`);\n    });\n    ro.observe(stage);\n    return () => {\n      io.disconnect();\n      ro.disconnect();\n    };\n  }, []);\n\n  const index = count ? mod(step, count) : 0;\n  const solid = variant === \"solid\";\n  const pad = solid ? \"px-[0.22em]\" : \"\";\n  // Apothem of the regular prism: half the face size / tan(π / faces).\n  const k = Math.round((0.5 / Math.tan(Math.PI / faces)) * 10000) / 10000;\n  const radius = axis === \"x\" ? `calc(var(--tp-h, 1.2em) * ${k})` : `calc(var(--tp-w, 4em) * ${k})`;\n  const rotate = axis === \"x\" ? \"rotateX\" : \"rotateY\";\n  const sign = axis === \"x\" ? 1 : -1;\n  const turn = `${duration}ms ${OVERSHOOT}`;\n\n  const faceTone =\n    variant === \"accent\"\n      ? \"text-[var(--tp-accent)]\"\n      : solid\n        ? \"rounded-[0.14em] bg-[var(--tp-accent)] text-zinc-950 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12),inset_0_1px_0_rgb(255_255_255/0.35)]\"\n        : \"\";\n\n  const prism = (\n    <span\n      className=\"pointer-events-none absolute inset-0 [transform-style:preserve-3d]\"\n      style={{\n        transform: `translateZ(calc(${radius} * -1)) ${rotate}(${sign * step * angle}deg)`,\n        transition: `transform ${turn}`,\n      }}\n    >\n      {Array.from({ length: faces }, (_, face) => {\n        // The latest step (up to the next one) that lands on this face decides its word.\n        const t = step + 1 - mod(step + 1 - face, faces);\n        const front = mod(step, faces) === face;\n        return (\n          <span\n            key={face}\n            className={`absolute inset-0 flex items-center whitespace-nowrap [backface-visibility:hidden] ${pad} ${faceTone}`}\n            style={{\n              transform: `${rotate}(${-sign * face * angle}deg) translateZ(${radius})`,\n              opacity: front ? 1 : solid ? 1 : 0.2,\n              filter: solid && !front ? \"brightness(0.55)\" : undefined,\n              transition: `opacity ${duration}ms ease, filter ${duration}ms ease`,\n            }}\n          >\n            {count ? words[mod(t, count)] : null}\n          </span>\n        );\n      })}\n    </span>\n  );\n\n  const crossfade = words.map((word, i) => (\n    <span\n      key={i}\n      className={`col-start-1 row-start-1 whitespace-nowrap transition-opacity duration-500 ${pad} ${faceTone}`}\n      style={{ opacity: i === index ? 1 : 0 }}\n    >\n      {word}\n    </span>\n  ));\n\n  const canPause = showPauseButton && count > 1 && !finished;\n\n  return (\n    <span\n      {...props}\n      className={`inline-flex items-baseline gap-[0.18em] ${className}`}\n      style={{ [\"--tp-accent\" as string]: accent, ...style }}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        if (event.pointerType === \"mouse\") setHovered(true);\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        setHovered(false);\n      }}\n    >\n      <span className=\"sr-only\">{srLabel ?? words.join(\", \")}</span>\n      <span\n        ref={stageRef}\n        aria-hidden=\"true\"\n        data-paused={!running || undefined}\n        className=\"relative inline-grid align-baseline\"\n        style={{ perspective: `${perspective}px` }}\n      >\n        {/* Every word stacked invisibly in one cell reserves the width of the longest one. */}\n        {reduceMotion\n          ? crossfade\n          : words.map((word, i) => (\n              <span key={i} className={`invisible col-start-1 row-start-1 whitespace-nowrap ${pad}`}>\n                {word}\n              </span>\n            ))}\n        {!reduceMotion && prism}\n      </span>\n      {canPause && (\n        <button\n          type=\"button\"\n          onClick={() => setUserPaused((p) => !p)}\n          aria-label={userPaused ? \"Resume word rotation\" : \"Pause word rotation\"}\n          className=\"relative inline-grid size-6 shrink-0 cursor-pointer place-items-center self-center rounded-full border border-current/15 text-current/60 outline-hidden transition-colors hover:border-current/40 hover:text-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tp-accent)]\"\n        >\n          <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-2.5\" fill=\"currentColor\">\n            {userPaused ? <path d=\"M3 1.8v8.4L10 6 3 1.8Z\" /> : <path d=\"M2.5 1.5h2.4v9H2.5zM7.1 1.5h2.4v9H7.1z\" />}\n          </svg>\n        </button>\n      )}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/text-prism"
}
