{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hover-preview-link","type":"registry:component","title":"Hover preview link","description":"Hover a link to get a preview card that trails your cursor and leans as it moves.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/hover-preview-link.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  useVelocity,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\nconst CARD_W = 232;\nconst GAP = 10;\nconst GUTTER = 8;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Loose enough that the card visibly trails the cursor, tight enough that\n// it never feels like it is swimming.\nconst FOLLOW = { stiffness: 260, damping: 26, mass: 0.6 } as const;\n// A card dragged sideways leans into the motion; 7deg at a brisk flick is\n// noticeable without looking like it will fall off.\nconst MAX_TILT = 7;\nconst TILT_AT_SPEED = 1400;\n// Holds the card up after a touch press ends, so it can actually be read.\nconst TOUCH_LINGER = 1400;\n\ntype Mode = \"cursor\" | \"anchored\";\n\nexport function HoverPreviewLink({\n  href,\n  title,\n  domain,\n  image,\n  children,\n  simulate,\n  className,\n}: {\n  /** Acts out a pointer without one, for demos: x is where it sits across\n   * the link (0 to 1), below puts the card under the text. null: gone. */\n  simulate?: { x: number; below?: boolean } | null;\n  href: string;\n  title: string;\n  domain: string;\n  image: ReactNode;\n  children: ReactNode;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const wrap = useRef<HTMLSpanElement>(null);\n  const [open, setOpen] = useState<Mode | null>(null);\n  const [below, setBelow] = useState(false);\n  const linger = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  // x is the card's left edge relative to the link, raw from the pointer;\n  // the spring gives it lag and its velocity drives the lean.\n  const targetX = useMotionValue(0);\n  const x = useSpring(targetX, FOLLOW);\n  const speed = useVelocity(x);\n  const tilt = useSpring(\n    useTransform(speed, [-TILT_AT_SPEED, TILT_AT_SPEED], [MAX_TILT, -MAX_TILT], {\n      clamp: true,\n    }),\n    { stiffness: 400, damping: 30 },\n  );\n\n  useEffect(() => () => clearTimeout(linger.current), []);\n\n  // Screen pixels per layout pixel: not 1 when an ancestor is scaled (a\n  // zoomed-out preview), and x is in layout pixels.\n  const scaleOf = (el: HTMLElement, r: DOMRect) =>\n    el.offsetWidth ? r.width / el.offsetWidth : 1;\n\n  // Keeps the card inside the viewport so it can't widen a phone page.\n  const clampLeft = (left: number) => {\n    const el = wrap.current;\n    if (!el) return left;\n    const r = el.getBoundingClientRect();\n    const s = scaleOf(el, r);\n    const vw = document.documentElement.clientWidth;\n    const min = (GUTTER - r.left) / s;\n    const max = (vw - GUTTER - r.left) / s - CARD_W;\n    return Math.round(Math.min(Math.max(left, min), Math.max(min, max)));\n  };\n\n  const place = (mode: Mode, clientX?: number) => {\n    const el = wrap.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const centre =\n      mode === \"cursor\" && clientX !== undefined\n        ? (clientX - r.left) / scaleOf(el, r)\n        : el.offsetWidth / 2;\n    return clampLeft(centre - CARD_W / 2);\n  };\n\n  const show = (mode: Mode, clientX?: number) => {\n    const el = wrap.current;\n    const left = place(mode, clientX);\n    if (!el || left === undefined) return;\n    clearTimeout(linger.current);\n    // Flip below when there isn't room above in the viewport.\n    setBelow(el.getBoundingClientRect().top < 200);\n    if (!open) {\n      // Appears where the pointer is, rather than sliding in from the last spot.\n      targetX.jump(left);\n      x.jump(left);\n      tilt.jump(0);\n    } else {\n      targetX.set(left);\n    }\n    setOpen(mode);\n  };\n\n  const hide = () => {\n    clearTimeout(linger.current);\n    setOpen(null);\n  };\n\n  // A simulated pointer: the card appears where it lands and then trails it\n  // through the same spring, so the lean is the real one.\n  const simX = simulate?.x;\n  const simBelow = simulate?.below ?? false;\n  const simOpen = useRef(false);\n  useEffect(() => {\n    const el = wrap.current;\n    if (!el) return;\n    if (simX === undefined) {\n      if (simOpen.current) setOpen(null);\n      simOpen.current = false;\n      return;\n    }\n    const left = clampLeft(simX * el.offsetWidth - CARD_W / 2);\n    if (!simOpen.current) {\n      targetX.jump(left);\n      x.jump(left);\n      tilt.jump(0);\n      setBelow(simBelow);\n      setOpen(\"cursor\");\n      simOpen.current = true;\n    } else {\n      targetX.set(left);\n    }\n    // Only a new simulated position should move the card.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [simX, simBelow]);\n\n  return (\n    <span ref={wrap} className=\"relative inline-block\">\n      <a\n        href={href}\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        onPointerEnter={(e) => {\n          if (e.pointerType !== \"touch\") show(\"cursor\", e.clientX);\n        }}\n        onPointerMove={(e) => {\n          if (e.pointerType === \"touch\" || !open) return;\n          if (reduceMotion) return;\n          targetX.set(place(\"cursor\", e.clientX) ?? 0);\n        }}\n        onPointerLeave={(e) => {\n          if (e.pointerType !== \"touch\") hide();\n        }}\n        onPointerDown={(e) => {\n          if (e.pointerType === \"touch\") show(\"anchored\");\n        }}\n        onPointerUp={(e) => {\n          if (e.pointerType !== \"touch\") return;\n          clearTimeout(linger.current);\n          linger.current = setTimeout(hide, TOUCH_LINGER);\n        }}\n        onPointerCancel={hide}\n        onFocus={(e) => {\n          if (e.currentTarget.matches(\":focus-visible\")) show(\"anchored\");\n        }}\n        onBlur={hide}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") hide();\n        }}\n        className={cn(\n          \"rounded-[2px] font-medium text-foreground underline decoration-border decoration-1 underline-offset-4 outline-hidden transition-[text-decoration-color] duration-150 ease-out hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n          className,\n        )}\n      >\n        {children}\n      </a>\n\n      <AnimatePresence>\n        {open ? (\n          <motion.span\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute left-0 z-20 block\",\n              below ? \"top-full\" : \"bottom-full\",\n            )}\n            style={{\n              x,\n              rotate: reduceMotion ? 0 : tilt,\n              width: CARD_W,\n              paddingTop: below ? GAP : 0,\n              paddingBottom: below ? 0 : GAP,\n              // Pivots at the edge nearest the link, like it is held there.\n              transformOrigin: below ? \"50% 0%\" : \"50% 100%\",\n            }}\n          >\n            <motion.span\n              className=\"block overflow-hidden rounded-xl bg-background p-1 shadow-raised\"\n              style={{ transformOrigin: below ? \"50% 0%\" : \"50% 100%\" }}\n              initial={\n                reduceMotion\n                  ? { opacity: 0 }\n                  : { opacity: 0, scale: 0.88, y: below ? -6 : 6, filter: \"blur(4px)\" }\n              }\n              animate={{\n                opacity: 1,\n                scale: 1,\n                y: 0,\n                filter: \"blur(0px)\",\n                transition: { duration: 0.18, ease: EASE_OUT },\n              }}\n              exit={{\n                opacity: 0,\n                scale: reduceMotion ? 1 : 0.96,\n                filter: reduceMotion ? \"blur(0px)\" : \"blur(2px)\",\n                transition: { duration: 0.12, ease: EASE_OUT },\n              }}\n            >\n              {/* Concentric: 12px card = 8px image + 4px padding. */}\n              <span className=\"block h-[104px] overflow-hidden rounded-lg bg-muted outline outline-1 -outline-offset-1 outline-[light-dark(oklch(0_0_0/0.1),oklch(1_0_0/0.1))]\">\n                {image}\n              </span>\n              <span className=\"block px-2 pt-2 pb-1.5\">\n                <span className=\"block truncate text-[13px] font-medium text-foreground\">\n                  {title}\n                </span>\n                <span className=\"block truncate text-xs text-muted-foreground\">{domain}</span>\n              </span>\n            </motion.span>\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n// Thumbnails drawn from tokens so they sit right in both themes.\nfunction ArticleThumb() {\n  return (\n    <svg viewBox=\"0 0 224 104\" className=\"size-full\" aria-hidden>\n      <rect x=\"20\" y=\"22\" width=\"92\" height=\"9\" rx=\"2\" className=\"fill-foreground/80\" />\n      <rect x=\"20\" y=\"38\" width=\"64\" height=\"9\" rx=\"2\" className=\"fill-foreground/80\" />\n      <rect x=\"20\" y=\"60\" width=\"104\" height=\"4\" rx=\"2\" className=\"fill-muted-foreground/50\" />\n      <rect x=\"20\" y=\"70\" width=\"96\" height=\"4\" rx=\"2\" className=\"fill-muted-foreground/50\" />\n      <rect x=\"20\" y=\"80\" width=\"72\" height=\"4\" rx=\"2\" className=\"fill-muted-foreground/50\" />\n      <circle cx=\"170\" cy=\"52\" r=\"30\" className=\"fill-background\" />\n      <path\n        d=\"M146 62c10-2 16-14 26-14s14 10 22 10\"\n        fill=\"none\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        className=\"stroke-foreground\"\n      />\n      <circle cx=\"172\" cy=\"48\" r=\"3\" className=\"fill-foreground\" />\n    </svg>\n  );\n}\n\nfunction RepoThumb() {\n  const rows = [\n    [20, 40, 30],\n    [32, 56, 0],\n    [32, 28, 44],\n    [44, 64, 0],\n    [32, 36, 0],\n    [20, 16, 0],\n  ];\n  return (\n    <svg viewBox=\"0 0 224 104\" className=\"size-full\" aria-hidden>\n      <rect x=\"12\" y=\"12\" width=\"200\" height=\"80\" rx=\"6\" className=\"fill-background\" />\n      <circle cx=\"24\" cy=\"23\" r=\"2.5\" className=\"fill-border\" />\n      <circle cx=\"32\" cy=\"23\" r=\"2.5\" className=\"fill-border\" />\n      <circle cx=\"40\" cy=\"23\" r=\"2.5\" className=\"fill-border\" />\n      {rows.map(([indent, a, b], i) => (\n        <g key={i}>\n          <rect x={indent} y={36 + i * 9} width={a} height=\"4\" rx=\"2\" className=\"fill-foreground/70\" />\n          {b ? (\n            <rect\n              x={indent + a + 5}\n              y={36 + i * 9}\n              width={b}\n              height=\"4\"\n              rx=\"2\"\n              className=\"fill-muted-foreground/50\"\n            />\n          ) : null}\n        </g>\n      ))}\n    </svg>\n  );\n}\n\n// The index card's show: a cursor drifts onto \"Motion\" from the left,\n// crosses it (the card trails and leans), eases back a little, then leaves.\n// [ms after the previous step, where the cursor sits across the link].\nconst SHOW: [wait: number, x: number | null][] = [\n  [400, 0.08],\n  [140, 0.55],\n  [160, 0.95],\n  [900, 0.6],\n  [1100, null],\n];\nconst SHOW_REST = 1500;\n\nexport default function HoverPreviewLinkDemo() {\n  const play = usePreviewPlay();\n  const [simX, setSimX] = useState<number | null>(null);\n\n  useEffect(() => {\n    if (play !== true) return;\n    let timer: ReturnType<typeof setTimeout>;\n    const step = (i: number) => {\n      const [wait, x] = SHOW[i % SHOW.length];\n      const rest = i > 0 && i % SHOW.length === 0 ? SHOW_REST : 0;\n      timer = setTimeout(() => {\n        setSimX(x);\n        step(i + 1);\n      }, wait + rest);\n    };\n    step(0);\n    // Unhovering takes the pointer away; the card leaves by its own exit.\n    return () => {\n      clearTimeout(timer);\n      setSimX(null);\n    };\n  }, [play]);\n\n  return (\n    <p\n      className={cn(\n        \"text-[15px] leading-7 text-muted-foreground\",\n        // An index card is too short for a card above a two-line paragraph.\n        // Narrower there, so \"Motion\" lands on the last of four lines and\n        // its card fits above it inside the preview, with the text still\n        // (all but) centred at rest.\n        play === null ? \"w-[min(400px,100%)]\" : \"w-[220px] pt-4\",\n      )}\n    >\n      Most of my motion thinking comes from{\" \"}\n      <HoverPreviewLink\n        href=\"https://emilkowal.ski/ui/great-animations\"\n        title=\"Great animations\"\n        domain=\"emilkowal.ski\"\n        image={<ArticleThumb />}\n      >\n        this essay\n      </HoverPreviewLink>\n      , and the springs in this lab are tuned with{\" \"}\n      <HoverPreviewLink\n        href=\"https://github.com/motiondivision/motion\"\n        title=\"motiondivision/motion\"\n        domain=\"github.com\"\n        image={<RepoThumb />}\n        simulate={simX === null ? null : { x: simX }}\n      >\n        Motion\n      </HoverPreviewLink>\n      .\n    </p>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["text"],"docs":"From ui lab: https://lab.xevrion.dev/lab/hover-preview-link"}