Avatar
A round picture of a person, with initials while it loads or when there is none.
shadcn's base-nova avatar with one change: AvatarBadge sits on the inline end, so a presence dot crosses to the bottom-left corner in a right-to-left page. Otherwise it takes the kit's look from the theme alone — the fallback on the muted plane, the badge on --primary, which every Vibra palette points at the accent. Name an avatar once, with aria-label on its root, and hide its picture and initials, or a screen reader hears the initials while the face loads; given a name, the root — and a badge given one — is role="img" by itself, because aria-label on a span with no role is prohibited and screen readers drop it. The kit draws its people with avatarFor(name) faces, and AvatarGroup (the kit item) adds names and an overflow count.
Install
npx shadcn@latest add @vibra/avatarNeeds the @vibra registry in your components.json — set it up once.
Examples
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@/components/ui/avatar"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
const REVENUE_TEAM = ["Maren Kovač", "Hiro Tanaka", "Saoirse Byrne", "Joaquín Ortega"]
export default function AvatarDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-8">
<div className="flex items-center gap-3">
<Avatar size="sm">
<AvatarImage src={avatarFor("Petra Lindqvist")} alt="Petra Lindqvist" />
<AvatarFallback>{getInitials("Petra Lindqvist")}</AvatarFallback>
</Avatar>
<Avatar>
<AvatarImage src={avatarFor("Femi Adebayo")} alt="Femi Adebayo" />
<AvatarFallback>{getInitials("Femi Adebayo")}</AvatarFallback>
</Avatar>
<Avatar size="lg">
<AvatarImage src={avatarFor("Uma Raghavan")} alt="Uma Raghavan" />
<AvatarFallback>{getInitials("Uma Raghavan")}</AvatarFallback>
</Avatar>
{/* No picture on file: the initials stand in. */}
<Avatar size="lg">
<AvatarFallback>{getInitials("Wim de Vries")}</AvatarFallback>
</Avatar>
</div>
<AvatarGroup role="group" aria-label="Revenue team">
{REVENUE_TEAM.map((name) => (
<Avatar key={name}>
<AvatarImage src={avatarFor(name)} alt={name} />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
))}
<AvatarGroupCount>
<span aria-hidden="true" className="text-xs tabular-nums">
+3
</span>
<span className="sr-only">and 3 more</span>
</AvatarGroupCount>
</AvatarGroup>
</div>
)
}Fallbacks
The picture, the initials while it loads or when there is none, and a glyph when there is no name either.
import { UserIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
const PEOPLE = [
{ label: "Maren Kovač", caption: "A face", src: avatarFor("Maren Kovač"), fallback: getInitials("Maren Kovač") },
{ label: "Nadia Costa", caption: "No picture yet", src: undefined, fallback: getInitials("Nadia Costa") },
{ label: "kwame@kestrel.example", caption: "Invited, no name", src: undefined, fallback: null },
]
// The chain an avatar falls down: the picture, the initials while it loads or
// when there is none, and a glyph when there is not even a name. Each avatar
// is named once, on its root, so a screen reader never hears "NC".
export default function AvatarFallbacks() {
return (
<ul className="flex flex-wrap items-start justify-center gap-6">
{PEOPLE.map((person) => (
<li key={person.label} className="flex w-28 flex-col items-center gap-2 text-center">
<Avatar size="lg" role="img" aria-label={person.label}>
{person.src ? <AvatarImage src={person.src} alt="" /> : null}
<AvatarFallback aria-hidden="true">
{person.fallback ?? <UserIcon className="size-4" />}
</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground">{person.caption}</span>
</li>
))}
</ul>
)
}Presence
A dot on the corner and the word under it; do-not-disturb adds a bar and offline is hollow, so no two differ by colour alone.
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { getInitials } from "@/lib/format"
const TEAM = [
{ name: "Maren Kovač", status: "Online", dot: "bg-success" },
{ name: "Hiro Tanaka", status: "Away", dot: "bg-warning" },
{ name: "Saoirse Byrne", status: "Do not disturb", dot: "bg-danger", mark: true },
{ name: "Joaquín Ortega", status: "Offline", dot: "bg-background inset-ring-2 inset-ring-muted-foreground" },
]
// Presence as a dot on the avatar's corner, with the word under it: the word
// carries the state, the dot is the glance. Do-not-disturb adds a bar and
// offline is hollow, so the four differ by more than colour.
export default function AvatarPresence() {
return (
<ul className="flex flex-wrap items-start justify-center gap-5">
{TEAM.map((person) => (
<li key={person.name} className="flex w-24 flex-col items-center gap-1.5 text-center">
<Avatar size="lg" aria-hidden="true">
<AvatarImage src={avatarFor(person.name)} alt="" />
<AvatarFallback>{getInitials(person.name)}</AvatarFallback>
<AvatarBadge className={cn(person.dot, person.mark && "text-danger-foreground")}>
{person.mark ? <MinusIcon strokeWidth={4} /> : null}
</AvatarBadge>
</Avatar>
<span className="text-sm font-medium">{person.name.split(" ")[0]}</span>
<span className="text-xs text-muted-foreground">{person.status}</span>
</li>
))}
</ul>
)
}With an icon badge
AvatarBadge carrying a tick for the approvers who have signed off, each avatar named with its state.
import { CheckIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
const APPROVERS = [
{ name: "Maren Kovač", approved: true },
{ name: "Hiro Tanaka", approved: true },
{ name: "Uma Raghavan", approved: false },
]
// AvatarBadge carrying a glyph: a tick on the approvers who have signed off.
// It fills in the accent, as the primitive's badge does, and each avatar is
// named with its state so the tick is never the only telling.
export default function AvatarApprovals() {
const approved = APPROVERS.filter((person) => person.approved).length
return (
<div className="flex flex-col items-center gap-3">
<ul className="flex items-start gap-5">
{APPROVERS.map((person) => (
<li key={person.name} className="flex flex-col items-center gap-1.5">
<Avatar
size="lg"
role="img"
aria-label={`${person.name}, ${person.approved ? "approved" : "not yet approved"}`}
>
<AvatarImage src={avatarFor(person.name)} alt="" />
<AvatarFallback aria-hidden="true">{getInitials(person.name)}</AvatarFallback>
{person.approved ? (
<AvatarBadge>
<CheckIcon strokeWidth={3} />
</AvatarBadge>
) : null}
</Avatar>
<span aria-hidden="true" className="text-xs text-muted-foreground">
{person.name.split(" ")[0]}
</span>
</li>
))}
</ul>
<p className="text-sm">
Q3 budget · <span className="tabular-nums">{approved} of {APPROVERS.length}</span> approved
</p>
</div>
)
}Unread count
A count on the top corner of a conversation's face; the row says the number in words.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
const THREADS = [
{ name: "Saoirse Byrne", preview: "Can we move the Kestrel renewal call to Friday?", unread: 3 },
{ name: "Joaquín Ortega", preview: "Invoice INV-2041 is paid, closing the ticket.", unread: 0 },
{ name: "Petra Lindqvist", preview: "Sent the Q3 numbers for review.", unread: 1 },
]
// A count on the avatar's top corner, on the inline end so it crosses over in
// a right-to-left page. The badge is drawn for the eye; the row says the
// number in words for a screen reader.
export default function AvatarUnread() {
return (
<ul className="flex w-full max-w-sm flex-col">
{THREADS.map((thread) => (
<li key={thread.name} className="flex items-center gap-3 py-2">
<span className="relative shrink-0">
<Avatar aria-hidden="true">
<AvatarImage src={avatarFor(thread.name)} alt="" />
<AvatarFallback>{getInitials(thread.name)}</AvatarFallback>
</Avatar>
{thread.unread > 0 ? (
<Badge
aria-hidden="true"
className="absolute -top-1 -end-1.5 h-4 min-w-4 px-1 text-2xs tabular-nums ring-2 ring-background"
>
{thread.unread}
</Badge>
) : null}
</span>
<div className="flex min-w-0 flex-col">
<p className={thread.unread > 0 ? "text-sm font-semibold" : "text-sm font-medium"}>
{thread.name}
{thread.unread > 0 ? <span className="sr-only">, {thread.unread} unread</span> : null}
</p>
<p className="truncate text-xs text-muted-foreground">{thread.preview}</p>
</div>
</li>
))}
</ul>
)
}Stacks at each size
AvatarGroup at sm, default and lg; the overflow count takes the size of the faces beside it.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from "@/components/ui/avatar"
const TEAM = ["Maren Kovač", "Hiro Tanaka", "Saoirse Byrne", "Joaquín Ortega", "Uma Raghavan", "Femi Adebayo", "Petra Lindqvist"]
const SHOWN = 4
// The same stack at each size. The overflow count reads the size of the faces
// beside it, so it matches without being told; each face is named on its
// root, and the count says how many more in words.
export default function AvatarStackSizes() {
return (
<dl className="grid grid-cols-[auto_auto] items-center gap-x-5 gap-y-3">
{(["sm", "default", "lg"] as const).map((size) => (
<div key={size} className="contents">
<dt className="font-mono text-xs text-muted-foreground">{size}</dt>
<dd>
<AvatarGroup role="group" aria-label={`Finance team, ${TEAM.length} people`}>
{TEAM.slice(0, SHOWN).map((name) => (
<Avatar key={name} size={size} role="img" aria-label={name}>
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback aria-hidden="true">{getInitials(name)}</AvatarFallback>
</Avatar>
))}
<AvatarGroupCount>
<span aria-hidden="true" className={size === "lg" ? "text-sm tabular-nums" : "text-xs tabular-nums"}>
+{TEAM.length - SHOWN}
</span>
<span className="sr-only">and {TEAM.length - SHOWN} more</span>
</AvatarGroupCount>
</AvatarGroup>
</dd>
</div>
))}
</dl>
)
}Stack with a caption
Faces beside the sentence that names them, hidden from a screen reader so they are not read twice.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarGroup, AvatarImage } from "@/components/ui/avatar"
const APPROVERS = ["Maren Kovač", "Hiro Tanaka", "Uma Raghavan"]
// Faces beside the sentence that names them: the sentence is what a screen
// reader reads, so the stack is hidden from it rather than read out twice.
export default function AvatarStackCaption() {
return (
<div className="flex max-w-sm items-center gap-3">
<AvatarGroup aria-hidden="true" className="shrink-0">
{APPROVERS.map((name) => (
<Avatar key={name} size="sm">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Maren, Hiro</span> and 6 others approved the Q3 budget.
</p>
</div>
)
}Stack that opens the list
The whole stack as one button, and a popover with every name and role behind it.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from "@/components/ui/avatar"
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
const EDITORS = [
{ name: "Maren Kovač", role: "Owner" },
{ name: "Hiro Tanaka", role: "Editor" },
{ name: "Saoirse Byrne", role: "Editor" },
{ name: "Joaquín Ortega", role: "Editor" },
{ name: "Uma Raghavan", role: "Editor" },
{ name: "Femi Adebayo", role: "Editor" },
{ name: "Petra Lindqvist", role: "Commenter" },
{ name: "Kwame Larsen", role: "Commenter" },
{ name: "Nadia Costa", role: "Viewer" },
]
const SHOWN = 4
function Face({ name, size }: { name: string; size?: "sm" }) {
return (
<Avatar size={size}>
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
)
}
// The whole stack is one button — one tab stop, named for what it opens —
// and the list it opens has every name and role, which a +5 never could.
export default function AvatarStackList() {
return (
<Popover>
<PopoverTrigger
aria-label={`${EDITORS.length} people have access to the Q3 board report`}
className="rounded-full focus-ring"
>
<AvatarGroup aria-hidden="true">
{EDITORS.slice(0, SHOWN).map((person) => (
<Face key={person.name} name={person.name} />
))}
<AvatarGroupCount className="text-xs tabular-nums">+{EDITORS.length - SHOWN}</AvatarGroupCount>
</AvatarGroup>
</PopoverTrigger>
<PopoverContent align="start" className="w-64">
<PopoverHeader>
<PopoverTitle>Q3 board report</PopoverTitle>
<PopoverDescription>{EDITORS.length} people have access</PopoverDescription>
</PopoverHeader>
<ul className="flex flex-col gap-2">
{EDITORS.map((person) => (
<li key={person.name} className="flex items-center gap-2">
<span aria-hidden="true">
<Face name={person.name} size="sm" />
</span>
<span className="truncate">{person.name}</span>
<span className="ms-auto text-xs text-muted-foreground">{person.role}</span>
</li>
))}
</ul>
</PopoverContent>
</Popover>
)
}Choose a person
Faces as the options of a radio group: the arrow keys move the choice, and the chosen face takes a tick.
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
const TEAM = ["Hiro Tanaka", "Saoirse Byrne", "Joaquín Ortega", "Uma Raghavan"]
// Faces as the options of a radio group — native radios under them, so the
// arrow keys move the choice. The chosen face takes a tick in the accent and
// its name the heavier weight: two cues, never the colour alone.
export default function AvatarPicker() {
const [assignee, setAssignee] = React.useState(TEAM[0])
return (
<fieldset className="flex flex-col items-center gap-3">
<legend className="mb-3 text-sm font-medium">Assign the INV-2041 follow-up</legend>
<div className="flex gap-4">
{TEAM.map((name) => (
<label key={name} className="flex w-16 flex-col items-center gap-1.5 text-center">
<input
type="radio"
name="avatar-picker-assignee"
value={name}
checked={assignee === name}
onChange={() => setAssignee(name)}
className="peer sr-only"
/>
<span className="rounded-full peer-focus-visible:focus-outline">
<Avatar size="lg" aria-hidden="true">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
{assignee === name ? (
<AvatarBadge>
<CheckIcon strokeWidth={3} />
</AvatarBadge>
) : null}
</Avatar>
</span>
<span className="text-xs text-muted-foreground peer-checked:font-semibold peer-checked:text-foreground">
{name.split(" ")[0]}{" "}
<span className="sr-only">{name.split(" ").slice(1).join(" ")}</span>
</span>
</label>
))}
</div>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
Assigned to {assignee}.
</p>
</fieldset>
)
}With a hover card
A comment author's face links to the profile and previews it on hover or focus.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
const AUTHOR = "Hiro Tanaka"
// Hidden wherever it appears: the link carries the name, and the card prints it.
function Face({ size }: { size?: "lg" }) {
return (
<Avatar size={size} aria-hidden="true">
<AvatarImage src={avatarFor(AUTHOR)} alt="" />
<AvatarFallback>{getInitials(AUTHOR)}</AvatarFallback>
</Avatar>
)
}
// A comment's author: the face links to the profile and, on hover or focus,
// previews it. The link is named for where it goes; the card repeats the name
// beside a larger face, with what a reader wants before they write back.
export default function AvatarHoverCard() {
return (
<div className="flex max-w-sm items-start gap-3">
<HoverCard>
<HoverCardTrigger
href="/team/hiro-tanaka"
aria-label={`${AUTHOR}'s profile`}
className="shrink-0 rounded-full focus-ring"
>
<Face />
</HoverCardTrigger>
<HoverCardContent align="start" className="flex w-72 gap-3">
<Face size="lg" />
<div className="flex flex-col gap-0.5">
<p className="font-medium">{AUTHOR}</p>
<p className="text-xs text-muted-foreground">Finance lead · Tokyo, 11:40 pm there</p>
<p className="pt-1 text-xs">Usually replies within a day.</p>
</div>
</HoverCardContent>
</HoverCard>
<div className="flex flex-col gap-0.5 text-sm">
<p>
<span className="font-medium">{AUTHOR}</span> <span className="text-muted-foreground">· 2h ago</span>
</p>
<p className="text-muted-foreground">Blue Harbor paid INV-2041 in full. Closing the dunning run.</p>
</div>
</div>
)
}In a sentence
A face sized to the line of text, hidden because the name beside it is what gets read.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
function Person({ name }: { name: string }) {
return (
<span className="inline-flex items-baseline gap-1 font-medium text-foreground">
<Avatar aria-hidden="true" className="size-4.5 self-center">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback className="text-avatar">{getInitials(name)}</AvatarFallback>
</Avatar>
{name}
</span>
)
}
// A face small enough to sit in a line of text: sized to the line, centred on
// it, and hidden, because the name printed beside it is what gets read.
export default function AvatarInline() {
return (
<ul className="flex max-w-md flex-col gap-2 text-sm text-muted-foreground">
<li>
<Person name="Maren Kovač" /> assigned INV-2041 to <Person name="Hiro Tanaka" />.
</li>
<li>
<Person name="Saoirse Byrne" /> moved Harborview Robotics to <span className="text-foreground">Negotiation</span>.
</li>
</ul>
)
}Profile photo
Larger than lg for a profile header, with a named change-photo control on the inline-end corner.
import { CameraIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
// Larger than lg for a profile header: size the root, and the initials with
// it. The change-photo control sits on the inline-end corner, named, since a
// camera glyph alone says nothing to a screen reader.
export default function AvatarProfile() {
return (
<div className="flex items-center gap-4">
<div className="relative shrink-0">
<Avatar aria-hidden="true" className="size-16">
<AvatarImage src={avatarFor("Maren Kovač")} alt="" />
<AvatarFallback className="text-lg">MK</AvatarFallback>
</Avatar>
<Button
variant="outline"
size="icon-xs"
aria-label="Change photo"
tooltip="Change photo"
className="absolute -end-1 -bottom-1 rounded-full"
>
<CameraIcon aria-hidden="true" />
</Button>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-base font-semibold">Maren Kovač</p>
<p className="truncate text-sm text-muted-foreground">Head of finance · maren@northwind.example</p>
</div>
</div>
)
}Workspaces
A workspace is a square mark and a person a round one, so a switcher never mixes them up.
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
const WORKSPACES = [
{ name: "Northwind Analytics", detail: "Pro · 12 seats" },
{ name: "Northwind Freight", detail: "Team · 4 seats" },
{ name: "Harborview Robotics", detail: "You are a guest" },
]
// People are round; a workspace is a square mark, so the two never read as
// each other in a switcher. Without a logo the mark is its initials in ink —
// decoration, never the accent.
export default function AvatarWorkspace() {
return (
<ul className="flex w-full max-w-xs flex-col gap-1">
{WORKSPACES.map((workspace) => (
<li key={workspace.name} className="flex items-center gap-3 rounded-lg px-2 py-1.5">
<Avatar aria-hidden="true" className="rounded-lg after:rounded-lg">
<AvatarFallback className="rounded-lg bg-foreground text-xs font-medium text-background">
{getInitials(workspace.name)}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<p className="truncate text-sm font-medium">{workspace.name}</p>
<p className="text-xs text-muted-foreground">{workspace.detail}</p>
</div>
</li>
))}
</ul>
)
}Deactivated
A member who has left stays in the list: the face recedes, a mark and the words say why.
import { BanIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
const MEMBERS = [
{ name: "Kwame Larsen", detail: "Admin · active today", active: true },
{ name: "Lior Keller", detail: "Deactivated Mar 3 · 14 reports kept", active: false },
]
// A deactivated member stays in the list — their reports are still theirs —
// with the face receded and a ban mark on it, the state in words beside it,
// and the way back as a button named for them.
export default function AvatarDeactivated() {
return (
<ul className="flex w-full max-w-md flex-col divide-y divide-border panel">
{MEMBERS.map((member) => (
<li key={member.name} className="flex items-center gap-3 px-3 py-2.5">
<Avatar aria-hidden="true" className={member.active ? undefined : "opacity-60"}>
<AvatarImage src={avatarFor(member.name)} alt="" />
<AvatarFallback>{getInitials(member.name)}</AvatarFallback>
{member.active ? null : (
<AvatarBadge className="bg-muted-foreground text-background">
<BanIcon strokeWidth={3} />
</AvatarBadge>
)}
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<p className={member.active ? "text-sm font-medium" : "text-sm font-medium text-muted-foreground"}>
{member.name}
</p>
<p className="truncate text-xs text-muted-foreground">{member.detail}</p>
</div>
{member.active ? null : (
<Button variant="outline" size="xs" className="shrink-0">
Reactivate{" "}
<span className="sr-only">{member.name}</span>
</Button>
)}
</li>
))}
</ul>
)
}No one yet
Empty seats as dashed avatars above the action that fills them.
import { UserIcon, UserPlusIcon } from "lucide-react"
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
// Before anyone is on a report: empty seats drawn as dashed avatars — the
// kit's idle-slot outline, at the field boundary's contrast — above the one
// action that fills them.
export default function AvatarEmpty() {
return (
<div className="flex flex-col items-center gap-3 text-center">
<AvatarGroup aria-hidden="true">
{[0, 1, 2].map((seat) => (
<Avatar key={seat} className="after:border-dashed after:border-input">
<AvatarFallback className="bg-card">
<UserIcon className="size-4" />
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">No one else is on this report</p>
<p className="text-xs text-muted-foreground">Invite a teammate to edit Q3 board report with you.</p>
</div>
<Button variant="outline" size="sm">
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
Invite teammate
</Button>
</div>
)
}Right to left
The same presence and stack in Arabic: the dot crosses to the bottom-left, and the stack overlaps the other way.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@/components/ui/avatar"
const TEAM = ["Hiro Tanaka", "Saoirse Byrne", "Joaquín Ortega"]
function Row({ online, team, more }: { online: string; team: string; more: string }) {
return (
<div className="flex items-center gap-4">
<Avatar size="lg" role="img" aria-label={online}>
<AvatarImage src={avatarFor("Maren Kovač")} alt="" />
<AvatarFallback aria-hidden="true">MK</AvatarFallback>
<AvatarBadge className="bg-success" />
</Avatar>
<AvatarGroup role="group" aria-label={team}>
{TEAM.map((name) => (
<Avatar key={name} role="img" aria-label={name}>
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback aria-hidden="true">{getInitials(name)}</AvatarFallback>
</Avatar>
))}
<AvatarGroupCount>
<span aria-hidden="true" className="text-xs tabular-nums">
+4
</span>
<span className="sr-only">{more}</span>
</AvatarGroupCount>
</AvatarGroup>
</div>
)
}
// The same presence and stack in English and in Arabic. The presence dot sits
// on the inline end, so it crosses to the bottom-left corner; the stack
// overlaps the other way and ends on its count.
export default function AvatarRtl() {
return (
<div className="flex flex-col items-center gap-4">
<Row online="Maren Kovač, online" team="Sales team" more="and 4 more" />
<div dir="rtl" lang="ar">
<Row online="مارين كوفاتش، متصلة" team="فريق المبيعات" more="و4 آخرون" />
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "default" | "sm" | "lg" | "default" | 32px, 24px or 40px. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
/**
* A name on a face needs a role to live on: `aria-label` on a span with none
* is prohibited, so screen readers drop it (axe aria-prohibited-attr — the
* files browser's owner column, 25 faces, was nothing but these). A face or a
* badge that is given a name is an image of that name.
*/
function namedRole(props: { role?: React.AriaRole; "aria-label"?: string; "aria-labelledby"?: string }) {
return props.role ?? (props["aria-label"] || props["aria-labelledby"] ? "img" : undefined)
}
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
role={namedRole(props)}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
role={namedRole(props)}
className={cn(
// On the inline end, where a line of text ends: the bottom-right
// corner in English and the bottom-left in a right-to-left page.
"absolute end-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}