Skip to content
  1. Records
  2. Avatar

Avatar

The photo of a member or the mark of a workspace, with initials when no image is available.

Leonel NjoyaFabrice YopaGabriel TekomboEric Cabrel Tiogo
import { Avatar } from '@/components/avatar'

export default function Example({ members, hiddenCount }) {
  return (
    <div className="flex items-center -space-x-2">
      {members.map((member) => (
        <Avatar
          key={member.id}
          src={member.avatarUrl}
          alt={member.name}
          className="size-8 ring-2 ring-white dark:ring-zinc-900"
        />
      ))}
      <Avatar
        initials={`+${hiddenCount}`}
        className="size-8 bg-zinc-100 text-zinc-600 ring-2 ring-white dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-900"
      />
    </div>
  )
}
<script setup lang="ts">
import Avatar from '@/components/avatar.vue'

defineProps<{ members: { id: string; name: string; avatarUrl: string }[]; hiddenCount: number }>()
</script>

<template>
  <div class="flex items-center -space-x-2">
    <Avatar
      v-for="member in members"
      :key="member.id"
      :src="member.avatarUrl"
      :alt="member.name"
      class="size-8 ring-2 ring-white dark:ring-zinc-900"
    />
    <Avatar
      :initials="`+${hiddenCount}`"
      class="size-8 bg-zinc-100 text-zinc-600 ring-2 ring-white dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-900"
    />
  </div>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef, useState } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'

type AvatarProps = {
  src?: string | null
  square?: boolean
  initials?: string
  alt?: string
  className?: string
}

const base = [
  'inline-grid *:col-start-1 *:row-start-1 shrink-0 align-middle',
  '[--avatar-radius:20%] outline outline-black/10 dark:outline-white/10 -outline-offset-1',
]

const shapes = {
  square: '*:rounded-(--avatar-radius) rounded-(--avatar-radius)',
  round: '*:rounded-full rounded-full',
}

const initialsStyles = 'size-full p-[5%] fill-current font-medium uppercase text-[48px] select-none'

export function Avatar({
  src = null,
  square = false,
  initials,
  alt = '',
  className,
  ...props
}: AvatarProps & React.ComponentPropsWithoutRef<'span'>) {
  let [failedSrc, setFailedSrc] = useState<string | null>(null)
  let showImage = Boolean(src) && src !== failedSrc
  let namedInitials = Boolean(alt) && !showImage

  return (
    <span
      data-slot="avatar"
      {...(!showImage && !initials && alt ? { role: 'img', 'aria-label': alt } : {})}
      {...props}
      className={clsx(className, base, shapes[square ? 'square' : 'round'])}
    >
      {initials && (
        <svg
          className={initialsStyles}
          viewBox="0 0 100 100"
          role={namedInitials ? 'img' : undefined}
          aria-hidden={namedInitials ? undefined : 'true'}
        >
          {namedInitials && <title>{alt}</title>}
          <text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
            {initials}
          </text>
        </svg>
      )}
      {showImage && (
        <img
          className="size-full"
          src={src!}
          alt={alt}
          ref={(image) => {
            if (image?.complete && image.naturalWidth === 0) setFailedSrc(src)
          }}
          onError={() => setFailedSrc(src)}
        />
      )}
    </span>
  )
}

const focusRing = 'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-accent-500'

type AvatarButtonProps = AvatarProps &
  ({ alt: string } | { 'aria-label': string } | { 'aria-labelledby': string }) &
  (
    | ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
    | ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
  )

export const AvatarButton = forwardRef(function AvatarButton(
  { src, square = false, initials, alt, className, ...props }: AvatarButtonProps,
  ref: React.ForwardedRef<HTMLButtonElement>
) {
  let classes = clsx(className, 'relative inline-grid', square ? 'rounded-[20%]' : 'rounded-full', focusRing)
  let avatar = (
    <TouchTarget>
      <Avatar src={src} square={square} initials={initials} alt={alt} />
    </TouchTarget>
  )

  return typeof props.href === 'string' ? (
    <Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
      {avatar}
    </Link>
  ) : (
    <Headless.Button {...props} className={classes} ref={ref}>
      {avatar}
    </Headless.Button>
  )
})

Also copy Button.

<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue'

const props = withDefaults(
  defineProps<{
    src?: string | null
    square?: boolean
    initials?: string
    alt?: string
  }>(),
  { src: null, square: false, alt: '' }
)

const base = [
  'inline-grid *:col-start-1 *:row-start-1 shrink-0 align-middle',
  '[--avatar-radius:20%] outline outline-black/10 dark:outline-white/10 -outline-offset-1',
]

