Link Preview
An inline link that reveals a hoverable preview card — thumbnail, title, description and domain — with edge-aware flipping and touch-safe degradation.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/link-preview.jsonPrompt
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 delay —
openDelayfilters out pointers that are merely passing over a paragraph of links; the same single timer runs the close path withcloseDelay, 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 everyoverflow-clipping ancestor, because that box — not the window — is what would cut the card in half.scrollandresizere-run the same measurement while open. - Prefetched metadata — the component takes a
previewobject 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 degradation —
matchMedia("(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-mutedbox, so the card's geometry (and therefore the flip decision made a moment earlier) stays valid regardless of when the image finishes loading.