Navigation

Link Preview

An inline link that reveals a hoverable preview card — thumbnail, title, description and domain — with edge-aware flipping and touch-safe degradation.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Globe } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"

export interface LinkPreviewData {
  title: string
  description?: string
  /** Absolute image URL. Rendered into a fixed 16/9 box so loading never shifts the card. */
  image?: string
  /** Shown in the footer line, e.g. "en.wikipedia.org". */
  domain?: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/link-preview.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "LinkPreview" component using
motion/react for the card transition and lucide-react for the domain glyph.

Contract
- export const LinkPreview = React.forwardRef<HTMLAnchorElement, LinkPreviewProps>.
  LinkPreviewProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>,
  "href"> — the ref, className and every remaining native prop (target, rel,
  onClick…) land on the real <a>, because the anchor is what the consumer
  actually styles and clicks.
- href: string (required) and preview: { title: string; description?: string;
  image?: string; domain?: string }.
- The component NEVER fetches anything. Metadata is pre-fetched by the
  consumer — a build-time OG scrape, a CMS field, or a cached endpoint — and
  passed in whole. That keeps it a pure render, avoids a request storm on a
  page full of links, and keeps it usable in a server-rendered article.
- openDelay = 300, closeDelay = 150 (ms), side: "top" | "bottom" = "top",
  width = 288 (px, applied as an inline style so it can be tuned per link).

Behavior
- Open state is derived, not imperatively toggled: track `hovered` and
  `focused` as separate booleans, compute desired = !coarsePointer &&
  !dismissed && (hovered || focused), and run ONE effect that, whenever
  desired !== open, schedules a single timeout (openDelay when opening,
  closeDelay when closing) and commits. The effect's cleanup clears the
  timer, so re-entering mid-delay cancels the pending flip and unmounting
  leaks nothing — no timer refs to hand-manage.
- Both hover and keyboard focus honour openDelay: a quick Tab sweep through
  a paragraph of links should not machine-gun cards open.
- The mouse handlers sit on the positioned wrapper that contains BOTH the
  anchor and the card, and the card's outer positioner uses padding
  (pb-2 / pt-2) rather than margin for its offset. Together these mean the
  pointer can travel from the link onto the card without ever leaving the
  wrapper, so the card is genuinely hoverable — that is the whole difference
  from a tooltip.
- Escape closes immediately (a document-level keydown listener, added only
  while open, because the user may be hovering with focus elsewhere) and
  latches a `dismissed` flag so the card does not instantly re-open under a
  still-resting pointer. A tiny effect clears the latch once hovered and
  focused are both false.
- Edge flipping: just before committing an open, measure the trigger with
  getBoundingClientRect() and compare the space above/below against an
  ESTIMATED card height (image ? width * 9/16 : 0, plus a constant text-block
  height, plus a margin). Estimating rather than measuring the mounted card
  means the very first paint is already on the correct side — no flip flash.
  The available space is NOT the viewport: walk up from the trigger and narrow
  the box by every ancestor whose computed overflow-y isn't "visible", because
  the card is absolutely positioned and any such box clips it long before the
  window edge does. A card inside a fixed-height panel that measured itself
  against the viewport would happily open into a side that is scissored off.
  While open, `resize` and `scroll` (capture + passive) re-run the same
  measurement so the choice cannot go stale; the card is positioned relative
  to the trigger, so scrolling otherwise moves it correctly on its own. Both
  listeners are removed when the card closes or the component unmounts.
- Layout constraint to hand to whoever places the component: the card lives in
  the trigger's own stacking context, so an ancestor with overflow-hidden (a
  fixed-height preview stage, a card with a clipped media box, a scroll area)
  crops it, and flipping cannot rescue a container shorter than the card
  itself. Leave at least card-height + 12px of room on the side it opens
  toward, or portal the card out if the layout genuinely cannot spare it.
- Touch: a matchMedia("(pointer: coarse)") subscription (with its change
  listener cleaned up) forces the component back to being nothing but a
  plain <a>. On a phone the link is tapped, not previewed.
