# Hover card

> A preview card that opens when a link is hovered or focused, for content sighted users can glance at.

Docs: https://hextaui.com/docs/hover-card
Markdown: https://hextaui.com/docs/hover-card.md

```tsx title="components/examples/hover-card/demo.tsx"
"use client"

import { IconMapPin } from "@tabler/icons-react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
  createHoverCardHandle,
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

type Person = {
  handle: string
  name: string
  initials: string
  bio: string
  location: string
}

const people: Record<string, Person> = {
  mira: {
    handle: "mira",
    name: "Mira Okafor",
    initials: "MO",
    bio: "Design engineer. Obsessed with easing curves.",
    location: "Lagos",
  },
  jun: {
    handle: "jun",
    name: "Jun Park",
    initials: "JP",
    bio: "Maintains the motion tokens and keeps the docs honest about what ships.",
    location: "Seoul",
  },
  sol: {
    handle: "sol",
    name: "Sol Ferreira",
    initials: "SF",
    bio: "Accessibility.",
    location: "Lisbon",
  },
}

const profile = createHoverCardHandle<Person>()

function Mention({ person }: { person: Person }) {
  return (
    <HoverCardTrigger
      handle={profile}
      payload={person}
      href="#"
      delay={250}
      render={
        <a className="font-medium text-foreground underline decoration-border underline-offset-4 hover:decoration-foreground" />
      }
    >
      @{person.handle}
    </HoverCardTrigger>
  )
}

export function HoverCardDemo() {
  return (
    <>
      <p className="max-w-sm text-center text-sm/relaxed text-muted-foreground">
        Shipped by <Mention person={people.mira} />, reviewed by{" "}
        <Mention person={people.jun} /> and tested with a screen reader by{" "}
        <Mention person={people.sol} />.
      </p>
      <HoverCard handle={profile}>
        {({ payload }) => (
          <HoverCardContent arrow>
            {payload && (
              <div className="flex gap-3">
                <Avatar>
                  <AvatarFallback>{payload.initials}</AvatarFallback>
                </Avatar>
                <div className="flex min-w-0 flex-col gap-1">
                  <p className="font-medium">{payload.name}</p>
                  <p className="text-muted-foreground">{payload.bio}</p>
                  <p className="flex items-center gap-1 pt-1 text-xs text-muted-foreground">
                    <IconMapPin className="size-3.5" aria-hidden="true" />
                    {payload.location}
                  </p>
                </div>
              </div>
            )}
          </HoverCardContent>
        )}
      </HoverCard>
    </>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/hover-card.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/hover-card.tsx"
"use client"

import * as React from "react"
import {
  DirectionProvider,
  useDirection,
  type TextDirection,
} from "@base-ui/react/direction-provider"
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "cn"

import { useSizeMorph } from "@/lib/motion"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

function readDirection(element: Element | null | undefined) {
  if (!element || !element.isConnected) {
    return undefined
  }
  return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr"
}

function HoverCard<Payload>({
  onOpenChange,
  children,
  ...props
}: PreviewCardPrimitive.Root.Props<Payload>) {
  const inherited = useDirection()
  const [triggerDirection, setTriggerDirection] =
    React.useState<TextDirection>()
  const direction = triggerDirection === "rtl" ? "rtl" : inherited

  return (
    <DirectionProvider direction={direction}>
      <PreviewCardPrimitive.Root
        onOpenChange={(open, eventDetails) => {
          onOpenChange?.(open, eventDetails)
          if (open && !eventDetails.isCanceled && eventDetails.trigger) {
            setTriggerDirection(readDirection(eventDetails.trigger))
          }
        }}
        {...props}
      >
        {children}
      </PreviewCardPrimitive.Root>
    </DirectionProvider>
  )
}

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

function HoverCardPortal(props: PreviewCardPrimitive.Portal.Props) {
  return (
    <PreviewCardPrimitive.Portal data-slot="hover-card-portal" {...props} />
  )
}

type HoverCardContentProps = PreviewCardPrimitive.Popup.Props &
  Pick<
    PreviewCardPrimitive.Positioner.Props,
    | "side"
    | "align"
    | "sideOffset"
    | "alignOffset"
    | "anchor"
    | "collisionPadding"
    | "collisionAvoidance"
    | "collisionBoundary"
    | "sticky"
    | "positionMethod"
    | "disableAnchorTracking"
  > & {
    portalProps?: Omit<PreviewCardPrimitive.Portal.Props, "children">
    arrow?: boolean
  }

function HoverCardContent({
  className,
  children,
  side = "bottom",
  align = "center",
  arrow = false,
  sideOffset = arrow ? 10 : 6,
  alignOffset = 0,
  anchor,
  collisionPadding = 8,
  collisionAvoidance,
  collisionBoundary,
  sticky,
  positionMethod,
  disableAnchorTracking,
  portalProps,
  ...props
}: HoverCardContentProps) {
  const direction = useDirection()

  return (
    <PreviewCardPrimitive.Portal {...portalProps}>
      <PreviewCardPrimitive.Positioner
        data-slot="hover-card-positioner"
        dir={direction === "rtl" ? "rtl" : undefined}
        side={side}
        align={align}
        sideOffset={sideOffset}
        alignOffset={alignOffset}
        anchor={anchor}
        collisionPadding={collisionPadding}
        collisionAvoidance={collisionAvoidance}
        collisionBoundary={collisionBoundary}
        sticky={sticky}
        positionMethod={positionMethod}
        disableAnchorTracking={disableAnchorTracking}
        className="isolate z-50 outline-none focus-visible:outline-hidden data-instant:transition-none motion-safe:transition-[top,left,right,bottom] motion-safe:duration-200 motion-safe:ease-out-quint"
      >
        <PreviewCardPrimitive.Popup
          data-slot="hover-card-content"
          className={mergeClassName(
            "relative flex h-(--popup-height,auto) max-h-(--available-height) w-64 max-w-(--available-width) origin-(--transform-origin) flex-col gap-2.5 overflow-x-hidden overflow-y-auto overscroll-none rounded-lg bg-popover p-3 text-sm wrap-anywhere text-popover-foreground shadow-md/5 ring-(length:--hairline) ring-foreground/10 transition-[opacity,scale,translate,width,height] duration-250 ease-spring outline-none focus-visible:outline-hidden data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quint data-ending-style:data-instant:transition-none data-starting-style:opacity-0 motion-safe:data-ending-style:scale-97 motion-safe:data-starting-style:scale-95 motion-safe:data-[side=bottom]:data-starting-style:-translate-y-1 motion-safe:data-[side=inline-end]:data-starting-style:-translate-x-1 motion-safe:data-[side=inline-start]:data-starting-style:translate-x-1 motion-safe:data-[side=left]:data-starting-style:translate-x-1 motion-safe:data-[side=right]:data-starting-style:-translate-x-1 motion-safe:data-[side=top]:data-starting-style:translate-y-1 motion-reduce:transition-opacity motion-safe:rtl:data-[side=inline-end]:data-starting-style:translate-x-1 motion-safe:rtl:data-[side=inline-start]:data-starting-style:-translate-x-1 forced-colors:border",
            className
          )}
          {...props}
        >
          <HoverCardViewport>{children}</HoverCardViewport>
        </PreviewCardPrimitive.Popup>
        {arrow && <HoverCardArrow />}
      </PreviewCardPrimitive.Positioner>
    </PreviewCardPrimitive.Portal>
  )
}

const slide =
  "[&>[data-current]]:transition-[translate,opacity,filter] [&>[data-previous]]:transition-[translate,opacity,filter] [&>[data-current]]:duration-300 [&>[data-previous]]:duration-200 [&>[data-current]]:ease-out-quint [&>[data-previous]]:ease-out-quint [&>[data-current][data-starting-style]]:opacity-0 [&>[data-previous][data-ending-style]]:opacity-0 motion-safe:[&>[data-current][data-starting-style]]:blur-[2px] motion-safe:[&>[data-previous][data-ending-style]]:blur-[2px] motion-safe:data-[activation-direction~=right]:[&>[data-current][data-starting-style]]:translate-x-6 motion-safe:data-[activation-direction~=right]:[&>[data-previous][data-ending-style]]:-translate-x-6 motion-safe:data-[activation-direction~=left]:[&>[data-current][data-starting-style]]:-translate-x-6 motion-safe:data-[activation-direction~=left]:[&>[data-previous][data-ending-style]]:translate-x-6 motion-safe:data-[activation-direction~=down]:[&>[data-current][data-starting-style]]:translate-y-3 motion-safe:data-[activation-direction~=down]:[&>[data-previous][data-ending-style]]:-translate-y-3 motion-safe:data-[activation-direction~=up]:[&>[data-current][data-starting-style]]:-translate-y-3 motion-safe:data-[activation-direction~=up]:[&>[data-previous][data-ending-style]]:translate-y-3 motion-reduce:[&>*]:transition-opacity"

function HoverCardViewport({ children }: { children: React.ReactNode }) {
  const morphRef = useSizeMorph<HTMLDivElement>({
    axis: "height",
    duration: 250,
  })

  return (
    <PreviewCardPrimitive.Viewport
      data-slot="hover-card-viewport"
      className={cn(
        "relative flex min-h-0 flex-col gap-[inherit] [&>*]:flex [&>*]:flex-col [&>*]:gap-[inherit]",
        slide
      )}
    >
      <div
        ref={morphRef}
        data-slot="hover-card-body"
        className="flex min-w-0 flex-col gap-[inherit] data-morphing:overflow-clip"
      >
        {children}
      </div>
    </PreviewCardPrimitive.Viewport>
  )
}

function HoverCardArrow({
  className,
  ...props
}: PreviewCardPrimitive.Arrow.Props) {
  return (
    <PreviewCardPrimitive.Arrow
      data-slot="hover-card-arrow"
      className={mergeClassName(
        "z-10 h-1.5 w-3 overflow-clip transition-opacity duration-150 ease-out-quint before:absolute before:bottom-0 before:left-1/2 before:size-[calc(var(--spacing)*1.5*sqrt(2))] before:-translate-x-1/2 before:translate-y-1/2 before:rotate-45 before:bg-popover before:ring-(length:--hairline) before:ring-foreground/10 data-[side=bottom]:-top-1.5 data-[side=inline-end]:-left-[9px] data-[side=inline-end]:-rotate-90 data-[side=inline-start]:-right-[9px] data-[side=inline-start]:rotate-90 data-[side=left]:-right-[9px] data-[side=left]:rotate-90 data-[side=right]:-left-[9px] data-[side=right]:-rotate-90 data-[side=top]:-bottom-1.5 data-[side=top]:rotate-180 motion-safe:animate-in motion-safe:fade-in-0 rtl:data-[side=inline-end]:right-[-9px] rtl:data-[side=inline-end]:left-auto rtl:data-[side=inline-end]:rotate-90 rtl:data-[side=inline-start]:right-auto rtl:data-[side=inline-start]:left-[-9px] rtl:data-[side=inline-start]:-rotate-90 data-closed:opacity-0",
        className
      )}
      {...props}
    />
  )
}

const createHoverCardHandle = PreviewCardPrimitive.createHandle

export {
  HoverCard,
  HoverCardTrigger,
  HoverCardPortal,
  HoverCardContent,
  HoverCardArrow,
  createHoverCardHandle,
}
export type { HoverCardContentProps }
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"
```

