Skip to content

AvatarGroup

Stack multiple avatars in a group.

Demo

This requires the following components to be installed:

This requires the following keys to be installed:

This requires the following theme to be installed:

Component

AvatarGroup.vue
vue
<script lang="ts">
import type { VariantProps } from 'tailwind-variants'
import Avatar from '@/ui/components/Avatar.vue'
import { avatarGroupInjectionKey } from '@/ui/keys/avatar-group'
import theme from '@/ui/theme/avatar-group'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, provide } from 'vue'

const avatarGroup = tv(theme)

type AvatarGroupVariants = VariantProps<typeof avatarGroup>

export interface AvatarGroupProps {
  as?: any
  size?: AvatarGroupVariants['size']
  max?: number | string
  class?: any
  ui?: Partial<typeof avatarGroup.slots>
}

export interface AvatarGroupSlots {
  default: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = defineProps<AvatarGroupProps>()
const slots = defineSlots<AvatarGroupSlots>()

const ui = computed(() => avatarGroup({
  size: props.size,
}))

const max = computed(() => typeof props.max === 'string' ? Number.parseInt(props.max, 10) : props.max)

const children = computed(() => {
  let children = slots.default?.()
  if (children?.length) {
    children = children.flatMap((child: any) => {
      if (typeof child.type === 'symbol') {
        // `v-if="false"` or commented node
        if (typeof child.children === 'string') {
          return null
        }

        return child.children
      }

      return child
    }).filter(Boolean)
  }

  return children || []
})

const visibleAvatars = computed(() => {
  if (!children.value.length) {
    return []
  }

  if (!max.value || max.value <= 0) {
    return [...children.value].reverse()
  }

  return [...children.value].slice(0, max.value).reverse()
})

const hiddenCount = computed(() => {
  if (!children.value.length) {
    return 0
  }

  return children.value.length - visibleAvatars.value.length
})

provide(avatarGroupInjectionKey, computed(() => ({
  size: props.size,
})))
</script>

<template>
  <Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <Avatar v-if="hiddenCount > 0" :text="`+${hiddenCount}`" :class="ui.base({ class: props.ui?.base })" />
    <component :is="avatar" v-for="(avatar, count) in visibleAvatars" :key="count" :class="ui.base({ class: props.ui?.base })" />
  </Primitive>
</template>
AvatarGroup.vue
vue
<script lang="ts">
import type { VariantProps } from 'tailwind-variants'
import Avatar from '@/UI/Components/Avatar.vue'
import { avatarGroupInjectionKey } from '@/UI/Keys/avatar-group'
import theme from '@/UI/Theme/avatar-group'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, provide } from 'vue'

const avatarGroup = tv(theme)

type AvatarGroupVariants = VariantProps<typeof avatarGroup>

export interface AvatarGroupProps {
  as?: any
  size?: AvatarGroupVariants['size']
  max?: number | string
  class?: any
  ui?: Partial<typeof avatarGroup.slots>
}

