Mobile app launch retro
Wed, 15:00 – 15:45 CEST
Video call
4 going
Free, copy-and-go Avatar components built on the SevenUI Avatar primitive.Read the primitive docs.
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
const sizes = [
{ label: "sm", px: 24, size: "sm" as const },
{ label: "default", px: 32, size: "default" as const },
{ label: "lg", px: 40, size: "lg" as const },
{
label: "xl",
px: 48,
size: "lg" as const,
className: "data-[size=lg]:size-12",
text: "text-base",
},
{
label: "2xl",
px: 64,
size: "lg" as const,
className: "data-[size=lg]:size-16",
text: "text-lg",
},
];
export default function Avatar01() {
return (
<ul
aria-label="Avatar sizes"
className="flex w-full max-w-md flex-wrap items-end justify-center gap-x-5 gap-y-6 sm:justify-between"
>
{sizes.map((item) => (
<li key={item.label} className="flex flex-col items-center gap-3">
<Avatar size={item.size} className={item.className}>
<AvatarImage src="/placeholder.svg" alt="Maya Lindqvist" />
<AvatarFallback className={item.text}>ML</AvatarFallback>
</Avatar>
<Avatar size={item.size} className={item.className}>
<AvatarFallback className={item.text}>ML</AvatarFallback>
</Avatar>
<div className="flex min-w-12 flex-col items-center gap-0.5 self-stretch border-t border-border pt-2">
<span className="text-xs font-medium">{item.label}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{item.px}px
</span>
</div>
</li>
))}
</ul>
);
}
npx shadcn@latest add @sevenui/component/avatar-01pnpm dlx shadcn@latest add @sevenui/component/avatar-01yarn dlx shadcn@latest add @sevenui/component/avatar-01bunx --bun shadcn@latest add @sevenui/component/avatar-01People and members
Teams and workspaces
Apps and bots
"use client";
import { Bot, CalendarDays, Webhook } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
const people = [
{ name: "Daniel Okafor", initials: "DO" },
{ name: "Sofia Marchetti", initials: "SM" },
];
const workspaces = [
{ name: "Northwind Labs", initials: "NL" },
{ name: "Acme Studio", initials: "AS" },
];
const apps = [
{ name: "Deploy webhook", icon: Webhook },
{ name: "Calendar sync", icon: CalendarDays },
{ name: "Release bot", icon: Bot },
];
export default function Avatar02() {
return (
<div className="grid w-full max-w-lg gap-6 sm:grid-cols-3">
<section className="flex flex-col gap-3">
<div className="flex gap-2">
{people.map((person) => (
<Avatar key={person.name} size="lg">
<AvatarImage src="/placeholder.svg" alt={person.name} />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
))}
</div>
<div>
<h3 className="text-sm font-medium">Circle</h3>
<p className="text-xs text-muted-foreground">People and members</p>
</div>
</section>
<section className="flex flex-col gap-3">
<div className="flex gap-2">
{workspaces.map((workspace, index) => (
<Avatar
key={workspace.name}
size="lg"
role="img"
aria-label={workspace.name}
className="rounded-xl after:rounded-xl"
>
<AvatarFallback
className={
index === 0
? "rounded-xl bg-primary font-medium text-primary-foreground"
: "rounded-xl bg-secondary font-medium text-secondary-foreground"
}
>
{workspace.initials}
</AvatarFallback>
</Avatar>
))}
</div>
<div>
<h3 className="text-sm font-medium">Rounded</h3>
<p className="text-xs text-muted-foreground">Teams and workspaces</p>
</div>
</section>
<section className="flex flex-col gap-3">
<div className="flex gap-2">
{apps.map((app) => (
<Avatar
key={app.name}
size="lg"
role="img"
aria-label={app.name}
className="rounded-md after:rounded-md"
>
<AvatarFallback
className="rounded-md bg-muted text-foreground"
>
<app.icon aria-hidden="true" className="size-5" />
</AvatarFallback>
</Avatar>
))}
</div>
<div>
<h3 className="text-sm font-medium">Square</h3>
<p className="text-xs text-muted-foreground">Apps and bots</p>
</div>
</section>
</div>
);
}
npx shadcn@latest add @sevenui/component/avatar-02pnpm dlx shadcn@latest add @sevenui/component/avatar-02yarn dlx shadcn@latest add @sevenui/component/avatar-02bunx --bun shadcn@latest add @sevenui/component/avatar-02Tints are derived from each name, so a teammate keeps the same color in every list, thread, and mention.
"use client";
import { UserX } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const members = [
"Amara Nwosu",
"Lucas Brandt",
"Hana Sato",
"Mateo Alvarez",
"Ingrid Holm",
"Ravi Chandran",
];
const tints = [
"bg-chart-1/20 text-foreground",
"bg-chart-2/20 text-foreground",
"bg-chart-3/20 text-foreground",
"bg-chart-4/25 text-foreground",
"bg-chart-5/20 text-foreground",
];
function getInitials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
// Same name, same tint: a stable hash keeps colors consistent across screens.
function getTint(name: string) {
let hash = 0;
for (const char of name) {
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
}
return tints[hash % tints.length];
}
export default function Avatar03() {
return (
<TooltipProvider>
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-wrap gap-2">
{members.map((name) => (
<Tooltip key={name}>
<TooltipTrigger
aria-label={name}
className="rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Avatar size="lg">
<AvatarFallback className={`font-medium ${getTint(name)}`}>
{getInitials(name)}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
))}
<Tooltip>
<TooltipTrigger
aria-label="Deactivated account"
className="rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Avatar size="lg" className="after:border-dashed">
<AvatarFallback className="bg-transparent">
<UserX aria-hidden="true" className="size-4" />
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>Deactivated account</TooltipContent>
</Tooltip>
</div>
<p className="text-sm text-muted-foreground">
Tints are derived from each name, so a teammate keeps the same color
in every list, thread, and mention.
</p>
</div>
</TooltipProvider>
);
}
npx shadcn@latest add @sevenui/component/avatar-03pnpm dlx shadcn@latest add @sevenui/component/avatar-03yarn dlx shadcn@latest add @sevenui/component/avatar-03bunx --bun shadcn@latest add @sevenui/component/avatar-03Elena Petrova
Online · Active now
Tomás Ferreira
Away · Back at 2:30 PM
Aisha Rahman
Do not disturb · Focus time until 4:00 PM
Jonas Weber
Offline · Last seen 2 hours ago
Icon badges for verified, owner, and snoozed accounts.
"use client";
import { BadgeCheck, Crown, Moon } from "lucide-react";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
const presence = [
{
name: "Elena Petrova",
initials: "EP",
status: "Online",
detail: "Active now",
badge: "bg-success",
},
{
name: "Tomás Ferreira",
initials: "TF",
status: "Away",
detail: "Back at 2:30 PM",
badge: "bg-warning",
},
{
name: "Aisha Rahman",
initials: "AR",
status: "Do not disturb",
detail: "Focus time until 4:00 PM",
badge: "bg-destructive",
},
{
name: "Jonas Weber",
initials: "JW",
status: "Offline",
detail: "Last seen 2 hours ago",
badge: "bg-muted-foreground",
},
];
const iconBadge =
"group-data-[size=lg]/avatar:size-4 group-data-[size=lg]/avatar:[&>svg]:size-3";
export default function Avatar04() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
<ul className="flex flex-col gap-3">
{presence.map((person) => (
<li key={person.name} className="flex items-center gap-3">
<Avatar>
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>{person.initials}</AvatarFallback>
<AvatarBadge aria-hidden="true" className={person.badge} />
</Avatar>
<div className="min-w-0 flex-1 text-sm">
<p className="truncate font-medium">{person.name}</p>
<p className="truncate text-xs text-muted-foreground">
<span className="text-foreground">{person.status}</span> ·{" "}
{person.detail}
</p>
</div>
</li>
))}
</ul>
<div className="flex items-center gap-5 border-t border-border pt-4">
<Avatar size="lg">
<AvatarFallback>KM</AvatarFallback>
<AvatarBadge role="img" aria-label="Verified" className={iconBadge}>
<BadgeCheck aria-hidden="true" />
</AvatarBadge>
</Avatar>
<Avatar size="lg">
<AvatarFallback>LB</AvatarFallback>
<AvatarBadge
role="img"
aria-label="Workspace owner"
className={`bg-warning text-warning-foreground ${iconBadge}`}
>
<Crown aria-hidden="true" />
</AvatarBadge>
</Avatar>
<Avatar size="lg">
<AvatarFallback>NS</AvatarFallback>
<AvatarBadge
role="img"
aria-label="Notifications paused"
className={`bg-secondary text-secondary-foreground ${iconBadge}`}
>
<Moon aria-hidden="true" />
</AvatarBadge>
</Avatar>
<p className="text-xs text-muted-foreground">
Icon badges for verified, owner, and snoozed accounts.
</p>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/avatar-04pnpm dlx shadcn@latest add @sevenui/component/avatar-04yarn dlx shadcn@latest add @sevenui/component/avatar-04bunx --bun shadcn@latest add @sevenui/component/avatar-04Fetching the photo. The fallback waits 600 ms to avoid a flash.
"use client";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Status = "idle" | "loading" | "loaded" | "error";
const sources = [
{ value: "valid", label: "Valid image", src: "/placeholder.svg" },
// An undecodable data URI fails like a dead link, without a network 404.
{ value: "broken", label: "Broken URL", src: "data:image/png;base64,broken" },
{ value: "none", label: "No image", src: undefined },
];
const messages: Record<Status, string> = {
idle: "No image set. Initials render right away.",
loading: "Fetching the photo. The fallback waits 600 ms to avoid a flash.",
loaded: "Photo loaded and faded in.",
error: "The photo failed to load, so initials took its place.",
};
const statusLabel: Record<Status, string> = {
idle: "Idle",
loading: "Loading",
loaded: "Loaded",
error: "Error",
};
const statusVariant: Record<Status, "secondary" | "outline" | "destructive"> =
{
idle: "outline",
loading: "secondary",
loaded: "secondary",
error: "destructive",
};
export default function Avatar05() {
const [source, setSource] = React.useState("valid");
const [status, setStatus] = React.useState<Status>("loading");
const current = sources.find((item) => item.value === source) ?? sources[0];
function handleSourceChange(value: string[]) {
const next = value[0];
if (!next || next === source) return;
const nextSource = sources.find((item) => item.value === next);
setSource(next);
setStatus(nextSource?.src ? "loading" : "idle");
}
return (
<div className="flex w-full max-w-sm flex-col items-center gap-5">
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
value={[source]}
onValueChange={handleSourceChange}
aria-label="Image source"
>
{sources.map((item) => (
<ToggleGroupItem key={item.value} value={item.value}>
{item.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<Avatar
key={source}
className={`size-20 ${status === "loading" ? "animate-pulse bg-muted" : ""}`}
>
{current.src ? (
<AvatarImage
src={current.src}
alt="Priya Raman"
onLoadingStatusChange={setStatus}
className="transition-opacity duration-300 data-[starting-style]:opacity-0"
/>
) : null}
<AvatarFallback delay={current.src ? 600 : 0} className="text-xl">
PR
</AvatarFallback>
</Avatar>
<div
className="flex flex-col items-center gap-2 text-center"
aria-live="polite"
>
<Badge variant={statusVariant[status]}>{statusLabel[status]}</Badge>
<p className="text-sm text-balance text-muted-foreground">
{messages[status]}
</p>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/avatar-05pnpm dlx shadcn@latest add @sevenui/component/avatar-05yarn dlx shadcn@latest add @sevenui/component/avatar-05bunx --bun shadcn@latest add @sevenui/component/avatar-05Compact
Table cells
Default
Lists and headers
Loose
Cards and panels
Fan out
Spreads on hover, focus, or tap
"use client";
import * as React from "react";
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@/components/ui/avatar";
const team = [
{ name: "Olivia Chen", initials: "OC" },
{ name: "Marcus Reid", initials: "MR" },
{ name: "Leila Haddad", initials: "LH" },
{ name: "Sven Karlsson", initials: "SK" },
{ name: "Nora Quinn", initials: "NQ" },
];
const rows = [
{
label: "Compact",
hint: "Table cells",
size: "sm" as const,
group: "-space-x-2",
count: "+9",
},
{
label: "Default",
hint: "Lists and headers",
size: "default" as const,
group: "-space-x-1.5",
count: "+9",
},
{
label: "Loose",
hint: "Cards and panels",
size: "lg" as const,
group: "-space-x-1",
count: "+9",
},
];
export default function Avatar06() {
// Hover and focus spread the stack on desktop; a tap toggles it on touch.
const [spread, setSpread] = React.useState(false);
return (
<div className="flex w-full max-w-md flex-col divide-y divide-border">
{rows.map((row) => (
<div
key={row.label}
className="flex items-center justify-between gap-3 py-3 sm:gap-4"
>
<div className="min-w-0">
<p className="text-sm font-medium">{row.label}</p>
<p className="text-xs text-muted-foreground">{row.hint}</p>
</div>
<AvatarGroup className={`shrink-0 ${row.group}`}>
{team.slice(0, 4).map((person, index) => (
<Avatar
key={person.name}
size={row.size}
className={index === 3 ? "max-sm:hidden" : undefined}
>
<AvatarImage src="/placeholder.svg" alt={person.name} />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
))}
<AvatarGroupCount className="text-xs font-medium tabular-nums">
{row.count}
<span className="sr-only"> more collaborators</span>
</AvatarGroupCount>
</AvatarGroup>
</div>
))}
<div className="flex items-center justify-between gap-3 py-3 sm:gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">Fan out</p>
<p className="text-xs text-muted-foreground">
Spreads on hover, focus, or tap
</p>
</div>
<button
type="button"
aria-label="View all 5 collaborators"
aria-pressed={spread}
onClick={() => setSpread((current) => !current)}
className="group/fan shrink-0 rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span
className={`flex ${spread ? "-space-x-0.5" : "-space-x-3"} group-hover/fan:-space-x-0.5 group-focus-visible/fan:-space-x-0.5 *:transition-[margin] *:duration-300 *:ease-out motion-reduce:*:transition-none`}
>
{team.map((person) => (
<Avatar key={person.name} className="ring-2 ring-background">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
))}
</span>
</button>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/avatar-06pnpm dlx shadcn@latest add @sevenui/component/avatar-06yarn dlx shadcn@latest add @sevenui/component/avatar-06bunx --bun shadcn@latest add @sevenui/component/avatar-06Grace Adeyemi
grace@northwind.io
Grace Adeyemi
Staff engineer · Platform
Grace Adeyemi
Signed in · Pro plan
Assigned toGAGrace Adeyemiby Tomás
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
export default function Avatar07() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>GA</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">Grace Adeyemi</p>
<p className="truncate text-sm text-muted-foreground">
grace@northwind.io
</p>
</div>
</div>
<Separator />
<div className="flex flex-col items-center gap-2 text-center">
<Avatar className="size-16">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback className="text-lg">GA</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">Grace Adeyemi</p>
<p className="text-xs text-muted-foreground">
Staff engineer · Platform
</p>
</div>
</div>
<Separator />
<div className="flex flex-row-reverse items-center gap-3 text-right">
<Avatar size="lg">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>GA</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">Grace Adeyemi</p>
<p className="truncate text-xs text-muted-foreground">
Signed in · Pro plan
</p>
</div>
</div>
<Separator />
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm text-muted-foreground">
Assigned to
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted py-0.5 pr-2.5 pl-0.5 font-medium text-foreground">
<Avatar size="sm">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>GA</AvatarFallback>
</Avatar>
Grace Adeyemi
</span>
by Tomás
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/avatar-07pnpm dlx shadcn@latest add @sevenui/component/avatar-07yarn dlx shadcn@latest add @sevenui/component/avatar-07bunx --bun shadcn@latest add @sevenui/component/avatar-073 new
Select an update to mark it as viewed. The gradient ring fades to a hairline.
"use client";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
type RingState = "unseen" | "seen" | "live";
const updates: {
id: string;
name: string;
initials: string;
state: RingState;
}[] = [
{ id: "ines", name: "Inès Moreau", initials: "IM", state: "live" },
{ id: "kofi", name: "Kofi Mensah", initials: "KM", state: "unseen" },
{ id: "yuki", name: "Yuki Tanaka", initials: "YT", state: "unseen" },
{ id: "arjun", name: "Arjun Mehta", initials: "AM", state: "unseen" },
{ id: "freya", name: "Freya Lund", initials: "FL", state: "seen" },
];
const ringClass: Record<RingState, string> = {
unseen: "bg-linear-to-tr from-chart-1 via-chart-4 to-chart-2",
seen: "bg-border",
live: "bg-destructive",
};
const stateLabel: Record<RingState, string> = {
unseen: "new update",
seen: "viewed",
live: "live now",
};
export default function Avatar08() {
const [viewed, setViewed] = React.useState<string[]>([]);
function markViewed(id: string) {
setViewed((current) => (current.includes(id) ? current : [...current, id]));
}
const remaining = updates.filter(
(item) => item.state === "unseen" && !viewed.includes(item.id),
).length;
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-medium">Team updates</h3>
<p className="text-xs text-muted-foreground tabular-nums" aria-live="polite">
{remaining === 0 ? "All caught up" : `${remaining} new`}
</p>
</div>
<ul className="-mx-1 flex gap-3 overflow-x-auto px-1 pt-1 pb-2">
{updates.map((item) => {
const state: RingState =
item.state === "unseen" && viewed.includes(item.id)
? "seen"
: item.state;
return (
<li key={item.id} className="shrink-0">
<button
type="button"
onClick={() => markViewed(item.id)}
aria-label={`${item.name}, ${stateLabel[state]}`}
className="group/ring flex w-16 flex-col items-center gap-1.5 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="relative">
<span
className={`block rounded-full p-0.5 transition-colors duration-300 ${ringClass[state]} ${state === "live" ? "motion-safe:animate-pulse" : ""}`}
>
<span className="block rounded-full bg-background p-0.5">
<Avatar className="size-12 transition-transform duration-200 ease-out after:hidden group-hover/ring:scale-95 motion-reduce:transition-none">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback className="font-medium">
{item.initials}
</AvatarFallback>
</Avatar>
</span>
</span>
{state === "live" ? (
<span
aria-hidden="true"
className="absolute -bottom-1 left-1/2 -translate-x-1/2 rounded-sm bg-destructive px-1 text-[10px] leading-4 font-semibold tracking-wide text-background uppercase ring-2 ring-background"
>
Live
</span>
) : null}
</span>
<span
className={`w-full truncate text-center text-xs ${state === "seen" ? "text-muted-foreground" : "font-medium"}`}
>
{item.name.split(" ")[0]}
</span>
</button>
</li>
);
})}
</ul>
<p className="text-xs text-muted-foreground">
Select an update to mark it as viewed. The gradient ring fades to a
hairline.
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/avatar-08pnpm dlx shadcn@latest add @sevenui/component/avatar-08yarn dlx shadcn@latest add @sevenui/component/avatar-08bunx --bun shadcn@latest add @sevenui/component/avatar-08The export job times out on workspaces with more than 40k rows. I traced it to the CSV writer buffering everything in memory.
Confirmed on staging. Streaming the rows in 5k chunks brings the Acme export down from 94s to 11s.
"use client";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
type Comment = {
id: number;
author: string;
initials: string;
image?: string;
role?: string;
time: string;
body: string;
};
const initialComments: Comment[] = [
{
id: 1,
author: "Priya Raman",
initials: "PR",
image: "/placeholder.svg",
role: "Author",
time: "2h ago",
body: "The export job times out on workspaces with more than 40k rows. I traced it to the CSV writer buffering everything in memory.",
},
{
id: 2,
author: "Marcus Webb",
initials: "MW",
time: "1h ago",
body: "Confirmed on staging. Streaming the rows in 5k chunks brings the Acme export down from 94s to 11s.",
},
];
export default function Avatar09() {
const [comments, setComments] = React.useState(initialComments);
const [draft, setDraft] = React.useState("");
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = draft.trim();
if (!body) return;
setComments((current) => [
...current,
{
id: current.length + 1,
author: "Elena Cruz",
initials: "EC",
time: "Just now",
body,
},
]);
setDraft("");
}
return (
<section
aria-labelledby="avatar-09-title"
className="flex w-full max-w-md flex-col gap-5"
>
<h3 id="avatar-09-title" className="text-sm font-medium">
Discussion
<span className="ml-1.5 text-muted-foreground tabular-nums">
{comments.length}
</span>
</h3>
<ol className="relative flex flex-col gap-5 before:absolute before:top-2 before:bottom-2 before:left-4 before:w-px before:bg-border">
{comments.map((comment) => (
<li key={comment.id} className="relative flex gap-3">
<Avatar className="ring-4 ring-background">
{comment.image && (
<AvatarImage src={comment.image} alt="" />
)}
<AvatarFallback>{comment.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="font-medium">{comment.author}</span>
{comment.role && (
<Badge variant="outline" className="h-4.5 px-1.5">
{comment.role}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{comment.time}
</span>
</div>
<p className="text-sm leading-relaxed text-pretty text-muted-foreground">
{comment.body}
</p>
</div>
</li>
))}
</ol>
<form onSubmit={handleSubmit} className="flex gap-3">
<Avatar>
<AvatarFallback className="bg-primary text-primary-foreground">
EC
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<label htmlFor="avatar-09-reply" className="sr-only">
Write a reply
</label>
<Textarea
id="avatar-09-reply"
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Reply to the thread…"
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={!draft.trim()}>
Comment
</Button>
</div>
</div>
</form>
</section>
);
}
npx shadcn@latest add @sevenui/component/avatar-09pnpm dlx shadcn@latest add @sevenui/component/avatar-09yarn dlx shadcn@latest add @sevenui/component/avatar-09bunx --bun shadcn@latest add @sevenui/component/avatar-09Pull requests reviewed and merged
12NKYou3 more merges to reach the top 63
"use client";
import * as React from "react";
import { cn } from "cn";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Period = "week" | "month";
type Contributor = {
name: string;
initials: string;
image?: string;
team: string;
merged: number;
};
const boards: Record<Period, Contributor[]> = {
week: [
{
name: "Adaeze Okonkwo",
initials: "AO",
image: "/placeholder.svg",
team: "Payments",
merged: 14,
},
{ name: "Rafael Souza", initials: "RS", team: "Platform", merged: 11 },
{
name: "Mei Lin",
initials: "ML",
image: "/placeholder.svg",
team: "Mobile",
merged: 9,
},
{ name: "Tobias Klein", initials: "TK", team: "Platform", merged: 7 },
{ name: "Farah Siddiqui", initials: "FS", team: "Growth", merged: 6 },
{
name: "Liam O'Connor",
initials: "LO",
image: "/placeholder.svg",
team: "Payments",
merged: 5,
},
],
month: [
{ name: "Rafael Souza", initials: "RS", team: "Platform", merged: 46 },
{
name: "Adaeze Okonkwo",
initials: "AO",
image: "/placeholder.svg",
team: "Payments",
merged: 41,
},
{ name: "Farah Siddiqui", initials: "FS", team: "Growth", merged: 33 },
{
name: "Mei Lin",
initials: "ML",
image: "/placeholder.svg",
team: "Mobile",
merged: 29,
},
{
name: "Liam O'Connor",
initials: "LO",
image: "/placeholder.svg",
team: "Payments",
merged: 24,
},
{ name: "Tobias Klein", initials: "TK", team: "Platform", merged: 22 },
],
};
const you: Record<Period, { rank: number; merged: number }> = {
week: { rank: 12, merged: 3 },
month: { rank: 9, merged: 17 },
};
// DOM order stays 1-2-3 for screen readers; CSS order lifts first place
// into the middle of the podium.
const podium = [
{
place: 1,
avatar: "data-[size=lg]:size-18 ring-2 ring-primary ring-offset-2 ring-offset-card",
step: "h-16",
badge: "bg-primary text-primary-foreground",
},
{
place: 2,
avatar: "data-[size=lg]:size-14",
step: "h-10",
badge: "bg-secondary text-secondary-foreground",
},
{
place: 3,
avatar: "data-[size=lg]:size-14",
step: "h-6",
badge: "bg-muted text-foreground",
},
];
export default function Avatar10() {
const [period, setPeriod] = React.useState<Period>("week");
const ranked = boards[period];
const me = you[period];
return (
<section
aria-labelledby="avatar-10-title"
className="flex w-full max-w-sm flex-col gap-5 rounded-xl border bg-card p-4 text-card-foreground"
>
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col">
<h3 id="avatar-10-title" className="text-sm font-medium">
Top reviewers
</h3>
<p className="text-xs text-muted-foreground">
Pull requests reviewed and merged
</p>
</div>
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
value={[period]}
onValueChange={(value) => {
if (value[0]) setPeriod(value[0] as Period);
}}
aria-label="Time period"
>
<ToggleGroupItem value="week">Week</ToggleGroupItem>
<ToggleGroupItem value="month">Month</ToggleGroupItem>
</ToggleGroup>
</header>
<ol aria-label="Podium" className="grid grid-cols-3 items-end gap-2">
{podium.map((slot) => {
const person = ranked[slot.place - 1];
return (
<li
key={slot.place}
className={cn(
"flex min-w-0 flex-col items-center gap-2",
slot.place === 1 && "order-2",
slot.place === 2 && "order-1",
slot.place === 3 && "order-3",
)}
>
<Avatar size="lg" className={slot.avatar}>
{person.image && <AvatarImage src={person.image} alt="" />}
<AvatarFallback className="text-base font-medium">
{person.initials}
</AvatarFallback>
<AvatarBadge
aria-hidden="true"
className={cn(
"-right-0.5 -bottom-0.5 size-5! text-[0.7rem] font-semibold tabular-nums",
slot.badge,
)}
>
{slot.place}
</AvatarBadge>
</Avatar>
<div className="flex w-full min-w-0 flex-col items-center text-center">
<span className="w-full truncate text-xs font-medium">
<span className="sr-only">Rank {slot.place}: </span>
{person.name.split(" ")[0]}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{person.merged} merged
</span>
</div>
<div
aria-hidden="true"
className={cn(
"w-full rounded-t-md bg-muted transition-[height] duration-300 ease-out motion-reduce:transition-none",
slot.step,
)}
/>
</li>
);
})}
</ol>
<ol
aria-label="Runners-up"
className="-mt-3 flex flex-col divide-y border-t"
>
{ranked.slice(3).map((person, index) => (
<li key={person.name} className="flex items-center gap-3 py-2.5">
<span className="w-5 text-right text-xs text-muted-foreground tabular-nums">
{index + 4}
</span>
<Avatar>
{person.image && <AvatarImage src={person.image} alt="" />}
<AvatarFallback className="text-xs">
{person.initials}
</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{person.name}</span>
<span className="text-xs text-muted-foreground">
{person.team}
</span>
</span>
<span className="text-sm tabular-nums">{person.merged}</span>
</li>
))}
</ol>
<p className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2.5">
<span className="w-5 text-right text-xs text-muted-foreground tabular-nums">
{me.rank}
</span>
<Avatar>
<AvatarFallback className="bg-primary text-xs text-primary-foreground">
NK
</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">You</span>
<span className="text-xs text-muted-foreground">
{ranked[5].merged - me.merged + 1} more merges to reach the top 6
</span>
</span>
<span className="text-sm font-medium tabular-nums">{me.merged}</span>
</p>
</section>
);
}
npx shadcn@latest add @sevenui/component/avatar-10pnpm dlx shadcn@latest add @sevenui/component/avatar-10yarn dlx shadcn@latest add @sevenui/component/avatar-10bunx --bun shadcn@latest add @sevenui/component/avatar-10Shown on your comments, mentions, and in the team directory.
Square images work best. PNG, JPG, or WebP up to 2 MB.
"use client";
import * as React from "react";
import { Trash2Icon, UploadIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
const maxBytes = 2 * 1024 * 1024;
export default function Avatar11() {
const inputRef = React.useRef<HTMLInputElement>(null);
const [photo, setPhoto] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
// Revoke the previous object URL whenever the photo changes or unmounts.
React.useEffect(() => {
return () => {
if (photo?.startsWith("blob:")) URL.revokeObjectURL(photo);
};
}, [photo]);
function handleFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("That file isn't an image. Choose a PNG, JPG, or WebP.");
return;
}
if (file.size > maxBytes) {
setError("That image is over 2 MB. Choose a smaller one.");
return;
}
setError(null);
setPhoto(URL.createObjectURL(file));
}
return (
<section
aria-labelledby="avatar-11-title"
className="flex w-full max-w-md flex-col gap-4 rounded-xl border bg-card p-5 text-card-foreground"
>
<div className="flex flex-col gap-1">
<h3 id="avatar-11-title" className="text-sm font-medium">
Profile photo
</h3>
<p className="text-sm text-muted-foreground">
Shown on your comments, mentions, and in the team directory.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<Avatar className="size-16">
{photo && <AvatarImage src={photo} alt="Your profile photo" />}
<AvatarFallback className="text-lg font-medium">AM</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => inputRef.current?.click()}
>
<UploadIcon data-icon="inline-start" aria-hidden="true" />
{photo ? "Replace photo" : "Upload photo"}
</Button>
{photo && (
<Button
size="sm"
variant="ghost"
onClick={() => setPhoto(null)}
className="text-destructive hover:text-destructive"
>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Remove
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
Square images work best. PNG, JPG, or WebP up to 2 MB.
</p>
</div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFile}
className="sr-only"
tabIndex={-1}
aria-label="Profile photo file"
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</section>
);
}
npx shadcn@latest add @sevenui/component/avatar-11pnpm dlx shadcn@latest add @sevenui/component/avatar-11yarn dlx shadcn@latest add @sevenui/component/avatar-11bunx --bun shadcn@latest add @sevenui/component/avatar-11"use client";
import * as React from "react";
import {
AtSignIcon,
GitMergeIcon,
HeartIcon,
MessageSquareIcon,
UserPlusIcon,
} from "lucide-react";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
const kinds = {
mention: { icon: AtSignIcon, label: "Mention", tone: "bg-chart-1" },
comment: { icon: MessageSquareIcon, label: "Comment", tone: "bg-chart-2" },
merge: { icon: GitMergeIcon, label: "Merged", tone: "bg-chart-4" },
like: { icon: HeartIcon, label: "Reaction", tone: "bg-destructive" },
join: { icon: UserPlusIcon, label: "New member", tone: "bg-success" },
} as const;
const initialNotifications = [
{
id: 1,
kind: "mention" as const,
actor: "Nadia Haddad",
initials: "NH",
image: "/placeholder.svg",
text: "mentioned you in Q3 pricing review",
time: "4m",
unread: true,
},
{
id: 2,
kind: "merge" as const,
actor: "Owen Fitzgerald",
initials: "OF",
text: "merged “Fix invoice rounding” into main",
time: "32m",
unread: true,
},
{
id: 3,
kind: "comment" as const,
actor: "Mei Tanaka",
initials: "MT",
image: "/placeholder.svg",
text: "replied: “Let's ship the flag to 10% first.”",
time: "1h",
unread: true,
},
{
id: 4,
kind: "like" as const,
actor: "Carlos Mendes",
initials: "CM",
text: "reacted to your release notes",
time: "3h",
unread: false,
},
{
id: 5,
kind: "join" as const,
actor: "Ingrid Solberg",
initials: "IS",
text: "joined the Growth workspace",
time: "Yesterday",
unread: false,
},
];
export default function Avatar12() {
const [notifications, setNotifications] =
React.useState(initialNotifications);
const unreadCount = notifications.filter((item) => item.unread).length;
function markRead(id: number) {
setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, unread: false } : item)),
);
}
return (
<section
aria-labelledby="avatar-12-title"
className="w-full max-w-sm overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md"
>
<header className="flex items-center justify-between gap-2 border-b px-4 py-3">
<h3 id="avatar-12-title" className="text-sm font-medium">
Notifications
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-xs text-primary-foreground tabular-nums">
{unreadCount}
<span className="sr-only"> unread</span>
</span>
)}
</h3>
<Button
variant="ghost"
size="xs"
disabled={unreadCount === 0}
onClick={() =>
setNotifications((current) =>
current.map((item) => ({ ...item, unread: false })),
)
}
>
Mark all as read
</Button>
</header>
<ul className="max-h-96 divide-y overflow-y-auto">
{notifications.map((item) => {
const kind = kinds[item.kind];
const Icon = kind.icon;
return (
<li key={item.id}>
<button
type="button"
onClick={() => markRead(item.id)}
className="flex w-full items-start gap-3 px-4 py-3 text-left outline-none hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
>
<Avatar size="lg">
{item.image && <AvatarImage src={item.image} alt="" />}
<AvatarFallback>{item.initials}</AvatarFallback>
<AvatarBadge
className={`-right-1 -bottom-1 size-5! text-background ${kind.tone} [&>svg]:size-3!`}
>
<Icon aria-hidden="true" />
</AvatarBadge>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm leading-snug">
<span className="font-medium">{item.actor}</span>{" "}
<span className="text-muted-foreground">{item.text}</span>
</span>
<span className="text-xs text-muted-foreground">
{kind.label} · {item.time}
</span>
</span>
{item.unread && (
<span
className="mt-1.5 size-2 shrink-0 rounded-full bg-primary"
role="img"
aria-label="Unread"
/>
)}
</button>
</li>
);
})}
</ul>
</section>
);
}
npx shadcn@latest add @sevenui/component/avatar-12pnpm dlx shadcn@latest add @sevenui/component/avatar-12yarn dlx shadcn@latest add @sevenui/component/avatar-12bunx --bun shadcn@latest add @sevenui/component/avatar-12Wed, 15:00 – 15:45 CEST
Video call
4 going
"use client";
import * as React from "react";
import {
CheckIcon,
ClockIcon,
HelpCircleIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type Rsvp = "yes" | "maybe" | "no";
type Attendee = {
name: string;
initials: string;
image?: string;
rsvp: Rsvp;
};
const statusStyle: Record<
Rsvp,
{ icon: typeof CheckIcon; label: string; tone: string }
> = {
yes: {
icon: CheckIcon,
label: "Going",
tone: "bg-success text-success-foreground",
},
maybe: {
icon: HelpCircleIcon,
label: "Maybe",
tone: "bg-warning text-warning-foreground",
},
no: {
icon: XIcon,
label: "Declined",
tone: "bg-muted-foreground text-background",
},
};
const attendees: Attendee[] = [
{ name: "Aisha Bello", initials: "AB", image: "/placeholder.svg", rsvp: "yes" },
{ name: "Lukas Brandt", initials: "LB", rsvp: "yes" },
{ name: "Yuki Sato", initials: "YS", image: "/placeholder.svg", rsvp: "maybe" },
{ name: "Omar Farouk", initials: "OF", rsvp: "no" },
{ name: "Clara Jensen", initials: "CJ", rsvp: "yes" },
{ name: "Mateo Rossi", initials: "MR", rsvp: "maybe" },
{ name: "Freya Olsen", initials: "FO", rsvp: "yes" },
];
const visible = 5;
export default function Avatar13() {
const [myRsvp, setMyRsvp] = React.useState<Rsvp | null>(null);
const [joined, setJoined] = React.useState(false);
const everyone: Attendee[] = myRsvp
? [{ name: "You", initials: "ME", rsvp: myRsvp }, ...attendees]
: attendees;
const going = everyone.filter((person) => person.rsvp === "yes").length;
const hidden = everyone.slice(visible);
return (
<article className="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-card p-4 text-card-foreground">
<div className="flex gap-3">
<div className="flex w-12 shrink-0 flex-col items-center overflow-hidden rounded-lg border text-center">
<span className="w-full bg-primary py-0.5 text-[0.65rem] font-medium tracking-wide text-primary-foreground uppercase">
Oct
</span>
<span className="py-1 text-lg font-semibold tabular-nums">14</span>
</div>
<div className="flex min-w-0 flex-col gap-1">
<h3 className="text-sm font-medium">Mobile app launch retro</h3>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ClockIcon className="size-3.5" aria-hidden="true" />
Wed, 15:00 – 15:45 CEST
</p>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<VideoIcon className="size-3.5" aria-hidden="true" />
Video call
</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<TooltipProvider delay={200}>
<AvatarGroup role="group" aria-label={`${everyone.length} invited`} className="shrink-0">
{everyone.slice(0, visible).map((person) => {
const status = statusStyle[person.rsvp];
const Icon = status.icon;
return (
<Tooltip key={person.name}>
<TooltipTrigger
render={<Avatar size="lg" tabIndex={0} />}
aria-label={`${person.name}, ${status.label}`}
className="outline-none focus-visible:ring-ring/60!"
>
{person.image && (
<AvatarImage src={person.image} alt="" />
)}
<AvatarFallback>{person.initials}</AvatarFallback>
<AvatarBadge className={`size-4! ${status.tone} [&>svg]:size-2.5!`}>
<Icon aria-hidden="true" strokeWidth={3} />
</AvatarBadge>
</TooltipTrigger>
<TooltipContent>
{person.name} · {status.label}
</TooltipContent>
</Tooltip>
);
})}
{hidden.length > 0 && (
<Tooltip>
<TooltipTrigger
render={<AvatarGroupCount tabIndex={0} />}
aria-label={`${hidden.length} more: ${hidden.map((p) => p.name).join(", ")}`}
className="size-10 text-xs outline-none"
>
+{hidden.length}
</TooltipTrigger>
<TooltipContent>
{hidden.map((person) => person.name).join(", ")}
</TooltipContent>
</Tooltip>
)}
</AvatarGroup>
</TooltipProvider>
<p className="shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
<span className="font-medium text-foreground tabular-nums">{going}</span>{" "}
going
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3">
<span className="text-xs text-muted-foreground">Will you attend?</span>
<ToggleGroup
variant="outline"
size="sm"
value={myRsvp ? [myRsvp] : []}
onValueChange={(value) => {
const next = (value[0] as Rsvp | undefined) ?? null;
setMyRsvp(next);
if (next === "no") setJoined(false);
}}
aria-label="Your RSVP"
>
<ToggleGroupItem value="yes">Yes</ToggleGroupItem>
<ToggleGroupItem value="maybe">Maybe</ToggleGroupItem>
<ToggleGroupItem value="no">No</ToggleGroupItem>
</ToggleGroup>
</div>
<Button
className="w-full"
variant={joined ? "outline" : "default"}
disabled={myRsvp === "no"}
aria-pressed={joined}
onClick={() => setJoined((current) => !current)}
>
<VideoIcon data-icon="inline-start" aria-hidden="true" />
{joined ? "Leave call" : "Join call"}
</Button>
</article>
);
}
npx shadcn@latest add @sevenui/component/avatar-13pnpm dlx shadcn@latest add @sevenui/component/avatar-13yarn dlx shadcn@latest add @sevenui/component/avatar-13bunx --bun shadcn@latest add @sevenui/component/avatar-134 files
"use client";
import * as React from "react";
import {
FileSpreadsheetIcon,
FileTextIcon,
FileVideoIcon,
PresentationIcon,
} from "lucide-react";
import { cn } from "cn";
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@/components/ui/avatar";
type Person = { id: string; name: string; initials: string; image?: string };
const people: Person[] = [
{ id: "ib", name: "Isabel Brooks", initials: "IB", image: "/placeholder.svg" },
{ id: "kw", name: "Kenji Watanabe", initials: "KW" },
{ id: "ad", name: "Amara Diallo", initials: "AD", image: "/placeholder.svg" },
{ id: "pv", name: "Pieter de Vries", initials: "PV" },
{ id: "sl", name: "Selin Kaya", initials: "SK" },
];
const byId = Object.fromEntries(people.map((person) => [person.id, person]));
const files = [
{
name: "FY26 budget model.xlsx",
icon: FileSpreadsheetIcon,
owner: "ib",
shared: ["kw", "ad", "pv", "sl"],
edited: "12 min ago",
},
{
name: "Brand refresh pitch.key",
icon: PresentationIcon,
owner: "ad",
shared: ["ib", "sl"],
edited: "2 hours ago",
},
{
name: "Onboarding walkthrough.mp4",
icon: FileVideoIcon,
owner: "kw",
shared: ["pv"],
edited: "Yesterday",
},
{
name: "Vendor contract — Lumen.pdf",
icon: FileTextIcon,
owner: "pv",
shared: [],
edited: "Sep 18",
},
];
function PersonAvatar({
person,
size,
className,
}: {
person: Person;
size?: "sm" | "default";
className?: string;
}) {
return (
<Avatar size={size} className={className}>
{person.image && <AvatarImage src={person.image} alt="" />}
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
);
}
export default function Avatar14() {
const [ownerFilter, setOwnerFilter] = React.useState<string | null>(null);
const visibleFiles = ownerFilter
? files.filter((file) => file.owner === ownerFilter)
: files;
return (
<section
aria-labelledby="avatar-14-title"
className="flex w-full max-w-xl flex-col gap-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 id="avatar-14-title" className="text-sm font-medium">
Shared with me
</h3>
<fieldset className="flex items-center gap-2">
<legend className="sr-only">Filter by owner</legend>
<span className="text-xs text-muted-foreground" aria-hidden="true">
Owner
</span>
<div className="flex -space-x-1.5">
{people.slice(0, 4).map((person) => {
const active = ownerFilter === person.id;
return (
<button
key={person.id}
type="button"
aria-pressed={active}
aria-label={`Only files owned by ${person.name}`}
onClick={() => setOwnerFilter(active ? null : person.id)}
className={cn(
"rounded-full outline-none transition-[opacity,transform] hover:z-10 hover:-translate-y-0.5 focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-ring/50",
ownerFilter && !active && "opacity-40",
active && "z-10",
)}
>
<PersonAvatar
person={person}
className={cn(
"ring-2 ring-background",
active && "ring-primary",
)}
/>
</button>
);
})}
</div>
</fieldset>
</div>
<ul className="divide-y rounded-xl border bg-card text-card-foreground">
{visibleFiles.map((file) => {
const Icon = file.icon;
const owner = byId[file.owner];
const shared = file.shared.map((id) => byId[id]);
return (
<li key={file.name} className="flex items-center gap-3 px-3 py-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" aria-hidden="true" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{file.name}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<PersonAvatar person={owner} size="sm" className="size-4" />
<span className="truncate">
{owner.name} · {file.edited}
</span>
</span>
</div>
{shared.length > 0 ? (
<AvatarGroup
role="group"
className="hidden shrink-0 -space-x-1.5 sm:flex"
aria-label={`Shared with ${shared.map((p) => p.name).join(", ")}`}
>
{shared.slice(0, 2).map((person) => (
<PersonAvatar key={person.id} person={person} size="sm" />
))}
{shared.length > 2 && (
<AvatarGroupCount className="size-6 text-[0.65rem]">
+{shared.length - 2}
</AvatarGroupCount>
)}
</AvatarGroup>
) : (
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
Only owner
</span>
)}
</li>
);
})}
</ul>
<p className="text-xs text-muted-foreground" aria-live="polite">
{ownerFilter
? `${visibleFiles.length} of ${files.length} files owned by ${byId[ownerFilter].name}`
: `${files.length} files`}
</p>
</section>
);
}
npx shadcn@latest add @sevenui/component/avatar-14pnpm dlx shadcn@latest add @sevenui/component/avatar-14yarn dlx shadcn@latest add @sevenui/component/avatar-14bunx --bun shadcn@latest add @sevenui/component/avatar-14Step 2 of 3
Projects are more useful with the people who work on them. You can always invite more later.
4 of 5 seats open on the Starter plan
"use client";
import * as React from "react";
import { CheckIcon, ClockIcon, PlusIcon, XIcon } from "lucide-react";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type Invite = { email: string; name?: string; image?: string };
const seats = 5;
// Empty seats have no identity of their own, so each gets a fixed id.
const openSeatIds = Array.from({ length: seats - 1 }, (_, i) => `seat-${i + 2}`);
const suggestions: Invite[] = [
{
email: "noor.aziz@fernbank.co",
name: "Noor Aziz",
image: "/placeholder.svg",
},
{ email: "theo.laurent@fernbank.co", name: "Theo Laurent" },
{ email: "wanjiru.kamau@fernbank.co", name: "Wanjiru Kamau" },
];
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function initialsFor(invite: Invite) {
const source = invite.name ?? invite.email.split("@")[0];
return source
.split(/[\s._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");
}
export default function Avatar15() {
const [invites, setInvites] = React.useState<Invite[]>([]);
const [draft, setDraft] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
// Emails already sent; the list can still change, and new ones send again.
const [sent, setSent] = React.useState<string[]>([]);
const unsent = invites.filter((invite) => !sent.includes(invite.email));
const remaining = seats - 1 - invites.length;
const openSuggestions = suggestions.filter(
(suggestion) =>
!invites.some((invite) => invite.email === suggestion.email),
);
function add(invite: Invite) {
if (remaining <= 0) {
setError("All seats on the Starter plan are taken.");
return;
}
if (invites.some((item) => item.email === invite.email)) {
setError(`${invite.email} is already on the list.`);
return;
}
setInvites((current) => [...current, invite]);
setError(null);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const email = draft.trim().toLowerCase();
if (!emailPattern.test(email)) {
setError("Enter a full email address, like sam@fernbank.co.");
return;
}
add({ email });
setDraft("");
}
return (
<section
aria-labelledby="avatar-15-title"
className="flex w-full max-w-md flex-col gap-6 rounded-xl border bg-card p-5 text-card-foreground sm:p-6"
>
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted-foreground tabular-nums">
Step 2 of 3
</p>
<h3 id="avatar-15-title" className="text-lg font-semibold">
Invite your team to Fernbank
</h3>
<p className="text-sm text-pretty text-muted-foreground">
Projects are more useful with the people who work on them. You can
always invite more later.
</p>
</div>
<div className="flex flex-col gap-2">
<ul aria-label="Seats" className="flex flex-wrap items-center gap-2.5">
<li>
<Avatar className="size-11">
<AvatarImage src="/placeholder.svg" alt="You, Riya Shah" />
<AvatarFallback className="bg-primary text-primary-foreground">
RS
</AvatarFallback>
</Avatar>
</li>
{invites.map((invite) => (
<li key={invite.email} className="group/seat relative">
<Avatar className="size-11">
{invite.image && <AvatarImage src={invite.image} alt="" />}
<AvatarFallback className="font-medium">
{initialsFor(invite)}
</AvatarFallback>
<AvatarBadge className="size-4! bg-warning text-warning-foreground [&>svg]:size-2.5!">
<ClockIcon aria-hidden="true" strokeWidth={3} />
</AvatarBadge>
</Avatar>
<button
type="button"
onClick={() => {
setInvites((current) =>
current.filter((item) => item.email !== invite.email),
);
setSent((current) =>
current.filter((email) => email !== invite.email),
);
setError(null);
}}
aria-label={`Remove ${invite.name ?? invite.email}, invite pending`}
className="absolute -top-1 -right-1 z-20 flex size-4.5 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity outline-none group-hover/seat:opacity-100 hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50 pointer-coarse:opacity-100"
>
<XIcon className="size-3" aria-hidden="true" />
</button>
</li>
))}
{openSeatIds.slice(0, Math.max(remaining, 0)).map((id) => (
<li
key={id}
className="flex size-11 items-center justify-center rounded-full border border-dashed border-muted-foreground/40 text-muted-foreground/60"
>
<PlusIcon className="size-4" aria-hidden="true" />
<span className="sr-only">Open seat</span>
</li>
))}
</ul>
<p className="text-xs text-muted-foreground" aria-live="polite">
{remaining > 0
? `${remaining} of ${seats} seats open on the Starter plan`
: "Every seat is filled. Upgrade to add more people."}
</p>
</div>
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-2">
<label htmlFor="avatar-15-email" className="text-sm font-medium">
Email address
</label>
<div className="flex gap-2">
<Input
id="avatar-15-email"
type="email"
autoComplete="off"
placeholder="sam@fernbank.co"
value={draft}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "avatar-15-error" : undefined}
disabled={remaining <= 0}
/>
<Button type="submit" variant="outline" disabled={remaining <= 0}>
Add
</Button>
</div>
{error && (
<p id="avatar-15-error" role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</form>
{openSuggestions.length > 0 && remaining > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-xs text-muted-foreground">
Already at fernbank.co
</h4>
<ul className="-mx-2 flex flex-col">
{openSuggestions.map((suggestion) => (
<li
key={suggestion.email}
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
>
<Avatar>
{suggestion.image && (
<AvatarImage src={suggestion.image} alt="" />
)}
<AvatarFallback className="text-xs">
{initialsFor(suggestion)}
</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{suggestion.name}</span>
<span className="truncate text-xs text-muted-foreground">
{suggestion.email}
</span>
</span>
<Button
size="sm"
variant="ghost"
onClick={() => add(suggestion)}
aria-label={`Invite ${suggestion.name}`}
>
Invite
</Button>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<Button variant="ghost">Skip for now</Button>
{invites.length > 0 && unsent.length === 0 ? (
<p
role="status"
className="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<CheckIcon className="size-4 text-success" aria-hidden="true" />
{invites.length === 1 ? "Invite sent" : `${invites.length} invites sent`}
</p>
) : (
<Button
disabled={unsent.length === 0}
onClick={() =>
setSent((current) => [
...current,
...unsent.map((invite) => invite.email),
])
}
>
{unsent.length > 1
? `Send ${unsent.length} invites`
: unsent.length === 1
? "Send invite"
: "Send invites"}
</Button>
)}
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/avatar-15pnpm dlx shadcn@latest add @sevenui/component/avatar-15yarn dlx shadcn@latest add @sevenui/component/avatar-15bunx --bun shadcn@latest add @sevenui/component/avatar-154 others in this doc
"use client";
import * as React from "react";
import { EyeIcon, MousePointer2Icon, XIcon } from "lucide-react";
import { cn } from "cn";
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type Collaborator = {
id: string;
name: string;
initials: string;
image?: string;
section: string;
activity: string;
ring: string;
fill: string;
};
const sections = [
{ id: "summary", title: "Summary", words: 180 },
{ id: "goals", title: "Goals and non-goals", words: 420 },
{ id: "pricing", title: "Pricing changes", words: 610 },
{ id: "rollout", title: "Rollout plan", words: 350 },
{ id: "risks", title: "Open risks", words: 140 },
];
const collaborators: Collaborator[] = [
{
id: "mira",
name: "Mira Castellanos",
initials: "MC",
image: "/placeholder.svg",
section: "pricing",
activity: "Editing",
ring: "ring-chart-1",
fill: "bg-chart-1",
},
{
id: "dev",
name: "Dev Patel",
initials: "DP",
section: "pricing",
activity: "Commenting",
ring: "ring-chart-2",
fill: "bg-chart-2",
},
{
id: "hana",
name: "Hana Kim",
initials: "HK",
image: "/placeholder.svg",
section: "rollout",
activity: "Viewing",
ring: "ring-chart-4",
fill: "bg-chart-4",
},
{
id: "ollie",
name: "Ollie Brennan",
initials: "OB",
section: "summary",
activity: "Viewing",
ring: "ring-chart-5",
fill: "bg-chart-5",
},
];
function CollaboratorAvatar({
person,
className,
size,
}: {
person: Collaborator;
className?: string;
size?: "sm" | "default" | "lg";
}) {
return (
<Avatar size={size} className={className}>
{person.image && <AvatarImage src={person.image} alt="" />}
<AvatarFallback className="text-xs font-medium">
{person.initials}
</AvatarFallback>
</Avatar>
);
}
export default function Avatar16() {
const [followingId, setFollowingId] = React.useState<string | null>(null);
const following = collaborators.find((person) => person.id === followingId);
// Escape anywhere in the widget stops following, like in Figma or Docs.
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.key === "Escape" && followingId) setFollowingId(null);
}
return (
<section
aria-labelledby="avatar-16-title"
onKeyDown={handleKeyDown}
className={cn(
"flex w-full max-w-lg flex-col overflow-hidden rounded-xl border bg-card text-card-foreground ring-2 ring-transparent transition-shadow",
following?.ring,
)}
>
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex min-w-0 flex-col">
<h3 id="avatar-16-title" className="truncate text-sm font-medium">
RFC: Usage-based billing
</h3>
<p className="text-xs text-muted-foreground">
{collaborators.length} others in this doc
</p>
</div>
<TooltipProvider delay={150}>
<AvatarGroup
role="group"
aria-label="Collaborators. Select one to follow their view"
className="-space-x-1.5"
>
{collaborators.map((person) => {
const active = person.id === followingId;
return (
<Tooltip key={person.id}>
<TooltipTrigger
render={
<button
type="button"
aria-pressed={active}
aria-label={`Follow ${person.name}, ${person.activity.toLowerCase()}`}
onClick={() =>
setFollowingId(active ? null : person.id)
}
className={cn(
"relative rounded-full outline-none transition-transform hover:z-10 hover:-translate-y-0.5 focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-ring/60",
active && "z-10",
)}
/>
}
>
<CollaboratorAvatar
person={person}
className={cn(
"ring-2 ring-background",
active && ["ring-offset-2 ring-offset-card", person.ring],
)}
/>
</TooltipTrigger>
<TooltipContent>
{active ? "Stop following" : `Follow ${person.name}`}
</TooltipContent>
</Tooltip>
);
})}
</AvatarGroup>
</TooltipProvider>
</header>
<div aria-live="polite">
{following && (
<div className="flex items-center gap-2 border-b bg-muted/60 px-4 py-2 text-xs">
<span
className={cn("size-2 shrink-0 rounded-full", following.fill)}
aria-hidden="true"
/>
<EyeIcon
className="size-3.5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate">
Following {following.name.split(" ")[0]}
<span className="hidden sm:inline">
{" "}
· press Esc to stop
</span>
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setFollowingId(null)}
aria-label={`Stop following ${following.name}`}
>
<XIcon aria-hidden="true" />
</Button>
</div>
)}
</div>
<nav aria-label="Document outline" className="p-2">
<ol className="flex flex-col">
{sections.map((section) => {
const here = collaborators.filter(
(person) => person.section === section.id,
);
const isFollowed = following?.section === section.id;
const dimmed = following && !isFollowed;
return (
<li key={section.id}>
<a
href={`#${section.id}`}
onClick={(event) => event.preventDefault()}
aria-current={isFollowed ? "location" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm outline-none transition-[background-color,opacity] hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring/50",
isFollowed && "bg-muted",
dimmed && "opacity-45",
)}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">
{section.title}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{section.words} words
</span>
</span>
{here.length > 0 && (
<span className="flex items-center gap-2">
{isFollowed && following && (
<span className="hidden items-center gap-1 text-xs text-muted-foreground sm:flex">
<MousePointer2Icon
className="size-3"
aria-hidden="true"
/>
{following.activity}
</span>
)}
<AvatarGroup
className="-space-x-1"
aria-label={`Here now: ${here.map((person) => person.name).join(", ")}`}
>
{here.map((person) => (
<CollaboratorAvatar
key={person.id}
person={person}
size="sm"
className={cn(
person.id === followingId &&
["z-10 ring-2!", person.ring],
)}
/>
))}
</AvatarGroup>
</span>
)}
</a>
</li>
);
})}
</ol>
</nav>
</section>
);
}
npx shadcn@latest add @sevenui/component/avatar-16pnpm dlx shadcn@latest add @sevenui/component/avatar-16yarn dlx shadcn@latest add @sevenui/component/avatar-16bunx --bun shadcn@latest add @sevenui/component/avatar-16