PatternsCSS
Entity Card
One card frame for every collection: cover or media, title, badges, actions, stats and footer.
Next.js
Ready- Deploys
- 412
- Uptime
- 99.99%
Updated 2 hours agoHono
Building- Deploys
- 1,183
- Uptime
- 99.95%
Updated 6 minutes agoAstro
Ready- Deploys
- 96
- Uptime
- 99.98%
Updated Yesterday
Installation
npx shadcn@latest add https://ui-collections.vercel.app/r/glass/entity-card.jsonAlso installs the theme, cn(). Packages: clsx, tailwind-merge.
Usage
entity-card-demo.tsx
"use client";
import { useState } from "react";
import { Badge } from "@/registry/kit/ui/badge";
import { EntityCard, EntityCardStats } from "@/registry/kit/ui/entity-card";
const projects = [
{ name: "acme/web", framework: "Next.js", status: "Ready", hue: "from-accent/40", deploys: 412, uptime: "99.99%", updated: "2 hours ago" },
{ name: "acme/api", framework: "Hono", status: "Building", hue: "from-info/40", deploys: 1183, uptime: "99.95%", updated: "6 minutes ago" },
{ name: "acme/docs", framework: "Astro", status: "Ready", hue: "from-warning/40", deploys: 96, uptime: "99.98%", updated: "Yesterday" },
];
export default function EntityCardDemo() {
const [selected, setSelected] = useState(projects[0]!.name);
return (
<ul className="grid w-full max-w-4xl gap-4 sm:grid-cols-3">
{projects.map((p) => (
<li key={p.name}>
<EntityCard
selected={selected === p.name}
onOpen={() => setSelected(p.name)}
title={p.name}
subtitle={p.framework}
cover={<div className={`h-24 bg-gradient-to-br ${p.hue} to-transparent`} />}
badges={
<Badge variant={p.status === "Ready" ? "success" : "info"} dot>
{p.status}
</Badge>
}
footer={<span className="text-xs text-muted">Updated {p.updated}</span>}
>
<EntityCardStats
items={[
{ label: "Deploys", value: p.deploys.toLocaleString("en-US") },
{ label: "Uptime", value: p.uptime },
]}
/>
</EntityCard>
</li>
))}
</ul>
);
}API
title / subtitleReactNodeHeading lines.
hrefstringMakes the whole card a single link.
onOpen() => voidMakes the whole card a single button instead, e.g. to select it or open a drawer.
cover / mediaReactNodeTop visual or leading tile.
badges / actions / footerReactNodeSlots.
<EntityCardStats>{ items: { label; value }[] }Small stat boxes inside the card.
Source
components/ui/entity-card.tsx
import { cn } from "@/registry/kit/lib/utils";
export interface EntityCardProps {
/** Top area: a preview, gradient or image. */
cover?: React.ReactNode;
/** Leading tile beside the title, used instead of a cover. */
media?: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
/** Makes the title a link; the whole card stays one tab stop. */
href?: string;
/** Makes the title a button instead, for opening the item in place (a drawer, a selection). */
onOpen?: () => void;
/** Badges or chips under the title. */
badges?: React.ReactNode;
/** Overflow menu or buttons in the top-right corner. */
actions?: React.ReactNode;
selected?: boolean;
children?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}
/** One card frame for every collection, so grids of projects, people or files share anatomy, spacing and hover. */
// Stretched over the card so the whole card is one target, still one tab stop.
const stretched =
"rounded-md outline-none after:absolute after:inset-0 after:rounded-[inherit] hover:[&>span]:text-accent focus-visible:outline-2 focus-visible:outline-accent";
export function EntityCard({
cover,
media,
title,
subtitle,
href,
onOpen,
badges,
actions,
selected = false,
children,
footer,
className,
}: EntityCardProps) {
const heading = <span className="line-clamp-2 text-[15px] leading-snug font-medium break-words text-fg">{title}</span>;
return (
<article
className={cn(
"group/card surface relative flex h-full flex-col overflow-hidden rounded-card transition-[translate,background-color,outline-color] duration-300 ease-theme hover:-translate-y-0.5",
"outline-1 -outline-offset-1 outline-transparent",
selected && "bg-surface-selected outline-accent/60",
className,
)}
>
{cover && <div className="relative">{cover}</div>}
<div className="flex flex-1 flex-col gap-3 p-5">
<div className="flex items-start gap-3">
{media}
<div className="min-w-0 flex-1">
{href ? (
<a href={href} className={stretched}>
{heading}
</a>
) : onOpen ? (
<button type="button" onClick={onOpen} aria-pressed={selected} className={cn(stretched, "cursor-pointer text-left")}>
{heading}
</button>
) : (
heading
)}
{subtitle && <p className="mt-0.5 truncate text-xs text-muted">{subtitle}</p>}
</div>
{/* Above the stretched link so its buttons stay clickable. */}
{actions && <div className="relative z-10 -mt-1 -mr-1.5 shrink-0">{actions}</div>}
</div>
{badges && <div className="flex flex-wrap items-center gap-1.5">{badges}</div>}
{children}
</div>
{footer && <div className="border-t border-fg/[0.07] bg-fg/[0.02] px-5 py-3">{footer}</div>}
</article>
);
}
/** Small stat boxes inside an EntityCard: label and value, two to four across. */
export function EntityCardStats({
items,
className,
}: {
items: { label: string; value: React.ReactNode }[];
className?: string;
}) {
return (
<dl className={cn("grid auto-cols-fr grid-flow-col gap-2", className)}>
{items.map((item) => (
<div key={item.label} className="flex min-w-0 flex-col-reverse gap-0.5 rounded-field bg-fg/[0.04] px-3 py-2">
<dt className="truncate text-[11px] text-muted">{item.label}</dt>
<dd className="truncate text-sm font-medium tabular-nums">{item.value}</dd>
</div>
))}
</dl>
);
}