---
name: team-members
title: Team Members
url: https://ui.frontic.com/components/sections/team-members.md
tier: sections
install: npx @frontic/ui add team-members
files:
  - components/sections/team-members/TeamMembers.vue
  - components/sections/team-members/index.ts
  - components/sections/team-members/types.ts
registryDependencies:
  - avatar
  - badge
  - button
  - card
  - dropdown-menu
  - input
  - item
  - select
  - text
dependencies:
  - "@lucide/vue"
---

# Team Members

Team management card: a seat count, an invite row with email and role, and each member with a role select and a menu to resend, revoke or remove.

## Install

```sh
npx @frontic/ui add team-members
```

Built from: [Avatar](https://ui.frontic.com/components/ui/avatar.md), [Badge](https://ui.frontic.com/components/ui/badge.md), [Button](https://ui.frontic.com/components/ui/button.md), [Card](https://ui.frontic.com/components/ui/card.md), [Dropdown Menu](https://ui.frontic.com/components/ui/dropdown-menu.md), [Input](https://ui.frontic.com/components/ui/input.md), [Item](https://ui.frontic.com/components/ui/item.md), [Select](https://ui.frontic.com/components/ui/select.md), [Text](https://ui.frontic.com/lib/text.md)

npm packages: `@lucide/vue`

## Examples

```vue
<script setup lang="ts">
import { ref } from 'vue'
import { TeamMembers, type TeamMember, type TeamRole } from '@/components/sections/team-members'

const avatar = (n: number) => `https://static.frontic.com/cdn-cgi/image/width=128,format=auto/ui/avatars/${n}.jpg`

const roles: TeamRole[] = [
  { value: 'owner', label: 'Owner' },
  { value: 'admin', label: 'Admin' },
  { value: 'editor', label: 'Editor' },
  { value: 'support', label: 'Support' },
]

const members = ref<TeamMember[]>([
  { name: 'Lena Berg', email: 'lena@basecamp-store.com', role: 'owner', avatar: avatar(10) },
  { name: 'Jonas Keller', email: 'jonas@basecamp-store.com', role: 'admin', avatar: avatar(4) },
  { name: 'Mira Okafor', email: 'mira@basecamp-store.com', role: 'editor' },
  { name: 'Tom Haas', email: 'tom@basecamp-store.com', role: 'support', avatar: avatar(5) },
  { name: 'Sara Lind', email: 'sara.lind@gmail.com', role: 'editor', pending: true },
])

function invite(email: string, role: string) {
  const local = email.split('@')[0]!
  members.value = [...members.value, { name: local.charAt(0).toUpperCase() + local.slice(1), email, role, pending: true }]
}
</script>

<template>
  <div class="mx-auto w-full max-w-5xl">
    <TeamMembers
      v-model:members="members"
      :roles="roles"
      invite-role="editor"
      :seats="`${members.length} of 10 seats`"
      description="People who can manage Basecamp Store."
      invite-placeholder="colleague@basecamp-store.com"
      @invite="invite"
      @revoke="(member) => (members = members.filter((m) => m !== member))"
      @remove="(member) => (members = members.filter((m) => m !== member))"
    />
  </div>
</template>
```

## API reference

### TeamMembers

Lists team members with roles, and lets the user invite, change roles and remove members.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `roles` (required) | `TeamRole[]` |  | The roles a member can have, shown in the role selects. |
| `ownerRole` | `string` | `'owner'` | The role nobody can be given or lose: its member has no role menu and no actions. |
| `inviteRole` | `string` |  | The role a new invite starts with. |
| `seats` | `string` |  | Beside the title, e.g. how many seats are taken. |
| `title` | `string` | `'Team'` | Shown as the card's title. |
| `description` | `string` | `'People who can manage your store.'` | Shown under the title. |
| `invitePlaceholder` | `string` | `'colleague@example.com'` | Shown in the empty invite email field. |
| `emailLabel` | `string` | `'Email address'` | Screen-reader label of the invite email field. |
| `roleLabel` | `string` | `'Role'` | Screen-reader label of the invite role select. |
| `inviteLabel` | `string` | `'Invite'` | Shown on the button that sends the invite. |
| `pendingLabel` | `string` | `'Pending'` | Shown on the badge of a member who was invited but has not joined yet. |
| `activityLabel` | `string` | `'View activity'` | Shown on the menu item that opens a member's activity. |
| `removeLabel` | `string` | `'Remove from team'` | Shown on the menu item that removes a member from the team. |
| `resendLabel` | `string` | `'Resend invite'` | Shown on the menu item that sends a pending invite again. |
| `revokeLabel` | `string` | `'Revoke invite'` | Shown on the menu item that withdraws a pending invite. |
| `memberRoleLabel` | `((member: TeamMember) => string)`: `(member: TeamMember): string` | ``Role of ${member.name}`` | Screen-reader label of a member's role select. |
| `memberActionsLabel` | `((member: TeamMember) => string)`: `(member: TeamMember): string` | ``Actions for ${member.name}`` | Screen-reader label of a member's action menu button. |
| `surface` | `'raised' \| 'bare'` | `'raised'` | Bare drops the edge, shadow and inset, for a section placed inside another Card. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `members` | `TeamMember[]` | `[]` | The team's members and pending invites; changing a role updates it. |

| Event | Payload | Description |
| --- | --- | --- |
| `remove` | `member: TeamMember` | Fires with an active member when the admin removes them. |
| `update:members` | `value: TeamMember[]` | Fires with the new value when `members` changes; `v-model:members` binds it. |
| `invite` | `email: string, role: string` | Fires with the email and role when the admin invites a new member. |
| `activity` | `member: TeamMember` | Fires with the member when the admin opens their activity. |
| `resend` | `member: TeamMember` | Fires with a pending member when the admin resends their invitation. |
| `revoke` | `member: TeamMember` | Fires with a pending member when the admin revokes their invitation. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-surface` | `'raised' \| 'bare'` | Carries the `surface` prop. |

## Exports

```ts
import { type TeamMember, type TeamRole } from '@/components/sections/team-members'
```

- `TeamMember`: below
- `TeamRole`: below

```ts
interface TeamMember {
  name: string
  email: string
  /** The value of one of the roles. */
  role: string
  /** The member's picture, shown in the avatar. */
  avatar?: string
  /** Avatar fallback; the name's initials when left out. */
  initials?: string
  /** Invited, not yet joined. */
  pending?: boolean
}

interface TeamRole {
  value: string
  label: string
}
```

## Source

`components/sections/team-members/TeamMembers.vue`

```vue
<script lang="ts">
/** Lists team members with roles, and lets the user invite, change roles and remove members. */
export default {}
</script>

<script setup lang="ts">
import { UserPlusIcon, MoreHorizontalIcon } from '@lucide/vue'

import type { HTMLAttributes } from 'vue'
import type { TeamMember, TeamRole } from './types'
import { computed, ref } from 'vue'
import { Avatar, AvatarFallback, AvatarImage } from '@library/components/ui/avatar'
import { Badge } from '@library/components/ui/badge'
import { Button } from '@library/components/ui/button'
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@library/components/ui/card'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@library/components/ui/dropdown-menu'
import { Input } from '@library/components/ui/input'
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from '@library/components/ui/item'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@library/components/ui/select'
import { initials } from '@library/lib/text'

const props = withDefaults(
  defineProps<{
    /** The roles a member can have, shown in the role selects. */
    roles: TeamRole[]
    /** The role nobody can be given or lose: its member has no role menu and no actions. */
    ownerRole?: string
    /** The role a new invite starts with. */
    inviteRole?: string
    /** Beside the title, e.g. how many seats are taken. */
    seats?: string
    /** Shown as the card's title. */
    title?: string
    /** Shown under the title. */
    description?: string
    /** Shown in the empty invite email field. */
    invitePlaceholder?: string
    /** Screen-reader label of the invite email field. */
    emailLabel?: string
    /** Screen-reader label of the invite role select. */
    roleLabel?: string
    /** Shown on the button that sends the invite. */
    inviteLabel?: string
    /** Shown on the badge of a member who was invited but has not joined yet. */
    pendingLabel?: string
    /** Shown on the menu item that opens a member's activity. */
    activityLabel?: string
    /** Shown on the menu item that removes a member from the team. */
    removeLabel?: string
    /** Shown on the menu item that sends a pending invite again. */
    resendLabel?: string
    /** Shown on the menu item that withdraws a pending invite. */
    revokeLabel?: string
    /** Screen-reader label of a member's role select. */
    memberRoleLabel?: (member: TeamMember) => string
    /** Screen-reader label of a member's action menu button. */
    memberActionsLabel?: (member: TeamMember) => string
    /** Bare drops the edge, shadow and inset, for a section placed inside another Card. */
    surface?: 'raised' | 'bare'
    class?: HTMLAttributes['class']
  }>(),
  {
    surface: 'raised',
    ownerRole: 'owner',
    title: 'Team',
    description: 'People who can manage your store.',
    invitePlaceholder: 'colleague@example.com',
    emailLabel: 'Email address',
    roleLabel: 'Role',
    inviteLabel: 'Invite',
    pendingLabel: 'Pending',
    activityLabel: 'View activity',
    removeLabel: 'Remove from team',
    resendLabel: 'Resend invite',
    revokeLabel: 'Revoke invite',
    memberRoleLabel: (member: TeamMember) => `Role of ${member.name}`,
    memberActionsLabel: (member: TeamMember) => `Actions for ${member.name}`,
  },
)
const emit = defineEmits<{
  /** Fires with the email and role when the admin invites a new member. */
  invite: [email: string, role: string]
  /** Fires with the member when the admin opens their activity. */
  activity: [member: TeamMember]
  /** Fires with an active member when the admin removes them. */
  remove: [member: TeamMember]
  /** Fires with a pending member when the admin resends their invitation. */
  resend: [member: TeamMember]
  /** Fires with a pending member when the admin revokes their invitation. */
  revoke: [member: TeamMember]
}>()

/** The team's members and pending invites; changing a role updates it. */
const members = defineModel<TeamMember[]>('members', { default: () => [] })

const assignable = computed(() => props.roles.filter((role) => role.value !== props.ownerRole))
const email = ref('')
const role = ref(props.inviteRole ?? assignable.value[0]?.value ?? '')

const initialsOf = (member: TeamMember) => initials(member.name, member.initials)

function invite() {
  if (!email.value) return
  emit('invite', email.value, role.value)
  email.value = ''
}

function setRole(member: TeamMember, value: string) {
  members.value = members.value.map((m) => (m === member ? { ...m, role: value } : m))
}
</script>

<template>
  <Card :surface="surface" :class="props.class">
    <CardHeader>
      <CardTitle>{{ title }}</CardTitle>
      <CardDescription v-if="description">{{ description }}</CardDescription>
      <CardAction v-if="seats">
        <Badge>{{ seats }}</Badge>
      </CardAction>
    </CardHeader>
    <CardContent class="@container flex flex-col gap-6">
      <form class="flex flex-col gap-2 @md:flex-row" @submit.prevent="invite">
        <Input v-model="email" type="email" :placeholder="invitePlaceholder" :aria-label="emailLabel" />
        <div class="flex gap-2">
          <Select v-model="role">
            <SelectTrigger class="w-32 flex-1 @md:flex-none" :aria-label="roleLabel">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem v-for="option in assignable" :key="option.value" :value="option.value">{{ option.label }}</SelectItem>
            </SelectContent>
          </Select>
          <Button type="submit">
            <UserPlusIcon data-icon />
            {{ inviteLabel }}
          </Button>
        </div>
      </form>
      <ItemGroup>
        <Item v-for="member in members" :key="member.email" size="sm" variant="outline">
          <ItemMedia>
            <Avatar>
              <AvatarImage v-if="member.avatar" :src="member.avatar" :alt="member.name" />
              <AvatarFallback>{{ initialsOf(member) }}</AvatarFallback>
            </Avatar>
          </ItemMedia>
          <ItemContent class="min-w-40">
            <ItemTitle>{{ member.name }}</ItemTitle>
            <ItemDescription class="truncate">{{ member.email }}</ItemDescription>
          </ItemContent>
          <ItemActions class="ms-auto">
            <Badge v-if="member.pending" variant="primary" size="sm">{{ pendingLabel }}</Badge>
            <Select :model-value="member.role" :disabled="member.role === ownerRole" @update:model-value="setRole(member, String($event))">
              <SelectTrigger size="sm" class="w-28" :aria-label="memberRoleLabel(member)">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem v-for="option in roles" :key="option.value" :value="option.value" :disabled="option.value === ownerRole">{{ option.label }}</SelectItem>
              </SelectContent>
            </Select>
            <DropdownMenu>
              <DropdownMenuTrigger as-child>
                <Button variant="ghost" color="neutral" size="icon-sm" :aria-label="memberActionsLabel(member)" :disabled="member.role === ownerRole">
                  <MoreHorizontalIcon data-icon />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuItem v-if="member.pending" @select="emit('resend', member)">{{ resendLabel }}</DropdownMenuItem>
                <DropdownMenuItem v-else @select="emit('activity', member)">{{ activityLabel }}</DropdownMenuItem>
                <DropdownMenuSeparator />
                <DropdownMenuItem v-if="member.pending" color="destructive" @select="emit('revoke', member)">{{ revokeLabel }}</DropdownMenuItem>
                <DropdownMenuItem v-else color="destructive" @select="emit('remove', member)">{{ removeLabel }}</DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </ItemActions>
        </Item>
      </ItemGroup>
    </CardContent>
  </Card>
</template>
```

`components/sections/team-members/types.ts`

```ts
export interface TeamMember {
  name: string
  email: string
  /** The value of one of the roles. */
  role: string
  /** The member's picture, shown in the avatar. */
  avatar?: string
  /** Avatar fallback; the name's initials when left out. */
  initials?: string
  /** Invited, not yet joined. */
  pending?: boolean
}

export interface TeamRole {
  value: string
  label: string
}
```