- Reduced motion: useReducedMotion() swaps the spring + scale/translate entry
  for a plain 120ms opacity fade. The card still opens, closes and flips.

Rendering & styling
- Every element in the tree is phrasing content (<span> with display
  utilities, never <div>), because the component is meant to sit inside a
  <p> of running prose — a <div> there is invalid HTML and breaks hydration.
- Card: rounded-xl border bg-popover text-popover-foreground shadow-lg,
  overflow-hidden, z-50, absolute left-1/2 -translate-x-1/2, anchored with
  bottom-full or top-full depending on the resolved side, max-w-[90vw].
- Thumbnail: a native <img> inside an aspect-[16/9] bg-muted box with
  object-cover and loading="lazy" — the card's height is final before the
  image byte arrives, so nothing reflows on load. alt="" is deliberate: the
  image is decorative and the title/description right beneath it already
  carry the meaning through aria-describedby.
- Text block: title (line-clamp-1, text-foreground), description
  (line-clamp-2, text-xs text-muted-foreground), domain row (Globe icon +
  truncate, text-muted-foreground).
- Trigger: text-primary with an underline whose decoration goes from
  decoration-primary/40 to decoration-primary on hover, plus
  focus-visible:ring-2 focus-visible:ring-ring.
- Accessibility: the card is role="tooltip" with a useId()-generated id, and
  the anchor gets aria-describedby pointing at it only while open, so the
  description is announced from the element that actually holds focus.
- Semantic tokens only — bg-popover, text-popover-foreground, bg-muted,
  text-muted-foreground, text-foreground, text-primary, border, ring-ring.
  No hex/rgb/oklch anywhere.

Customization levers
- Delays: openDelay is the hover-intent knob (200-500ms reads well);
  closeDelay only needs to cover the pointer's travel time onto the card —
  raise it if you increase the padding bridge.
- Card anatomy: image, description and domain are each independently
  optional, so the same component covers a rich external link and a bare
  internal one; add a favicon <img> next to the domain, or a "cited by"
  count row, without touching the open/close machinery.
- Width & shape: `width` plus the card's rounded-xl / shadow-lg are the
  visual dial; a 240px text-only card and a 400px hero-image card are both
  reasonable with the same contract.
- Horizontal clamping is intentionally not implemented — only the vertical
  side flips. If your links reach the left/right viewport edge, clamp the
  translate with a measured offset in the same computeSide() pass.
- Async metadata: keep the component pure and wrap it — fetch in the parent,
  render a Skeleton-shaped preview object while pending, and swap in the
  real one. Do not move the fetch inside; a prose page can hold dozens of
  these.
- Router links: pass your framework's Link via `asChild`-style composition,
  or simply render the component around an internal href — the anchor
  already receives every native prop it needs.

Concepts

  • Hover intent delayopenDelay filters out pointers that are merely passing over a paragraph of links; the same single timer runs the close path with closeDelay, and its effect cleanup cancels a pending flip the moment intent reverses.
  • Hoverable card — the offset to the trigger is padding on the positioner, not margin, and the hover handlers live on a wrapper containing both link and card, so travelling onto the card never fires a mouse-leave. A tooltip is pointer-events-none; this deliberately is not.
  • Clip-aware edge flip — the side is decided from getBoundingClientRect() against an estimated card height before the card ever mounts, so the first paint is already correct; the room available is the viewport narrowed by every overflow-clipping ancestor, because that box — not the window — is what would cut the card in half. scroll and resize re-run the same measurement while open.
  • Prefetched metadata — the component takes a preview object and issues zero requests, which keeps a page of forty links from becoming forty fetches and lets the same markup render on the server.
  • Coarse-pointer degradationmatchMedia("(pointer: coarse)") turns the whole thing back into an ordinary anchor on touch, where there is no hover to express intent and a card would only steal the tap.
  • Layout-stable thumbnail — the image sits in a fixed aspect-[16/9] bg-muted box, so the card's geometry (and therefore the flip decision made a moment earlier) stays valid regardless of when the image finishes loading.

On This Page