Hover Card

For sighted users to preview content available behind a link.

import * as stylex from "@stylexjs/stylex"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { HStack, VStack } from "@/components/ui/stack"
import { color, leading, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  name: { fontWeight: 600 },
  meta: { fontSize: text.xs, lineHeight: leading.xs, color: color.mutedForeground },
})

export default function HoverCardDemo() {
  return (
    <HoverCard>
      <HoverCardTrigger delay={200} closeDelay={100} render={<Button variant="link" />}>
        @nextjs
      </HoverCardTrigger>
      <HoverCardContent>
        <HStack gap={3}>
          <Avatar>
            <AvatarImage src="https://github.com/vercel.png" alt="@nextjs" />
            <AvatarFallback>NX</AvatarFallback>
          </Avatar>
          <VStack gap={0.5}>
            <span {...stylex.props(styles.name)}>@nextjs</span>
            <span>The React framework, created and maintained by @vercel.</span>
            <span {...stylex.props(styles.meta)}>Joined December 2021</span>
          </VStack>
        </HStack>
      </HoverCardContent>
    </HoverCard>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. The card's opaque box is the shared popup surface. state-styles.ts reads the popup's transition state.

Copy and paste the following code into your project.

components/ui/hover-card.tsx
"use client"

import type { CSSProperties } from "react"
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"

import { stateStyles } from "@/lib/state-styles"
import { states } from "@/theme/states"
import { duration, ease, layer, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  hidden: { opacity: 0 },
  content: {
    zIndex: layer.popup,
    width: "16rem",
    maxWidth: "var(--available-width)",
    borderRadius: radius.xl,
    padding: space.s2_5,
    fontSize: text.sm,
    lineHeight: leading.sm,
    transformOrigin: "var(--transform-origin)",
    transitionProperty: "opacity, transform",
    transitionDuration: {
      default: duration.fade,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
})

const slide = stylex.create({
  top: { transform: `scale(0.95) translateY(${space.s2})` },
  bottom: { transform: `scale(0.95) translateY(calc(-1 * ${space.s2}))` },
  left: { transform: `scale(0.95) translateX(${space.s2})` },
  right: { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
  "inline-start": { transform: `scale(0.95) translateX(${space.s2})` },
  "inline-end": { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
})

type Overrides = { xstyle?: StyleXStyles; className?: string }
type StateOverrides = Overrides & { style?: CSSProperties }

function HoverCard<Payload>(props: PreviewCardPrimitive.Root.Props<Payload>) {
  return <PreviewCardPrimitive.Root {...props} />
}

function HoverCardTrigger<Payload>(props: PreviewCardPrimitive.Trigger.Props<Payload>) {
  return <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
}

type HoverCardContentProps = Omit<PreviewCardPrimitive.Popup.Props, "className" | "style"> &
  Pick<PreviewCardPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset"> &
  StateOverrides

function HoverCardContent({
  xstyle,
  style,
  className,
  side = "bottom",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
  ...props
}: HoverCardContentProps) {
  return (
    <PreviewCardPrimitive.Portal>
      <PreviewCardPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        {...stylex.props(states.positioner)}
      >
        <PreviewCardPrimitive.Popup
          data-slot="hover-card-content"
          {...props}
          {...stateStyles(
            (state) => [
              states.popupSurface,
              styles.content,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                styles.hidden,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                slide[state.side],
              xstyle,
            ],
            { className, style },
          )}
        />
      </PreviewCardPrimitive.Positioner>
    </PreviewCardPrimitive.Portal>
  )
}

export { HoverCard, HoverCardContent, HoverCardTrigger }

Update the import paths to match your project setup.

Usage

import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
<HoverCard>
  <HoverCardTrigger href="https://github.com/vercel">@nextjs</HoverCardTrigger>
  <HoverCardContent>The React framework, created and maintained by @vercel.</HoverCardContent>
</HoverCard>

Composition

HoverCard
├── HoverCardTrigger
└── HoverCardContent      portal + positioner + popup

HoverCardTrigger renders an <a>. Pass render to anchor the card to a different element.

A hover card opens on hover and on keyboard focus, never on press. A touch screen has no hover, so the card never opens there. Put nothing in it that a reader cannot also reach another way.

Trigger delays

Use delay and closeDelay on the trigger to set how long the pointer has to rest before the card opens, and how long it stays open after the pointer leaves. closeDelay keeps the card open while the pointer crosses the gap into it.

import { Button } from "@/components/ui/button"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { HStack } from "@/components/ui/stack"

export default function HoverCardDelay() {
  return (
    <HStack gap={2} wrap="wrap" justify="center">
      <HoverCard>
        <HoverCardTrigger delay={0} closeDelay={0} render={<Button variant="outline" />}>
          Immediate
        </HoverCardTrigger>
        <HoverCardContent>Opens the moment the pointer lands.</HoverCardContent>
      </HoverCard>
      <HoverCard>
        <HoverCardTrigger render={<Button variant="outline" />}>Default</HoverCardTrigger>
        <HoverCardContent>Waits 600ms to open and 300ms to close.</HoverCardContent>
      </HoverCard>
      <HoverCard>
        <HoverCardTrigger delay={1000} closeDelay={600} render={<Button variant="outline" />}>
          Patient
        </HoverCardTrigger>
        <HoverCardContent>Waits a second, then stays while you cross the gap.</HoverCardContent>
      </HoverCard>
    </HStack>
  )
}

Sides

Use side on HoverCardContent to pick the edge it opens from, and align to slide it along that edge. Base UI flips the side when there is no room.

import { Button } from "@/components/ui/button"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { HStack, VStack } from "@/components/ui/stack"

const sides = ["top", "right", "bottom", "left"] as const

export default function HoverCardSides() {
  return (
    <HStack gap={2} wrap="wrap" justify="center">
      {sides.map((side) => (
        <HoverCard key={side}>
          <HoverCardTrigger delay={100} closeDelay={100} render={<Button variant="outline" />}>
            {side}
          </HoverCardTrigger>
          <HoverCardContent side={side}>
            <VStack gap={1}>
              <span>Preview</span>
              <span>This card opens on the {side} of its trigger.</span>
            </VStack>
          </HoverCardContent>
        </HoverCard>
      ))}
    </HStack>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI PreviewCard part of the same name and accepts its props. See the Base UI reference for the full list. Each styled part also takes xstyle and className.

HoverCard

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean, details) => void—
handlePreviewCardHandle—

HoverCardTrigger

PropTypeDefault
delaynumber600
closeDelaynumber300
payloadPayload—

HoverCardContent

PropTypeDefault
side"top" | "bottom" | "left" | "right""bottom"
sideOffsetnumber4
align"start" | "center" | "end""center"
alignOffsetnumber0

The card is 16rem wide and clamped to --available-width, so it narrows rather than overflowing a phone-width viewport. Change the width through xstyle.

Data attributes

AttributeOnEffect
data-slot="hover-card-content"the popupPopup surface
data-sidethe popupWhich way it slides in from
data-starting-style, data-ending-stylethe popupBase UI writes them while the card animates
data-open, data-closedthe popupBase UI writes them