export interface AvatarGroupSlots {
  default: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = defineProps<AvatarGroupProps>()
const slots = defineSlots<AvatarGroupSlots>()

const ui = computed(() => avatarGroup({
  size: props.size,
}))

const max = computed(() => typeof props.max === 'string' ? Number.parseInt(props.max, 10) : props.max)

const children = computed(() => {
  let children = slots.default?.()
  if (children?.length) {
    children = children.flatMap((child: any) => {
      if (typeof child.type === 'symbol') {
        // `v-if="false"` or commented node
        if (typeof child.children === 'string') {
          return null
        }

        return child.children
      }

      return child
    }).filter(Boolean)
  }

  return children || []
})

const visibleAvatars = computed(() => {
  if (!children.value.length) {
    return []
  }

  if (!max.value || max.value <= 0) {
    return [...children.value].reverse()
  }

  return [...children.value].slice(0, max.value).reverse()
})

const hiddenCount = computed(() => {
  if (!children.value.length) {
    return 0
  }

  return children.value.length - visibleAvatars.value.length
})

provide(avatarGroupInjectionKey, computed(() => ({
  size: props.size,
})))
</script>

<template>
  <Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <Avatar v-if="hiddenCount > 0" :text="`+${hiddenCount}`" :class="ui.base({ class: props.ui?.base })" />
    <component :is="avatar" v-for="(avatar, count) in visibleAvatars" :key="count" :class="ui.base({ class: props.ui?.base })" />
  </Primitive>
</template>

Theme

avatar-group.ts
ts
export default {
  slots: {
    root: '',
    base: '',
  },
  variants: {
    size: {
      '3xs': {
        base: 'ring -me-0.5',
      },
      '2xs': {
        base: 'ring -me-0.5',
      },
      'xs': {
        base: 'ring -me-0.5',
      },
      'sm': {
        base: 'ring-2 -me-1.5',
      },
      'md': {
        base: 'ring-2 -me-1.5',
      },
      'lg': {
        base: 'ring-2 -me-1.5',
      },
      'xl': {
        base: 'ring-3 -me-2',
      },
      '2xl': {
        base: 'ring-3 -me-2',
      },
      '3xl': {
        base: 'ring-3 -me-2',
      },
    },
  },
  defaultVariants: {
    size: '',
  },
}
View Nuxt UI theme
avatar-group.ts
ts
export default {
  slots: {
    root: 'inline-flex flex-row-reverse justify-end',
    base: 'relative rounded-full ring-bg first:me-0',
  },
  variants: {
    size: {
      '3xs': {
        base: 'ring -me-0.5',
      },
      '2xs': {
        base: 'ring -me-0.5',
      },
      'xs': {
        base: 'ring -me-0.5',
      },
      'sm': {
        base: 'ring-2 -me-1.5',
      },
      'md': {
        base: 'ring-2 -me-1.5',
      },
      'lg': {
        base: 'ring-2 -me-1.5',
      },
      'xl': {
        base: 'ring-3 -me-2',
      },
      '2xl': {
        base: 'ring-3 -me-2',
      },
      '3xl': {
        base: 'ring-3 -me-2',
      },
    },
  },
  defaultVariants: {
    size: 'md',
  },
}

Test

To test this component, you can use the following test file:

AvatarGroup.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Avatar from '@/ui/components/Avatar.vue'
import AvatarGroup from '@/ui/components/AvatarGroup.vue'
import theme from '@/ui/theme/avatar-group'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'

const AvatarGroupWrapper = defineComponent({
  components: {
    Avatar,
    AvatarGroup,
  },
  template: `<AvatarGroup>
  <Avatar src="https://github.com/vuejs.png" alt="Vue.js" />
  <Avatar src="https://github.com/nuxt.png" alt="Nuxt" />
  <Avatar src="https://github.com/unjs.png" alt="UnJS" />
</AvatarGroup>`,
})

describe('avatar group', () => {
  const sizes = Object.keys(theme.variants.size) as any

  it.each<[string, RenderOptions<typeof AvatarGroup>]>([
    // Props
    ['with max', { props: { max: 2 } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ['with as', { props: { as: 'span' } }],
    ['with class', { props: { class: 'justify-start' } }],
    ['with ui', { props: { ui: { base: 'rounded-lg' } } }],
    // Slots
    ['with default slot', {}],
  ])('renders %s correctly', (name, options) => {
    render(AvatarGroupWrapper, { attrs: { 'data-testid': 'avatar-group' }, ...options })

    expect(screen.getByTestId('avatar-group')).toMatchSnapshot()
  })
})
AvatarGroup.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Avatar from '@/UI/Components/Avatar.vue'
import AvatarGroup from '@/UI/Components/AvatarGroup.vue'
import theme from '@/UI/Theme/avatar-group'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'

const AvatarGroupWrapper = defineComponent({
  components: {
    Avatar,
    AvatarGroup,
  },
  template: `<AvatarGroup>
  <Avatar src="https://github.com/vuejs.png" alt="Vue.js" />
  <Avatar src="https://github.com/nuxt.png" alt="Nuxt" />
  <Avatar src="https://github.com/unjs.png" alt="UnJS" />
</AvatarGroup>`,
})

describe('avatar group', () => {
  const sizes = Object.keys(theme.variants.size) as any

  it.each<[string, RenderOptions<typeof AvatarGroup>]>([
    // Props
    ['with max', { props: { max: 2 } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ['with as', { props: { as: 'span' } }],
    ['with class', { props: { class: 'justify-start' } }],
    ['with ui', { props: { ui: { base: 'rounded-lg' } } }],
    // Slots
    ['with default slot', {}],
  ])('renders %s correctly', (name, options) => {
    render(AvatarGroupWrapper, { attrs: { 'data-testid': 'avatar-group' }, ...options })

    expect(screen.getByTestId('avatar-group')).toMatchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
c0b14 - feat: add avatar-group component (#48) on 12/17/2024