{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-image-reveal",
  "type": "registry:ui",
  "title": "Text Image Reveal",
  "description": "Editorial text where marked words reveal an image: an inline pill that pushes the line apart, or a floating print that follows the pointer.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/text-image-reveal.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useMotionValue, useSpring, useTransform, useVelocity } from \"motion/react\";\n\nexport type TextImageRevealMode = \"inline\" | \"float\";\nexport type TextImageRevealCue = \"underline\" | \"dotted\" | \"highlight\";\nexport interface TextImageRevealSpring {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface TextImageRevealProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Running text. Mark revealable words with `<TextImageReveal.Word image alt>`. */\n  children: React.ReactNode;\n  /** \"inline\" grows a pill-shaped image inside the line; \"float\" shows a larger image near the pointer or word. */\n  mode?: TextImageRevealMode;\n  /** Width of the inline pill, in em (it scales with the font size). */\n  pillWidth?: number;\n  /** Width of the floating image in px (height is 3:4 of it). */\n  floatWidth?: number;\n  /** Maximum tilt of the floating image, in degrees. It leans with the pointer's horizontal speed. */\n  tilt?: number;\n  /** Float mode: the image follows the mouse. Off: it anchors above the word. */\n  followPointer?: boolean;\n  /** Spring for the pill width and the floating image. */\n  spring?: TextImageRevealSpring;\n  /** How revealable words are marked in the text. */\n  cue?: TextImageRevealCue;\n  /** Accent for the cue and focus ring. */\n  accent?: string;\n}\n\nexport interface TextImageRevealWordProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> {\n  /** Image URL (a data URI works too). */\n  image: string;\n  /** Describes the image. Exposed to assistive tech through aria-describedby. */\n  alt: string;\n  children: React.ReactNode;\n}\n\ninterface RevealContext {\n  mode: TextImageRevealMode;\n  pillWidth: number;\n  floatWidth: number;\n  tilt: number;\n  followPointer: boolean;\n  spring: TextImageRevealSpring;\n  cue: TextImageRevealCue;\n  reduced: boolean;\n  rootRef: React.RefObject<HTMLDivElement | null>;\n}\n\nconst DEFAULT_SPRING: TextImageRevealSpring = { stiffness: 320, damping: 28, mass: 0.7 };\nconst RevealCtx = React.createContext<RevealContext | null>(null);\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst CUES: Record<TextImageRevealCue, string> = {\n  underline:\n    \"underline decoration-[var(--tir-accent)] decoration-[0.07em] underline-offset-[0.2em] data-[open=true]:decoration-[0.12em]\",\n  dotted:\n    \"underline decoration-dotted decoration-zinc-400 decoration-[0.08em] underline-offset-[0.2em] data-[open=true]:decoration-solid data-[open=true]:decoration-[var(--tir-accent)] dark:decoration-zinc-500\",\n  highlight:\n    \"rounded-[0.2em] bg-[color-mix(in_srgb,var(--tir-accent)_16%,transparent)] px-[0.12em] data-[open=true]:bg-[color-mix(in_srgb,var(--tir-accent)_28%,transparent)]\",\n};\n\n/**\n * Editorial text in which marked words reveal an image: inline (the word grows a pill-shaped\n * thumbnail and pushes the line apart) or float (a larger image springs in near the pointer and\n * leans with its speed). Words are real buttons: hover, keyboard focus or a tap reveals the image,\n * Escape hides it, and the image's description is linked with aria-describedby.\n */\nexport function TextImageReveal({\n  children,\n  mode = \"inline\",\n  pillWidth = 2.4,\n  floatWidth = 200,\n  tilt = 8,\n  followPointer = true,\n  spring = DEFAULT_SPRING,\n  cue = \"underline\",\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...rest\n}: TextImageRevealProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const reduced = usePrefersReducedMotion();\n  const ctx: RevealContext = { mode, pillWidth, floatWidth, tilt, followPointer, spring, cue, reduced, rootRef };\n\n  return (\n    <RevealCtx.Provider value={ctx}>\n      <div\n        ref={rootRef}\n        {...rest}\n        className={`relative ${className}`}\n        style={{ ...style, [\"--tir-accent\" as string]: accent }}\n      >\n        {children}\n      </div>\n    </RevealCtx.Provider>\n  );\n}\n\n/** A word inside <TextImageReveal> that reveals an image. Also available as `TextImageReveal.Word`. */\nexport function TextImageRevealWord({\n  image,\n  alt,\n  children,\n  className = \"\",\n  onPointerDown,\n  onPointerEnter,\n  onPointerMove,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  onClick,\n  onKeyDown,\n  ...rest\n}: TextImageRevealWordProps) {\n  const parent = React.useContext(RevealCtx);\n  const fallbackRef = React.useRef<HTMLDivElement>(null);\n  const ctx: RevealContext = parent ?? {\n    mode: \"inline\",\n    pillWidth: 2.4,\n    floatWidth: 200,\n    tilt: 8,\n    followPointer: true,\n    spring: DEFAULT_SPRING,\n    cue: \"underline\",\n    reduced: false,\n    rootRef: fallbackRef,\n  };\n  const { mode, pillWidth, floatWidth, tilt, followPointer, spring, cue, reduced } = ctx;\n\n  const [open, setOpen] = React.useState(false);\n  const descId = React.useId();\n  const buttonRef = React.useRef<HTMLButtonElement>(null);\n  const lastPointer = React.useRef(\"mouse\");\n  const floatHeight = Math.round(floatWidth * 0.75);\n\n  // Floating image position (relative to the root), smoothed by a spring; the lean follows its velocity.\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const springX = useSpring(x, spring);\n  const springY = useSpring(y, spring);\n  const velocityX = useVelocity(springX);\n  const lean = useTransform(velocityX, [-1400, 0, 1400], [-tilt, 0, tilt], { clamp: true });\n  // Alternate the resting tilt between words so a sentence of images doesn't look stamped.\n  const side = image.length % 2 === 0 ? 1 : -1;\n\n  const rootBox = () => ctx.rootRef.current?.getBoundingClientRect() ?? null;\n\n  /** Places the image centred above a point (in root coordinates), kept inside the root's width. */\n  const placeAt = (px: number, py: number, jump: boolean) => {\n    const root = rootBox();\n    const width = root?.width ?? floatWidth;\n    const nx = Math.max(-16, Math.min(width - floatWidth + 16, px - floatWidth / 2));\n    const ny = py - floatHeight - 14 < -floatHeight * 0.6 ? py + 28 : py - floatHeight - 14;\n    x.set(Math.round(nx));\n    y.set(Math.round(ny));\n    if (jump) {\n      springX.jump(Math.round(nx));\n      springY.jump(Math.round(ny));\n    }\n  };\n\n  /** Anchors the floating image above the word (keyboard, touch, or followPointer off). */\n  const placeAtWord = (jump: boolean) => {\n    const root = rootBox();\n    const word = buttonRef.current?.getBoundingClientRect();\n    if (!root || !word) return;\n    placeAt(word.left - root.left + word.width / 2, word.top - root.top, jump);\n  };\n\n  const show = (anchor: \"word\" | { clientX: number; clientY: number }) => {\n    if (mode === \"float\") {\n      if (anchor === \"word\" || !followPointer) placeAtWord(true);\n      else {\n        const root = rootBox();\n        if (root) placeAt(anchor.clientX - root.left, anchor.clientY - root.top, true);\n      }\n    }\n    setOpen(true);\n  };\n\n  // Touch / pen: a tap elsewhere closes the image (some browsers never blur a tapped button).\n  React.useEffect(() => {\n    if (!open) return;\n    const onDown = (event: PointerEvent) => {\n      if (event.pointerType === \"mouse\") return;\n      if (buttonRef.current?.contains(event.target as Node)) return;\n      setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onDown, true);\n  }, [open]);\n\n  const cueClass = CUES[cue];\n\n  return (\n    <>\n      <button\n        ref={buttonRef}\n        type=\"button\"\n        {...rest}\n        aria-expanded={open}\n        aria-describedby={descId}\n        data-open={open}\n        className={`inline cursor-default rounded-[0.18em] bg-transparent p-0 text-left [color:inherit] [font:inherit] [letter-spacing:inherit] outline-hidden transition-[text-decoration-color,background-color,text-decoration-thickness] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tir-accent)] ${cueClass} ${className}`}\n        onPointerDown={(event) => {\n          lastPointer.current = event.pointerType;\n          onPointerDown?.(event);\n        }}\n        onPointerEnter={(event) => {\n          lastPointer.current = event.pointerType;\n          if (event.pointerType === \"mouse\") show({ clientX: event.clientX, clientY: event.clientY });\n          onPointerEnter?.(event);\n        }}\n        onPointerMove={(event) => {\n          // Motion values only — no React state on pointer move.\n          if (event.pointerType === \"mouse\" && mode === \"float\" && followPointer && !reduced) {\n            const root = rootBox();\n            if (root) placeAt(event.clientX - root.left, event.clientY - root.top, false);\n          }\n          onPointerMove?.(event);\n        }}\n        onPointerLeave={(event) => {\n          // Keep it open while the word also has keyboard focus.\n          if (event.pointerType === \"mouse\" && !buttonRef.current?.matches(\":focus-visible\")) setOpen(false);\n          onPointerLeave?.(event);\n        }}\n        onFocus={(event) => {\n          // Keyboard focus reveals; a mouse click or tap that focuses the button does not.\n          if (event.currentTarget.matches(\":focus-visible\")) show(\"word\");\n          onFocus?.(event);\n        }}\n        onBlur={(event) => {\n          setOpen(false);\n          onBlur?.(event);\n        }}\n        onClick={(event) => {\n          // Mouse hover already reveals; taps and Enter / Space toggle.\n          const keyboard = event.detail === 0;\n          if (keyboard || lastPointer.current !== \"mouse\") {\n            if (open) setOpen(false);\n            else show(\"word\");\n          }\n          onClick?.(event);\n        }}\n        onKeyDown={(event) => {\n          // Escape hides only this image, and only while it is showing.\n          if (event.key === \"Escape\" && open) {\n            event.preventDefault();\n            event.stopPropagation();\n            setOpen(false);\n          }\n          onKeyDown?.(event);\n        }}\n      >\n        {children}\n        {mode === \"inline\" ? (\n          <motion.span\n            aria-hidden=\"true\"\n            initial={false}\n            animate={{ width: open ? `${pillWidth}em` : \"0em\", marginLeft: open ? \"0.22em\" : \"0em\", opacity: open ? 1 : 0 }}\n            transition={reduced ? { duration: 0 } : { type: \"spring\", ...spring, opacity: { duration: 0.18 } }}\n            className=\"inline-block h-[0.82em] overflow-hidden rounded-full align-[-0.1em] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12)]\"\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element -- the shipped component must not depend on next/image */}\n            <img\n              src={image}\n              alt=\"\"\n              draggable={false}\n              decoding=\"async\"\n              className=\"block h-full max-w-none object-cover\"\n              style={{ width: `${pillWidth}em` }}\n            />\n          </motion.span>\n        ) : null}\n      </button>\n      {/* Hidden from reading order but still resolved by aria-describedby. */}\n      <span id={descId} hidden>\n        {`Image: ${alt}`}\n      </span>\n      {mode === \"float\" ? (\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 z-20 block\"\n          style={{ x: reduced ? x : springX, y: reduced ? y : springY, rotate: reduced ? 0 : lean, width: floatWidth, height: floatHeight }}\n        >\n          <AnimatePresence>\n            {open ? (\n              <motion.span\n                key=\"image\"\n                className=\"block size-full overflow-hidden rounded-xl bg-zinc-200 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.45),0_0_0_1px_rgb(0_0_0/0.08)] dark:bg-zinc-800 dark:shadow-[0_24px_48px_-20px_rgb(0_0_0/0.8),0_0_0_1px_rgb(255_255_255/0.1)]\"\n                initial={reduced ? { opacity: 1 } : { opacity: 0, scale: 0.6, rotate: -side * tilt }}\n                animate={{ opacity: 1, scale: 1, rotate: reduced ? 0 : side * tilt * 0.35 }}\n                exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.85, transition: { duration: 0.16 } }}\n                transition={reduced ? { duration: 0 } : { type: \"spring\", ...spring, opacity: { duration: 0.16 } }}\n              >\n                {/* eslint-disable-next-line @next/next/no-img-element -- the shipped component must not depend on next/image */}\n                <img src={image} alt=\"\" draggable={false} decoding=\"async\" className=\"block size-full object-cover\" />\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </motion.span>\n      ) : null}\n    </>\n  );\n}\n\nTextImageReveal.Word = TextImageRevealWord;\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/text-image-reveal"
}
