{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A PerspectiveText3D text animation component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  motion,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport type { RefObject } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type PerspectiveTextDriver = \"scroll\" | \"pointer\" | \"static\";\nexport type PerspectiveTextSplit = \"lines\" | \"words\";\n\nexport interface PerspectiveText3DProps {\n  /** Alternative to `text` when the content is static JSX-free copy. */\n  children?: string;\n  className?: string;\n  /** Scrollable ancestor observed when `driver` is \"scroll\". */\n  container?: RefObject<HTMLElement | null>;\n  /** Z-axis separation between lines/words, in pixels. */\n  depth?: number;\n  driver?: PerspectiveTextDriver;\n  /** CSS perspective distance, in pixels. */\n  perspective?: number;\n  /** Maximum rotation around the X axis, in degrees. */\n  rotateX?: number;\n  /** Maximum rotation around the Y axis, in degrees. */\n  rotateY?: number;\n  split?: PerspectiveTextSplit;\n  text?: string;\n}\n\nconst DEFAULT_PERSPECTIVE = 800;\nconst DEFAULT_DEPTH = 40;\nconst DEFAULT_ROTATE_X = 8;\nconst DEFAULT_ROTATE_Y = 10;\nconst POINTER_RANGE_MULTIPLIER = 2;\nconst SPRING_TRANSITION = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\n\nconst splitContent = (\n  content: string,\n  split: PerspectiveTextSplit\n): string[] => {\n  if (split === \"words\") {\n    return content.split(/\\s+/).filter((segment) => segment.length > 0);\n  }\n  const lines = content.split(\"\\n\").filter((line) => line.length > 0);\n  return lines.length > 0 ? lines : [content];\n};\n\n/**\n * PerspectiveText3D — text on a 3D plane that tilts and separates in\n * depth. Lines or words are pushed along the Z axis and the whole plane\n * reacts to scroll progress or the pointer.\n */\nexport default function PerspectiveText3D({\n  children,\n  className,\n  container,\n  depth = DEFAULT_DEPTH,\n  driver = \"static\",\n  perspective = DEFAULT_PERSPECTIVE,\n  rotateX = DEFAULT_ROTATE_X,\n  rotateY = DEFAULT_ROTATE_Y,\n  split = \"words\",\n  text,\n}: PerspectiveText3DProps) {\n  const content = text ?? children ?? \"\";\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const shouldReduceMotion = useReducedMotion();\n  const [pointerTilt, setPointerTilt] = useState({ x: 0, y: 0 });\n\n  useEffect(() => {\n    const wrapper = wrapperRef.current;\n    if (!wrapper || driver !== \"pointer\" || shouldReduceMotion) {\n      return;\n    }\n\n    const handlePointerMove = (event: PointerEvent) => {\n      const rect = wrapper.getBoundingClientRect();\n      const relativeX = (event.clientX - rect.left) / rect.width - 0.5;\n      const relativeY = (event.clientY - rect.top) / rect.height - 0.5;\n      setPointerTilt({\n        x: -relativeY * rotateX * POINTER_RANGE_MULTIPLIER,\n        y: relativeX * rotateY * POINTER_RANGE_MULTIPLIER,\n      });\n    };\n    const handlePointerLeave = () => setPointerTilt({ x: 0, y: 0 });\n\n    wrapper.addEventListener(\"pointermove\", handlePointerMove);\n    wrapper.addEventListener(\"pointerleave\", handlePointerLeave);\n    return () => {\n      wrapper.removeEventListener(\"pointermove\", handlePointerMove);\n      wrapper.removeEventListener(\"pointerleave\", handlePointerLeave);\n    };\n  }, [driver, rotateX, rotateY, shouldReduceMotion]);\n\n  const { scrollYProgress } = useScroll({ container, target: wrapperRef });\n  const scrollRotateXRaw = useTransform(\n    scrollYProgress,\n    [0, 1],\n    [rotateX, -rotateX]\n  );\n  const scrollRotateYRaw = useTransform(\n    scrollYProgress,\n    [0, 1],\n    [-rotateY, rotateY]\n  );\n  const scrollRotateX = useSpring(scrollRotateXRaw, SPRING_TRANSITION);\n  const scrollRotateY = useSpring(scrollRotateYRaw, SPRING_TRANSITION);\n\n  if (shouldReduceMotion) {\n    return (\n      <p className={className} style={{ whiteSpace: \"pre-line\" }}>\n        {content}\n      </p>\n    );\n  }\n\n  const segments = splitContent(content, split);\n  const midIndex = (segments.length - 1) / 2;\n  const planeAnimate =\n    driver === \"pointer\"\n      ? { rotateX: pointerTilt.x, rotateY: pointerTilt.y }\n      : driver === \"static\"\n        ? { rotateX, rotateY }\n        : undefined;\n  const planeInitial =\n    driver === \"scroll\" ? undefined : { rotateX: 0, rotateY: 0 };\n\n  return (\n    <div\n      className={cn(\"inline-block\", className)}\n      ref={wrapperRef}\n      style={{ perspective }}\n    >\n      {/* Every segment below is aria-hidden, so the readable copy lives here.\n          An aria-label on this plain div would be dropped by assistive tech. */}\n      <span className=\"sr-only\">{content}</span>\n      <motion.div\n        animate={planeAnimate}\n        initial={planeInitial}\n        style={{\n          transformStyle: \"preserve-3d\",\n          ...(driver === \"scroll\"\n            ? { rotateX: scrollRotateX, rotateY: scrollRotateY }\n            : {}),\n        }}\n        transition={SPRING_TRANSITION}\n      >\n        {segments.map((segment, index) => (\n          <div\n            aria-hidden=\"true\"\n            // biome-ignore lint/suspicious/noArrayIndexKey: segments have no stable id\n            key={index}\n            style={{\n              display: split === \"lines\" ? \"block\" : \"inline-block\",\n              transform: `translateZ(${(index - midIndex) * depth}px)`,\n            }}\n          >\n            {segment}\n            {split === \"words\" && index < segments.length - 1 ? \" \" : null}\n          </div>\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/perspective-text-3d/index.tsx","type":"registry:ui"}],"name":"perspective-text-3d","registryDependencies":[],"title":"Perspective Text 3d","type":"registry:ui"}