Hover Card
Hover or focus a @mention to preview the profile in a card, with an open delay, a grace period and a Follow button.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/hover-card.json'use client'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import './hover-card.css'
export interface HoverUser { name: string; handle: string; bio: string; followers: number; following: number }
export interface HoverCardProps {
user: HoverUser
openDelay?: number
closeDelay?: number
children: ReactNode
href?: string
fill?: 'dark' | 'light'
className?: string
}
const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n))
/** Delayed profile preview on hover or focus. */
export function HoverCard({ user, openDelay = 350, closeDelay = 200, children, href = '#', fill = 'dark', className }: HoverCardProps) {
const [open, setOpen] = useState(false)
const [side, setSide] = useState<'top' | 'bottom'>('bottom')
const [follow, setFollow] = useState(false)
const t = useRef<ReturnType<typeof setTimeout> | null>(null)
const root = useRef<HTMLSpanElement>(null)
const id = useId()
const clear = () => { if (t.current) clearTimeout(t.current) }
const show = () => {
clear()
t.current = setTimeout(() => {
const r = root.current?.getBoundingClientRect()
setSide(r && r.bottom + 240 > innerHeight ? 'top' : 'bottom')
setOpen(true)
}, openDelay)
}
const hide = () => { clear(); t.current = setTimeout(() => setOpen(false), closeDelay) }
useEffect(() => clear, [])
return (
<span ref={root} className={['sd-hvc', className].filter(Boolean).join(' ')} data-fill={fill} onMouseEnter={show} onMouseLeave={hide} onKeyDown={(e) => e.key === 'Escape' && setOpen(false)}>
<a className="sd-hvc__t" href={href} aria-describedby={open ? id : undefined} onFocus={show} onBlur={hide} onClick={(e) => href === '#' && e.preventDefault()}>{children}</a>
{open && (
<span id={id} role="tooltip" className="sd-hvc__card" data-side={side} onFocus={clear} onBlur={hide}>
<span className="sd-hvc__top">
<span className="sd-hvc__av" aria-hidden>{user.name.split(' ').map((w) => w[0]).join('').slice(0, 2)}</span>
<button type="button" className="sd-hvc__f" aria-pressed={follow} onClick={() => setFollow(!follow)}>{follow ? 'Following' : 'Follow'}</button>
</span>
<span className="sd-hvc__n" style={{ display: 'block' }}>{user.name}</span>
<span className="sd-hvc__h">@{user.handle}</span>
<span className="sd-hvc__bio" style={{ display: 'block' }}>{user.bio}</span>
<span className="sd-hvc__st"><span><b>{k(user.following)}</b> Following</span><span><b>{k(user.followers + (follow ? 1 : 0))}</b> Followers</span></span>
</span>
)}
</span>
)
}.sd-hvc { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-hvc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-hvc button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-hvc :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-hvc .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-hvc { position: relative; display: inline-block; }
.sd-hvc__t { font-weight: 600; text-decoration: underline; text-decoration-color: var(--mute); text-underline-offset: 3px; color: var(--fg); border-radius: 4px; }
.sd-hvc__card { position: absolute; left: 50%; width: 260px; padding: 14px; border-radius: 16px; background: var(--bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--line), 0 20px 40px -18px rgb(0 0 0 / 0.55); transform: translateX(-50%); animation: sd-hvc-in 180ms cubic-bezier(0.2, 1, 0.3, 1); z-index: 5; text-align: left; font-size: 13.5px; line-height: 1.4; }
.sd-hvc[data-fill='light'] .sd-hvc__card { box-shadow: inset 0 0 0 1px var(--line), 0 20px 40px -18px rgb(0 0 0 / 0.2); }
.sd-hvc__card[data-side='bottom'] { top: calc(100% + 8px); }
.sd-hvc__card[data-side='top'] { bottom: calc(100% + 8px); }
@keyframes sd-hvc-in { from { opacity: 0; transform: translateX(-50%) translateY(4px) scale(0.97); } }
.sd-hvc__top { display: flex; align-items: center; justify-content: space-between; }
.sd-hvc__av { width: 44px; height: 44px; border-radius: 99px; display: grid; place-items: center; background: var(--strong); color: var(--on); font-weight: 650; }
.sd-hvc__f { height: 30px; padding: 0 14px; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; font-size: 12.5px; font-weight: 600; }
.sd-hvc__f[aria-pressed='true'] { background: transparent !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--line); }
.sd-hvc__n { margin-top: 10px; font-weight: 650; }
.sd-hvc__h { color: var(--mute); font-size: 12.5px; }
.sd-hvc__bio { margin: 8px 0; }
.sd-hvc__st { display: flex; gap: 14px; font-size: 12.5px; color: var(--mute); }
.sd-hvc__st b { color: var(--fg); font-weight: 600; }
.sd-hvc__p { max-width: 300px; font-size: 14.5px; line-height: 1.6; color: var(--mute); text-align: center; }
@media (prefers-reduced-motion: reduce) { .sd-hvc *, .sd-hvc *::before, .sd-hvc *::after { animation: none !important; transition: none !important; } }Overview
A short open delay keeps cards from flickering as the pointer crosses text, and a grace period lets you move into the card to use its button.
The card positions above or below the trigger depending on space, and opens on keyboard focus as well.
When to use
- Mentions, author bylines, avatars in lists.
When not to use
- Touch-first UIs: hover does not exist; link to the profile instead.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/hover-card.json$
npx shadcn@latest add https://sundaeui.dev/r/hover-card.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/hover-card.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/hover-card.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy hover-card.tsx and hover-card.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { HoverCard } from '@/components/sundae/hover-card'
<HoverCard />Props
| Prop | Type | Default |
|---|---|---|
user*Profile to show. | { name: string; handle: string; bio: string; followers: number; following: number } | — |
openDelay / closeDelayDelays in ms. | number | 350 / 200 |
children*The trigger text. | ReactNode | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The trigger is a link; the card is linked with aria-describedby while open.
- Escape closes it; it also opens on focus so keyboard users get the same preview.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-hvc; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Hover Card free?
- Yes. Hover Card is free for personal and commercial projects, with full source on this page.
- How do I install Hover Card?
- Run npx shadcn@latest add https://sundaeui.dev/r/hover-card.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Hover Card support keyboard and screen readers?
- The trigger is a link; the card is linked with aria-describedby while open. Escape closes it; it also opens on focus so keyboard users get the same preview.
- Does Hover Card respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Info Tip
Components · RefinedAvatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Snooze Menu
Components · Refined- Pro
Feature Tour
Components · Refined Contact Card
Components · Refined