Skip to content
goodtabs UI
BrowseBrowse components

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

Design engineering7 members: Mira Sato, Jonas Keller, Priya Nair, Theo Laurent, Ana Costa, Sam Okafor, Lea Fischer
View Design engineering

Three avatars, a +4 circle, the name as a link and the View pill.

Many members

Everyone12 members: Mira Sato, Jonas Keller, Priya Nair, Theo Laurent, Ana Costa, Sam Okafor, Lea Fischer, Noah Brandt, Yuki Tanaka, Omar Haddad, Elin Berg, Ravi Menon

Five avatars before the count and a custom action in the slot.

In a list

Platform4 members: Jonas Keller, Ravi Menon, Elin Berg, Omar Haddad
View Platform
Growth2 members: Ana Costa, Noah Brandt
View Growth
Support5 members: Lea Fischer, Yuki Tanaka, Sam Okafor, Priya Nair, Theo Laurent
View Support

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

PropTypeDefaultDescription
namestringThe team's name.
members{ name: string; image?: string }[]Every member. Initials show when there is no image.
maxnumber3Avatars shown before the +n circle.
actionReactNodeSomething on the right. Replaces the default View link.
hrefstringMakes 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