what is behind a link, shown while the hand rests on it
npx shadcn add @ag/hover-cardAlready installed? Preview an update
npx shadcn add @ag/hover-card --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
- What a hover card holds stands in it as in a frame (
InFrame): a profile, a stat, a web preview draws no box of its own there. It now depends onpanel.
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/registry/base-nova/ui/hover-card"
import { Image } from "@/registry/base-nova/ui/image"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
// #region person
// A person's card behind their name. The link wears the page's link style.
const LINK =
"font-medium underline decoration-current/35 underline-offset-2 hover:decoration-current"
function Person() {
return (
<p className="max-w-sm text-sm leading-6">
the registry is kept by{" "}
<HoverCard>
<HoverCardTrigger href="https://adriangalilea.com" className={LINK}>
adrian galilea
</HoverCardTrigger>
<HoverCardContent>
<div className="flex items-center gap-3">
<Image
src="/adriangalilea.jpg"
alt=""
width={40}
height={40}
className="size-10 rounded-full object-cover"
/>
<div className="min-w-0">
<p className="font-medium">adrian galilea</p>
<p className="text-current/55 text-xs">adriangalilea.com</p>
</div>
</div>
<p className="text-current/75">
builds interfaces for reading film and the tools around them.
</p>
</HoverCardContent>
</HoverCard>
, one component at a time.
</p>
)
}
// #endregion
// #region preview
// A page's preview behind its link, opening sooner.
function Preview() {
return (
<p className="max-w-sm text-sm leading-6">
the shot was taken at{" "}
<HoverCard>
<HoverCardTrigger
href="/lightbox/shore.jpg"
delay={300}
className={LINK}
>
the shore
</HoverCardTrigger>
<HoverCardContent side="top" className="w-64 gap-0 overflow-hidden p-0">
<Image
src="/lightbox/shore.jpg"
alt=""
width={256}
height={144}
className="aspect-video w-full object-cover"
/>
<p className="px-3 py-2 text-current/75 text-xs">
shore.jpg, the full picture
</p>
</HoverCardContent>
</HoverCard>{" "}
just after the light went.
</p>
)
}
// #endregion
export default function HoverCardDemo() {
return (
<div className="space-y-8">
<Sample
name="plain"
glance
title="a card behind a link"
description="it opens when the hand rests on the link or the keyboard reaches it, and stays while the hand crosses to it"
with="person preview"
>
<div className="space-y-4">
<Person />
<Preview />
</div>
</Sample>
<Sample name="glass" title="in glass, over a picture" with="person">
<MaterialProvider material="glass">
<div className="grid place-items-center rounded-2xl bg-[url(/lightbox/field.jpg)] bg-cover bg-center p-12 text-white">
<Person />
</div>
</MaterialProvider>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A HOVER CARD: what is behind a link, shown while the hand rests on it or the
// keyboard is on it (a person's card behind their name, a page's preview behind its
// address), on Base UI's preview card. The parts are shadcn's:
//
// <HoverCard>
// <HoverCardTrigger href="/people/ada">ada</HoverCardTrigger>
// <HoverCardContent>…</HoverCardContent>
// </HoverCard>
//
// It waits before opening (600 ms: passing over a link on the way elsewhere opens
// nothing) and before closing (300 ms: the hand crossing the gap to the card keeps it),
// both set on the trigger. The trigger is a link, and stays one: the card only previews
// where it goes, so nothing in it may be found nowhere else (a touch or a screen reader
// never sees it). Esc and a press outside close it. Arrowless, as the popover is. Two
// materials (`material` on HoverCardContent, or a MaterialProvider round it): plain,
// the page's popover surface; glass, the material's glass. What is inside wears the
// same material, and stands in the card as in a frame (`InFrame`): a profile, a stat,
// anything that draws a box when alone draws none in it.
import { PreviewCard as Base } from "@base-ui/react/preview-card"
import type * as React from "react"
import { cn } from "@/lib/utils"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { InFrame } from "@/registry/base-nova/ui/panel"
import { usePortalTarget } from "@/registry/base-nova/ui/portal"
/** The card's state: uncontrolled, or `open` and `onOpenChange`. */
export const HoverCard = Base.Root
/** The link it previews: an `<a>` in the page's own link style, `render` for a
* router's own (`<Link href />`). While open it carries `data-popup-open`. `delay`
* and `closeDelay` are ms the hand rests before it opens and leaves before it
* closes. */
export function HoverCardTrigger({
delay = 600,
closeDelay = 300,
...props
}: React.ComponentProps<typeof Base.Trigger>) {
return (
<Base.Trigger
data-slot="hover-card-trigger"
delay={delay}
closeDelay={closeDelay}
{...props}
/>
)
}
const PLAIN_FACE =
"rounded-xl border border-border bg-popover text-popover-foreground shadow-lg transition-[opacity,scale] duration-150 ease-out motion-reduce:transition-none"
/** Its motion through the material, which owns the glass's transition. */
const GLASS_FACE = cn(
glassVariants({ shape: "surface" }),
"[--glass-radius:--theme(--radius-2xl)] [--glass-blur:16px] [--glass-moves:opacity_150ms_var(--ease-out),scale_150ms_var(--ease-out)]",
)
export interface HoverCardContentProps
extends React.ComponentProps<typeof Base.Popup> {
side?: React.ComponentProps<typeof Base.Positioner>["side"]
align?: React.ComponentProps<typeof Base.Positioner>["align"]
sideOffset?: number
/** Where it is portalled (a player's layer, so it shows in fullscreen). */
container?: React.ComponentProps<typeof Base.Portal>["container"]
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
}
/** The card, portalled and anchored to its link, in its material. */
export function HoverCardContent({
side = "bottom",
align = "center",
sideOffset = 8,
container,
material,
scheme,
className,
children,
...props
}: HoverCardContentProps) {
const m = useMaterial(material)
const s = useScheme(scheme)
const target = usePortalTarget()
return (
<Base.Portal container={container ?? target}>
<Base.Positioner
side={side}
align={align}
sideOffset={sideOffset}
className="z-50 max-w-(--available-width) outline-none"
>
<Base.Popup
data-slot="hover-card-content"
data-material={m}
className={cn(
schemeOf(s),
m === "glass" ? GLASS_FACE : PLAIN_FACE,
"flex w-72 max-w-full origin-(--transform-origin) flex-col gap-2 p-4 text-sm outline-none",
"data-[ending-style]:scale-98 data-[ending-style]:opacity-0 data-[starting-style]:scale-98 data-[starting-style]:opacity-0",
className,
)}
{...props}
>
<MaterialProvider material={m} scheme={s}>
<InFrame>{children}</InFrame>
</MaterialProvider>
</Base.Popup>
</Base.Positioner>
</Base.Portal>
)
}