Cards

Hover Reveal Card

A card whose hidden action row rises into view on hover and exits at half duration on leave.

Install

npx shadcn@latest add @paragon/hover-reveal-card

hover-reveal-card.tsx

"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 };