const shapes = {
  square: '*:rounded-(--avatar-radius) rounded-(--avatar-radius)',
  round: '*:rounded-full rounded-full',
}

const initialsStyles = 'size-full p-[5%] fill-current font-medium uppercase text-[48px] select-none'

const failedSrc = ref<string | null>(null)
const showImage = computed(() => Boolean(props.src) && props.src !== failedSrc.value)
const namedInitials = computed(() => Boolean(props.alt) && !showImage.value)
const image = useTemplateRef<HTMLImageElement>('image')

watch(
  [image, () => props.src],
  ([element]) => {
    if (element?.complete && element.naturalWidth === 0) failedSrc.value = props.src
  },
  { flush: 'post' }
)
</script>

<template>
  <span
    data-slot="avatar"
    :role="!showImage && !initials && alt ? 'img' : undefined"
    :aria-label="!showImage && !initials && alt ? alt : undefined"
    :class="[base, shapes[square ? 'square' : 'round']]"
  >
    <svg
      v-if="initials"
      :class="initialsStyles"
      viewBox="0 0 100 100"
      :role="namedInitials ? 'img' : undefined"
      :aria-hidden="namedInitials ? undefined : 'true'"
    >
      <title v-if="namedInitials">{{ alt }}</title>
      <text x="50%" y="50%" alignment-baseline="middle" dominant-baseline="middle" text-anchor="middle" dy=".125em">
        {{ initials }}
      </text>
    </svg>
    <img v-if="showImage" ref="image" class="size-full" :src="src!" :alt="alt" @error="failedSrc = src" />
  </span>
</template>

Component API

PropDefaultDescription
Avatarrenders a <span> element
src-Image URL. Drawn on top of the initials when both are set, and dropped if it fails to load.
initials-Letters drawn when there is no image, under it until it loads, and in its place when it fails.
alt-Accessible name. Leave it empty when the name is already printed nearby.
squarefalseCorners rounded at 20% of the size instead of a circle.
AvatarButtonrenders a <button> element
href-Turns the avatar into a link instead of a button.
src-Image URL. Drawn on top of the initials when both are set.
initials-Letters drawn when there is no image, or when it fails to load.
alt-Accessible name of the button. Required unless you pass `aria-label` or `aria-labelledby` instead.
squarefalseCorners rounded at 20% of the size instead of a circle.
PropDefaultDescription
Avatarrenders a <span> element
src-Image URL. Drawn on top of the initials when both are set, and dropped if it fails to load.
initials-Letters drawn when there is no image, under it until it loads, and in its place when it fails.
alt-Accessible name. Leave it empty when the name is already printed nearby.
squarefalseCorners rounded at 20% of the size instead of a circle.
AvatarButtonrenders a <button> element
href-Turns the avatar into a link instead of a button.
src-Image URL. Drawn on top of the initials when both are set.
initials-Letters drawn when there is no image, or when it fails to load.
alt-Accessible name of the button. Required unless you pass `aria-label` or `aria-labelledby` instead.
squarefalseCorners rounded at 20% of the size instead of a circle.
disabledfalseDisables the button. Ignored when `href` is set.

Usage

An avatar has no size of its own: it fills the box you give it with a size-* utility. Set alt when the avatar is the only place the name appears. When the name is printed next to it, leave alt empty so screen readers do not read the name twice.

Member next to a name

The most common layout: the photo leads, the name and role follow. The name is already visible, so the image stays decorative:

Franck Dakia

Billing admin

import { Avatar } from '@/components/avatar'

export default function Example({ member }) {
  return (
    <div className="flex items-center gap-3">
      <Avatar src={member.avatarUrl} className="size-10" />
      <div className="text-sm/5">
        <p className="font-medium text-zinc-950 dark:text-white">{member.name}</p>
        <p className="text-zinc-500 dark:text-zinc-400">Billing admin</p>
      </div>
    </div>
  )
}
<script setup lang="ts">
import Avatar from '@/components/avatar.vue'

defineProps<{ member: { name: string; avatarUrl: string } }>()
</script>

<template>
  <div class="flex items-center gap-3">
    <Avatar :src="member.avatarUrl" class="size-10" />
    <div class="text-sm/5">
      <p class="font-medium text-zinc-950 dark:text-white">{{ member.name }}</p>
      <p class="text-zinc-500 dark:text-zinc-400">Billing admin</p>
    </div>
  </div>
</template>

Sizes

Match the line it sits on

Size the avatar to its context: small inside a table cell or a mention, larger in a page header. Initials scale with the box:

