Search for pages, actions, and quick links.
Directories, workload and the avatar patterns teams need. 7 blocks.
Who is over-committed, on a scale that makes the comparison possible.
src/components/blocks/people/people-workload.tsx"use client";
import { cn } from "@dashboardpack/core/lib/utils";
const TEAM = [
{ name: "Ada Lovelace", initials: "AL", role: "Engineering", assigned: 21, capacity: 24 },
{ name: "Grace Hopper", initials: "GH", role: "Engineering", assigned: 28, capacity: 24 },
{ name: "Alan Turing", initials: "AT", role: "Research", assigned: 18, capacity: 24 },
{ name: "Katherine Johnson", initials: "KJ", role: "Analysis", assigned: 12, capacity: 20 },
];
/**
* Who is over-committed.
*
* The bar is scaled to the largest capacity in the team, not to each person's own — otherwise
* someone at 12/20 and someone at 21/24 would draw nearly identical bars, and the comparison the
* list exists for becomes impossible. Over-capacity is called out in words as well as colour,
* because "over by 4" is the actionable part.
*/
export function PeopleWorkload() {
const scale = Math.max(...TEAM.map((person) => person.capacity));
return (
<ul className="space-y-3">
{TEAM.map((person) => {
const over = person.assigned > person.capacity;
return (
<li key={person.name} className="flex items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary">
{person.initials}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-medium">{person.name}</p>
<p
className={cn(
"shrink-0 text-xs tabular-nums",
over ? "font-semibold text-destructive" : "text-muted-foreground",
)}
>
{person.assigned}/{person.capacity}
{over && ` · over by ${person.assigned - person.capacity}`}
</p>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", over ? "bg-destructive" : "bg-primary")}
style={{ width: `${Math.min(100, (person.assigned / scale) * 100)}%` }}
/>
</div>
</div>
</li>
);
})}
</ul>
);
}Overlapping avatars with an overflow count and one accessible name.
src/components/blocks/people/people-avatar-stack.tsx"use client";
const MEMBERS = [
{ name: "Ada Lovelace", initials: "AL" },
{ name: "Grace Hopper", initials: "GH" },
{ name: "Alan Turing", initials: "AT" },
{ name: "Katherine Johnson", initials: "KJ" },
{ name: "Barbara Liskov", initials: "BL" },
{ name: "Edsger Dijkstra", initials: "ED" },
{ name: "Margaret Hamilton", initials: "MH" },
];
/**
* Overlapping avatars with an overflow count.
*
* The visible avatars are `aria-hidden` and the whole group carries one accessible name listing
* everyone — seven separate announcements of two-letter initials tell a screen-reader user
* nothing, while "Shared with Ada Lovelace, Grace Hopper and 5 others" is the actual content.
*
* `-ms-2` for the overlap rather than `-ml-2`, so the stack overlaps in the reading direction
* and reverses correctly under RTL. The ring is `ring-background`, which keeps the separation
* visible in both themes without a hardcoded white.
*/
export function PeopleAvatarStack() {
const shown = MEMBERS.slice(0, 4);
const extra = MEMBERS.length - shown.length;
const label =
`Shared with ${shown.map((member) => member.name).join(", ")}` +
(extra > 0 ? ` and ${extra} ${extra === 1 ? "other" : "others"}` : "");
return (
<div className="flex items-center gap-3">
<div className="flex items-center" role="img" aria-label={label}>
{shown.map((member) => (
<span
key={member.name}
aria-hidden="true"
className="flex size-8 items-center justify-center rounded-full bg-muted text-[11px] font-semibold ring-2 ring-background first:ms-0 -ms-2"
>
{member.initials}
</span>
))}
{extra > 0 && (
<span
aria-hidden="true"
className="-ms-2 flex size-8 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary ring-2 ring-background"
>
+{extra}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{MEMBERS.length} collaborators</p>
</div>
);
}Members with role, status and actions that work on a touch device.
src/components/blocks/people/people-directory-row.tsx"use client";
import { Mail, MoreHorizontal } from "lucide-react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
const PEOPLE = [
{ name: "Ada Lovelace", initials: "AL", email: "[email protected]", role: "Admin", status: "Active" },
{ name: "Grace Hopper", initials: "GH", email: "[email protected]", role: "Editor", status: "Active" },
{ name: "Alan Turing", initials: "AT", email: "[email protected]", role: "Viewer", status: "Invited" },
];
/**
* A directory row with the actions people actually take.
*
* Two things worth copying. The row actions are always present rather than revealed on hover: a
* hover-only control does not exist on a touch device, and this is a list people operate from a
* tablet as often as a desktop.
*
* And every icon-only button has an accessible name that includes *whose* row it is — six
* buttons all called "More" give a screen-reader user a list of six identical options.
*/
export function PeopleDirectoryRow() {
return (
<ul className="divide-y divide-border">
{PEOPLE.map((person) => (
<li key={person.email} className="flex items-center gap-3 py-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{person.initials}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{person.name}</p>
<p className="truncate text-xs text-muted-foreground">{person.email}</p>
</div>
<Badge variant="outline" className="hidden shrink-0 text-[11px] sm:inline-flex">
{person.role}
</Badge>
<Badge
variant={person.status === "Active" ? "success" : "secondary"}
className="shrink-0 text-[11px]"
>
{person.status}
</Badge>
<div className="flex shrink-0 items-center">
<Button variant="ghost" size="icon" className="size-8" aria-label={`Email ${person.name}`}>
<Mail className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={`More actions for ${person.name}`}
>
<MoreHorizontal className="size-4" />
</Button>
</div>
</li>
))}
</ul>
);
}The owner gets a badge, not a disabled select that reads as temporary.
src/components/blocks/people/role-assignment-row.tsx"use client";
import { useState } from "react";
import { Avatar, AvatarFallback } from "@dashboardpack/core/components/ui/avatar";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@dashboardpack/core/components/ui/select";
/**
* Member rows where the role is editable in place and the owner is protected.
*
* The owner's row renders a static badge instead of a select. An organisation that lets its last
* owner demote themselves locks everybody out of billing, and a disabled select still reads as
* "temporarily unavailable" rather than "not possible" — so it is not rendered as a control at all.
*
* Each select carries its own accessible name naming the person, because "Role" repeated four times
* is indistinguishable when a screen reader lists the form controls on the page. `aria-label` on the
* trigger is required here: a `role="combobox"` never takes its name from its contents.
*/
export function RoleAssignmentRow() {
const [members, setMembers] = useState([
{ name: "Ana Whitfield", email: "[email protected]", initials: "AW", role: "owner" },
{ name: "Marcus Oyelaran", email: "[email protected]", initials: "MO", role: "admin" },
{ name: "Priya Raman", email: "[email protected]", initials: "PR", role: "editor" },
{ name: "Tomas Berg", email: "[email protected]", initials: "TB", role: "viewer" },
]);
const roles = [
{ id: "admin", label: "Admin" },
{ id: "editor", label: "Editor" },
{ id: "viewer", label: "Viewer" },
];
return (
<ul className="divide-y rounded-lg border">
{members.map((member) => (
<li key={member.email} className="flex flex-wrap items-center gap-3 p-3">
<Avatar className="h-9 w-9 shrink-0">
<AvatarFallback className="text-[11px]">{member.initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{member.name}</p>
<p className="truncate text-xs text-muted-foreground">{member.email}</p>
</div>
{member.role === "owner" ? (
// Not a disabled select: this is not a temporarily unavailable control, it is a
// role that cannot be changed from here at all.
<Badge variant="secondary">Owner</Badge>
) : (
<Select
value={member.role}
onValueChange={(role) =>
setMembers((prev) =>
prev.map((entry) => (entry.email === member.email ? { ...entry, role } : entry)),
)
}
>
<SelectTrigger className="h-8 w-[120px]" aria-label={`Role for ${member.name}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</li>
))}
</ul>
);
}Remaining time, so the resend button means something.
src/components/blocks/people/invite-pending-list.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Mail } from "lucide-react";
/**
* Pending invitations, with the expiry that makes the resend button meaningful.
*
* An invite list showing only email addresses cannot answer the question people actually have: is
* this one still valid. So each row states how long is left, and an expired invite is visually
* distinct and offers a resend rather than a revoke.
*
* Expiry is shown as a remaining duration rather than a date, because "expires in 2 days" is
* immediately actionable where "expires 30 Jul" requires knowing today's date. The absolute date
* stays in the `title` for anyone who needs it.
*
* The initial-less rows use a mail icon rather than fabricating initials from an email local part —
* "an" from "ana@" reads as a name the system has invented.
*/
export function InvitePendingList() {
const invites = [
{ email: "[email protected]", role: "Editor", left: "6 days", exact: "6 August 2026", expired: false },
{ email: "[email protected]", role: "Viewer", left: "2 days", exact: "2 August 2026", expired: false },
{ email: "[email protected]", role: "Viewer", left: "Expired", exact: "24 July 2026", expired: true },
];
return (
<ul className="divide-y rounded-lg border">
{invites.map((invite) => (
<li key={invite.email} className="flex flex-wrap items-center gap-3 p-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Mail className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{invite.email}</p>
<p className="text-xs text-muted-foreground">
Invited as {invite.role} ·{" "}
<span title={invite.exact}>
{invite.expired ? "Expired" : `Expires in ${invite.left}`}
</span>
</p>
</div>
{invite.expired ? (
<>
<Badge variant="outline" className="text-destructive">
Expired
</Badge>
<Button variant="outline" size="sm">
Resend
</Button>
</>
) : (
<>
<Badge variant="secondary">Pending</Badge>
<Button variant="ghost" size="sm">
Revoke
</Button>
</>
)}
</li>
))}
</ul>
);
}Scaled against the busiest person, so 130% and 180% look different.
src/components/blocks/people/team-workload.tsx"use client";
import { Avatar, AvatarFallback } from "@dashboardpack/core/components/ui/avatar";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* Capacity per person, where over-allocation is the state worth designing for.
*
* A workload bar that stops at 100% cannot show the only problem it exists to reveal. Here the
* track holds a 100% marker and the fill is allowed to run past it, so someone at 130% is visibly
* different from someone at exactly capacity.
*
* Widths are scaled against the highest allocation rather than against 100, so the over-allocated
* bar fits its track and the others stay proportional to it. Scaling against 100 and letting the
* overflow clip would make 130% and 180% look identical.
*
* The marker is `aria-hidden` and each row carries `aria-valuetext` with the real percentage, so
* the capacity line is a visual aid and the number is what gets announced.
*/
export function TeamWorkload() {
const team = [
{ name: "Ana Whitfield", initials: "AW", pct: 130, tasks: 13 },
{ name: "Marcus Oyelaran", initials: "MO", pct: 96, tasks: 9 },
{ name: "Priya Raman", initials: "PR", pct: 72, tasks: 7 },
{ name: "Tomas Berg", initials: "TB", pct: 38, tasks: 4 },
];
// Scale against the busiest person, never against 100 — otherwise every over-allocation
// clips to the same full bar and the degree of the problem is lost.
const scale = Math.max(100, ...team.map((member) => member.pct));
return (
<ul className="divide-y rounded-lg border">
{team.map((member) => {
const over = member.pct > 100;
return (
<li key={member.name} className="flex items-center gap-3 p-3">
<Avatar className="h-9 w-9 shrink-0">
<AvatarFallback className="text-[11px]">{member.initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-baseline gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium">{member.name}</p>
<p className="text-xs tabular-nums text-muted-foreground">
{member.tasks} tasks
</p>
{over && (
<Badge variant="warning" className="text-[10px]">
Over capacity
</Badge>
)}
</div>
<div
role="progressbar"
aria-label={`Allocation for ${member.name}`}
aria-valuemin={0}
aria-valuemax={scale}
aria-valuenow={member.pct}
aria-valuetext={`${member.pct}% allocated`}
className="relative h-2 overflow-hidden rounded-full bg-muted"
>
<div
className={cn("h-full rounded-full", over ? "bg-warning" : "bg-primary")}
style={{ width: `${(member.pct / scale) * 100}%` }}
/>
{/* The 100% capacity line, positioned logically so it mirrors under RTL. */}
<span
aria-hidden="true"
className="absolute inset-y-0 w-px bg-foreground/40"
style={{ insetInlineStart: `${(100 / scale) * 100}%` }}
/>
</div>
</div>
<span className="w-12 shrink-0 text-end text-sm font-semibold tabular-nums">
{member.pct}%
</span>
</li>
);
})}
</ul>
);
}Negative logical margins, so the stack leans the right way under RTL.
src/components/blocks/people/avatar-stack.tsx"use client";
import { Avatar, AvatarFallback } from "@dashboardpack/core/components/ui/avatar";
/**
* Overlapping avatars with an overflow count.
*
* The overlap uses a negative *logical* margin — `-ms-2` — so the stack leans the correct way in
* both directions. `-ml-2` is the obvious way to write this and it reverses the overlap under RTL:
* the avatars pile onto the wrong edge and the ring order inverts, which is subtle enough that
* nobody notices until an RTL user does. This is the case the project's lint rule was written for.
*
* `ring-background` rather than `border` for the separation, because a ring draws outside the box
* and so does not shrink the avatar or shift the overlap arithmetic.
*
* Names are in a single `sr-only` sentence rather than one per avatar, so a screen reader reads
* "Ana Whitfield, Marcus Oyelaran and 4 others" instead of announcing five separate images. The
* avatars themselves are decorative once the list exists.
*/
export function AvatarStack() {
const people = [
{ name: "Ana Whitfield", initials: "AW" },
{ name: "Marcus Oyelaran", initials: "MO" },
{ name: "Priya Raman", initials: "PR" },
{ name: "Tomas Berg", initials: "TB" },
];
const extra = 4;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center">
{people.map((person, index) => (
<Avatar
key={person.name}
// -ms-2, not -ml-2: the logical form keeps the lean correct under RTL.
className={index === 0 ? "h-9 w-9 ring-2 ring-background" : "-ms-2 h-9 w-9 ring-2 ring-background"}
>
<AvatarFallback className="text-[11px]">{person.initials}</AvatarFallback>
</Avatar>
))}
<span className="-ms-2 flex h-9 w-9 items-center justify-center rounded-full bg-muted text-[11px] font-medium ring-2 ring-background">
+{extra}
</span>
</div>
<p className="text-sm text-muted-foreground">
<span className="sr-only">
Assigned to {people.map((person) => person.name).join(", ")} and {extra} others.
</span>
<span aria-hidden="true">
{people.length + extra} people assigned
</span>
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center">
{people.slice(0, 3).map((person, index) => (
<Avatar
key={person.name}
className={index === 0 ? "h-7 w-7 ring-2 ring-background" : "-ms-1.5 h-7 w-7 ring-2 ring-background"}
>
<AvatarFallback className="text-[10px]">{person.initials}</AvatarFallback>
</Avatar>
))}
</div>
<p className="text-xs text-muted-foreground">Compact variant, for a table cell</p>
</div>
</div>
);
}