Skip to contentVibraUI
Data display

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-cell

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
namestring—The person's name; the initials fall back to it.
emailstring—The second line, unless a description replaces it.
descriptionReact.ReactNode—Replaces the email on the second line — a role, a team, a last-seen.
srcstring—Avatar image; the initials show until it loads.
fallbackstring—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.
hrefstring—Turns the whole cell into a link. Leave it unset when the row is already one.
classNamestring—Merged onto the root, which is the link or a plain div; the remaining props are spread onto it.

Dependencies

Source

components/ui/user-cell.tsx
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 }