The whole card is one link, while buttons inside still get their own clicks.
Disclosureno dependencies
01Preview
billing-worker
billing.internal
Retry failed webhooks with exponential backoff
Buildingretry-queue· 1h ago
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup.
03Usage
import { InteractiveCard, InteractiveCardLink, InteractiveCardAction, InteractiveCardArrow } from "@/components/ui/interactive-card";
<InteractiveCard glow className="gap-3 p-4">
<h3 className="text-[13px] font-medium">
<InteractiveCardLink href="/projects/stealth-web">stealth-web</InteractiveCardLink>
</h3>
<p className="text-[12.5px] text-fg-2">Fix double charge when 3-D Secure redirects back</p>
<InteractiveCardAction>
<StarButton />
</InteractiveCardAction>
<InteractiveCardArrow />
</InteractiveCard>
// With a router link
<InteractiveCardLink render={<Link href="/projects/stealth-web" />}>stealth-web</InteractiveCardLink>04Source
"use client";
import { cloneElement, createContext, isValidElement, use, useRef } from "react";
import { cn } from "@/lib/cn";
const CardContext = createContext<{ disabled: boolean }>({ disabled: false });
export type InteractiveCardProps = React.ComponentProps<"div"> & {
/** A faint light that follows the mouse across the surface. Mouse only; off under reduced motion. */
glow?: boolean;
/** Dims the card and takes its link out of the tab order. Nested actions keep their own state. */
disabled?: boolean;
};
/**
* A card whose whole surface is one link. The link's ::after stretches over the
* card, so the target is the card while the name is still the link's only text;
* nested controls sit above that layer in InteractiveCardAction.
*/
export function InteractiveCard({ glow = false, disabled = false, className, children, onPointerMove, onPointerLeave, ...rest }: InteractiveCardProps) {
const frame = useRef(0);
return (
<CardContext value={{ disabled }}>
<div
data-disabled={disabled || undefined}
data-glow={glow || undefined}
onPointerMove={(e) => {
onPointerMove?.(e);
if (!glow || e.pointerType !== "mouse") return;
const el = e.currentTarget;
const { clientX, clientY } = e;
// Written straight to CSS variables, once per frame, so following the mouse never re-renders.
cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
const r = el.getBoundingClientRect();
el.style.setProperty("--glow-x", `${clientX - r.left}px`);
el.style.setProperty("--glow-y", `${clientY - r.top}px`);
});
}}
onPointerLeave={(e) => {
onPointerLeave?.(e);
cancelAnimationFrame(frame.current);
}}
className={cn(
"group/card relative isolate flex min-w-0 flex-col rounded-xl border border-line-2 bg-raised shadow-[var(--shadow)]",
"transition-[border-color,background-color,scale] duration-150 ease-out",
"hover:border-fg-4 data-disabled:hover:border-line-2",
// Press feedback belongs to the link only; pressing a nested action leaves the card still.
"has-[[data-card-link]:active]:scale-[0.985] has-[[data-card-link]:active]:duration-100 motion-reduce:has-[[data-card-link]:active]:scale-100",
// The ring draws around the card, because the card is what the link opens.
"has-[[data-card-link]:focus-visible]:outline-solid has-[[data-card-link]:focus-visible]:outline-1 has-[[data-card-link]:focus-visible]:outline-offset-2 has-[[data-card-link]:focus-visible]:outline-fg-3",
"data-disabled:opacity-60",
glow &&
cn(
"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:opacity-0 before:content-['']",
"before:bg-[radial-gradient(260px_circle_at_var(--glow-x,50%)_var(--glow-y,0px),color-mix(in_oklab,var(--fg)_6%,transparent),transparent_70%)]",
"before:transition-opacity before:duration-300 hover:before:opacity-100 data-disabled:before:hidden motion-reduce:before:hidden",
),
className,
)}
{...rest}
>
{children}
</div>
</CardContext>
);
}
export type InteractiveCardLinkProps = React.ComponentProps<"a"> & {
/** Render your router's link instead of an <a>: `render={<Link href="/projects/web" />}`. */
render?: React.ReactElement<{ className?: string; children?: React.ReactNode }>;
};
/** The card's one destination. Put the card's name in it, nothing else. */
export function InteractiveCardLink({ render, className, children, onClick, ...rest }: InteractiveCardLinkProps) {
const { disabled } = use(CardContext);
const classes = cn(
"rounded-sm text-fg outline-none",
// The stretched hit area, with the card's radius so the corners press as they look.
"after:absolute after:inset-0 after:z-0 after:rounded-xl after:content-['']",
"[-webkit-tap-highlight-color:transparent] touch-manipulation",
disabled ? "cursor-not-allowed after:cursor-not-allowed" : "cursor-pointer",
className,
);
const shared = {
"data-card-link": "",
"aria-disabled": disabled || undefined,
tabIndex: disabled ? -1 : undefined,
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => {
if (disabled) {
e.preventDefault();
return;
}
onClick?.(e);
},
};
if (render && isValidElement(render)) {
return cloneElement(render, { ...rest, ...shared, className: cn(classes, render.props.className), children } as React.HTMLAttributes<HTMLElement>);
}
return (
<a className={classes} {...rest} {...shared}>
{children}
</a>
);
}
export type InteractiveCardActionProps = React.ComponentProps<"div">;
/** Lifts buttons, menus and secondary links above the card's link so they get their own clicks. */
export function InteractiveCardAction({ className, ...rest }: InteractiveCardActionProps) {
return <div className={cn("relative z-10 flex items-center", className)} {...rest} />;
}
export type InteractiveCardArrowProps = React.ComponentProps<"svg"> & {
/** Points right for a page in this app, up-right for somewhere else. */
external?: boolean;
};
/** A quiet arrow that leans toward the destination while the card is hovered. */
export function InteractiveCardArrow({ external = false, className, ...rest }: InteractiveCardArrowProps) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={cn(
"shrink-0 text-fg-4 transition-[translate,color] duration-200 ease-out-quart group-hover/card:text-fg-2 motion-reduce:transition-colors",
external ? "group-hover/card:translate-x-0.5 group-hover/card:-translate-y-0.5" : "group-hover/card:translate-x-0.5",
"group-data-disabled/card:translate-0! group-data-disabled/card:text-fg-4!",
className,
)}
{...rest}
>
{external ? <path d="M5 11 11 5M6 5h5v5" /> : <path d="M3 8h10M9 4l4 4-4 4" />}
</svg>
);
}05Props
InteractiveCard
| Prop | Type | Default | Description |
|---|---|---|---|
| glow | boolean | false | A faint light that follows the mouse across the surface. Mouse only. |
| disabled | boolean | false | Dims the card, stops the hover step and takes the link out of the tab order. |
InteractiveCardLink
| Prop | Type | Default | Description |
|---|---|---|---|
| href | string | — | Where the card goes. Every native anchor prop passes through. |
| render | ReactElement | — | Your router's link element to render instead of <a>; it receives the classes and children. |
| children | ReactNode | — | The card's name: the link's only text, so it reads well in a list of links. |
InteractiveCardAction
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Buttons, menus or secondary links lifted above the card's hit area. |
InteractiveCardArrow
| Prop | Type | Default | Description |
|---|---|---|---|
| external | boolean | false | Up-right arrow for a destination outside the app; it leans diagonally on hover. |
06Notes
Behavior
- The link's ::after covers the card, so the card is the target while the link's name stays short. Cmd-click, middle-click and “Copy link” work, because it's a real link.
- Anything in InteractiveCardAction sits above that layer and gets its own clicks, hover and focus; pressing it doesn't press the card.
- The trade-off of a stretched link: text in the card can't be selected by dragging. Put anything people need to copy in an action.
- The glow position is written to CSS variables once per frame, so following the mouse never re-renders React.
Motion
- Hover steps the border one level in 150ms and nudges the arrow 2px toward where it goes, on ease-out-quart.
- Pressing the card scales it to 0.985 in 100ms: large surfaces move less than buttons, so it reads as give, not bounce.
- The glow fades in over 300ms and tracks the mouse directly. Reduced motion drops the press scale and the glow; the border step stays.
Accessibility
- One link per card, named by the title only, instead of a clickable div or a link wrapping the whole card.
- The focus ring draws around the card, not the title text, because the card is what the link opens.
- Disabled cards set aria-disabled, leave the tab order and ignore clicks, but stay readable.