<Avatar src={user.avatarUrl} className="size-5" />
<Avatar src={user.avatarUrl} className="size-7" />
<Avatar src={user.avatarUrl} className="size-9" />
<Avatar src={user.avatarUrl} className="size-12" />
<Avatar :src="user.avatarUrl" class="size-5" />
<Avatar :src="user.avatarUrl" class="size-7" />
<Avatar :src="user.avatarUrl" class="size-9" />
<Avatar :src="user.avatarUrl" class="size-12" />

Variants

Member without a photo

initials draws the letters in the current text color. The component sets no background, so give it one for light mode and one for dark mode:

Sanix Darker
<Avatar initials="SD" className="size-10 bg-teal-600 text-white dark:bg-teal-500 dark:text-teal-950" />
<Avatar initials="SD" class="size-10 bg-teal-600 text-white dark:bg-teal-500 dark:text-teal-950" />

Letters while the photo loads

Pass both src and initials. The image is drawn over the letters, so they show until the photo arrives:

<Avatar
  src={member.avatarUrl}
  initials={member.initials}
  className="size-10 bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200"
/>
<Avatar
  :src="member.avatarUrl"
  :initials="member.initials"
  class="size-10 bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200"
/>

Workspaces and integrations

Keep circles for people and use square for a workspace, a customer company or a connected app. The corner radius is 20% of the size, so it stays in proportion at any size:

<Avatar square initials="NW" className="size-10 bg-zinc-900 text-white dark:bg-white dark:text-zinc-950" />
<Avatar square initials="NW" className="size-7 bg-zinc-900 text-white dark:bg-white dark:text-zinc-950" />
<Avatar square initials="NW" class="size-10 bg-zinc-900 text-white dark:bg-white dark:text-zinc-950" />
<Avatar square initials="NW" class="size-7 bg-zinc-900 text-white dark:bg-white dark:text-zinc-950" />

Composition

Who has access

Overlap avatars with -space-x-* and close the stack with an initials avatar for the members left out. A ring in the page background color cuts each face from the next one:

Leonel NjoyaFabrice YopaGabriel TekomboEric Cabrel Tiogo
import { Avatar } from '@/components/avatar'

export default function Example({ members, hiddenCount }) {
  return (
    <div className="flex items-center -space-x-2">
      {members.map((member) => (
        <Avatar
          key={member.id}
          src={member.avatarUrl}
          alt={member.name}
          className="size-8 ring-2 ring-white dark:ring-zinc-900"
        />
      ))}
      <Avatar
        initials={`+${hiddenCount}`}
        className="size-8 bg-zinc-100 text-zinc-600 ring-2 ring-white dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-900"
      />
    </div>
  )
}
<script setup lang="ts">
import Avatar from '@/components/avatar.vue'

defineProps<{ members: { id: string; name: string; avatarUrl: string }[]; hiddenCount: number }>()
</script>

<template>
  <div class="flex items-center -space-x-2">
    <Avatar
      v-for="member in members"
      :key="member.id"
      :src="member.avatarUrl"
      :alt="member.name"
      class="size-8 ring-2 ring-white dark:ring-zinc-900"
    />
    <Avatar
      :initials="`+${hiddenCount}`"
      class="size-8 bg-zinc-100 text-zinc-600 ring-2 ring-white dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-900"
    />
  </div>
</template>

Interaction

AvatarButton adds a focus ring and, on touch screens, a hit area of at least 44 pixels. It holds no text, so name it with aria-label or alt.

Open the account menu

Without href, AvatarButton renders a Headless UI button, the usual trigger for the signed-in user menu:

Without href, AvatarButton renders a native button, the usual trigger for the signed-in user menu:

import { AvatarButton } from '@/components/avatar'

export default function Example({ user, onOpen }) {
  return <AvatarButton src={user.avatarUrl} aria-label="Account menu" className="size-8" onClick={onOpen} />
}
<script setup lang="ts">
import AvatarButton from '@/components/avatar-button.vue'

defineProps<{ user: { avatarUrl: string } }>()
const emit = defineEmits<{ open: [] }>()
</script>

<template>
  <AvatarButton :src="user.avatarUrl" aria-label="Account menu" class="size-8" @click="emit('open')" />
</template>

With href, it becomes a link through the kit Link component. Here alt carries the name, which also names the link:

Franck Dakia
<AvatarButton href={`/members/${member.id}`} src={member.avatarUrl} alt={member.name} className="size-8" />
<AvatarButton :href="`/members/${member.id}`" :src="member.avatarUrl" :alt="member.name" class="size-8" />