Skip to content
All examples

Team

Search members, change a role in place, remove someone with a chance to undo, and invite people by email with validation.

Add this example to a project that has prfct installed. The command writes the files below and installs the components they use.

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/team.json
components/team/team.tsx
"use client"

import * as React from "react"
import { MailIcon, SearchIcon, UserPlusIcon, XIcon } from "lucide-react"

import aurora from "./aurora.svg"
import ember from "./ember.svg"
import tide from "./tide.svg"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { toast } from "@/components/ui/toast"

/** The roles an admin can give, by name. The owner's is fixed. */
const roles = { admin: "Admin", member: "Member", viewer: "Viewer" } as const

type Role = keyof typeof roles
type Member = {
  name: string
  email: string
  role: Role | "owner"
  image?: string
}
type Invitation = { email: string; role: Role; sent: string }

const initialMembers: Member[] = [
  {
    name: "Maya Chen",
    email: "maya@northwind.dev",
    role: "owner",
    image: aurora.src,
  },
  {
    name: "Jonah Okafor",
    email: "jonah@northwind.dev",
    role: "admin",
    image: ember.src,
  },
  {
    name: "Lena Park",
    email: "lena@northwind.dev",
    role: "member",
    image: tide.src,
  },
  { name: "Priya Raman", email: "priya@northwind.dev", role: "member" },
  { name: "Tomás Rivera", email: "tomas@northwind.dev", role: "viewer" },
]

const initialInvitations: Invitation[] = [
  { email: "sam@northwind.dev", role: "member", sent: "Invited 2 days ago" },
  { email: "ines@northwind.dev", role: "viewer", sent: "Invited last week" },
]

const EMAIL = /^\S+@\S+\.\S+$/

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function RoleSelect({
  name,
  value,
  onChange,
}: {
  /** Whose role it is: every row has a select, so each needs its own name. */
  name: string
  value: Role
  onChange: (role: Role) => void
}) {
  return (
    <Select
      items={roles}
      value={value}
      onValueChange={(role) => {
        if (role) onChange(role)
      }}
    >
      <SelectTrigger
        size="sm"
        className="w-36 @max-md:w-auto @max-md:flex-1"
        aria-label={`Role of ${name}`}
      >
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        {Object.entries(roles).map(([role, label]) => (
          <SelectItem key={role} value={role}>
            {label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  )
}

function MemberRow({
  member,
  onRoleChange,
  onRemove,
}: {
  member: Member
  onRoleChange: (role: Role) => void
  onRemove: () => void
}) {
  return (
    <Item variant="outline" size="sm">
      <ItemMedia>
        <Avatar>
          {member.image && <AvatarImage src={member.image} alt="" />}
          <AvatarFallback>{initials(member.name)}</AvatarFallback>
        </Avatar>
      </ItemMedia>
      {/* min-w-0 lets a long email truncate instead of wrapping the row. */}
      <ItemContent className="min-w-0 gap-0.5">
        <ItemTitle>{member.name}</ItemTitle>
        <ItemDescription className="truncate">{member.email}</ItemDescription>
      </ItemContent>
      {member.role === "owner" ? (
        <span className="px-3 text-sm text-muted-foreground">Owner</span>
      ) : (
        // On a phone the controls take a line of their own under the name.
        <ItemActions className="@max-md:basis-full">
          <RoleSelect
            name={member.name}
            value={member.role}
            onChange={onRoleChange}
          />
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={`Remove ${member.name}`}
            onClick={onRemove}
          >
            <XIcon />
          </Button>
        </ItemActions>
      )}
    </Item>
  )
}

function InvitationRow({
  invitation,
  onRevoke,
}: {
  invitation: Invitation
  onRevoke: () => void
}) {
  return (
    <Item variant="outline" size="sm">
      <ItemMedia>
        <Avatar>
          <AvatarFallback>
            <MailIcon className="size-4" aria-hidden="true" />
          </AvatarFallback>
        </Avatar>
      </ItemMedia>
      <ItemContent className="min-w-0 gap-0.5">
        <ItemTitle className="truncate">{invitation.email}</ItemTitle>
        <ItemDescription>
          {roles[invitation.role]} · {invitation.sent}
        </ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button variant="ghost" size="sm" onClick={onRevoke}>
          Revoke
        </Button>
      </ItemActions>
    </Item>
  )
}

function InviteDialog({
  taken,
  onInvite,
}: {
  /** Addresses that already belong to a member or an invitation. */
  taken: string[]
  onInvite: (invitation: Invitation) => void
}) {
  const [open, setOpen] = React.useState(false)
  const [error, setError] = React.useState<string>()

  function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const data = new FormData(event.currentTarget)
    const email = String(data.get("email")).trim().toLowerCase()
    const problem = !EMAIL.test(email)
      ? "Enter an email address like ada@example.com."
      : taken.includes(email)
        ? "This person is already on the team or invited."
        : undefined
    if (problem) {
      setError(problem)
      event.currentTarget.querySelector("input")?.focus()
      return
    }
    const role = data.get("role") as Role
    onInvite({ email, role, sent: "Invited just now" })
    setOpen(false)
  }

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next)
        if (!next) setError(undefined)
      }}
    >
      <DialogTrigger render={<Button />}>
        <UserPlusIcon data-icon="inline-start" />
        Invite people
      </DialogTrigger>
      <DialogContent>
        <form noValidate onSubmit={submit} className="flex flex-col gap-5">
          <DialogHeader>
            <DialogTitle>Invite to Northwind</DialogTitle>
            <DialogDescription>
              They get an email with a link that works for seven days.
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field data-invalid={error ? true : undefined}>
              <FieldLabel htmlFor="invite-email">Email</FieldLabel>
              <Input
                id="invite-email"
                name="email"
                type="email"
                autoComplete="off"
                placeholder="ada@example.com"
                onChange={() => setError(undefined)}
                aria-invalid={error ? true : undefined}
                aria-describedby={error ? "invite-error" : undefined}
              />
              {error && <FieldError id="invite-error">{error}</FieldError>}
            </Field>
            <Field>
              <FieldLabel htmlFor="invite-role">Role</FieldLabel>
              <NativeSelect
                id="invite-role"
                name="role"
                defaultValue="member"
                className="w-full"
              >
                {Object.entries(roles).map(([role, label]) => (
                  <NativeSelectOption key={role} value={role}>
                    {label}
                  </NativeSelectOption>
                ))}
              </NativeSelect>
            </Field>
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button variant="outline" />}>
              Cancel
            </DialogClose>
            <Button type="submit">Send invitation</Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}