```tsx
<HoverCard>
  <HoverCardTrigger href="/profile" render={<Button variant="link" nativeButton={false} render={<a />} />}>
    @hextaui
  </HoverCardTrigger>
  <HoverCardContent>
    Components built on shadcn/ui.
  </HoverCardContent>
</HoverCard>
```

A hover card is a preview, not a menu or a dialog. The trigger stays a normal link, so everything in the card must also be on the page it links to.

## Composition

```text
HoverCard
├── HoverCardTrigger
└── HoverCardContent
```

## Examples

### Side

Set `side` and `align` on `<HoverCardContent />`. Logical sides like `inline-end` follow the reading direction, and the card flips or shifts when it would leave the screen.

```tsx title="components/examples/hover-card/sides.tsx"
import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

const sides = ["top", "inline-end", "bottom", "inline-start"] as const

export function HoverCardSides() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      {sides.map((side) => (
        <HoverCard key={side}>
          <HoverCardTrigger
            href="#"
            delay={200}
            render={
              <Button variant="outline" nativeButton={false} render={<a />} />
            }
          >
            {side}
          </HoverCardTrigger>
          <HoverCardContent side={side} className="w-48">
            Opens on the {side} side, and flips when there isn’t room.
          </HoverCardContent>
        </HoverCard>
      ))}
    </div>
  )
}
```

### Delay

`delay` and `closeDelay` on the trigger set how long the pointer must rest before the card opens and how long it lingers after leaving. The 600ms default stops cards from flashing open as the pointer crosses a page.

```tsx title="components/examples/hover-card/delay.tsx"
import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardDelay() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <HoverCard>
        <HoverCardTrigger
          href="#"
          render={
            <Button variant="outline" nativeButton={false} render={<a />} />
          }
        >
          Default (600ms)
        </HoverCardTrigger>
        <HoverCardContent className="w-56">
          Waits long enough that passing the pointer over the link doesn’t open
          it.
        </HoverCardContent>
      </HoverCard>
      <HoverCard>
        <HoverCardTrigger
          href="#"
          delay={150}
          closeDelay={100}
          render={
            <Button variant="outline" nativeButton={false} render={<a />} />
          }
        >
          Fast (150ms)
        </HoverCardTrigger>
        <HoverCardContent className="w-56">
          Opens almost right away and closes quickly.
        </HoverCardContent>
      </HoverCard>
    </div>
  )
}
```

### Inline link

Use `render` to make the trigger any link, including one inside a sentence. When a link wraps onto two lines, the card anchors to the line you hovered.

```tsx title="components/examples/hover-card/inline.tsx"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardInline() {
  return (
    <p className="max-w-sm text-sm text-muted-foreground">
      Built on{" "}
      <HoverCard>
        <HoverCardTrigger
          href="https://base-ui.com"
          render={
            <a className="font-medium text-foreground underline decoration-border underline-offset-4 hover:decoration-foreground" />
          }
        >
          Base UI
        </HoverCardTrigger>
        <HoverCardContent className="w-60">
          Unstyled, accessible React primitives from the creators of Radix,
          Floating UI and Material UI.
        </HoverCardContent>
      </HoverCard>{" "}
      primitives, styled with Tailwind CSS and theme tokens, and ready to copy
      into your project.
    </p>
  )
}
```

