Team card
An avatar stack with the team name, a member count and one action.
npx shadcn@latest add https://ui.goodtabs.co/r/team-card.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/team-card
Three avatars, a +4 circle, the name as a link and the View pill.
Many members
Five avatars before the count and a custom action in the slot.
In a list
The row variant stacked with hairlines inside one bordered frame.
Usage
import { TeamCard } from "@/components/ui/team-card"
<TeamCard
name="Design engineering"
href="/teams/design"
members={[{ name: "Mira Sato", image: "/mira.png" }, { name: "Jonas Keller" }]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | The team's name. | |
| members | { name: string; image?: string }[] | Every member. Initials show when there is no image. | |
| max | number | 3 | Avatars shown before the +n circle. |
| action | ReactNode | Something on the right. Replaces the default View link. | |
| href | string | Makes the name a link and adds the View pill. | |
| variant | "card" | "row" | "card" | card has its own frame; row has none, for lists with divide-y. |
Source
team-card/team-card.tsx
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export type TeamMember = {
/** Full name. Used for the image alt and the initials fallback. */
name: string;
/** Avatar url. Initials show while it loads or when it is missing. */
image?: string;
};
type TeamCardProps = Omit<React.ComponentProps<typeof Card>, "children"> & {
/** The team's name. */
name: string;
/** Every member. Only the first `max` get an avatar, the rest become a count. */
members: TeamMember[];
/** How many avatars to show before the "+n" circle. */
max?: number;
/** Something on the right, for example a button or a menu. Replaces the default "View" link. */
action?: React.ReactNode;
/** Where the team lives. Adds the "View" link and makes the name a link. */
href?: string;
/** "card" has its own frame; "row" has none, for stacking inside one bordered list. */
variant?: "card" | "row";
};
function initials(name: string) {
const parts = name.trim().split(/\s+/);
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
/**
* An overlapping avatar stack, the team name, a member count and one action.
* Avatars beyond `max` fold into a "+n" circle.
*/
export function TeamCard({
name,
members,
max = 3,
action,
href,
variant = "card",
className,
...props
}: TeamCardProps) {
const shown = members.slice(0, max);
const rest = members.length - shown.length;
const count = members.length === 1 ? "1 member" : `${members.length} members`;
return (
<Card
size="sm"
data-slot="team-card"
data-variant={variant}
className={cn("w-full", variant === "row" && "rounded-none bg-transparent shadow-none ring-0", className)}
{...props}
>
<CardContent className="flex items-center gap-3">
<div className="flex shrink-0 -space-x-1" aria-hidden="true">
{shown.map((member) => (
<Avatar key={member.name} className="size-7 ring-2 ring-card">
{member.image && <AvatarImage src={member.image} alt="" />}
<AvatarFallback className="text-xs text-foreground/70">{initials(member.name)}</AvatarFallback>
</Avatar>
))}
{rest > 0 && (
<span className="relative inline-grid size-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-xs text-foreground ring-2 ring-card">
+{rest}
</span>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{href ? (
<a
href={href}
className="truncate rounded-sm text-sm font-medium text-foreground outline-none hover:text-foreground/85 focus-visible:ring-2 focus-visible:ring-ring/50"
>
{name}
</a>
) : (
<span className="truncate text-sm font-medium text-foreground">{name}</span>
)}
<span className="font-mono text-xs text-muted-foreground">
{count}
<span className="sr-only">: {members.map((m) => m.name).join(", ")}</span>
</span>
</div>
{action !== undefined ? (
<div className="shrink-0">{action}</div>
) : href ? (
<Button asChild variant="outline" size="sm" className="shrink-0 rounded-full">
<a href={href}>
View<span className="sr-only"> {name}</span>
</a>
</Button>
) : null}
</CardContent>
</Card>
);
}
For AI agents
Hand your agent this page, the markdown at /components/team-card/prompt.md, or the Copy AI prompt button above.
When to use
- Team and workspace lists in settings, admin and onboarding
- A members summary at the top of a project page
- Anywhere a group needs a face and a count in one row
When not to use
- For a single person, use a profile row with one avatar
- When every member needs to be named or actioned, use a table or list
Agent notes
- Server component: pass a client element into action if it needs handlers
- Without href and action there is nothing on the right; href adds the View pill and links the name
- Avatars use shadcn Avatar at size-7 with a ring-card ring, so put the card on bg-card or a matching surface
- variant="row" drops the frame; stack rows inside a rounded-lg border bg-card divide-y box