User cell
A person in one cell: their face, their name, and one quieter line under it.
Server-compatible: no hooks, no client boundary. The cell is always two lines deep — a description takes the second line from the email rather than adding a third. Presence is a dot ringed in the page background plus the word itself for a screen reader, so it never reads by colour alone. href turns the whole cell into a link and underlines the name on hover; leave it unset when the row around the cell is already a link or a button, since neither may contain another.
Install
$
npx shadcn@latest add @vibra/user-cellNeeds the @vibra registry in your components.json — set it up once.
Examples
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { DateCell } from "@/components/ui/table-cells"
import { UserCell, type UserCellStatus } from "@/components/ui/user-cell"
const HOUR = 3_600_000
// A fixed reference instant, not Date.now(): a statically built page freezes
// the server's copy at build time, so a live clock here would read one thing in
// the HTML and another once the reader's own clock takes over. format-demo.tsx
// pins its instant for the same reason.
const NOW = new Date(2026, 8, 4, 12, 0, 0).getTime()
type Member = {
id: string
name: string
email: string
role: string
presence: UserCellStatus
lastSeen: Date
}
const members: Member[] = [
{
id: "u_1",
name: "Olivia Martin",
email: "olivia.martin@vibra.example",
role: "Owner",
presence: "online",
lastSeen: new Date(NOW - 3 * 60_000),
},
{
id: "u_2",
name: "Jackson Lee",
email: "jackson.lee@vibra.example",
role: "Admin",
presence: "busy",
lastSeen: new Date(NOW - 40 * 60_000),
},
{
id: "u_3",
name: "Isabella Nguyen",
email: "isabella.nguyen@vibra.example",
role: "Engineer",
presence: "away",
lastSeen: new Date(NOW - 5 * HOUR),
},
{
id: "u_4",
name: "William Kim",
email: "william.kim@vibra.example",
role: "Engineer",
presence: "offline",
lastSeen: new Date(NOW - 3 * 24 * HOUR),
},
]
const columns: SimpleTableColumn<Member>[] = [
{
key: "name",
header: "Member",
cell: (member) => (
<UserCell
name={member.name}
email={member.email}
status={member.presence}
href={`#${member.id}`}
/>
),
},
{
key: "role",
header: "Role",
cell: (member) => <StatusBadge status={member.role} variant="neutral" dot={false} size="sm" />,
},
{
key: "lastSeen",
header: "Last seen",
align: "right",
cell: (member) => <DateCell date={member.lastSeen} relative />,
},
]
export default function UserCellDemo() {
return (
<SimpleTable
className="w-full"
columns={columns}
rows={members}
rowKey="id"
caption="Workspace members"
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | — | The person's name; the initials fall back to it. |
| string | — | The second line, unless a description replaces it. | |
| description | React.ReactNode | — | Replaces the email on the second line — a role, a team, a last-seen. |
| src | string | — | Avatar image; the initials show until it loads. |
| fallback | string | — | Replaces the initials taken from name. |
| status | "online" | "offline" | "busy" | "away" | — | Adds a presence dot at the avatar's bottom-right, named in text for a screen reader. |
| size | "sm" | "default" | "default" | sm drops the avatar to size-6 and the two lines to text-xs and 11px. |
| href | string | — | Turns the whole cell into a link. Leave it unset when the row is already one. |
| className | string | — | Merged onto the root, which is the link or a plain div; the remaining props are spread onto it. |
Dependencies
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export type UserCellStatus = "online" | "offline" | "busy" | "away"
// The same four presence tones status-indicator uses, so a dot means the same
// thing wherever a person appears.
const STATUS_COLORS: Record<UserCellStatus, string> = {
online: "bg-success",
busy: "bg-danger",
away: "bg-warning",
offline: "bg-muted-foreground",
}
// Read out beside the name, so presence never depends on the dot's colour.
const STATUS_LABELS: Record<UserCellStatus, string> = {
online: "Online",
busy: "Busy",
away: "Away",
offline: "Offline",
}
const userCellVariants = cva("flex min-w-0 items-center", {
variants: {
size: {
default: "gap-2.5 text-sm",
sm: "gap-2 text-xs",
},
interactive: {
true: "rounded-md focus-ring [&:hover_[data-slot=user-cell-name]]:underline",
false: "",
},
},
defaultVariants: { size: "default", interactive: false },
})
type UserCellSize = NonNullable<VariantProps<typeof userCellVariants>["size"]>
// COUPLED TO registry/vibra/ui/avatar.tsx, as in avatar-group: Avatar's own
// data-[size=…] variants outrank a plain utility, so the sizes are set as flat
// classes tailwind-merge folds into the primitive's base `size-8`/`text-sm`.
const AVATAR_SIZES: Record<UserCellSize, { root: string; text: string; dot: string }> = {
default: { root: "size-8", text: "text-xs", dot: "size-2.5" },
sm: { root: "size-6", text: "text-avatar", dot: "size-2" },
}
export type UserCellProps = React.ComponentProps<"div"> & {
name: string
email?: string
/** Replaces the email on the second line — a role, a team, a last-seen. */
description?: React.ReactNode
src?: string
/** Replaces the initials taken from `name`. */
fallback?: string
status?: UserCellStatus
size?: UserCellSize
/** Turns the whole cell into a link. Leave it unset when the row around it is already one. */
href?: string
}
/** A person in one cell: their face, their name, and one quieter line under it. */
function UserCell({
className,
name,
email,
description,
src,
fallback,
status,
size = "default",
href,
...props
}: UserCellProps) {
const sizing = AVATAR_SIZES[size]
const secondary = description ?? email
const Root = (href ? "a" : "div") as React.ElementType
return (
<Root
data-slot="user-cell"
data-size={size}
data-status={status}
href={href}
className={cn(userCellVariants({ size, interactive: Boolean(href) }), className)}
{...props}
>
<span data-slot="user-cell-avatar" className="relative shrink-0">
<Avatar className={sizing.root}>
{src ? <AvatarImage src={src} alt="" /> : null}
<AvatarFallback className={sizing.text}>
{fallback ?? getInitials(name)}
</AvatarFallback>
</Avatar>
{status ? (
<span
data-slot="user-cell-status"
aria-hidden="true"
className={cn(
"absolute end-0 bottom-0 z-10 rounded-full ring-2 ring-background",
sizing.dot,
STATUS_COLORS[status]
)}
/>
) : null}
</span>
<span data-slot="user-cell-text" className="flex min-w-0 flex-col leading-tight">
<span data-slot="user-cell-name" className="truncate font-medium">
{name}
{status ? <span className="sr-only">{STATUS_LABELS[status]}</span> : null}
</span>
{secondary ? (
<span
data-slot="user-cell-secondary"
className={cn("truncate text-muted-foreground", size === "sm" ? "text-2xs" : "text-xs")}
>
{secondary}
</span>
) : null}
</span>
</Root>
)
}
export { UserCell, userCellVariants }