Link Preview
Show a preview of what a link points at when the pointer rests on it.
The primitives are unstyled building blocks maintained by @ieedan.
import { Div, P, Span } from "@implementjs/core";
import { CalendarDaysIcon } from "@implementjs/lucide";
import { Avatar, AvatarFallback, AvatarImage } from "@/lib/components/ui/avatar";
import {
LinkPreview,
LinkPreviewContent,
LinkPreviewPortal,
LinkPreviewTrigger,
} from "@/lib/components/ui/link-preview";
export default function LinkPreviewDemo() {
return P(
{ class: "max-w-sm text-sm text-muted-foreground" },
"The primitives are unstyled building blocks maintained by ",
LinkPreview(
LinkPreviewTrigger(
{ href: "https://github.com/ieedan", target: "_blank", rel: "noreferrer" },
"@ieedan",
),
LinkPreviewPortal(
LinkPreviewContent(
Div(
{ class: "flex gap-4" },
Avatar(
{ class: "size-12" },
AvatarImage({ src: "https://github.com/ieedan.png", alt: "@ieedan" }),
AvatarFallback("AB"),
),
Div(
{ class: "space-y-1" },
Div({ class: "text-sm font-semibold" }, "@ieedan"),
P({ class: "text-sm" }, "Building implement — a signal-based UI framework."),
Div(
{ class: "flex items-center gap-2 pt-1" },
CalendarDaysIcon({ "aria-hidden": true, class: "size-4 opacity-70" }),
Span({ class: "text-xs text-muted-foreground" }, "Joined December 2021"),
),
),
),
),
),
),
".",
);
}A link preview is a panel that appears when the pointer rests on a link, the way a repository or a profile card appears on GitHub. LinkPreview is the root, LinkPreviewTrigger is the link, and LinkPreviewContent is the panel. Wrap the panel in LinkPreviewPortal when it needs to escape overflow.
import {
LinkPreview,
LinkPreviewContent,
LinkPreviewPortal,
LinkPreviewTrigger,
} from "@implementjs/primitives";
LinkPreview(
LinkPreviewTrigger({ href: "https://github.com/ieedan" }, "@ieedan"),
LinkPreviewPortal(LinkPreviewContent("A preview of where the link goes.")),
);
Each part accepts optional props and children — pass a props object when you need attributes, or pass children directly. See createComponent. Extra props on the trigger and the content are forwarded onto the underlying A or Div.
This is a hover affordance, not a way to reach content. It never opens on touch, and everything inside the panel is taken out of the tab order, so put nothing in it that isn't also reachable somewhere else. While open, the page behind cannot scroll; pass preventScroll: false to leave it scrollable.
The trigger
LinkPreviewTrigger renders an A. It is a real link: pass href, target, rel, and it navigates like any other. The preview is what hovering adds.
LinkPreviewTrigger(
{ href: "https://github.com/ieedan", target: "_blank", rel: "noreferrer" },
"@ieedan",
);
Resting the pointer on it opens the preview after openDelay; moving away closes it after closeDelay. Keyboard focus opens it too, but only when the focus is visible — clicking the link doesn't pop the panel open under your cursor.
Delays
openDelay is how long the pointer has to rest before the panel appears, so sweeping across a paragraph of links doesn't flash a panel for each one. closeDelay is how long the panel stays after the pointer has actually left.
LinkPreview(
{ openDelay: 300, closeDelay: 150 },
LinkPreviewTrigger({ href: "/docs" }, "the docs"),
LinkPreviewContent("Opens sooner, leaves sooner."),
);
Moving off the link toward the panel is not "leaving". While the preview is open it tracks a safe zone between the link and the panel, so the pointer can cut diagonally across the gap without the panel closing under it. The close only starts once the pointer leaves that zone — or stalls inside it, which reads as wandering rather than travelling.
Open state
LinkPreview owns whether the panel is open. Hover and focus drive it, but you can seed it with a boolean or hand it a signal to read and write from outside (signal() returns a writable unchanged, so the same prop accepts both):
const open = signal(false);
LinkPreview(
{ open },
LinkPreviewTrigger({ href: "/docs" }, "the docs"),
LinkPreviewContent("Hello"),
);
Button({ onClick: () => open.set(false) }, "Close");
disabled turns the preview off without touching the link:
LinkPreview({ disabled: isCompact }, LinkPreviewTrigger({ href: "/docs" }, "the docs") /* … */);
Selecting text in the panel
Text inside the panel is selectable. While a selection is being dragged — and while one stands — the panel stays open even if the pointer wanders off it, so the selection doesn't vanish mid-drag. Clicking elsewhere clears the selection and dismisses the panel.
Portal
LinkPreviewPortal is the Portal helper under a link preview name. It renders its children into document.body by default so the panel is not clipped by overflow or trapped in a parent stacking context — worth having when the link lives inside a scrolling column of prose. Context still resolves from where you declared it.
LinkPreviewPortal(LinkPreviewContent("Hello"));
LinkPreviewPortal({ to: overlayRoot }, LinkPreviewContent("Hello"));
Styling
Trigger and content expose data-state as "open" or "closed". Content also sets data-side ("top", "bottom", "left", "right") and data-align, so motion can grow out of the link.
Positioning writes CSS variables on the content: --ip-link-preview-content-transform-origin for origin-aware scale, --ip-link-preview-anchor-width / --ip-link-preview-anchor-height to match the link, and --ip-link-preview-content-available-width / --ip-link-preview-content-available-height to stay inside the viewport.
LinkPreviewTrigger({ class: "font-medium underline underline-offset-4" }, "@ieedan");
LinkPreviewContent(
{
class:
"absolute z-50 w-80 origin-(--ip-link-preview-content-transform-origin) rounded-md border bg-popover p-4 text-sm shadow-md transition data-[state=closed]:hidden data-[state=closed]:data-[side=top]:translate-y-2",
},
"A preview of where the link goes.",
);
data-state is there for visibility and open versus closed. data-side is the actual placed side (after flip), so enter and exit stay pointed at the link.
API Reference
LinkPreview
The root. Owns whether the preview is open, the hover delays, and provides that to the parts inside it.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state. |
preventScroll | boolean | true | When true, the page behind cannot scroll while the preview is open. The panel can still scroll if you give it overflow. |
disabled | Signal<boolean> | boolean | false | While true the preview never opens. The link still navigates. |
openDelay | number | 700 | How long the pointer must rest on the link before the preview opens, in milliseconds. |
closeDelay | number | 300 | How long the preview stays up after the pointer leaves, in milliseconds. This is the window the pointer has to travel from the link to the preview. |
LinkPreviewTrigger
The link the preview hangs off. Renders an anchor, so pass href and it navigates like any other link; hovering or keyboard-focusing it opens the preview. Renders a A; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-link-preview-trigger] | Present |
[data-state] | "open" | "closed" |
LinkPreviewContent
The preview panel. Style it against data-state and data-side; the primitive does not hide it for you. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "top" | Preferred side of the link to place the panel. |
align | "start" | "center" | "end" | "center" | How the panel aligns along the chosen side. |
offset | number | 0 | Distance in pixels between the link and the panel. |
| Data attribute | Value |
|---|---|
[data-link-preview-content] | Present |
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
| CSS variable | Description |
|---|---|
--ip-link-preview-content-transform-origin | The transform origin of the content element. |
--ip-link-preview-content-available-width | The available width of the content element. |
--ip-link-preview-content-available-height | The available height of the content element. |
--ip-link-preview-anchor-width | The width of the anchor element. |
--ip-link-preview-anchor-height | The height of the anchor element. |
LinkPreviewPortal
Renders its children into another DOM parent so the panel escapes overflow and stacking. This is the core Portal helper; context still resolves from where the portal is declared.
| Prop | Type | Default | Description |
|---|---|---|---|
to | HTMLElement | Readable<HTMLElement> | document.body | The element to mount into. Also available as chained .To(target). |
disabled | boolean | Readable<boolean> | false | Mount in place instead of teleporting. Also available as chained .Disabled(value). |
