UIPackage
Menu

Framework

Change language

Boilerplate repo

Avatar

avatar ui
Boilerplate repo

Round or rounded-square user image with a fallback that shows initials or an icon when the image is missing or fails to load. Sizes from xs to 2xl, optional status dot, and a group composition for stacked avatar lists.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/avatar.json
Named registry: npx shadcn-vue@latest add @uipkge/avatar Installs to: app/components/ui/avatar/

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
class HTMLAttributes['class'] optional
size
'xs''sm''default''lg''xl''2xl'
optional
rounded
'none''sm''default''md''lg''xl''2xl''3xl''full'
optional
color
'default''primary''secondary''destructive''success''warning''info''error''muted'
optional
variant
'default''outlined''soft'
optional
tile boolean false optional
disabled boolean false optional
loading boolean false optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

AvatarContext
interface AvatarContext {
  imageStatus: Ref<AvatarImageStatus>
  setImageStatus: (status: AvatarImageStatus) => void
}

npm dependencies

Used by

Files installed (7)

  • app/components/ui/avatar/Avatar.vue 1.7 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, provide, ref } from 'vue'
    import { cn } from '@/lib/utils'
    import { avatarVariants } from './avatar.variants'
    import { AVATAR_INJECTION_KEY, type AvatarImageStatus } from './context'
    
    // Inlined unions: SFC compiler can't extract runtime props from
    // `AvatarVariants['size']` etc. indexed-access types.
    const props = withDefaults(
      defineProps<{
        class?: HTMLAttributes['class']
        size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | '2xl'
        rounded?: 'none' | 'sm' | 'default' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
        color?: 'default' | 'primary' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'error' | 'muted'
        variant?: 'default' | 'outlined' | 'soft'
        tile?: boolean
        disabled?: boolean
        loading?: boolean
      }>(),
      {
        tile: false,
        disabled: false,
        loading: false,
      },
    )
    
    const emit = defineEmits<{
      click: [event: MouseEvent]
    }>()
    
    function handleClick(event: MouseEvent) {
      if (!props.disabled) {
        emit('click', event)
      }
    }
    
    const imageStatus = ref<AvatarImageStatus>('idle')
    
    provide(AVATAR_INJECTION_KEY, {
      imageStatus,
      setImageStatus: (status) => {
        imageStatus.value = status
      },
    })
    
    const rootClasses = computed(() =>
      cn(
        avatarVariants({ size: props.size, rounded: props.rounded, color: props.color, variant: props.variant }),
        props.tile ? 'rounded-none' : '',
        props.disabled ? 'cursor-not-allowed opacity-50' : '',
        props.loading ? 'animate-pulse' : '',
        props.class,
      ),
    )
    </script>
    
    <template>
      <span :class="rootClasses" data-uipkge data-slot="avatar" @click="handleClick">
        <slot />
      </span>
    </template>
  • app/components/ui/avatar/AvatarFallback.vue 1.3 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, inject } from 'vue'
    import { cn } from '@/lib/utils'
    import { avatarFallbackVariants } from './avatar.variants'
    import { AVATAR_INJECTION_KEY } from './context'
    
    // Inlined unions: SFC compiler can't extract runtime props from
    // indexed-access types.
    const props = withDefaults(
      defineProps<{
        class?: HTMLAttributes['class']
        size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | '2xl'
        color?: 'default' | 'primary' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'error' | 'muted'
        text?: string
      }>(),
      {
        size: 'default',
        color: 'default',
      },
    )
    
    const emit = defineEmits<{
      click: [event: MouseEvent]
    }>()
    
    function handleClick(event: MouseEvent) {
      emit('click', event)
    }
    
    // Show fallback until a sibling AvatarImage reports loaded (Radix composition).
    const ctx = inject(AVATAR_INJECTION_KEY, null)
    const visible = computed(() => !ctx || ctx.imageStatus.value !== 'loaded')
    
    const rootClasses = computed(() => cn(avatarFallbackVariants({ size: props.size, color: props.color }), props.class))
    </script>
    
    <template>
      <span v-if="visible" :class="rootClasses" data-uipkge data-slot="avatar-fallback" @click="handleClick">
        <template v-if="text">{{ text }}</template>
        <slot v-else />
      </span>
    </template>
  • app/components/ui/avatar/AvatarGroup.vue 2.9 kB
    <script setup lang="ts">
    import type { HTMLAttributes, VNode } from 'vue'
    import { Comment, Fragment, Text, computed, defineComponent, useSlots } from 'vue'
    import { cn } from '@/lib/utils'
    
    export interface AvatarGroupProps {
      class?: HTMLAttributes['class']
      max?: number
      overlap?: boolean
      size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | '2xl'
      /** Override the total used for +N when only a subset of avatars is rendered. */
      total?: number
    }
    
    const props = withDefaults(defineProps<AvatarGroupProps>(), {
      overlap: true,
      size: 'default',
    })
    
    const emit = defineEmits<{
      click: [event: MouseEvent]
    }>()
    
    function handleClick(event: MouseEvent) {
      emit('click', event)
    }
    
    const slots = useSlots()
    
    function flattenVNodes(nodes: VNode[] | undefined): VNode[] {
      const out: VNode[] = []
      for (const node of nodes ?? []) {
        if (!node) continue
        if (node.type === Comment) continue
        if (node.type === Text && !String(node.children ?? '').trim()) continue
        if (node.type === Fragment) {
          out.push(...flattenVNodes(node.children as VNode[]))
        } else {
          out.push(node)
        }
      }
      return out
    }
    
    const allChildren = computed(() => flattenVNodes(slots.default?.() as VNode[] | undefined))
    const totalCount = computed(() => props.total ?? allChildren.value.length)
    // When overflowing, reserve one slot for the +N chip so max includes the badge.
    const isOverflowing = computed(() => props.max != null && totalCount.value > props.max)
    const visibleLimit = computed(() => (isOverflowing.value ? Math.max((props.max ?? 0) - 1, 0) : allChildren.value.length))
    const overflowCount = computed(() => (isOverflowing.value ? totalCount.value - visibleLimit.value : 0))
    const visibleChildren = computed(() => allChildren.value.slice(0, visibleLimit.value))
    
    const overflowSizeClass = computed(() => {
      switch (props.size) {
        case 'xs':
          return 'size-4 text-[8px]'
        case 'sm':
          return 'size-6 text-xs'
        case 'lg':
          return 'size-12 text-base'
        case 'xl':
          return 'size-16 text-lg'
        case '2xl':
          return 'size-20 text-xl'
        default:
          return 'size-8 text-sm'
      }
    })
    
    // Render pre-flattened VNodes from the default slot.
    const VisibleAvatars = defineComponent({
      name: 'AvatarGroupVisible',
      setup() {
        return () => visibleChildren.value
      },
    })
    </script>
    
    <template>
      <div
        :class="cn('flex items-center', overlap ? '-space-x-2' : 'gap-1', props.class)"
        data-uipkge
        data-slot="avatar-group"
        @click="handleClick"
      >
        <VisibleAvatars />
        <div
          v-if="isOverflowing"
          :class="
            cn(
              'bg-muted ring-background relative flex shrink-0 overflow-hidden rounded-full ring-2',
              overflowSizeClass,
            )
          "
        >
          <slot name="overflow" :count="overflowCount">
            <span class="flex size-full items-center justify-center font-medium">+{{ overflowCount }}</span>
          </slot>
        </div>
      </div>
    </template>
  • app/components/ui/avatar/AvatarImage.vue 1.7 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, inject, onBeforeUnmount, watch } from 'vue'
    import { cn } from '@/lib/utils'
    import { AVATAR_INJECTION_KEY } from './context'
    
    const props = withDefaults(
      defineProps<{
        class?: HTMLAttributes['class']
        src?: string
        alt?: string
        loading?: 'eager' | 'lazy'
        referrerpolicy?:
          | 'no-referrer'
          | 'no-referrer-when-downgrade'
          | 'origin'
          | 'origin-when-cross-origin'
          | 'same-origin'
          | 'strict-origin'
          | 'strict-origin-when-cross-origin'
          | 'unsafe-url'
          | ''
        crossorigin?: 'anonymous' | 'use-credentials'
      }>(),
      {
        loading: 'lazy',
      },
    )
    
    const emit = defineEmits<{
      error: [event: Event]
      load: [event: Event]
    }>()
    
    // Sibling AvatarFallback shows when image is not loaded — match Radix composition.
    const ctx = inject(AVATAR_INJECTION_KEY, null)
    const status = computed(() => ctx?.imageStatus.value ?? 'idle')
    
    watch(
      () => props.src,
      (src) => {
        ctx?.setImageStatus(src ? 'loading' : 'idle')
      },
      { immediate: true },
    )
    
    onBeforeUnmount(() => {
      ctx?.setImageStatus('idle')
    })
    
    function handleError(event: Event) {
      ctx?.setImageStatus('error')
      emit('error', event)
    }
    
    function handleLoad(event: Event) {
      ctx?.setImageStatus('loaded')
      emit('load', event)
    }
    </script>
    
    <template>
      <img
        v-if="src && status !== 'error'"
        :src="src"
        :alt="alt"
        :loading="loading"
        :referrerpolicy="referrerpolicy"
        :crossorigin="crossorigin"
        :class="cn('aspect-square size-full object-cover', props.class)"
        data-uipkge
        data-slot="avatar-image"
        @error="handleError"
        @load="handleLoad"
      />
    </template>
  • app/components/ui/avatar/avatar.variants.ts 3.3 kB
    import type { VariantProps } from 'class-variance-authority'
    import { cva } from 'class-variance-authority'
    
    /**
     * Variant definitions live in their own file (rather than the package
     * `index.ts` or inline in the SFC) so `Avatar.vue` / `AvatarFallback.vue`
     * can `import { avatarVariants } from './avatar.variants'` without
     * creating a circular dependency back through the index. The circular
     * form caused intermittent `$setup.avatarVariants is not a function`
     * errors during dev SSR.
     */
    export const avatarVariants = cva('relative flex shrink-0 overflow-hidden', {
      variants: {
        size: {
          xs: 'size-4',
          sm: 'size-6',
          default: 'size-8',
          lg: 'size-12',
          xl: 'size-16',
          '2xl': 'size-20',
        },
        rounded: {
          none: 'rounded-none',
          sm: 'rounded-sm',
          default: 'rounded-full',
          md: 'rounded-md',
          lg: 'rounded-lg',
          xl: 'rounded-xl',
          '2xl': 'rounded-2xl',
          '3xl': 'rounded-3xl',
          full: 'rounded-full',
        },
        color: {
          default: '',
          primary: 'bg-primary text-primary-foreground',
          secondary: 'bg-secondary text-secondary-foreground',
          destructive: 'bg-destructive text-destructive-foreground',
          success: 'bg-success text-white dark:text-black',
          warning: 'bg-warning text-black',
          info: 'bg-info text-white dark:text-black',
          error: 'bg-destructive text-white dark:text-black',
          muted: 'bg-muted text-muted-foreground',
        },
        variant: {
          default: '',
          outlined: 'border-2 border-current',
          soft: 'bg-opacity-20',
        },
      },
      compoundVariants: [
        { color: 'primary', variant: 'soft', class: 'bg-primary/20 text-primary' },
        { color: 'secondary', variant: 'soft', class: 'bg-secondary/20 text-secondary-foreground' },
        { color: 'destructive', variant: 'soft', class: 'bg-destructive/20 text-destructive' },
        { color: 'success', variant: 'soft', class: 'bg-success/20 text-success' },
        { color: 'warning', variant: 'soft', class: 'bg-warning/20 text-warning' },
        { color: 'info', variant: 'soft', class: 'bg-info/20 text-info' },
        { color: 'error', variant: 'soft', class: 'bg-destructive/20 text-destructive' },
      ],
      defaultVariants: {
        size: 'default',
        rounded: 'default',
        color: 'default',
      },
    })
    
    export type AvatarVariants = VariantProps<typeof avatarVariants>
    
    export const avatarFallbackVariants = cva(
      'flex size-full items-center justify-center rounded-full bg-muted font-medium',
      {
        variants: {
          size: {
            xs: 'text-[8px]',
            sm: 'text-xs',
            default: 'text-sm',
            lg: 'text-base',
            xl: 'text-lg',
            '2xl': 'text-xl',
          },
          color: {
            default: '',
            primary: 'bg-primary text-primary-foreground',
            secondary: 'bg-secondary text-secondary-foreground',
            destructive: 'bg-destructive text-destructive-foreground',
            success: 'bg-success text-white dark:text-black',
            warning: 'bg-warning text-black',
            info: 'bg-info text-white dark:text-black',
            error: 'bg-destructive text-white dark:text-black',
            muted: 'bg-muted text-muted-foreground',
          },
        },
        defaultVariants: {
          size: 'default',
          color: 'default',
        },
      },
    )
    
    export type AvatarFallbackVariants = VariantProps<typeof avatarFallbackVariants>
  • app/components/ui/avatar/context.ts 0.3 kB
    import type { InjectionKey, Ref } from 'vue'
    
    export type AvatarImageStatus = 'idle' | 'loading' | 'loaded' | 'error'
    
    export interface AvatarContext {
      imageStatus: Ref<AvatarImageStatus>
      setImageStatus: (status: AvatarImageStatus) => void
    }
    
    export const AVATAR_INJECTION_KEY: InjectionKey<AvatarContext> = Symbol('uipkge-avatar')
  • app/components/ui/avatar/index.ts 0.5 kB
    export { default as Avatar } from './Avatar.vue'
    export { default as AvatarFallback } from './AvatarFallback.vue'
    export { default as AvatarImage } from './AvatarImage.vue'
    export { default as AvatarGroup } from './AvatarGroup.vue'
    
    // Re-export variant API from the sibling file (kept separate to avoid the
    // Avatar.vue <-> index.ts circular import that broke dev SSR).
    export {
      avatarVariants,
      avatarFallbackVariants,
      type AvatarVariants,
      type AvatarFallbackVariants,
    } from './avatar.variants'

Raw manifest: https://uipkge.dev/r/vue/avatar.json