Avatar
An img element with fallback and Nuxt Image support.
Demo
Related Components
This requires the following components to be installed:
Related Composables
This requires the following composables to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<script lang="ts">
import type { AvatarFallbackProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Icon from '@/ui/components/Icon.vue'
import { useAvatarGroup } from '@/ui/composables/useAvatarGroup'
import theme from '@/ui/theme/avatar'
import { reactivePick } from '@vueuse/core'
import { AvatarFallback, AvatarImage, AvatarRoot, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
const avatar = tv(theme)
type AvatarVariants = VariantProps<typeof avatar>
export interface AvatarProps extends Pick<AvatarFallbackProps, 'delayMs'> {
as?: any
src?: string
alt?: string
icon?: string
text?: string
size?: AvatarVariants['size']
class?: any
ui?: Partial<typeof avatar.slots>
}
export interface AvatarSlots {
default: (props?: object) => any
}
</script>
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<AvatarProps>(), { as: 'span' })
defineSlots<AvatarSlots>()
const fallbackProps = useForwardProps(reactivePick(props, 'delayMs'))
const fallback = computed(() => props.text || (props.alt || '').split(' ').map(word => word.charAt(0)).join('').substring(0, 2))
const { size } = useAvatarGroup(props)
const ui = computed(() => avatar({
size: size.value,
}))
</script>
<template>
<AvatarRoot :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
<AvatarImage
v-if="src"
as="img"
:src="src"
:alt="alt"
v-bind="$attrs"
:class="ui.image({ class: props.ui?.image })"
/>
<AvatarFallback as-child v-bind="fallbackProps">
<slot>
<Icon v-if="icon" :name="icon" :class="ui.icon({ class: props.ui?.icon })" />
<span v-else :class="ui.fallback({ class: props.ui?.fallback })">{{ fallback }}</span>
</slot>
</AvatarFallback>
</AvatarRoot>
</template>vue
<script lang="ts">
import type { AvatarFallbackProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Icon from '@/UI/Components/Icon.vue'
import { useAvatarGroup } from '@/UI/Composables/useAvatarGroup'
import theme from '@/UI/Theme/avatar'
import { reactivePick } from '@vueuse/core'
import { AvatarFallback, AvatarImage, AvatarRoot, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
const avatar = tv(theme)
type AvatarVariants = VariantProps<typeof avatar>
export interface AvatarProps extends Pick<AvatarFallbackProps, 'delayMs'> {
as?: any
src?: string
alt?: string
icon?: string
text?: string
size?: AvatarVariants['size']
class?: any
ui?: Partial<typeof avatar.slots>
}
export interface AvatarSlots {
default: (props?: object) => any
}
</script>
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<AvatarProps>(), { as: 'span' })
defineSlots<AvatarSlots>()
const fallbackProps = useForwardProps(reactivePick(props, 'delayMs'))
const fallback = computed(() => props.text || (props.alt || '').split(' ').map(word => word.charAt(0)).join('').substring(0, 2))
const { size } = useAvatarGroup(props)
const ui = computed(() => avatar({
size: size.value,
}))
</script>
<template>
<AvatarRoot :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
<AvatarImage
v-if="src"
as="img"
:src="src"
:alt="alt"
v-bind="$attrs"
:class="ui.image({ class: props.ui?.image })"
/>
<AvatarFallback as-child v-bind="fallbackProps">
<slot>
<Icon v-if="icon" :name="icon" :class="ui.icon({ class: props.ui?.icon })" />
<span v-else :class="ui.fallback({ class: props.ui?.fallback })">{{ fallback }}</span>
</slot>
</AvatarFallback>
</AvatarRoot>
</template>Theme
ts
export default {
slots: {
root: '',
image: '',
fallback: '',
icon: '',
},
variants: {
size: {
'3xs': {
root: 'size-4 text-[8px]',
},
'2xs': {
root: 'size-5 text-[10px]',
},
'xs': {
root: 'size-6 text-xs',
},
'sm': {
root: 'size-7 text-sm',
},
'md': {
root: 'size-8 text-base',
},
'lg': {
root: 'size-9 text-lg',
},
'xl': {
root: 'size-10 text-xl',
},
'2xl': {
root: 'size-11 text-[22px]',
},
'3xl': {
root: 'size-12 text-2xl',
},
},
},
defaultVariants: {
size: '',
},
}View Nuxt UI theme
ts
export default {
slots: {
root: 'inline-flex items-center justify-center shrink-0 select-none overflow-hidden rounded-full align-middle bg-elevated',
image: 'h-full w-full rounded-[inherit] object-cover',
fallback: 'font-medium leading-none text-muted truncate',
icon: 'text-muted shrink-0',
},
variants: {
size: {
'3xs': {
root: 'size-4 text-[8px]',
},
'2xs': {
root: 'size-5 text-[10px]',
},
'xs': {
root: 'size-6 text-xs',
},
'sm': {
root: 'size-7 text-sm',
},
'md': {
root: 'size-8 text-base',
},
'lg': {
root: 'size-9 text-lg',
},
'xl': {
root: 'size-10 text-xl',
},
'2xl': {
root: 'size-11 text-[22px]',
},
'3xl': {
root: 'size-12 text-2xl',
},
},
},
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 theme from '@/ui/theme/avatar'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('avatar', () => {
const sizes = Object.keys(theme.variants.size) as any
it.each<[string, RenderOptions<typeof Avatar>]>([
// Props
['with src', { props: { src: 'https://github.com/vuejs.png' } }],
['with alt', { props: { alt: 'Vue.js' } }],
['with text', { props: { text: '+1' } }],
['with icon', { props: { icon: 'i-lucide-image' } }],
...sizes.map((size: string) => [`with size ${size}`, { props: { src: 'https://github.com/vuejs.png', size } }]),
['with as', { props: { as: 'section' } }],
['with class', { props: { class: 'bg-(--ui-bg)' } }],
['with ui', { props: { ui: { fallback: 'font-bold' } } }],
// Slots
['with default slot', { slots: { default: '🇫🇷' } }],
])('renders %s correctly', (name, options) => {
render(Avatar, { ...options })
expect(document.querySelector('span')).toMatchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import Avatar from '@/UI/Components/Avatar.vue'
import theme from '@/UI/Theme/avatar'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('avatar', () => {
const sizes = Object.keys(theme.variants.size) as any
it.each<[string, RenderOptions<typeof Avatar>]>([
// Props
['with src', { props: { src: 'https://github.com/vuejs.png' } }],
['with alt', { props: { alt: 'Vue.js' } }],
['with text', { props: { text: '+1' } }],
['with icon', { props: { icon: 'i-lucide-image' } }],
...sizes.map((size: string) => [`with size ${size}`, { props: { src: 'https://github.com/vuejs.png', size } }]),
['with as', { props: { as: 'section' } }],
['with class', { props: { class: 'bg-(--ui-bg)' } }],
['with ui', { props: { ui: { fallback: 'font-bold' } } }],
// Slots
['with default slot', { slots: { default: '🇫🇷' } }],
])('renders %s correctly', (name, options) => {
render(Avatar, { ...options })
expect(document.querySelector('span')).toMatchSnapshot()
})
})