A card whose hidden action row rises into view on hover and exits at half duration on leave.
npx shadcn@latest add @paragon/hover-reveal-card"use client";
import * as React from "react";
import { FileText } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Per-instance reveal CSS. The hidden↔revealed swap is a single retargetable
* transition with asymmetric timing: the transition declared on the *target*
* state governs the move, so the revealed rule carries the enter timing
* (--duration-base, --ease-out) and the hidden rule carries the exit timing
* (half duration, --ease-exit). Everything lives inside a
* (hover: hover) and (pointer: fine) gate — on touch devices the actions are
* simply always visible, and :focus-within is the keyboard path on desktop,
* so the actions are never unreachable.
*/
function revealStyles(id: string): string {
return `
@media (hover: hover) and (pointer: fine) {
[data-hrc="${id}"] [data-slot="hover-reveal-card-actions"] {
opacity: 0;
transform: translateY(12px);
filter: blur(4px);
pointer-events: none;
transition:
opacity calc(var(--duration-base) / 2) var(--ease-exit),
transform calc(var(--duration-base) / 2) var(--ease-exit),
filter calc(var(--duration-base) / 2) var(--ease-exit);
}
[data-hrc="${id}"]:is(:hover, :focus-within) [data-slot="hover-reveal-card-actions"] {
opacity: 1;
transform: translateY(0px);
filter: blur(0px);
pointer-events: auto;
transition:
opacity var(--duration-base) var(--ease-out),
transform var(--duration-base) var(--ease-out),
filter var(--duration-base) var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
[data-hrc="${id}"] [data-slot="hover-reveal-card-actions"] {
transform: none;
filter: none;
transition: opacity calc(var(--duration-base) / 2) var(--ease-exit);
}
[data-hrc="${id}"]:is(:hover, :focus-within) [data-slot="hover-reveal-card-actions"] {
transition: opacity var(--duration-base) var(--ease-out);
}
}
}
`;
}
export interface HoverRevealCardProps extends React.ComponentProps<"div"> {
/** Title line of the card. */
title?: string;
/** Meta line rendered under the title. */
description?: string;
/** Leading icon, rendered inside a soft chip. Defaults to a document icon. */
icon?: React.ReactNode;
/** Keeps the action row always visible and removes the reveal motion. */
static?: boolean;
/** Action row content. Defaults to Open / Share actions. */
children?: React.ReactNode;
}
/**
* A card whose action row rises into view on hover — the house enter
* (opacity + translateY(12px) + blur(4px), ease-out) — and settles back at
* half duration with ease-exit on leave. The row always occupies layout, so
* nothing reflows; only transform/opacity/filter animate. Keyboard users
* reveal it by tabbing into the actions (:focus-within), and on touch
* devices the row is permanently visible.
*/
function HoverRevealCard({
title = "June billing reconciliation",
description = "Updated 2 hr ago · Finance",
icon,
static: isStatic = false,
className,
children,
...props
}: HoverRevealCardProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
return (
<>
{!isStatic && <style href={`paragon-hover-reveal-card-${id}`} precedence="paragon">{revealStyles(id)}</style>}
<div
data-slot="hover-reveal-card"
data-hrc={isStatic ? undefined : id}
className={cn(
// Concentric: outer radius = action rounded-lg (10px) + p-4 (16px).
"w-72 rounded-[calc(var(--radius)+16px)] bg-card p-4 text-card-foreground shadow-border transition-[box-shadow] duration-150 ease-out",
!isStatic && "hover:shadow-border-hover focus-within:shadow-border-hover",
className,
)}
{...props}
>
<div className="flex items-center gap-3 px-1 pt-1 pb-5">
<div
aria-hidden
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0"
>
{icon ?? <FileText />}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-card-foreground">
{title}
</p>
<p className="mt-0.5 truncate text-[13px] text-muted-foreground">
{description}
</p>
</div>
</div>
<div data-slot="hover-reveal-card-actions" className="flex gap-2">
{children ?? (
<>
<HoverRevealCardAction>Open</HoverRevealCardAction>
<HoverRevealCardAction>Share</HoverRevealCardAction>
</>
)}
</div>
</div>
</>
);
}
export interface HoverRevealCardActionProps
extends React.ComponentProps<"button"> {
/** Disables press-scale feedback where the motion would distract. */
static?: boolean;
}
/** A quiet, equal-weight action for the card's reveal row. */
function HoverRevealCardAction({
className,
static: isStatic = false,
type = "button",
...props
}: HoverRevealCardActionProps) {
return (
<button
data-slot="hover-reveal-card-action"
type={type}
className={cn(
"relative inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-secondary px-3 text-[13px] font-medium whitespace-nowrap text-secondary-foreground transition-[scale,background-color] duration-150 ease-out outline-none hover:bg-secondary/80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0",
!isStatic && "active:not-disabled:scale-[0.97]",
className,
)}
{...props}
/>
);
}
export { HoverRevealCard, HoverRevealCardAction };