### Interactive content

Move the pointer from the link into the card and it stays open, so links and buttons inside can be clicked. The path between them is forgiving, so a diagonal move doesn’t close it.

```tsx title="components/examples/hover-card/rich-content.tsx"
import { IconExternalLink, IconStar } from "@tabler/icons-react"

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

export function HoverCardRichContent() {
  return (
    <HoverCard>
      <HoverCardTrigger
        href="https://github.com/preetsuthar17"
        render={<Button variant="link" nativeButton={false} render={<a />} />}
      >
        hextaui/components
      </HoverCardTrigger>
      <HoverCardContent className="w-72">
        <div className="flex flex-col gap-2">
          <p className="font-medium">hextaui/components</p>
          <p className="text-muted-foreground">
            Copy-paste React components with motion, keyboard support and RTL
            built in.
          </p>
          <div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
            <span className="flex items-center gap-1">
              <IconStar className="size-3.5" aria-hidden="true" />
              2.4k
            </span>
            <a
              href="https://github.com/preetsuthar17"
              className="flex items-center gap-1 text-foreground underline-offset-4 hover:underline"
            >
              Open on GitHub
              <IconExternalLink className="size-3.5" aria-hidden="true" />
            </a>
          </div>
        </div>
      </HoverCardContent>
    </HoverCard>
  )
}
```

