Hover Card

A preview of the content behind a link, shown on hover. Built with React Aria's PreviewTrigger and Popover.

"use client"

import { cn } from "cn"
import {
  Popover as PopoverPrimitive,
  PreviewTrigger as PreviewTriggerPrimitive,
  type PopoverProps as PopoverPrimitiveProps,
  type PreviewTriggerProps,
} from "@animate-ui/react/primitives/react-aria"
import { Button } from "@/components/ui/button"

function HoverCardTrigger({ children, ...props }: PreviewTriggerProps) {
  return (
    <PreviewTriggerPrimitive data-slot="hover-card-trigger" {...props}>
      {children}
    </PreviewTriggerPrimitive>
  )
}

function HoverCard({
  className,
  placement = "bottom",
  offset = 4,
  crossOffset = 0,
  ...props
}: Omit<PopoverPrimitiveProps, "className"> & {
  className?: string
}) {
  return (
    <PopoverPrimitive
      data-slot="hover-card-content"
      placement={placement}
      offset={offset}
      crossOffset={crossOffset}
      className={cn(
        "ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 z-50 origin-(--trigger-anchor-point) outline-hidden",
        className
      )}
      {...props}
    />
  )
}

export { HoverCard, HoverCardTrigger }

function toPlacement(
  side: "top" | "right" | "bottom" | "left",
  align: "start" | "center" | "end"
) {
  if (align === "center") return side
  if (side === "top" || side === "bottom") return `${side} ${align}` as const
  return `${side} ${align === "start" ? "top" : "bottom"}` as const
}

export default function HoverCardDemo({
  side = "bottom",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
}: {
  side?: "top" | "right" | "bottom" | "left"
  sideOffset?: number
  align?: "start" | "center" | "end"
  alignOffset?: number
}) {
  return (
    <HoverCardTrigger delay={10} closeDelay={100}>
      <Button variant="link">Hover Here</Button>
      <HoverCard
        className="flex w-64 flex-col gap-0.5"
        placement={toPlacement(side, align)}
        offset={sideOffset}
        crossOffset={alignOffset}
      >
        <div className="font-semibold">@nextjs</div>
        <div>The React Framework – created and maintained by @vercel.</div>
        <div className="mt-1 text-xs text-muted-foreground">
          Joined December 2021
        </div>
      </HoverCard>
    </HoverCardTrigger>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add hover-card
pnpm dlx shadcn@latest add hover-card
yarn dlx shadcn@latest add hover-card
bun x shadcn@latest add hover-card

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate hover-card
pnpm dlx @animate-ui/cli@latest animate hover-card
yarn dlx @animate-ui/cli@latest animate hover-card
bun x @animate-ui/cli@latest animate hover-card

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with an animated Popover. Everything else stays the same.

import {
  Link,
  Popover,
  PreviewTrigger,
} from '@animate-ui/react/primitives/react-aria';

export function ProfileLink() {
  return (
    <PreviewTrigger>
      <Link href="https://github.com/imskyleen/animate-ui">@animate-ui</Link>
      <Popover placement="bottom" offset={4}>
        Animated drop-in primitives for Radix UI, Base UI and React Aria.
      </Popover>
    </PreviewTrigger>
  );
}

Animation

React Aria has no Animate UI v1 counterpart: under a PreviewTrigger, the Popover moves like v1's Hover Card.

  • Popover grows from 0.8 and fades in, from the link (--trigger-anchor-point), on a spring (stiffness 400, damping 29) that overshoots a little before it settles.
  • Once the trigger's close delay is over, it closes the same way: it shrinks back to 0.8 on the same spring while it fades out, then unmounts.
  • Moving straight from one preview to the next during PreviewTrigger's warmup swaps them without animation, as React Aria intends (shouldSkipAnimation).
  • React Aria opens it on hover for pointer input, after a delay on keyboard focus, and on a long press: nothing sticks on touch screens.
  • Open from the start (defaultOpen on the PreviewTrigger), it plays its entrance on page load, like v1.
  • Under reduced motion, it only fades (no scale).

API Reference

Popover takes exactly the props of React Aria's Popover, plus:

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 400, damping: 29 }
exitTransition?
Transitiontransition

PreviewTrigger and Link take React Aria props only. They are not animated.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page