export default function TeamExample() {
  const [members, setMembers] = React.useState(initialMembers)
  const [invitations, setInvitations] = React.useState(initialInvitations)
  const [query, setQuery] = React.useState("")

  const needle = query.trim().toLowerCase()
  const found = members.filter((member) =>
    `${member.name} ${member.email}`.toLowerCase().includes(needle)
  )

  function changeRole(member: Member, role: Role) {
    setMembers((current) =>
      current.map((m) => (m.email === member.email ? { ...m, role } : m))
    )
  }

  function remove(member: Member) {
    const index = members.indexOf(member)
    setMembers((current) => current.filter((m) => m.email !== member.email))
    const id = toast.add({
      title: `${member.name} was removed`,
      actionProps: {
        children: "Undo",
        onClick: () => {
          // Closed with the click: a second one would add the member twice.
          toast.close(id)
          setMembers((current) => current.toSpliced(index, 0, member))
        },
      },
    })
  }

  function invite(invitation: Invitation) {
    setInvitations((current) => [invitation, ...current])
    toast.add({
      title: "Invitation sent",
      description: invitation.email,
      type: "success",
    })
  }

  return (
    <div className="@container">
      <div className="mx-auto flex max-w-3xl flex-col gap-8">
        <section aria-labelledby="team-members" className="flex flex-col gap-3">
          <div className="flex flex-wrap items-center justify-between gap-3">
            <h2 id="team-members" className="text-heading-xs">
              Members{" "}
              <span className="font-normal text-muted-foreground">
                {members.length}
              </span>
            </h2>
            <InviteDialog
              taken={[...members, ...invitations].map((entry) => entry.email)}
              onInvite={invite}
            />
          </div>
          <InputGroup>
            <InputGroupAddon>
              <SearchIcon />
            </InputGroupAddon>
            <InputGroupInput
              placeholder="Search by name or email"
              aria-label="Search members"
              value={query}
              onChange={(event) => setQuery(event.target.value)}
            />
          </InputGroup>
          {found.length > 0 ? (
            <ItemGroup>
              {found.map((member) => (
                <MemberRow
                  key={member.email}
                  member={member}
                  onRoleChange={(role) => changeRole(member, role)}
                  onRemove={() => remove(member)}
                />
              ))}
            </ItemGroup>
          ) : (
            <Empty className="border">
              <EmptyHeader>
                <EmptyTitle>No one matches “{query.trim()}”</EmptyTitle>
                <EmptyDescription>
                  Check the spelling, or invite them to the team.
                </EmptyDescription>
              </EmptyHeader>
            </Empty>
          )}
        </section>

        {invitations.length > 0 && (
          <section
            aria-labelledby="team-invitations"
            className="flex flex-col gap-3"
          >
            <h2 id="team-invitations" className="text-heading-xs">
              Pending invitations{" "}
              <span className="font-normal text-muted-foreground">
                {invitations.length}
              </span>
            </h2>
            <ItemGroup>
              {invitations.map((invitation) => (
                <InvitationRow
                  key={invitation.email}
                  invitation={invitation}
                  onRevoke={() =>
                    setInvitations((current) =>
                      current.filter((i) => i !== invitation)
                    )
                  }
                />
              ))}
            </ItemGroup>
          </section>
        )}
      </div>
    </div>
  )
}
components/team/aurora.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
  <rect width="80" height="80" fill="#e4eaff"/>
  <circle cx="40" cy="31" r="14" fill="#8ea6ff"/>
  <path d="M12 80c0-15.5 12.5-26 28-26s28 10.5 28 26z" fill="#8ea6ff"/>
</svg>
components/team/ember.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
  <rect width="80" height="80" fill="#fdebe0"/>
  <circle cx="40" cy="31" r="14" fill="#f0a072"/>
  <path d="M12 80c0-15.5 12.5-26 28-26s28 10.5 28 26z" fill="#f0a072"/>
</svg>
components/team/tide.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
  <rect width="80" height="80" fill="#dcf2ef"/>
  <circle cx="40" cy="31" r="14" fill="#6cbdb3"/>
  <path d="M12 80c0-15.5 12.5-26 28-26s28 10.5 28 26z" fill="#6cbdb3"/>
</svg>
app/team/page.tsx
import type { Metadata } from "next"

import Team from "@/components/team/team"
import { Toaster } from "@/components/ui/toast"

export const metadata: Metadata = {
  title: "Team",
  description: "Members and invitations: search, roles that change in place, removal with undo and an invite dialog.",
}

export default function TeamPage() {
  return (
    <Toaster>
      <main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
        <Team />
      </main>
    </Toaster>
  )
}