### Shared card

One card serves many links. Create a handle with `createHoverCardHandle`, give each trigger a `payload`, and read it in the card. Moving between names glides the card to the new link instead of closing and reopening it. The old content slides out the way you moved, the new content slides in, and the height eases between the two.

```tsx title="components/examples/hover-card/detached.tsx"
"use client"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
  createHoverCardHandle,
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

type Person = { name: string; initials: string; role: string }

const team: Person[] = [
  { name: "Ada Lovelace", initials: "AL", role: "Analyst" },
  { name: "Alan Turing", initials: "AT", role: "Research" },
  { name: "Grace Hopper", initials: "GH", role: "Compilers" },
]

const profileCard = createHoverCardHandle<Person>()

export function HoverCardDetached() {
  return (
    <div className="flex flex-col items-center gap-3">
      <ul className="flex flex-col items-start gap-2 text-sm">
        {team.map((person) => (
          <li key={person.name}>
            <HoverCardTrigger
              handle={profileCard}
              payload={person}
              href="#"
              delay={300}
              render={
                <a className="font-medium underline decoration-border underline-offset-4 hover:decoration-foreground" />
              }
            >
              {person.name}
            </HoverCardTrigger>
          </li>
        ))}
      </ul>
      <HoverCard handle={profileCard}>
        {({ payload }) => (
          <HoverCardContent side="inline-end" align="start" className="w-56">
            {payload ? (
              <div className="flex items-center gap-3">
                <Avatar>
                  <AvatarFallback>{payload.initials}</AvatarFallback>
                </Avatar>
                <div className="flex min-w-0 flex-col">
                  <p className="font-medium">{payload.name}</p>
                  <p className="text-muted-foreground">{payload.role}</p>
                </div>
              </div>
            ) : null}
          </HoverCardContent>
        )}
      </HoverCard>
    </div>
  )
}
```

### Arrow

`arrow` adds a pointer that joins the card's border without a seam. The side offset grows to make room for it, and it follows the card when it flips.

```tsx title="components/examples/hover-card/arrow.tsx"
import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardArrowDemo() {
  return (
    <HoverCard>
      <HoverCardTrigger
        href="#"
        delay={200}
        render={
          <Button variant="outline" nativeButton={false} render={<a />} />
        }
      >
        Release notes
      </HoverCardTrigger>
      <HoverCardContent arrow side="top" className="w-56">
        Version 2.4 adds shared hover cards and smoother text areas.
      </HoverCardContent>
    </HoverCard>
  )
}
```

### Loading content

Start fetching in `onOpenChange` and show a skeleton until the data arrives. When the content changes, the card eases to its new height instead of jumping.

```tsx title="components/examples/hover-card/async.tsx"
"use client"

import * as React from "react"

import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"
import { Skeleton, SkeletonText } from "@/components/ui/skeleton"

type Repo = { name: string; description: string; stars: number }

function fetchRepo(): Promise<Repo> {
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve({
          name: "hextaui/hextaui",
          description:
            "Components built on shadcn/ui and Base UI, with motion, keyboard support and edge cases handled. Copy them into your project and make them yours.",
          stars: 2140,
        }),
      900
    )
  )
}

export function HoverCardAsync() {
  const [repo, setRepo] = React.useState<Repo | null>(null)
  const request = React.useRef<Promise<void> | null>(null)

  return (
    <HoverCard
      onOpenChange={(open) => {
        if (open && !request.current) {
          request.current = fetchRepo().then(setRepo)
        }
      }}
    >
      <HoverCardTrigger
        href="#"
        delay={200}
        render={
          <a className="text-sm font-medium underline decoration-border underline-offset-4 hover:decoration-foreground" />
        }
      >
        hextaui/hextaui
      </HoverCardTrigger>
      <HoverCardContent className="w-72" aria-busy={!repo}>
        {repo ? (
          <div className="flex flex-col gap-1.5">
            <p className="font-medium">{repo.name}</p>
            <p className="text-muted-foreground">{repo.description}</p>
            <p className="text-xs text-muted-foreground">
              {repo.stars.toLocaleString("en-US")} stars
            </p>
          </div>
        ) : (
          <div className="flex flex-col gap-2">
            <Skeleton className="h-4 w-32" />
            <SkeletonText lines={2} />
          </div>
        )}
      </HoverCardContent>
    </HoverCard>
  )
}
```

