Display

Profile Card

One person card in three densities (vertical, horizontal, compact) with presence dot, badges, stats, a stretched-link name and initials fallback.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type ProfileCardLayout = "vertical" | "horizontal" | "compact"
export type ProfileCardStatus = "online" | "offline" | "busy"

export interface ProfileCardBadge {
  label: string
  /** Tone resolves to semantic tokens only, so badges keep working in any palette. */
  tone?: "neutral" | "primary" | "destructive"
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/profile-card.json

Prompt

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 verticalcompact is 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, meta in compact) through a hidden class 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 ::after covers 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, or overflow: hidden clips the overlay to the text box.
  • Lifted action row — actions render in a z-10 layer 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.

On This Page