Avatar
The photo of a member or the mark of a workspace, with initials when no image is available.
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
| Prop | Default | Description |
|---|---|---|
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. |
square | false | Corners 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. |
square | false | Corners rounded at 20% of the size instead of a circle. |
| Prop | Default | Description |
|---|---|---|
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. |
square | false | Corners 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. |
square | false | Corners rounded at 20% of the size instead of a circle. |
disabled | false | Disables 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:
<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:
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>Link to a member profile
With href, it becomes a link through the kit Link component. Here alt carries the name, which also names the link: