Display
User card
A person at a glance: avatar, name, role, a few facts and actions. Compact fits inside a hover card.
- Built on
<span>- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.0 kB
Try its options
Loading its options.
Code
import { Button } from "@/components/equip/button";
import { UserCard } from "@/components/equip/user-card";
export default function Demo() {
return (
<UserCard
variant="compact"
style={{ width: 208 }}
name="Mira Okafor"
role="Design systems lead"
facts={[
{ label: "Location", value: "Lisbon", icon: "map-pin" },
{ label: "Local time", value: "14:20", icon: "clock" },
]}
actions={
<Button size="s" variant="outline">
Message
</Button>
}
/>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<span style="width:208px" class="eq-user-card" data-variant="compact">
<span class="eq-user-card-head">
<span aria-hidden="true" class="eq-avatar eq-user-card-avatar" data-size="m" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">MO</span>
</span>
<span class="eq-user-card-who">
<strong class="eq-user-card-name">Mira Okafor</strong>
<span class="eq-user-card-role">Design systems lead</span>
</span>
</span>
<span class="eq-user-card-facts">
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" />
<path d="M9.5 9.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" />
</svg>
<span class="eq-user-card-label">Location: </span>
<span class="eq-user-card-value">Lisbon</span>
</span>
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 7v5l3.5 2.5" />
</svg>
<span class="eq-user-card-label">Local time: </span>
<span class="eq-user-card-value">14:20</span>
</span>
</span>
<span class="eq-user-card-actions">
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Message</span>
</button>
</span>
</span>Examples
Full card with actions
Each fact carries a hidden label, so a screen reader hears "Location: Lisbon" where the eye sees a pin and a city.
Code
import { Button } from "@/components/equip/button";
import { UserCard } from "@/components/equip/user-card";
export default function Example() {
return (
<UserCard
style={{ maxWidth: 320 }}
name="Mira Okafor"
role="Design systems lead"
href="#people/mira"
facts={[
{ label: "Location", value: "Lisbon, Portugal", icon: "map-pin" },
{ label: "Local time", value: "14:20, 1 hour ahead of you", icon: "clock" },
{ label: "Team", value: "Platform design", icon: "users" },
]}
actions={
<>
<Button size="s">Message</Button>
<Button size="s" variant="outline">
View profile
</Button>
</>
}
>
Out on Friday. Ask Tomás about token releases.
</UserCard>
);
}HTMLworks in any framework, with the part's CSS
<span style="max-width:320px" class="eq-user-card" data-variant="full">
<span class="eq-user-card-head">
<span aria-hidden="true" class="eq-avatar eq-user-card-avatar" data-size="l" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">MO</span>
</span>
<span class="eq-user-card-who">
<a class="eq-user-card-name" href="#people/mira">Mira Okafor</a>
<span class="eq-user-card-role">Design systems lead</span>
</span>
</span>
<span class="eq-user-card-facts">
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" />
<path d="M9.5 9.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" />
</svg>
<span class="eq-user-card-label">Location: </span>
<span class="eq-user-card-value">Lisbon, Portugal</span>
</span>
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 7v5l3.5 2.5" />
</svg>
<span class="eq-user-card-label">Local time: </span>
<span class="eq-user-card-value">14:20, 1 hour ahead of you</span>
</span>
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M3 20v-.5a5 5 0 0 1 5-5h2a5 5 0 0 1 5 5v.5" />
<path d="M15.5 5.2a3 3 0 0 1 0 5.6M17.5 14.6a5 5 0 0 1 3.5 4.9v.5" />
</svg>
<span class="eq-user-card-label">Team: </span>
<span class="eq-user-card-value">Platform design</span>
</span>
</span>
<span class="eq-user-card-note">Out on Friday. Ask Tomás about token releases.</span>
<span class="eq-user-card-actions">
<button type="button" class="eq-button" data-variant="solid" data-size="s">
<span class="eq-button-label">Message</span>
</button>
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">View profile</span>
</button>
</span>
</span>Compact, built for a hover card
Every element is a span, so the compact card is valid inside HoverCardContent, which itself sits inside a paragraph.
Assigned to Kwame MensahStaff engineer, PaymentsLocation: AccraLocal time: 13:20Team: Billinguntil the refund flow ships.
Code
import { UserCard } from "@/components/equip/user-card";
export default function Example() {
return (
<p style={{ margin: 0, maxWidth: 300, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>
Assigned to{" "}
<UserCard
variant="compact"
style={{ marginBlock: 8, padding: 12, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)" }}
name="Kwame Mensah"
role="Staff engineer, Payments"
href="#people/kwame"
facts={[
{ label: "Location", value: "Accra", icon: "map-pin" },
{ label: "Local time", value: "13:20", icon: "clock" },
{ label: "Team", value: "Billing", icon: "users" },
]}
/>
until the refund flow ships.
</p>
);
}HTMLworks in any framework, with the part's CSS
<p style="margin:0;max-width:300px;font-size:var(--eq-text-s);color:var(--eq-ink-2)">Assigned to <span style="margin-block:8px;padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l)" class="eq-user-card" data-variant="compact"><span class="eq-user-card-head"><span aria-hidden="true" class="eq-avatar eq-user-card-avatar" data-size="m" data-shape="round"><span class="eq-avatar-initials" aria-hidden="true">KM</span></span><span class="eq-user-card-who"><a class="eq-user-card-name" href="#people/kwame">Kwame Mensah</a><span class="eq-user-card-role">Staff engineer, Payments</span></span></span><span class="eq-user-card-facts"><span class="eq-user-card-fact"><svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" /><path d="M9.5 9.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" /></svg><span class="eq-user-card-label">Location: </span><span class="eq-user-card-value">Accra</span></span><span class="eq-user-card-fact"><svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 7v5l3.5 2.5" /></svg><span class="eq-user-card-label">Local time: </span><span class="eq-user-card-value">13:20</span></span><span class="eq-user-card-fact"><svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M3 20v-.5a5 5 0 0 1 5-5h2a5 5 0 0 1 5 5v.5" /><path d="M15.5 5.2a3 3 0 0 1 0 5.6M17.5 14.6a5 5 0 0 1 3.5 4.9v.5" /></svg><span class="eq-user-card-label">Team: </span><span class="eq-user-card-value">Billing</span></span></span></span>until the refund flow ships.</p>Team directory
Code
import { Button } from "@/components/equip/button";
import { UserCard } from "@/components/equip/user-card";
const people = [
{ name: "Sofia Lindqvist", role: "Engineering manager", city: "Stockholm", time: "15:20" },
{ name: "Arjun Mehta", role: "Site reliability", city: "Bengaluru", time: "19:50" },
{ name: "Lucas Moreau", role: "Product designer", city: "Lyon", time: "15:20" },
];
export default function Example() {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12 }}>
{people.map((person) => (
<UserCard
key={person.name}
name={person.name}
role={person.role}
facts={[
{ label: "Location", value: person.city, icon: "map-pin" },
{ label: "Local time", value: person.time, icon: "clock" },
]}
actions={
<Button size="s" variant="outline">
Message
</Button>
}
/>
))}
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px">
<span class="eq-user-card" data-variant="full">
<span class="eq-user-card-head">
<span aria-hidden="true" class="eq-avatar eq-user-card-avatar" data-size="l" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">SL</span>
</span>
<span class="eq-user-card-who">
<strong class="eq-user-card-name">Sofia Lindqvist</strong>
<span class="eq-user-card-role">Engineering manager</span>
</span>
</span>
<span class="eq-user-card-facts">
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" />
<path d="M9.5 9.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" />
</svg>
<span class="eq-user-card-label">Location: </span>
<span class="eq-user-card-value">Stockholm</span>
</span>
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 7v5l3.5 2.5" />
</svg>
<span class="eq-user-card-label">Local time: </span>
<span class="eq-user-card-value">15:20</span>
</span>
</span>
<span class="eq-user-card-actions">
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Message</span>
</button>
</span>
</span>
<span class="eq-user-card" data-variant="full">
<span class="eq-user-card-head">
<span aria-hidden="true" class="eq-avatar eq-user-card-avatar" data-size="l" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">AM</span>
</span>
<span class="eq-user-card-who">
<strong class="eq-user-card-name">Arjun Mehta</strong>
<span class="eq-user-card-role">Site reliability</span>
</span>
</span>
<span class="eq-user-card-facts">
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" />
<path d="M9.5 9.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" />
</svg>
<span class="eq-user-card-label">Location: </span>
<span class="eq-user-card-value">Bengaluru</span>
</span>
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 7v5l3.5 2.5" />
</svg>
<span class="eq-user-card-label">Local time: </span>
<span class="eq-user-card-value">19:50</span>
</span>
</span>
<span class="eq-user-card-actions">
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Message</span>
</button>
</span>
</span>
<span class="eq-user-card" data-variant="full">
<span class="eq-user-card-head">
<span aria-hidden="true" class="eq-avatar eq-user-card-avatar" data-size="l" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">LM</span>
</span>
<span class="eq-user-card-who">
<strong class="eq-user-card-name">Lucas Moreau</strong>
<span class="eq-user-card-role">Product designer</span>
</span>
</span>
<span class="eq-user-card-facts">
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" />
<path d="M9.5 9.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" />
</svg>
<span class="eq-user-card-label">Location: </span>
<span class="eq-user-card-value">Lyon</span>
</span>
<span class="eq-user-card-fact">
<svg aria-hidden="true" focusable="false" class="eq-icon eq-user-card-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 7v5l3.5 2.5" />
</svg>
<span class="eq-user-card-label">Local time: </span>
<span class="eq-user-card-value">15:20</span>
</span>
</span>
<span class="eq-user-card-actions">
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Message</span>
</button>
</span>
</span>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
name | string | none | The person's name. Also gives the avatar its initials. |
role | ReactNode | none | Job title or role, under the name. |
src | string | none | Avatar image URL. Without it the avatar shows initials. |
href | string | none | Turns the name into a link to the profile. |
facts | { label: string; value: ReactNode; icon?: IconName }[] | none | Short facts such as location, local time and team. The label is read by screen readers before the value. |
actions | ReactNode | none | One or two Buttons or links, such as Message. |
children | ReactNode | none | A short line under the facts, such as a status. Phrasing content only. |
variant | "full" | "compact" | "full" | full is a bordered card for the page; compact draws no box and runs the facts together, for a hover card. |
...rest | ComponentProps<"span"> | none | Every native span attribute, including style and ref. |
Anatomy
| root | .eq-user-card | A span with display: grid. The variant is on data-variant. |
|---|---|---|
| head | .eq-user-card-head | Avatar beside the name and role. |
| avatar | .eq-user-card-avatar | An Avatar with alt="", since the name is printed beside it. |
| who | .eq-user-card-who | |
| name | .eq-user-card-name | A link when href is set, otherwise strong. |
| role | .eq-user-card-role | |
| facts | .eq-user-card-facts | |
| fact | .eq-user-card-fact | |
| icon | .eq-user-card-icon | Decorative. |
| label | .eq-user-card-label | Visually hidden, read before the value. |
| value | .eq-user-card-value | |
| note | .eq-user-card-note | |
| actions | .eq-user-card-actions |
Notes
- Every element is a span with a display style, in both variants, so the card is valid inside HoverCardContent, which is a span that often sits in a paragraph.
- Keep children and actions to phrasing content too, such as links, buttons and spans, or the nesting stops being valid inside a paragraph.
- The examples show the compact variant standalone; to use it in a hover card, put it straight into HoverCardContent, which already draws the surface.
- Fact icons are decorative and each value is preceded by a hidden label, so a screen reader hears Location: Lisbon.
- The avatar is hidden from assistive tech because the name is written beside it.
- Local time is a string you pass in, so the server render stays deterministic; compute it after mount if it must be live.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/user-card.tsx81 lines
import type { ComponentProps, ReactNode } from "react";
import { Avatar } from "./avatar";
import { Icon, type IconName } from "./icon";
import "./user-card.css";
export type UserCardFact = {
/** Says what the value is, for screen readers, e.g. "Location". */
label: string;
/** The fact itself, already formatted, e.g. "Lisbon" or "14:20 local time". */
value: ReactNode;
/** A decorative icon drawn before the value. */
icon?: IconName;
};
export type UserCardProps = Omit<ComponentProps<"span">, "children"> & {
/** The person's name. Also gives the avatar its initials. */
name: string;
/** Job title or role, e.g. "Staff engineer, Payments". */
role?: ReactNode;
/** Avatar image URL. Without it the avatar shows initials. */
src?: string;
/** Turns the name into a link to the profile. */
href?: string;
/** Short facts such as location, local time and team. */
facts?: UserCardFact[];
/** One or two Buttons or links. */
actions?: ReactNode;
/** A short line under the facts, such as a status or a bio. Phrasing content only. */
children?: ReactNode;
/** full is a bordered card on the page; compact is borderless, for a HoverCardContent. */
variant?: "full" | "compact";
};
/**
* Everything is a span with a display style, so the compact card is valid inside a
* HoverCardContent, which is itself a span that often sits in a paragraph.
*/
export function UserCard({
name,
role,
src,
href,
facts,
actions,
variant = "full",
className,
children,
...rest
}: UserCardProps) {
const compact = variant === "compact";
return (
<span {...rest} className={["eq-user-card", className].filter(Boolean).join(" ")} data-variant={variant}>
<span className="eq-user-card-head">
<Avatar className="eq-user-card-avatar" name={name} src={src} alt="" size={compact ? "m" : "l"} />
<span className="eq-user-card-who">
{href ? (
<a className="eq-user-card-name" href={href}>
{name}
</a>
) : (
<strong className="eq-user-card-name">{name}</strong>
)}
{role != null && <span className="eq-user-card-role">{role}</span>}
</span>
</span>
{facts != null && facts.length > 0 && (
<span className="eq-user-card-facts">
{facts.map((fact) => (
<span className="eq-user-card-fact" key={fact.label}>
{fact.icon && <Icon className="eq-user-card-icon" name={fact.icon} size={compact ? 14 : "s"} />}
<span className="eq-user-card-label">{fact.label}: </span>
<span className="eq-user-card-value">{fact.value}</span>
</span>
))}
</span>
)}
{children != null && <span className="eq-user-card-note">{children}</span>}
{actions != null && <span className="eq-user-card-actions">{actions}</span>}
</span>
);
}components/equip/user-card.css141 lines
@layer equip {
.eq-user-card {
box-sizing: border-box;
display: grid;
gap: var(--eq-space-3);
min-width: 0;
max-width: 100%;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.45 var(--eq-font);
text-align: start;
}
.eq-user-card[data-variant="full"] {
padding: var(--eq-space-4);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
}
/* Compact sits on someone else's surface, such as a hover card, so it draws no box. */
.eq-user-card[data-variant="compact"] {
gap: var(--eq-space-2);
}
.eq-user-card-head {
display: flex;
align-items: center;
gap: var(--eq-space-3);
min-width: 0;
}
.eq-user-card[data-variant="compact"] .eq-user-card-head {
gap: var(--eq-space-2);
}
.eq-user-card-avatar {
flex: none;
}
.eq-user-card-who {
display: grid;
min-width: 0;
}
.eq-user-card-name {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
font-size: var(--eq-text-m);
line-height: 1.3;
overflow-wrap: anywhere;
}
.eq-user-card[data-variant="full"] .eq-user-card-name {
font-size: var(--eq-text-l);
}
a.eq-user-card-name {
justify-self: start;
text-decoration: underline;
text-decoration-color: var(--eq-line-strong);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
border-radius: 2px;
transition: text-decoration-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
a.eq-user-card-name:hover {
text-decoration-color: currentColor;
}
}
a.eq-user-card-name:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-user-card-role {
color: var(--eq-ink-2);
overflow-wrap: anywhere;
}
.eq-user-card-facts {
display: grid;
gap: var(--eq-space-1);
min-width: 0;
color: var(--eq-ink-2);
}
/* Compact runs the facts together on wrapping lines. */
.eq-user-card[data-variant="compact"] .eq-user-card-facts {
display: flex;
flex-wrap: wrap;
column-gap: var(--eq-space-3);
row-gap: 2px;
font-size: var(--eq-text-xs);
}
.eq-user-card-fact {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-user-card[data-variant="compact"] .eq-user-card-fact {
gap: var(--eq-space-1);
}
.eq-user-card-icon {
flex: none;
color: var(--eq-ink-3);
}
/* Read by screen readers before each value, so "Lisbon" is heard as "Location: Lisbon". */
.eq-user-card-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-user-card-value {
min-width: 0;
overflow-wrap: anywhere;
}
.eq-user-card-note {
display: block;
color: var(--eq-ink-2);
overflow-wrap: anywhere;
}
.eq-user-card-actions {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-2);
}
}