Skip to content

Avatar

An img element with fallback and Nuxt Image support.

Demo

This requires the following components to be installed:

This requires the following composables to be installed:

This requires the following theme to be installed:

Component

Avatar.vue
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>
Avatar.vue
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

avatar.ts
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
avatar.ts
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:

Avatar.test.ts
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()
  })
})
Avatar.test.ts
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()
  })
})

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
c7d71 - feat: add avatar component (#32) on 12/16/2024