Link Preview
A card about a link, revealed by resting the pointer 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"),
),
),
),
),
),
),
".",
);
}Installation
npx jsrepo add @implementjs/ui/link-preview
Nothing else comes with it — this one stands alone on @implementjs/core and @implementjs/primitives.
Copy the file below to src/lib/components/ui/link-preview.ts. It imports cn, so copy utils.ts to src/lib/utils.ts too.
import type { Child, ComponentProps } from "@implementjs/core";
import {
LinkPreview as LinkPreviewPrimitive,
LinkPreviewContent as LinkPreviewContentPrimitive,
LinkPreviewPortal as LinkPreviewPortalPrimitive,
LinkPreviewTrigger as LinkPreviewTriggerPrimitive,
} from "@implementjs/primitives";
import { createComponent } from "@implementjs/primitives";
import { cn } from "@/lib/utils";
export type LinkPreviewProps = ComponentProps<typeof LinkPreviewPrimitive>;
export type LinkPreviewTriggerProps = ComponentProps<typeof LinkPreviewTriggerPrimitive>;
export type LinkPreviewContentProps = ComponentProps<typeof LinkPreviewContentPrimitive>;
export const LinkPreviewPortal = LinkPreviewPortalPrimitive;
export const LinkPreview = createComponent(function LinkPreview(
props: LinkPreviewProps,
...children: Child[]
) {
return LinkPreviewPrimitive(props, ...children);
});
export const LinkPreviewTrigger = createComponent(function LinkPreviewTrigger(
{ class: className, ...props }: LinkPreviewTriggerProps,
...children: Child[]
) {
return LinkPreviewTriggerPrimitive(
{
...props,
"data-slot": "link-preview-trigger",
class: cn(
"rounded-sm font-medium underline underline-offset-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
className,
),
},
...children,
);
});
export const LinkPreviewContent = createComponent(function LinkPreviewContent(
{
offset = 8,
side = "top",
align = "center",
class: className,
...props
}: LinkPreviewContentProps,
...children: Child[]
) {
return LinkPreviewContentPrimitive(
{
...props,
"data-slot": "link-preview-content",
offset,
side,
align,
class: cn(
"absolute z-50 w-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
"origin-(--ip-link-preview-content-transform-origin)",
"transition-[opacity,translate,scale,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"data-[state=open]:block data-[state=open]:translate-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2",
"starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-95",
"starting:data-[state=open]:data-[side=bottom]:-translate-y-2 starting:data-[state=open]:data-[side=top]:translate-y-2 starting:data-[state=open]:data-[side=left]:translate-x-2 starting:data-[state=open]:data-[side=right]:-translate-x-2",
className,
),
},
...children,
);
});Usage
Pointer-only by design: a link preview is an enrichment, so it never opens on focus or on touch, and nothing inside it is reachable by keyboard that is not reachable another way. Put content in it, not controls.
The trigger is styled as an underlined link. The card is a 20rem popover panel that opens above the link by default.
import {
LinkPreview,
LinkPreviewContent,
LinkPreviewTrigger,
} from "@/lib/components/ui/link-preview";
LinkPreview(
LinkPreviewTrigger({ href: "https://github.com/ieedan" }, "@ieedan"),
LinkPreviewContent(
Div({ class: "flex gap-3" }, Avatar(AvatarFallback("AB")), Span("Aidan Bleser")),
),
);
Timing and placement
openDelay and closeDelay are on the root; side, align, and offset on the content. The styled content defaults to top / center / 8 — a card that sits above the sentence rather than covering the words after it.
API Reference
Every prop the styling does not consume is forwarded to the Link Preview primitive, so the tables below are the whole surface — the behavior props and the styling ones together.
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. Styled as an underlined link. Renders a A; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-link-preview-trigger] | Present |
[data-state] | "open" | "closed" |
LinkPreviewContent
The preview card. Styled as a 20rem popover panel that fades and scales in, and hides itself when closed. 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 | 8 | 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). |