### Controlled

Pass `open` and `onOpenChange`. The second argument says why it changed, such as `trigger-hover`, `trigger-focus` or `escape-key`.

```tsx title="components/examples/hover-card/controlled.tsx"
"use client"

import * as React from "react"

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

export function HoverCardControlled() {
  const [open, setOpen] = React.useState(false)
  const [reason, setReason] = React.useState("none")

  return (
    <div className="flex flex-col items-center gap-3">
      <HoverCard
        open={open}
        onOpenChange={(next, details) => {
          setOpen(next)
          setReason(details.reason)
        }}
      >
        <HoverCardTrigger
          href="#"
          render={<Button variant="link" nativeButton={false} render={<a />} />}
        >
          Release notes
        </HoverCardTrigger>
        <HoverCardContent className="w-60">
          Version 2.0 rebuilds every component on Base UI.
        </HoverCardContent>
      </HoverCard>
      <p className="text-sm text-muted-foreground">
        Open: {String(open)} · last reason: {reason}
      </p>
    </div>
  )
}
```

### Long content

Unbroken text wraps inside the card, and a card taller than the space beside the trigger scrolls instead of leaving the screen.

```tsx title="components/examples/hover-card/long-content.tsx"
import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardLongContent() {
  return (
    <HoverCard>
      <HoverCardTrigger
        href="#"
        render={<Button variant="link" nativeButton={false} render={<a />} />}
      >
        Long preview
      </HoverCardTrigger>
      <HoverCardContent>
        <p className="font-medium">
          https://example.com/a/really/long/url/without/any/spaces/at/all
        </p>
        <p className="text-muted-foreground">
          Long previews wrap inside the card, and when the card is taller than
          the space around the trigger it scrolls instead of leaving the screen.
          Keep previews short, though: everything here should also be on the
          linked page.
        </p>
      </HoverCardContent>
    </HoverCard>
  )
}
```

### Right to left

The card reads the trigger’s direction, so logical sides and alignment flip and the scale animation grows from the correct corner.

```tsx title="components/examples/hover-card/rtl.tsx"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardRtl() {
  return (
    <div dir="rtl">
      <HoverCard>
        <HoverCardTrigger
          href="#"
          render={<Button variant="link" nativeButton={false} render={<a />} />}
        >
          <span dir="ltr">@hextaui</span>
        </HoverCardTrigger>
        <HoverCardContent side="inline-end">
          <div className="flex gap-3">
            <Avatar>
              <AvatarFallback>هـ</AvatarFallback>
            </Avatar>
            <div className="flex min-w-0 flex-col gap-1">
              <p className="font-medium">هكستا</p>
              <p className="text-muted-foreground">
                مكونات مبنية على shadcn/ui مع حركة سلسة ودعم كامل للوحة
                المفاتيح.
              </p>
            </div>
          </div>
        </HoverCardContent>
      </HoverCard>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Focusing the trigger opens the card after the same delay as hover. Moving focus on closes it. |
| `Enter` | Follows the link, like any other link. |
| `Esc` | Closes the card. |

## Accessibility

- The card is a visual extra for sighted mouse and keyboard users. Screen readers only hear the link, so they aren’t forced through a preview on every link they pass.
- Nothing opens on touch screens, where there is no hover. A tap follows the link, which is why the destination has to hold the same information.
- Focus never moves into the card. If it needs controls that must be reachable from the keyboard, use a popover instead.
- With reduced motion on, the card fades without scaling, and a shared card jumps between links instead of gliding.

## API reference

Built on the Base UI preview card. Every part accepts the props of the primitive it wraps.

### HoverCard

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | – |  |
| `defaultOpen` | `boolean` | `false` |  |
| `onOpenChange` | `(open: boolean, details) => void` | – | details.reason is trigger-hover, trigger-focus, trigger-press, outside-press, escape-key, imperative-action or none. |
| `onOpenChangeComplete` | `(open: boolean) => void` | – | Called after the open or close animation ends. |
| `handle` | `HoverCardHandle<Payload>` | – | Connects triggers rendered outside the root. |
| `children` | `ReactNode \| ({ payload }) => ReactNode` | – | Use the function form to read the payload of the trigger that opened the card. |
| `actionsRef` | `RefObject<{ close, unmount }>` | – |  |

### HoverCardTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `href` | `string` | – |  |
| `delay` | `number` | `600` | Milliseconds before hover or focus opens the card. |
| `closeDelay` | `number` | `300` | Milliseconds the card stays open after leaving. |
| `handle` | `HoverCardHandle<Payload>` | – |  |
| `payload` | `Payload` | – | Passed to the card when this trigger opens it. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<a>` | Render your own link, such as <Button variant="link" /> or a router link. |

