Profile Card
One person card in three densities (vertical, horizontal, compact) with presence dot, badges, stats, a stretched-link name and initials fallback.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/profile-card.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ProfileCard" component. No runtime deps
beyond the cn() class-merge helper — icons arrive as ReactNode from the consumer.
Contract
- export type ProfileCardLayout = "vertical" | "horizontal" | "compact"
- export type ProfileCardStatus = "online" | "offline" | "busy"
- export interface ProfileCardBadge { label: string; tone?: "neutral" |
"primary" | "destructive" } — tone resolves to semantic tokens, default
"neutral".
- export interface ProfileCardStat { label: string; value: number | string }
— numbers are grouped by the component, strings are printed verbatim.
- export interface ProfileCardMeta { icon?: React.ReactNode; label: string }
— label is already formatted text; the component never formats dates or URLs,
so it stays locale-agnostic.
- export interface ProfileCardProps extends React.HTMLAttributes<HTMLElement> {
name: string; handle?: string; avatarUrl?: string; coverUrl?: string;
bio?: string; badges?: ProfileCardBadge[]; stats?: ProfileCardStat[];
meta?: ProfileCardMeta[]; actions?: React.ReactNode;
layout?: ProfileCardLayout; status?: ProfileCardStatus | null;
href?: string; onClick?: React.MouseEventHandler<HTMLElement> }
- Defaults: layout = "vertical", no status. Only `name` is required; every
other block is absent when its prop is absent.
- forwardRef<HTMLElement> onto the root <article>, merge className via cn(),
spread the remaining props on the root, and expose data-layout={layout} so
consumers can target a density from CSS. `handle` is printed verbatim — the
consumer supplies the "@".
Behavior
- One tree, three arrangements. Keep a single LAYOUT const: Record<layout,
{ root, cover, body, avatar, dot, content, identity, nameBox, nameText,
handle, badges, bio, stats, stat, meta, actions }> of class strings, and
render the SAME JSX for every layout — only the slot classes change. Blocks a
density has no room for are switched off with `hidden` in their slot, never by
branching the markup: switching density then neither remounts children nor
asks the host to reshape its data.
· vertical: rounded-xl, h-24 cover band, centered column (p-5, text-center),
size-20 avatar pulled up -mt-14 to overlap the band, stats/meta centered.
· horizontal: cover slot hidden, flex-row p-4, size-14 avatar on the left,
left-aligned column beside it.
· compact: rounded-lg p-2, size-9 avatar, name and handle on one baseline
row, bio + stats + meta slots hidden, badges and actions shrink-0 — this is
the mention-dropdown / result-row density.
- Off switch caveat: the bio's base classes include line-clamp-3, which sets
display:-webkit-box. `hidden` and `line-clamp-*` are different tailwind-merge
groups, so both classes survive the merge and CSS order decides — Tailwind
emits display utilities after line-clamp, so keep `hidden` in the slot (do not
invent a `line-clamp-none` dance) and it wins.
- Whole-card click = one stretched control, not a card-sized hit target. A small
NameControl subcomponent renders the name as <a href> when href is set, else
<button type="button"> when onClick is set, else a plain <span>. When either
is set, the control also gets after:absolute after:inset-0, and the root
<article> is `relative isolate`, so the ::after resolves to the whole card:
clicks anywhere on the card land on ONE link/button whose accessible name is
the person's name. Put the overlay on the control itself, never on a
truncating wrapper — overflow:hidden would clip the ::after down to the text
box and the rest of the card would stop reacting. `isolate` keeps the overlay
inside this card's stacking context so neighbouring cards never fight.
- Nested actions stay clickable: render `actions` in a `relative z-10` row so it
paints above the stretched overlay. A "Follow" click then never turns into a
card navigation. Buttons/links inside `actions` are wired by the consumer.
- Avatar fallback chain: initials = up to two whitespace-separated word
initials, uppercased, falling back to "?" for an empty name. Track failure in
a tiny hook that also FORGETS the failure the moment the url changes, using
render-phase adjust-state (compare a `seen` state to the incoming url) and
never an effect — otherwise a card recycled for the next person stays stuck on
initials though the new avatar is fine. Beyond onError, re-check the node on
attach via a ref callback: `node.complete && node.naturalWidth === 0` means a
server-rendered <img> already 404'd (or got rate-limited) before hydration
attached onError, and that event never comes back.
- Cover: the muted band is the box, the photo only fills it, so a 404 cover
leaves a clean band instead of a broken-image glyph. Apply the avatar's -mt-14
overlap only when coverUrl exists, or the avatar hangs outside the card.
- Presence dot: a map status -> { dot token, label }. Render an absolutely
positioned span with role="img" and aria-label={label} ("Online" / "Offline" /
"Busy") so colour alone never carries presence, plus ring-2 ring-card to cut
it out of the avatar. status={null} renders nothing.
- Stats are a real <dl>: one div per pair with flex-col-reverse, so the value
sits above the label visually while <dt> stays first in the DOM and the pair
still reads as "Followers 12,480". Format numbers through ONE module-level
Intl.NumberFormat("en-US") — an explicit locale, because Intl.*(undefined)
disagrees between server and client; a string value bypasses formatting.
- Badges and meta are <ul role="list"> with <li> children and no un-roled div
in between, so the ownership chain holds and screen readers never announce an
empty list. Meta icons are aria-hidden — the label carries the meaning.
- Overflow discipline: name and handle each `truncate` inside min-w-0 boxes, so
a 40-character handle never wraps the card into a one-word column; bio is
line-clamp-3 + break-words.
- Accessibility plumbing: useId() for the <article>'s aria-labelledby, pointing
at the <p> that holds the name.
- Nothing to clean up: no timers, no rAF, no observers, no window/navigator
reads, so the component is safe to server-render as-is.
Rendering & styling
- Semantic tokens only, no hex / rgb() / oklch() and no --chart-* on text (a
single-hue palette makes chart tokens unreadable as text). Root: border
bg-card text-card-foreground. Muted surfaces: bg-muted text-muted-foreground
(avatar disc, cover band, initials, neutral badge). Emphasis: text-foreground
for name and stat values, bg-primary text-primary-foreground for the primary
badge, border-destructive/40 bg-destructive/10 text-destructive for the
destructive badge, bg-primary / bg-muted-foreground / bg-destructive for the
online / offline / busy dot.
- Root: relative isolate flex w-full flex-col overflow-hidden. When clickable,
add cursor-pointer transition-colors hover:bg-accent plus
motion-reduce:transition-none — the hover state still applies under
prefers-reduced-motion, only the fade stops. That colour fade is the
component's only animation.
- Focus: the name control carries focus-visible:ring-2 focus-visible:ring-ring
focus-visible:outline-none focus-visible:underline, so keyboard users get the
card's hit target with a visible ring.
- Stat values use tabular-nums so a column of figures does not jitter.
Customization levers
- Add a density: one entry in the LAYOUT map (all sixteen slots) and the union
type. No markup changes, no new branch. Turning a block off inside a density
is one `hidden` in that slot.
- Which blocks appear: omit the prop. cover / bio / badges / stats / meta /
actions / status are each independently optional, so the same component covers
a bare "name + handle" chip and a full profile header.
- Presence vocabulary: extend the status map ("away", "dnd"…) with a token and
a label. Keep the label — it is the accessible text, not a decoration.
- Badge tones: extend the tone map with a token trio (surface / border / text).
Stay on primary / destructive / muted; do not reach for chart tokens.
- Proportions: cover height (h-24), avatar size (size-20 / 14 / 9) and the
overlap (-mt-14) are the three geometry knobs — keep the overlap near 70% of
the avatar size or the avatar drifts off the band.
- Router links: swap the <a> inside NameControl for your framework's Link
(next/link, react-router) — it is the only place a URL is turned into an
element, and the stretched ::after rides along on the same className.
- Locale: replace the "en-US" formatter with your app's locale constant; always
pass one explicitly rather than letting Intl read the host locale.
- Bio length: line-clamp-3 → any line-clamp-N (keep `hidden` in the compact
slot so the density switch still wins).
- List density: compact cards inside a <ul role="list"> with one <li> each make
a mention list or a directory; give each card onClick and let the stretched
link make the whole row the hit target.Concepts
- One contract, three densities — a per-layout map of class slots feeds one JSX tree, so
vertical→compactis a prop change: no remount, no second component, and the host never reshapes its data to fit a narrower row. - Slot-level off switch — a density hides the blocks it has no room for (
bio,stats,metain compact) through ahiddenclass in its slot rather than a markup branch, which keeps every layout structurally identical and diffable. - Stretched link with a readable name — the name's
::aftercovers the card, so "the whole card is clickable" costs the accessibility tree exactly one link (or button) named after the person, instead of a card-sized anonymous hit target. It has to sit on the control, not on a truncating wrapper, oroverflow: hiddenclips the overlay to the text box. - Lifted action row — actions render in a
z-10layer above that overlay, which is what keeps a "Follow" click from silently becoming a navigation; it is the standard failure mode of card-wide links. - Graceful avatar degradation — a missing, failing or rate-limited avatar falls back to initials; the failure is forgotten as soon as the URL changes (render-phase adjust-state, not an effect) and is re-detected on attach, because a server-rendered image can die before hydration ever wires up
onError. - Presence without colour dependency — the dot ships the word ("Online" / "Busy") as its accessible name, so the state survives a monochrome theme, a colour-blind reader and a screen reader alike.
Chat Thread
A contract-driven conversation stream — sender grouping, day and unread dividers, streaming append, load-older with position kept, and a scroll that never gets stolen from the reader.
Rating Summary
A review digest: average score, fractional star row and per-bucket distribution bars that double as filter toggles.
