{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "extruded-text",
  "type": "registry:ui",
  "title": "Extruded Text",
  "description": "Display text with real 3D depth from stacked, shaded layers that turns toward the pointer on a spring.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/extruded-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useMotionValue, useSpring, useTransform } from \"motion/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\ntype Tag = \"h1\" | \"h2\" | \"h3\" | \"p\" | \"div\" | \"span\";\n\nexport interface ExtrudedSpring {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface ExtrudedTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** The display text. */\n  children: string;\n  /** Element to render. */\n  as?: Tag;\n  /** Number of stacked layers behind the face (capped at 24). */\n  depth?: number;\n  /** Distance between layers in px. Total depth = depth × spacing. */\n  spacing?: number;\n  /** Side colour nearest the face (any CSS colour). */\n  sideColor?: string;\n  /** Side colour at the back; layers blend from sideColor to this. */\n  sideColorEnd?: string;\n  /** Maximum rotation toward the pointer, in degrees. */\n  maxTilt?: number;\n  /** Spring for the rotation. */\n  spring?: ExtrudedSpring;\n  /** Gentle idle sway while the pointer is away. */\n  sway?: boolean;\n  /** CSS perspective in px. */\n  perspective?: number;\n}\n\nconst MAX_LAYERS = 24;\n/** Resting angle: looking slightly up and left, so the extrusion shows below and to the right. */\nconst REST = { x: 14, y: -18 };\n\n/**\n * 3D extruded display text built from stacked copies of the text at increasing translateZ depths\n * inside a preserve-3d group. The group turns toward the pointer on a spring. Each layer blends from\n * the side colour to the end colour and darkens with depth; a light term driven by the current angle\n * (a CSS variable fed by a motion value) brightens or shades the sides as the text turns, without\n * re-rendering. Only the front face is exposed to assistive tech.\n */\nexport function ExtrudedText({\n  children,\n  as = \"div\",\n  depth = 14,\n  spacing = 1.5,\n  sideColor = \"#FF7448\",\n  sideColorEnd = \"#3B1206\",\n  maxTilt = 18,\n  spring = { stiffness: 120, damping: 14, mass: 0.8 },\n  sway = true,\n  perspective = 900,\n  className = \"\",\n  style,\n  onPointerEnter,\n  onPointerMove,\n  onPointerLeave,\n  ...props\n}: ExtrudedTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const documentHidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);\n  const rootRef = React.useRef<HTMLElement | null>(null);\n  const [hovering, setHovering] = React.useState(false);\n  const [inView, setInView] = React.useState(true);\n\n  const rxTarget = useMotionValue(REST.x);\n  const ryTarget = useMotionValue(REST.y);\n  const rotateX = useSpring(rxTarget, spring);\n  const rotateY = useSpring(ryTarget, spring);\n  // 0 = sides facing the light (top-left), 1 = sides in shadow.\n  const shadow = useTransform(() => {\n    const v = 0.5 + (rotateX.get() - rotateY.get()) / (4 * Math.max(maxTilt, 1));\n    return Math.round(Math.min(1, Math.max(0, v)) * 1000) / 1000;\n  });\n\n  const layers = Math.min(MAX_LAYERS, Math.max(1, Math.round(depth)));\n  const swaying = sway && !reduceMotion && !hovering && inView && !documentHidden;\n\n  const setRoot = (node: HTMLElement | null) => {\n    rootRef.current = node;\n  };\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));\n    io.observe(root);\n    return () => io.disconnect();\n  }, []);\n\n  React.useEffect(() => {\n    if (reduceMotion) {\n      rxTarget.jump(REST.x);\n      ryTarget.jump(REST.y);\n      rotateX.jump(REST.x);\n      rotateY.jump(REST.y);\n    }\n  }, [reduceMotion, rxTarget, ryTarget, rotateX, rotateY]);\n\n  // Idle sway: a slow figure-of-eight around the resting angle.\n  React.useEffect(() => {\n    if (!swaying) return;\n    let frame = 0;\n    const start = performance.now();\n    const tick = (now: number) => {\n      const t = (now - start) / 1000;\n      rxTarget.set(REST.x + Math.sin(t * 0.7) * 4);\n      ryTarget.set(REST.y + Math.sin(t * 0.45) * 7);\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [swaying, rxTarget, ryTarget]);\n\n  const track = (event: React.PointerEvent<HTMLElement>) => {\n    if (reduceMotion || event.pointerType === \"touch\") return;\n    const rect = event.currentTarget.getBoundingClientRect();\n    const px = (event.clientX - rect.left) / rect.width - 0.5;\n    const py = (event.clientY - rect.top) / rect.height - 0.5;\n    // Face turns to look at the pointer: right → rotateY+, up → rotateX+.\n    ryTarget.set(px * 2 * maxTilt);\n    rxTarget.set(-py * 2 * maxTilt);\n  };\n\n  const Component = as as React.ElementType;\n\n  return (\n    <Component\n      ref={setRoot}\n      {...props}\n      className={`relative inline-block p-[0.12em] ${className}`}\n      style={{ perspective: `${perspective}px`, ...style }}\n      onPointerEnter={(event: React.PointerEvent<HTMLElement>) => {\n        onPointerEnter?.(event);\n        if (event.pointerType !== \"touch\" && !reduceMotion) setHovering(true);\n        track(event);\n      }}\n      onPointerMove={(event: React.PointerEvent<HTMLElement>) => {\n        onPointerMove?.(event);\n        track(event);\n      }}\n      onPointerLeave={(event: React.PointerEvent<HTMLElement>) => {\n        onPointerLeave?.(event);\n        setHovering(false);\n        if (!reduceMotion) {\n          rxTarget.set(REST.x);\n          ryTarget.set(REST.y);\n        }\n      }}\n    >\n      <motion.span\n        className=\"relative block [transform-style:preserve-3d] will-change-transform\"\n        style={{ rotateX, rotateY, [\"--xt-shadow\" as string]: shadow }}\n      >\n        {Array.from({ length: layers }, (_, index) => {\n          const i = index + 1;\n          const t = layers > 1 ? (i - 1) / (layers - 1) : 0;\n          const mix = `color-mix(in oklab, ${sideColor}, ${sideColorEnd} ${Math.round(t * 100)}%)`;\n          // Deeper layers darken (occlusion); the angle-driven --xt-shadow adds up to 30% more.\n          const dark = `clamp(0%, calc(${Math.round(4 + t * 26)}% + var(--xt-shadow, 0.5) * 30%), 90%)`;\n          // The layer right behind the face is lifted slightly to read as a bevel highlight.\n          const color = i === 1 ? `color-mix(in oklab, ${sideColor}, white 22%)` : `color-mix(in oklab, ${mix}, black ${dark})`;\n          return (\n            <span\n              key={i}\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 select-none\"\n              style={{ transform: `translateZ(${Math.round(-i * spacing * 100) / 100}px)`, color }}\n            >\n              {children}\n            </span>\n          );\n        })}\n        <span className=\"relative block\">{children}</span>\n      </motion.span>\n    </Component>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/extruded-text"
}