| Attribute | Description |
| --- | --- |
| `data-slot="hover-card-trigger"` | Target the trigger in CSS. |
| `data-popup-open` | Present while this trigger’s card is open. |

### HoverCardContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"top" \| "bottom" \| "left" \| "right" \| "inline-start" \| "inline-end"` | `"bottom"` |  |
| `align` | `"start" \| "center" \| "end"` | `"center"` |  |
| `arrow` | `boolean` | `false` | Show a pointer toward the trigger. |
| `sideOffset` | `number \| OffsetFunction` | `6, or 10 with arrow` |  |
| `alignOffset` | `number \| OffsetFunction` | `0` |  |
| `collisionPadding` | `number \| Rect` | `8` | Space kept between the card and the viewport edge. |
| `collisionAvoidance` | `CollisionAvoidance` | – | Whether the card flips, shifts or does nothing on collision. |
| `sticky` | `boolean` | `false` |  |
| `anchor` | `Element \| RefObject \| VirtualElement` | – | Position against something other than the trigger. |
| `positionMethod` | `"absolute" \| "fixed"` | `"absolute"` |  |
| `disableAnchorTracking` | `boolean` | `false` |  |
| `portalProps` | `HoverCardPortalProps` | – | Props for the portal, such as container. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="hover-card-content"` | Target the card in CSS. |
| `data-open` | Present while the card is open. |
| `data-starting-style` | Present while the card animates in. |
| `data-ending-style` | Present while the card animates out. |
| `data-instant` | focus when keyboard focus opened the card, dismiss when Escape or an outside press closed it. The exit animation is skipped while it’s set. |
| `data-side` | The side the card settled on after collisions. |
| `data-align` | The alignment it settled on. |
| `--transform-origin` | Where the scale animation grows from, next to the trigger. |
| `--available-width` | Room left beside the trigger. The card never grows past it. |
| `--available-height` | Room left above or below. Taller content scrolls. |

| Positioner attribute | Description |
| --- | --- |
| `data-slot="hover-card-positioner"` | The element that moves. It glides when a shared card switches links. |
| `data-anchor-hidden` | Present when the trigger scrolls out of view. |

| Inner parts | Description |
| --- | --- |
| `data-slot="hover-card-viewport"` | Wraps the content. Carries data-activation-direction while a shared card switches links. |
| `data-slot="hover-card-body"` | Your content. Its height eases when it changes. |
| `data-slot="hover-card-arrow"` | The pointer, with data-side for its edge. |
| `--popup-height` | Set on the card while it resizes between links. |

### HoverCardPortal

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `container` | `HTMLElement \| ShadowRoot \| RefObject \| null` | `document.body` |  |
| `keepMounted` | `boolean` | `false` |  |

### createHoverCardHandle

Returns a handle for detached triggers. Its `open(triggerId)` and `close()` methods control the card from event handlers, and `isOpen` reads its state. Pass a type argument to type the payload.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
