Disclosure & OverlaysHover Card
A hover-summoned preview card — 500ms in, 200ms out.
Shipped the overlay wave with @abrahamphilip this morning.
The thinking
This exists for the one case a tooltip can't carry: a name or a link you might want a richer look at, but only if you actually pause on it. The load-bearing decision is that hover here has to mean intent, not contact — a cursor crossing a paragraph grazes a dozen triggers it never meant to open, so the card stays closed until you've dwelled long enough to have asked for it. Once that threshold is crossed I treat the card as the popover's work, not a new thing: same
bg-popover surface, same border-border hairline, the same popper-origin animate-pop-in, so a peek and a click summon visibly the same machinery. What lifts it past a default tooltip is reading the cursor's patience instead of its position — the timing, not the markup, is where the care goes.Interface design
Almost nothing here is visually new, and that's the decision:
bg-popover on a border-border hairline, rounded-lg, p-4, text-popover-foreground — the popover's chrome restated line for line rather than imported, because I keep every component's source whole in the catalog and the sameness should be readable, not hidden behind an import. Even w-72 is the popover's width, and it happens to be doing preview work here: a fixed width means the bio inside wraps the same way on its hundredth open as its first, and the card never resizes to fit whatever it holds. The only piece of the class string this card owns outright is origin-[--radix-hover-card-content-transform-origin], and that is the popover's trick with the variable renamed. It portals above everything at z-50 and rests a sideOffset of 6 off the trigger — close enough to belong to the name you paused on, far enough not to sit on it.Interaction
500 in, 200 out — the asymmetry is the whole component. Opening costs a real pause because this is a peek, not a click target: a cursor travelling across the page through a dozen usernames should summon nothing. But once you're reading the card, a wobbly cursor that briefly leaves the trigger shouldn't slam the door, so closing waits a forgiving 200ms. The surface itself borrows the popover's grammar wholesale —
animate-pop-in at 200ms, animate-pop-out at 150ms, both on cubic-bezier(0.23, 0.88, 0.26, 0.92), scaling from 0.96 at the popper origin so the card grows out of the thing you paused on. Leaving is faster than arriving; I hold every overlay to that, and a peek least of all gets to overstay.Sound design
There is no sound here, and it isn't an oversight. The global SoundLayer speaks through one capturing click listener — click, never hover, because hover is intent, not commitment — and everything this card does happens on hover, so the layer has nothing to answer. I didn't reach for the open/close pair either: a dialog earns paired audio because you explicitly asked for it, but a card that appears whenever a cursor lingers on a username would chirp its way across the page. So the component ships zero audio code and marks nothing
data-silent, because there's nothing to suppress — and if the trigger is a real link and you click through, that click taps like any a[href]. A glance should stay quiet.Empathy
Radix opens this on keyboard focus with the same 500ms wait and closes it on blur, but I won't call that accessible: everything tabbable inside the card is forced to
tabindex="-1", no aria ties the content to its trigger, and touch pointers are ignored outright — keyboard, screen-reader, and touch users get the trigger and only the trigger. That's why the demo's trigger is a real link that stands on its own; the card may repeat what lives elsewhere, never be the only place it lives. The closing rules carry the quiet care: Escape dismisses immediately, skipping the 200ms grace, and selecting text in the card holds it open, so drifting past the edge mid-highlight doesn't take your selection with it. Under reduced motion, motion-reduce:animate-none drops the pop and the card simply appears. Past w-72 the content just wraps — there's no overflow story, because a preview that needs a scrollbar has stopped being a preview."use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
// 500 in, 200 out — opening is earned with a real pause, closing
// tolerates a wobbly cursor. the asymmetry is the component.
const HoverCard = ({
openDelay = 500,
closeDelay = 200,
...props
}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Root>) => (
<HoverCardPrimitive.Root
openDelay={openDelay}
closeDelay={closeDelay}
{...props}
/>
);
HoverCard.displayName = "HoverCard";
const HoverCardTrigger = HoverCardPrimitive.Trigger;
// same pop grammar as the popover — written here rather than imported,
// like the menus: every component's source must read whole in the catalog: chrome surface, popper-origin scale,
// out faster than in
const HoverCardContent = ({
ref,
className,
align = "center",
sideOffset = 6,
...props
}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {
ref?: React.Ref<React.ComponentRef<typeof HoverCardPrimitive.Content>>;
}) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-[--radix-hover-card-content-transform-origin] rounded-lg border border-border bg-popover p-4 text-popover-foreground outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };