docs / patterns / entity-card
EntityCard
a card you assemble from parts: media, meta, title, body and actions, in any order. the same parts render a project tile, a list row or a showcase card, depending on the variant.
intensity · structuresrsc · server
provenance
shaped from sisu-pluslayer
core / patternsintensity
structures · organizes a region, stays out of the contentcomposition
stack for tiles in a grid, row for compact lists, showcase for the numbered selected-work cardinteractive on the root puts the arrow on the title and the lift on hovernever an EntityCard inside another cardan interactive card gets one destination, not a pile of competing buttonsa11y
EntityTitle renders anh3 · the hover arrow is decorative · media scaling stops under motion-reducejest-axedependencies
Card from the same packagelive demo · try it out
Project
Aurora tokens
A layered token pipeline that emits Tailwind v4 and DTCG.
customize
variantstack tile, row list, showcase card
interactivehover lift and title arrow
mediathe cover panel
actionsthe button row
titlethe h3 heading
bodythe description
usage
<EntityCard>
<EntityMedia>
<div className="h-full w-full [background:linear-gradient(135deg,color-mix(in_srgb,var(--color-accent)_30%,transparent),transparent)]" />
</EntityMedia>
<EntityMeta>
<Badge tone="accent">Project</Badge>
</EntityMeta>
<EntityTitle>Aurora tokens</EntityTitle>
<EntityBody>A layered token pipeline that emits Tailwind v4 and DTCG.</EntityBody>
<EntityActions>
<Button size="sm">Open</Button>
</EntityActions>
</EntityCard>props
| prop | type | default | notes |
|---|---|---|---|
| variant | "stack" | "row" | "showcase" | "stack" | layout: stack is media on top, row puts the media beside the content, showcase is the big numbered work card. row and showcase need EntityContent around the text parts. |
| className | string | — | merged onto the root Card, which adds a 2xl radius, overflow-hidden, and a flex column. |
| ...Card props | CardProps | — | EntityCard extends the Card primitive, so every Card prop is forwarded. |
get it
npx shadcn add https://usva.build/r/entity-card.jsonsource · components/ui/entity-card.tsxexactly what this command copies
import * as React from "react";
import { cn } from "@/lib/utils";
import { Card, type CardProps } from "./card";
export type EntityVariant = "stack" | "row" | "showcase";
export interface EntityCardProps extends CardProps {
/**
* Layout style:
* stack: media on top, content below (gallery tile; the default).
* row: media as a side panel, content beside it (compact list row).
* showcase: a large content column with an oversized title (the numbered
* "selected work" card).
*/
variant?: EntityVariant;
}
const variantRoot: Record<EntityVariant, string> = {
stack: "flex-col",
row: "flex-col sm:flex-row sm:items-stretch",
showcase: "flex-col",
};
export const EntityCard = React.forwardRef<HTMLDivElement, EntityCardProps>(
({ className, variant = "stack", ...p }, ref) => (
<Card
ref={ref}
data-variant={variant}
className={cn(
"group/entity flex overflow-hidden rounded-2xl",
variantRoot[variant],
className,
)}
{...p}
/>
),
);
EntityCard.displayName = "EntityCard";
/** The text column. Required for `row`/`showcase`; wraps meta, title, body. */
export const EntityContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...p }, ref) => (
<div
ref={ref}
className={cn(
"flex min-w-0 flex-1 flex-col",
"group-data-[variant=showcase]/entity:justify-end group-data-[variant=showcase]/entity:p-[clamp(1.25rem,3vw,2rem)]",
className,
)}
{...p}
/>
));
EntityContent.displayName = "EntityContent";
export const EntityMedia = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...p }, ref) => (
<div
ref={ref}
className={cn(
"relative aspect-video w-full overflow-hidden bg-sunken",
"group-data-[variant=row]/entity:aspect-square group-data-[variant=row]/entity:w-full group-data-[variant=row]/entity:sm:aspect-auto group-data-[variant=row]/entity:sm:w-40 group-data-[variant=row]/entity:sm:shrink-0",
"[&_img]:h-full [&_img]:w-full [&_img]:object-cover",
"[&>*]:transition-control [&>*]:duration-ambient [&>*]:ease-soft",
"group-hover/entity:[&>*]:scale-[1.03]",
"motion-reduce:[&>*]:transition-none motion-reduce:[&>*]:scale-100",
className,
)}
{...p}
/>
));
EntityMedia.displayName = "EntityMedia";
export const EntityMeta = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...p }, ref) => (
<div
ref={ref}
className={cn(
"flex flex-wrap items-center gap-2 px-5 pt-5 font-mono text-[0.7rem] uppercase tracking-[0.12em] tabular-nums text-muted",
"group-data-[variant=showcase]/entity:px-0 group-data-[variant=showcase]/entity:pt-0",
className,
)}
{...p}
/>
));
EntityMeta.displayName = "EntityMeta";
/** The oversized mono index marker, e.g. 001, for the showcase style. */
export const EntityIndex = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...p }, ref) => (
<span
ref={ref}
className={cn(
"font-mono text-sm font-bold tabular-nums tracking-[0.16em] text-accent-alt",
className,
)}
{...p}
/>
));
EntityIndex.displayName = "EntityIndex";
export const EntityTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...p }, ref) => (
<h3
ref={ref}
className={cn(
"flex items-center gap-1.5 text-balance px-5 pt-3 text-base font-semibold tracking-[-0.01em] text-ink",
"group-data-[variant=showcase]/entity:px-0 group-data-[variant=showcase]/entity:pt-4 group-data-[variant=showcase]/entity:text-[clamp(1.5rem,3vw,2.25rem)] group-data-[variant=showcase]/entity:font-bold group-data-[variant=showcase]/entity:leading-[1.05]",
"transition-layout duration-base ease-soft motion-reduce:transition-none",
"after:leading-none after:text-accent group-data-[interactive]/entity:after:content-['→']",
"group-hover/entity:gap-2.5",
className,
)}
{...p}
/>
));
EntityTitle.displayName = "EntityTitle";
export const EntityBody = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...p }, ref) => (
<p
ref={ref}
className={cn(
"px-5 pt-2 text-sm text-muted [text-wrap:pretty]",
"group-data-[variant=showcase]/entity:px-0 group-data-[variant=showcase]/entity:pt-4 group-data-[variant=showcase]/entity:max-w-prose group-data-[variant=showcase]/entity:leading-relaxed",
className,
)}
{...p}
/>
));
EntityBody.displayName = "EntityBody";
export const EntityActions = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...p }, ref) => (
<div
ref={ref}
className={cn(
"mt-auto flex items-center gap-2 p-5",
"group-data-[variant=showcase]/entity:px-0 group-data-[variant=showcase]/entity:pb-0 group-data-[variant=showcase]/entity:pt-6",
className,
)}
{...p}
/>
));
EntityActions.displayName = "EntityActions";