Preview card
A hover/focus link-preview card: an inline Trigger link opens a floating, elevated Popup after a short intent delay — and instantly on keyboard focus — showing a richer preview of what the link points to. Flush against the anchor, never modal, and built on Base UI for hover-intent timing and dismissal.
Basic
A PreviewCard.Trigger is an inline link that styles itself — an accent link that underlines on hover/focus, shows a focus-visible ring, and keeps a persistent underline while its card is open (no hand-authored classes). Hovering it (after a short intent delay) or focusing it from the keyboard opens the PreviewCard.Popup. The popup is elevated and flush — there is no arrow — and the page stays interactive beneath it.
Built on @noctis, the Stridge design language.
"use client";
import { PreviewCard } from "@stridge/noctis";
export default function PreviewCardBasic() {
return (
<p className="max-w-sm text-secondary">
Built on{" "}
<PreviewCard.Root>
{/* The trigger styles its own accent link, underline-on-intent, and focus ring — no classes. */}
<PreviewCard.Trigger href="https://stridge.com">@noctis</PreviewCard.Trigger>
<PreviewCard.Popup>
<PreviewCard.Title>Noctis</PreviewCard.Title>
<PreviewCard.Description>
The dark-by-default design language powering Stridge surfaces.
</PreviewCard.Description>
<PreviewCard.Meta>
<span>1.2k followers</span>
</PreviewCard.Meta>
</PreviewCard.Popup>
</PreviewCard.Root>
, the Stridge design language.
</p>
);
}
Rich content
Lay the card out with the thin content slots: PreviewCard.Media (a fixed-ratio thumbnail/OG-image frame), PreviewCard.Title, PreviewCard.Description, and PreviewCard.Meta (a subtle stats row). The popup stacks them in a column with a consistent gap and caps between a sensible min and max width; a card taller than the viewport caps at the available height and scrolls inside.
See the latest from Stridge.
"use client";
import { PreviewCard } from "@stridge/noctis";
export default function PreviewCardRich() {
return (
<p className="max-w-sm text-secondary">
See the latest from{" "}
<PreviewCard.Root>
<PreviewCard.Trigger href="https://stridge.com">Stridge</PreviewCard.Trigger>
<PreviewCard.Popup>
{/* Media reserves its 16/9 ratio up front; the gradient stands in for an OG image. */}
<PreviewCard.Media>
<div className="size-full bg-gradient-to-br from-accent to-surface-hover" />
</PreviewCard.Media>
<PreviewCard.Title>Stridge</PreviewCard.Title>
<PreviewCard.Description>
An end-to-end platform for building, theming, and shipping product surfaces.
</PreviewCard.Description>
<PreviewCard.Meta>
<span>4.8k stars</span>
<span>stridge.com</span>
</PreviewCard.Meta>
</PreviewCard.Popup>
</PreviewCard.Root>
.
</p>
);
}
Image loading
PreviewCard.Media reserves its aspect ratio up front, so the card never reflows as a thumbnail loads. Pair it with a skeleton fill while the image decodes and an onError fallback for broken images.
A preview with an image.
"use client";
import { Icon, PreviewCard } from "@stridge/noctis";
import { ImageOff } from "lucide-react";
import { useState } from "react";
export default function PreviewCardImageLoading() {
const [broken, setBroken] = useState(false);
return (
<p className="max-w-sm text-secondary">
A preview{" "}
<PreviewCard.Root>
<PreviewCard.Trigger href="https://stridge.com">with an image</PreviewCard.Trigger>
<PreviewCard.Popup>
{/* The Media frame reserves its ratio up front (the card never reflows as the image
loads); the surface-hover fill is the skeleton, and an `onError` falls back to a glyph. */}
<PreviewCard.Media className="grid place-items-center bg-surface-hover text-subtle">
{broken ? (
<Icon icon={ImageOff} />
) : (
// oxlint-disable-next-line next/no-img-element -- a docs example of the raw <img> load/error story; a real app may swap in next/image.
<img src="https://stridge.com/missing.png" alt="" onError={() => setBroken(true)} />
)}
</PreviewCard.Media>
<PreviewCard.Title>Stridge</PreviewCard.Title>
<PreviewCard.Description>
The media block reserves its space, so the card holds steady whether the image loads or fails.
</PreviewCard.Description>
</PreviewCard.Popup>
</PreviewCard.Root>
.
</p>
);
}
Hover timing
Hover-intent is a first-class knob. PreviewCard.Trigger opens on hover after delay (default 500 ms) and closes after closeDelay (default 200 ms) — a touch snappier than the platform norm — with a small grace area (the tight sideOffset of 4) so the pointer can travel from the trigger to the card without it closing. Keyboard focus opens the card instantly, with no scale animation. Tune delay / closeDelay per trigger.
Hover-intent is a knob. A snappy card opens the moment you arrive; a patient card waits until you mean it.
"use client";
import { PreviewCard } from "@stridge/noctis";
export default function PreviewCardTiming() {
return (
<p className="max-w-sm text-secondary">
Hover-intent is a knob. A{" "}
<PreviewCard.Root>
{/* Snappier than the 500/200 default — opens almost immediately. */}
<PreviewCard.Trigger href="https://stridge.com" delay={150} closeDelay={0}>
snappy
</PreviewCard.Trigger>
<PreviewCard.Popup>
<PreviewCard.Title>Quick to open</PreviewCard.Title>
<PreviewCard.Description>delay 150ms, closeDelay 0ms.</PreviewCard.Description>
</PreviewCard.Popup>
</PreviewCard.Root>{" "}
card opens the moment you arrive; a{" "}
<PreviewCard.Root>
{/* Patient — waits to be sure you meant it, lingers on the way out. */}
<PreviewCard.Trigger href="https://stridge.com" delay={700} closeDelay={400}>
patient
</PreviewCard.Trigger>
<PreviewCard.Popup>
<PreviewCard.Title>Slow to open</PreviewCard.Title>
<PreviewCard.Description>delay 700ms, closeDelay 400ms.</PreviewCard.Description>
</PreviewCard.Popup>
</PreviewCard.Root>{" "}
card waits until you mean it.
</p>
);
}
One card, many triggers
Several inline triggers can share a single card. Give each PreviewCard.Trigger a payload, read the active one from PreviewCard.Root's render-function child, and the card swaps to that trigger's content as you move between them — the canonical "paragraph of mentions" pattern.
The pioneers — @ada, @grace, and @katherine — all share one preview card.
"use client";
import { PreviewCard } from "@stridge/noctis";
interface Person {
name: string;
bio: string;
joined: string;
}
const ADA: Person = { name: "Ada Lovelace", bio: "Mathematician · wrote the first algorithm.", joined: "Joined 1843" };
const GRACE: Person = { name: "Grace Hopper", bio: "Computer scientist · compiler pioneer.", joined: "Joined 1944" };
const KATHERINE: Person = { name: "Katherine Johnson", bio: "Mathematician · orbital mechanics.", joined: "Joined 1953" };
export default function PreviewCardManyTriggers() {
return (
// Several inline triggers share ONE card. Each carries a `payload`; the Root's render-function
// child reads the active payload, so the card swaps to the hovered trigger's content.
<PreviewCard.Root>
{({ payload }) => {
const person = payload as Person | undefined;
return (
<>
<p className="max-w-sm text-secondary">
The pioneers —{" "}
<PreviewCard.Trigger href="https://stridge.com" payload={ADA}>
@ada
</PreviewCard.Trigger>
,{" "}
<PreviewCard.Trigger href="https://stridge.com" payload={GRACE}>
@grace
</PreviewCard.Trigger>
, and{" "}
<PreviewCard.Trigger href="https://stridge.com" payload={KATHERINE}>
@katherine
</PreviewCard.Trigger>{" "}
— all share one preview card.
</p>
<PreviewCard.Popup>
<PreviewCard.Title>{person?.name}</PreviewCard.Title>
<PreviewCard.Description>{person?.bio}</PreviewCard.Description>
<PreviewCard.Meta>{person?.joined}</PreviewCard.Meta>
</PreviewCard.Popup>
</>
);
}}
</PreviewCard.Root>
);
}
Positioning
The card opens below the trigger by default, flipping above when there's no room. Steer it with side and align (and sideOffset / alignOffset) on PreviewCard.Popup — alignment is direction-aware, so it mirrors under RTL. collisionPadding (default 8) keeps the card off the viewport edges.
Hover this link to see the card open above it.
"use client";
import { PreviewCard } from "@stridge/noctis";
export default function PreviewCardPositioning() {
return (
<p className="max-w-sm text-secondary">
Hover{" "}
<PreviewCard.Root>
<PreviewCard.Trigger href="https://stridge.com">this link</PreviewCard.Trigger>
<PreviewCard.Popup side="top" sideOffset={10}>
<PreviewCard.Title>Above the anchor</PreviewCard.Title>
<PreviewCard.Description>
This card opens on the top side, ten pixels clear of the trigger.
</PreviewCard.Description>
</PreviewCard.Popup>
</PreviewCard.Root>{" "}
to see the card open above it.
</p>
);
}
Keyboard
| Key | Action |
|---|---|
| Tab | Move focus to the trigger link — which opens the preview card instantly (no open animation). |
| Esc | Close the preview card; focus stays on the trigger. |
| Enter | Follow the trigger link (the card is supplementary — the link itself still navigates). |
The card opens on keyboard focus of the trigger, so it is reachable without a pointer. Because it is non-modal and supplementary, focus is not trapped inside it — the preview enriches the link without interrupting the reading flow.
Anatomy
Compose a preview card from its parts. PreviewCard.Root owns the open state (it accepts every Base UI PreviewCard.Root prop — open, defaultOpen, onOpenChange — and a render-function child that receives the active trigger's payload).
PreviewCard.Root— owns the open state; renders no element of its own.PreviewCard.Trigger— the inline link that opens the card, and styles its own accent affordance. Renders an<a>; opens on hover afterdelayms (default 500, with acloseDelaygrace period on leave, default 200) and instantly on keyboard focus. Compose your own anchor throughrender.PreviewCard.Popup— the floating, elevated, flush surface holding the preview. Props:side(default collision-aware),align(defaultcenter),sideOffset(default 4),alignOffset,collisionPadding(default 8),density(comfortable/compact). Caps at the available height; its content scrolls in an inner viewport.PreviewCard.Media— a fixed-aspect-ratio frame for a thumbnail or OG image; reserves its space and clips overflow.PreviewCard.Title— the card heading (lifts to the foreground role at medium weight).PreviewCard.Description— supporting copy (inherits the popup's secondary tone).PreviewCard.Meta— a subtle flex row for stats or label/value pairs.
Every rendered part carries a data-slot (preview-card-trigger, preview-card-popup, preview-card-viewport, preview-card-media, preview-card-title, preview-card-description, preview-card-meta) for host-side styling — pair it with the Base UI state attributes (data-popup-open, data-side, data-starting-style, data-ending-style, data-instant).
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every preview card in that region retunes — e.g. .marketing { --noctis-preview-card-popup-max-width: 24rem; } widens every card opened beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through.
PreviewCard.Root
No props of its own — forwards to the underlying Base UI part.