← ui

hover-card

ui

what is behind a link, shown while the hand rests on it

npx shadcn add @ag/hover-card
Already installed? Preview an update
npx shadcn add @ag/hover-card --dry-run

Your 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 on panel.
Updated 2026-10-14 · release notes

also called preview card

shelf controls

uses material, panel, portal

used by components

the demo above, verbatim

hover-card.demo.tsx
"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

hover-card.tsx
"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>
  )
}