Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Profil

Eine Erwähnung, die zeigt, wer jemand ist, mit Avatar, Rolle, kurzer Vorstellung, zwei Zahlen und einem Knopf zum Folgen.

Im Studio öffnen

Produkt

Ein Produkt, das in einer Geschichte vorkommt, mit Bild, Bewertung, Preis und einem Knopf, der es in den Warenkorb legt, ohne die Seite zu verlassen.

Im Studio öffnen

Begriff

Ein Begriff mit gepunkteter Linie, der sich selbst erklärt, für Glossare, Rechtstexte und Kurse. Ohne Knopf ist die Karte nur Text.

Im Studio öffnen

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
Bringt mit
utilskiticonavatar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 3 eigene Texte

Props

Die Schnittstelle HoverCardProps aus components/ui/hover-card.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    HoverCardVariant

    profilelinkproductdefinition

  • labelPflicht
    string

    The text in the flow that carries the card

  • hrefoptional
    string

    Where the label links to

  • titlePflicht
    string
  • subtitleoptional
    string

    profile: role or handle; link: the address; product: the price line; definition: the field

  • descriptionoptional
    string
  • avataroptional
    string

    profile: the name for the initials

  • mediaoptional
    React.ReactNode

    link and product: a picture

  • statsoptional
    { label: string; value: string }[]

    profile: small facts in a row

  • priceoptional
    string

    product: the price as shown

  • ratingoptional
    number

    product: 0 to 5

  • actionoptional
    string

    A button in the card, e.g. "Follow" or "Add to bag"

  • actionDoneoptional
    string

    The button text once the action is done, e.g. "Following" or "In your bag". The button then shows a check.

  • onActionoptional
    () => void
  • delayoptional
    number

    Wait before opening on hover, ms. Default 450.

  • defaultOpenoptional
    boolean

    Open 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
openEnglisch OpenDeutsch Öffnen
moreEnglisch More aboutDeutsch Mehr zu
ratingEnglisch out of 5 starsDeutsch 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
components/ui/hover-card.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert