Hover card
More about a person, a link, a product or a term while you rest on it. Opens on hover and on focus, Tab reaches its buttons, the first tap opens it on touch.
One of 9 components in Overlays
Variants
Details
What Hover card brings along, read from the registry and the source.
- Category
- Overlays
- Area
- Elements
- Variants
- 4
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/hover-card.tsx
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The HoverCardProps interface from components/ui/hover-card.tsx, with the comments from the code.
variantoptionalHoverCardVariantprofilelinkproductdefinition
labelrequiredstringThe text in the flow that carries the card
hrefoptionalstringWhere the label links to
titlerequiredstringsubtitleoptionalstringprofile: role or handle; link: the address; product: the price line; definition: the field
descriptionoptionalstringavataroptionalstringprofile: the name for the initials
mediaoptionalReact.ReactNodelink and product: a picture
statsoptional{ label: string; value: string }[]profile: small facts in a row
priceoptionalstringproduct: the price as shown
ratingoptionalnumberproduct: 0 to 5
actionoptionalstringA button in the card, e.g. "Follow" or "Add to bag"
actionDoneoptionalstringThe button text once the action is done, e.g. "Following" or "In your bag". The button then shows a check.
onActionoptional() => voiddelayoptionalnumberWait before opening on hover, ms. Default 450.
defaultOpenoptionalbooleanOpen on first render, for demos
Built-in texts
Hover card writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| open | English Open | German Öffnen |
| more | English More about | German Mehr zu |
| rating | English out of 5 stars | German von 5 Sternen |
Accessibility
Read from the source of the component.
- Hover card handles the keys Escape itself.
- Sets aria-controls, aria-expanded, aria-label and aria-pressed, so screen readers announce purpose and state.
- Uses the role group.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 296 lines
'use client';
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Avatar } from '@/components/ui/avatar';
/*
* A card that shows more about a person, a link, a product or a term while
* you rest on it. Opens on hover after a short wait and at once on keyboard
* focus, stays open while the pointer is on the card, closes on Escape and
* when focus or pointer leave both. The card sits right after its trigger in
* the document, so Tab reaches its buttons in order. On touch the first tap
* opens the card, the card itself carries the link.
* Inside a <p>, keep the media phrasing content (an <img> or <svg>), a <div>
* is not allowed there.
*/
const TEXTS = {
en: { open: 'Open', more: 'More about', rating: 'out of 5 stars' },
de: { open: 'Öffnen', more: 'Mehr zu', rating: 'von 5 Sternen' },
};
export type HoverCardVariant = 'profile' | 'link' | 'product' | 'definition';
export interface HoverCardProps {
variant?: HoverCardVariant;
/** The text in the flow that carries the card */
label: string;
/** Where the label links to */
href?: string;
title: string;
/** profile: role or handle; link: the address; product: the price line; definition: the field */
subtitle?: string;
description?: string;
/** profile: the name for the initials */
avatar?: string;
/** link and product: a picture */
media?: React.ReactNode;
/** profile: small facts in a row */
stats?: { label: string; value: string }[];
/** product: the price as shown */
price?: string;
/** product: 0 to 5 */
rating?: number;
/** A button in the card, e.g. "Follow" or "Add to bag" */
action?: string;
/** The button text once the action is done, e.g. "Following" or "In your bag". The button then shows a check. */
actionDone?: string;
onAction?: () => void;
/** Wait before opening on hover, ms. Default 450. */
delay?: number;
/** Open on first render, for demos */
defaultOpen?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TRIGGER: Record<HoverCardVariant, string> = {
profile: 'font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:underline underline-offset-4',
link: 'font-medium text-foreground underline decoration-[color-mix(in_oklab,var(--foreground)_35%,transparent)] underline-offset-[3px] hover:decoration-foreground',
product: 'font-medium text-foreground underline decoration-[color-mix(in_oklab,var(--primary)_55%,transparent)] decoration-2 underline-offset-[3px]',
definition: 'cursor-help text-foreground underline decoration-dotted decoration-[color-mix(in_oklab,var(--foreground)_55%,transparent)] underline-offset-[3px]',
};
/**
* More about a person, link, product or term on hover and focus.
* <HoverCard variant="profile" label="@mara" title="Mara Lindqvist" subtitle="Design lead" stats={[…]} action="Follow" />
* <HoverCard variant="link" label="the field guide" href="#" title="Field guide to clay" subtitle="studio-lehm.example" media={…} />
* <HoverCard variant="product" label="Dune vase" title="Dune vase" price="$59" media={…} action="Add to bag" />
* <HoverCard variant="definition" label="bisque firing" title="Bisque firing" description="…" />
*/
export function HoverCard({ variant = 'profile', label, href, title, subtitle, description, avatar, media, stats, price, rating, action, actionDone, onAction, delay = 450, defaultOpen, labels, className }: HoverCardProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const [open, setOpen] = useState(Boolean(defaultOpen));
const [done, setDone] = useState(false);
const [place, setPlace] = useState<{ above: boolean; shift: number }>({ above: false, shift: 0 });
const wrap = useRef<HTMLSpanElement>(null);
const card = useRef<HTMLSpanElement>(null);
const timer = useRef<number | undefined>(undefined);
const touch = useRef(false);
const quiet = useRef(false);
const id = useId();
const later = (fn: () => void, ms: number) => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(fn, ms);
};
useEffect(() => () => window.clearTimeout(timer.current), []);
// Stay inside the window: flip above when there is no room below, slide sideways at the edges.
// Measured again whenever the window changes size, so a card that opened in a small frame
// (an embed that grows to its content) moves back below as soon as the room is there.
useLayoutEffect(() => {
if (!open) return;
const measure = () => {
if (!card.current || !wrap.current) return;
const c = card.current.getBoundingClientRect();
const w = wrap.current.getBoundingClientRect();
// The room is the window, or a clipping box around the trigger when there is one
let minX = 16;
let maxX = document.documentElement.clientWidth - 16;
for (let n = wrap.current.parentElement; n && n !== document.body; n = n.parentElement) {
const o = getComputedStyle(n).overflowX;
if (o === 'hidden' || o === 'clip' || o === 'auto' || o === 'scroll') {
const r = n.getBoundingClientRect();
minX = Math.max(minX, r.left + 8);
maxX = Math.min(maxX, r.right - 8);
break;
}
}
const below = w.bottom + 10 + c.height <= window.innerHeight;
const above = w.top - 10 - c.height >= 0;
const center = w.left + w.width / 2;
const left = center - c.width / 2;
const shift = left < minX ? minX - left : left + c.width > maxX ? maxX - (left + c.width) : 0;
const next = { above: !below && above, shift: Math.round(shift) };
setPlace((p) => (p.above === next.above && p.shift === next.shift ? p : next));
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
setOpen(false);
// Back to the trigger without opening the card again
quiet.current = true;
wrap.current?.querySelector<HTMLElement>('[data-hc-trigger]')?.focus();
quiet.current = false;
};
const onDown = (e: PointerEvent) => !wrap.current?.contains(e.target as Node) && setOpen(false);
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onDown);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onDown);
};
}, [open]);
const Trigger = href ? 'a' : 'button';
const body = (() => {
if (variant === 'profile')
return (
<>
<span className="flex items-start gap-3">
<Avatar name={avatar ?? title} size="lg" />
<span className="min-w-0 pt-0.5">
<span className="block truncate text-[15px] font-semibold text-foreground">{title}</span>
{subtitle && <span className="block truncate text-[13px] text-muted-foreground">{subtitle}</span>}
</span>
</span>
{description && <span className="mt-3 block text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{description}</span>}
{stats?.length ? (
<span className="mt-3.5 flex gap-5 border-t border-border pt-3.5">
{stats.map((s) => (
<span key={s.label} className="text-[12.5px] text-muted-foreground">
<span className="font-semibold tabular-nums text-foreground">{s.value}</span> {s.label}
</span>
))}
</span>
) : null}
</>
);
if (variant === 'link')
return (
<>
{media && <span className="-mx-4 -mt-4 mb-3.5 block aspect-[1.91] overflow-hidden border-b border-border [&>*]:size-full">{media}</span>}
{subtitle && (
<span className="flex items-center gap-1.5 text-[12px] text-subtle-foreground">
<Icon name="globe" size={13} />
{subtitle}
</span>
)}
<span className="mt-1 block text-[14.5px] font-semibold leading-snug text-foreground">{title}</span>
{description && <span className="mt-1.5 line-clamp-3 block text-[13px] leading-relaxed text-muted-foreground">{description}</span>}
</>
);
if (variant === 'product')
return (
<span className="flex gap-3.5">
{media && <span className="block size-24 shrink-0 overflow-hidden rounded-lg border border-border bg-muted [&>*]:size-full">{media}</span>}
<span className="min-w-0 flex-1">
<span className="block text-[14.5px] font-semibold leading-snug text-foreground">{title}</span>
{subtitle && <span className="mt-0.5 block text-[12.5px] text-muted-foreground">{subtitle}</span>}
{rating !== undefined && (
<span className="mt-1.5 flex items-center gap-1 text-[12px] text-muted-foreground">
<Icon name="star" size={13} className="fill-[color-mix(in_oklab,var(--warning)_90%,transparent)] text-warning-foreground" />
<span className="tabular-nums">{rating.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</span>
<span className="sr-only">{t.rating}</span>
</span>
)}
{price && <span className="mt-2 block text-[15px] font-semibold tabular-nums text-foreground">{price}</span>}
</span>
</span>
);
return (
<>
{subtitle && <span className="block text-[11px] font-medium uppercase tracking-[0.09em] text-subtle-foreground">{subtitle}</span>}
<span className="mt-1 block text-[15px] font-semibold text-foreground">{title}</span>
{description && <span className="mt-1.5 block text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{description}</span>}
</>
);
})();
return (
<span
ref={wrap}
className={cn('relative inline-block', className)}
onPointerEnter={(e) => e.pointerType === 'mouse' && later(() => setOpen(true), delay)}
onPointerLeave={(e) => e.pointerType === 'mouse' && later(() => setOpen(false), 160)}
onFocus={() => {
window.clearTimeout(timer.current);
if (!quiet.current) setOpen(true);
}}
onBlur={(e) => {
if (!wrap.current?.contains(e.relatedTarget as Node)) later(() => setOpen(false), 60);
}}
>
<Trigger
data-hc-trigger
{...(href ? { href } : { type: 'button' as const })}
aria-expanded={open}
aria-controls={id}
onPointerDown={(e: React.PointerEvent) => (touch.current = e.pointerType !== 'mouse')}
onClick={(e: React.MouseEvent) => {
// On touch the first tap shows the card, the card carries the link
if (touch.current && !open) {
e.preventDefault();
setOpen(true);
}
}}
className={cn('rounded-[3px] outline-none transition-[color,text-decoration-color] duration-200 focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_60%,transparent)]', TRIGGER[variant])}
>
{label}
</Trigger>
<span
ref={card}
id={id}
role="group"
aria-label={title}
style={{ '--hc-shift': `${place.shift}px` } as React.CSSProperties}
className={cn(
!open && 'hidden',
'absolute left-1/2 z-40 w-[18.5rem] max-w-[calc(100vw-32px)] translate-x-[calc(-50%+var(--hc-shift))] whitespace-normal rounded-xl border border-border bg-popover p-4 text-left text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]',
open && 'block',
'transition-[opacity,scale,translate] duration-200 ease-[var(--ease-kit)] starting:scale-[0.97] starting:opacity-0 motion-reduce:transition-none',
place.above ? 'bottom-[calc(100%+10px)] origin-bottom' : 'top-[calc(100%+10px)] origin-top',
// A bridge, so the pointer can cross the gap to the card without closing it
'before:absolute before:inset-x-0 before:h-3 before:content-[""]',
place.above ? 'before:top-full' : 'before:bottom-full',
)}
>
{body}
{(action || href) && (
<span className="mt-4 flex gap-2">
{action && (
<button
type="button"
aria-pressed={actionDone ? done : undefined}
onClick={() => {
if (actionDone) setDone(!done);
onAction?.();
}}
className="inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground outline-none transition-[background-color,scale] duration-150 hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_35%,transparent)] active:scale-[0.97] sm:h-9"
>
{done && actionDone ? (
<>
<Icon name="check" size={14} strokeWidth={2.2} className="transition-[scale] duration-300 starting:scale-50" />
{actionDone}
</>
) : (
action
)}
</button>
)}
{href && (
<a
href={href}
aria-label={`${t.more} ${title}`}
className={cn('inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border-strong px-3.5 text-[13px] font-medium text-foreground outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:h-9', !action && 'flex-1')}
>
{t.open}
<Icon name="arrow-up-right" size={14} />
</a>
)}
</span>
)}
</span>
</span>
);
}Frequent questions
How do I install Hover card?
With aiw add @ai-website/hover-card. The command puts components/ui/hover-card.tsx into your project, the code is yours from then on. It brings along icon and avatar. It needs no further npm packages.
Which variants does Hover card have?
4 variants, Profile, Link preview, Product and Definition. You pick one with the variant prop, for example variant="link".
Can I translate the texts of Hover card?
Yes. Hover card carries 3 texts of its own in English and German, such as “Open”, “More about” and “out of 5 stars”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Hover card?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Hover card with aiw_components and install it with aiw_add @ai-website/hover-card. Name the variant in your prompt, for example variant="profile", and the assistant writes your content straight into the props.
Can I adjust Hover card without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching HoverCard code to copy.