a whole card that opens its place, its links and controls still their own
npx shadcn add @ag/card-linkAlready installed? Preview an update
npx shadcn add @ag/card-link --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-15
- A place on the same site opens in the same tab, through the site's router when it is given (
navigate), no longer always a new tab; ⌘ or Ctrl opens a new tab, as a link does. Another site's still opens in a new tab.
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import { CardLink } from "@/registry/base-nova/ui/card-link"
export default function Demo() {
return (
<div className="space-y-16">
<Sample
name="card"
glance
title="01 · a card that opens its page"
description="click anywhere; the link inside is its own; select text and nothing opens"
>
<CardLink
href="https://paulgraham.com/ds.html"
className="mx-auto max-w-md space-y-2 rounded-2xl border border-border p-4 transition-colors hover:bg-foreground/3"
>
<div className="font-semibold">Do Things that Don't Scale</div>
<p className="text-foreground/70 text-sm">
One of the most common types of advice we give at Y Combinator is to
do things that don't scale. Select this sentence: nothing
opens.
</p>
<a
href="https://paulgraham.com/ds.html"
target="_blank"
rel="noopener noreferrer"
className="text-sm underline"
>
paulgraham.com
</a>
</CardLink>
</Sample>
<Sample
name="nested"
title="02 · nested"
description="the inner card opens its own place, the outer one everything else"
>
<CardLink
href="https://paulgraham.com/"
className="mx-auto max-w-md space-y-3 rounded-2xl border border-border p-4 transition-colors hover:bg-foreground/3"
>
<div className="font-semibold">paulgraham.com</div>
<CardLink
href="https://paulgraham.com/ds.html"
className="rounded-xl border border-border p-3 text-sm transition-colors hover:bg-foreground/3"
>
Do Things that Don't Scale
</CardLink>
<a
href="https://paulgraham.com/"
target="_blank"
rel="noopener noreferrer"
className="text-sm underline"
>
open
</a>
</CardLink>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A WHOLE CARD THAT OPENS SOMEWHERE, the way an embedded post on X opens the post: a
// click anywhere on it goes to `href`, and everything a reader would expect to keep
// working keeps working.
//
// - Links, buttons, form controls and media controls inside do their own thing.
// - Selecting text is reading, not clicking: a click that ends a selection goes
// nowhere.
// - Nested cards: the innermost one wins (a quoted post opens the quoted post).
// - External places open in a new tab, as every link inside the card does. A
// place on the same site opens in the same tab, through the site's own router
// when it gives one (`navigate`); ⌘ or Ctrl opens it in a new tab, as a link
// does.
//
// The card itself is NOT a tab stop and has no link role: a keyboard reaches the
// destination through a real <a> the card already carries (a date, a mark, a name).
// That is the contract: put one inside. A second tab stop for the same place, or an
// `<article role=link>` around other links, is what this refuses to be.
import type * as React from "react"
import { cn } from "@/lib/utils"
const INTERACTIVE =
"a, button, input, select, textarea, label, summary, video, audio, [role=button], [contenteditable]"
export interface CardLinkProps extends React.ComponentProps<"div"> {
href: string
/** How the site goes to a place of its own (its router's push): default the
* browser's. Never asked for another site's. */
navigate?: (href: string) => void
}
/** A place on this site: a path, or an address on this origin. */
function ownPlace(href: string): boolean {
return new URL(href, window.location.href).origin === window.location.origin
}
export function CardLink({
href,
navigate,
className,
onClick,
children,
...props
}: CardLinkProps) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: a pointer shortcut over a card whose real link is inside it
// biome-ignore lint/a11y/useKeyWithClickEvents: the keyboard's way is that inner link; a second stop would duplicate it
<div
// A user's own slot (`tweet`, `tweet-quote`) wins; the card is found by its own
// attribute, so it never takes the name of what it wraps.
data-slot="card-link"
{...props}
data-card-link={href}
className={cn("cursor-pointer", className)}
onClick={(e) => {
onClick?.(e)
if (e.defaultPrevented) return
const target = e.target as HTMLElement
if (target.closest("[data-card-link]") !== e.currentTarget) return
if (target.closest(INTERACTIVE)) return
if (!window.getSelection()?.isCollapsed) return
if (!ownPlace(href) || e.metaKey || e.ctrlKey)
window.open(href, "_blank", "noopener,noreferrer")
else if (navigate) navigate(href)
else window.location.assign(href)
}}
>
{children}
</div>
)
}