Skip to content
UI Kit
Accent color

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

4 variants

Variants

Profile

A mention that shows who someone is, with avatar, role, a short bio, two numbers and a follow button.

Open in Studio

Product

A product named in a story, with picture, rating, price and a button to put it in the bag without leaving the page.

Open in Studio

Definition

A term with a dotted line that explains itself, for glossaries, legal pages and courses. Without a button the card is only text.

Open in Studio

Details

What Hover card brings along, read from the registry and the source.

Category
Overlays
Variants
4
Version
1.0.1 · since 1.1.0
File
components/ui/hover-card.tsx
Brings along
utilskiticonavatar
npm packages
None
Languages
English and German, 3 texts of its own

Props

The HoverCardProps interface from components/ui/hover-card.tsx, with the comments from the code.

  • variantoptional
    HoverCardVariant

    profilelinkproductdefinition

  • labelrequired
    string

    The text in the flow that carries the card

  • hrefoptional
    string

    Where the label links to

  • titlerequired
    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

Built-in texts

Hover card writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 3 texts
openEnglish OpenGerman Öffnen
moreEnglish More aboutGerman Mehr zu
ratingEnglish out of 5 starsGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard