Hover-Karte
Mehr über eine Person, einen Link, ein Produkt oder einen Begriff, während man darauf verweilt. Öffnet bei Hover und Fokus, Tab erreicht ihre Knöpfe, auf Touch öffnet der erste Tipp.
Eine von 9 Components in Overlays
Variants
Details
Was Hover-Karte mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Overlays
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/hover-card.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 3 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle HoverCardProps aus components/ui/hover-card.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalHoverCardVariantprofilelinkproductdefinition
labelPflichtstringThe text in the flow that carries the card
hrefoptionalstringWhere the label links to
titlePflichtstringsubtitleoptionalstringprofile: 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
Eingebaute Texte
Diese Texte schreibt Hover-Karte selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 3 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| open | Englisch Open | Deutsch Öffnen |
| more | Englisch More about | Deutsch Mehr zu |
| rating | Englisch out of 5 stars | Deutsch von 5 Sternen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Hover-Karte reagiert selbst auf die Tasten Escape.
- Setzt aria-controls, aria-expanded, aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 296 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Hover-Karte?
Mit aiw add @ai-website/hover-card. Der Befehl legt die Datei components/ui/hover-card.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und avatar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Hover-Karte?
4 Varianten, Profil, Linkvorschau, Produkt und Begriff. Du wählst sie mit der Prop variant, zum Beispiel variant="link".
Kann ich die Texte von Hover-Karte übersetzen?
Ja. Hover-Karte bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Öffnen“, „Mehr zu“ und „von 5 Sternen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Hover-Karte einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Hover-Karte mit aiw_components und installieren sie mit aiw_add @ai-website/hover-card. Nenn im Prompt die Variante, etwa variant="profile", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Hover-Karte ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden HoverCard-Code zum Kopieren.