AvatarGroup
Stack multiple avatars in a group.
Demo
Related Components
This requires the following components to be installed:
Related Keys
This requires the following keys to be installed:
Related Theme
This requires the following theme to be installed:
Component
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>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
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
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:
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()
})
})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()
})
})



