Avatar
An image element with a fallback for representing the user.
CNER
CNLRER
import { css } from 'remix/component'
import { Avatar, AvatarFallback, AvatarImage } from '#app/ui/avatar.tsx'
export function AvatarDemo() {
return () => (import { css } from 'remix/component'
import { Avatar, AvatarFallback, AvatarImage } from '#app/ui/avatar.tsx'
export function AvatarDemo() {
return () => (
<div
mix={css({
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: '3rem',
})}
>
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<Avatar mix={css({ borderRadius: 'var(--radius)' })}>
<AvatarImage src="https://github.com/evilrabbit.png" alt="@evilrabbit" />
<AvatarFallback>ER</AvatarFallback>
</Avatar>
<div
mix={css({
display: 'flex',
'& > :not(:last-child)': { marginInlineEnd: '-0.5rem' },
'& > [data-slot="avatar"]': {
boxShadow: '0 0 0 2px var(--background)',
filter: 'grayscale(100%)',
},
})}
>
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<Avatar>
<AvatarImage src="https://github.com/maxleiter.png" alt="@maxleiter" />
<AvatarFallback>LR</AvatarFallback>
</Avatar>
<Avatar>
<AvatarImage src="https://github.com/evilrabbit.png" alt="@evilrabbit" />
<AvatarFallback>ER</AvatarFallback>
</Avatar>
</div>
</div>
)
}Installation
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/avatar.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/avatar.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/avatar.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/avatar.jsonInstall the following dependencies:
$ bun add remix$ npm install remix$ pnpm add remix$ yarn add remixCopy and paste the following code into your project.
import { css, type Handle, type Props } from 'remix/component'
export type AvatarSize = 'default' | 'sm' | 'lg'
export type AvatarImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'
export interface AvatarProps extends Props<'span'> {
size?: AvatarSize
}
export type AvatarImageProps = Props<'img'> & {
onLoadingStatusChange?: (status: AvatarImageLoadingStatus) => void
}
export interface AvatarFallbackProps extends Props<'span'> {
/** Wait this many milliseconds before rendering, so fast-loading images never flash it. */
delayMs?: number
}
interface AvatarContextValue {
readonly status: AvatarImageLoadingStatus
setStatus(status: AvatarImageLoadingStatus): void
}
const avatarStyles = css({
position: 'relative',
display: 'flex',
width: '2rem',
height: '2rem',
flexShrink: 0,
overflow: 'hidden',
borderRadius: '9999px',
userSelect: 'none',
'&[data-size="lg"]': { width: '2.5rem', height: '2.5rem' },
'&[data-size="sm"]': { width: '1.5rem', height: '1.5rem' },
})
const avatarImageStyles = css({ aspectRatio: '1 / 1', width: '100%', height: '100%' })
const avatarFallbackStyles = css({
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '9999px',
backgroundColor: 'var(--muted)',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
color: 'var(--muted-foreground)',
'[data-slot="avatar"][data-size="sm"] &': { fontSize: '0.75rem', lineHeight: 'calc(1 / 0.75)' },
})
const avatarBadgeStyles = css({
position: 'absolute',
right: 0,
bottom: 0,
zIndex: '10',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '9999px',
backgroundColor: 'var(--primary)',
color: 'var(--primary-foreground)',
'--tw-ring-shadow': '0 0 0 2px var(--background)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
userSelect: 'none',
'[data-slot="avatar"][data-size="sm"] &': { width: '0.5rem', height: '0.5rem' },
'[data-slot="avatar"][data-size="sm"] & > svg': { display: 'none' },
'[data-slot="avatar"][data-size="default"] &': { width: '0.625rem', height: '0.625rem' },
'[data-slot="avatar"][data-size="default"] & > svg': { width: '0.5rem', height: '0.5rem' },
'[data-slot="avatar"][data-size="lg"] &': { width: '0.75rem', height: '0.75rem' },
'[data-slot="avatar"][data-size="lg"] & > svg': { width: '0.5rem', height: '0.5rem' },
})
const avatarGroupStyles = css({
display: 'flex',
'& > :not(:last-child)': { marginInlineEnd: '-0.5rem' },
'& > [data-slot="avatar"]': {
'--tw-ring-shadow': '0 0 0 2px var(--background)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
},
})
const avatarGroupCountStyles = css({
position: 'relative',
display: 'flex',
width: '2rem',
height: '2rem',
flexShrink: 0,
alignItems: 'center',
justifyContent: 'center',
borderRadius: '9999px',
backgroundColor: 'var(--muted)',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
color: 'var(--muted-foreground)',
'--tw-ring-shadow': '0 0 0 2px var(--background)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
'& > svg': { width: '1rem', height: '1rem' },
'[data-slot="avatar-group"]:has([data-size="lg"]) &': { width: '2.5rem', height: '2.5rem' },
'[data-slot="avatar-group"]:has([data-size="sm"]) &': { width: '1.5rem', height: '1.5rem' },
'[data-slot="avatar-group"]:has([data-size="lg"]) & > svg': {
width: '1.25rem',
height: '1.25rem',
},
'[data-slot="avatar-group"]:has([data-size="sm"]) & > svg': {
width: '0.75rem',
height: '0.75rem',
},
})
export function Avatar(handle: Handle<AvatarProps, AvatarContextValue>) {
let status: AvatarImageLoadingStatus = 'idle'
handle.context.set({
get status() {
return status
},
setStatus(next) {
if (next === status) return
status = next
void handle.update()
},
})
return () => {
let { size = 'default', mix, ...props } = handle.props
return <span data-slot="avatar" data-size={size} {...props} mix={[avatarStyles, mix]} />
}
}
/**
* Renders the image only once it has loaded; until then (and if it fails) the sibling
* `AvatarFallback` shows instead.
*/
export function AvatarImage(handle: Handle<AvatarImageProps>) {
let avatar = handle.context.get(Avatar)
// `null` until the first load starts after hydration (the server renders the fallback).
let loadedSrc: string | undefined | null = null
function load(src: string | undefined) {
loadedSrc = src
let { onLoadingStatusChange, referrerPolicy, crossOrigin } = handle.props
let report = (status: AvatarImageLoadingStatus) => {
if (loadedSrc !== src) return
avatar.setStatus(status)
onLoadingStatusChange?.(status)
}
if (!src) {
report('error')
return
}
report('loading')
let img = new window.Image()
img.onload = () => report('loaded')
img.onerror = () => report('error')
if (referrerPolicy) img.referrerPolicy = referrerPolicy
if (crossOrigin) img.crossOrigin = crossOrigin
img.src = src
}
handle.queueTask(() => load(handle.props.src || undefined))
handle.signal.addEventListener('abort', () => {
loadedSrc = null
avatar.setStatus('idle')
})
return () => {
let { onLoadingStatusChange: _, mix, ...props } = handle.props
let src = props.src || undefined
if (loadedSrc !== null && src !== loadedSrc) handle.queueTask(() => load(src))
if (avatar.status !== 'loaded' || src !== loadedSrc) return null
return <img data-slot="avatar-image" {...props} mix={[avatarImageStyles, mix]} />
}
}
export function AvatarFallback(handle: Handle<AvatarFallbackProps>) {
let avatar = handle.context.get(Avatar)
let delayElapsed = handle.props.delayMs === undefined
if (!delayElapsed) {
handle.queueTask(() => {
let timer = setTimeout(() => {
delayElapsed = true
void handle.update()
}, handle.props.delayMs)
handle.signal.addEventListener('abort', () => clearTimeout(timer))
})
}
return () => {
let { delayMs: _, mix, ...props } = handle.props
if (!delayElapsed || avatar.status === 'loaded') return null
return <span data-slot="avatar-fallback" {...props} mix={[avatarFallbackStyles, mix]} />
}
}
export function AvatarBadge(handle: Handle<Props<'span'>>) {
return () => {
let { mix, ...props } = handle.props
return <span data-slot="avatar-badge" {...props} mix={[avatarBadgeStyles, mix]} />
}
}
export function AvatarGroup(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return <div data-slot="avatar-group" {...props} mix={[avatarGroupStyles, mix]} />
}
}
export function AvatarGroupCount(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return <div data-slot="avatar-group-count" {...props} mix={[avatarGroupCountStyles, mix]} />
}
}Update the import paths to match your project setup.
Usage
import { Avatar, AvatarFallback, AvatarImage } from '#app/ui/avatar.tsx'<Avatar>
<AvatarImage src="https://github.com/shadcn.png" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>Composition
Use the following composition to build an Avatar:
Avatar
├── AvatarImage
├── AvatarFallback
└── AvatarBadge
AvatarGroup
├── Avatar
└── AvatarGroupCountAPI Reference
Avatar
The root <span> that wraps the avatar image and fallback.
| Prop | Type | Default |
|---|---|---|
size | "default" | "sm" | "lg" | "default" |
mix | MixValue | - |
AvatarImage
The avatar <img>. Renders only after the image has loaded.
| Prop | Type | Default |
|---|---|---|
src | string | - |
alt | string | - |
onLoadingStatusChange | (status: "idle" | "loading" | "loaded" | "error") => void | - |
mix | MixValue | - |
AvatarFallback
Rendered while the image is loading or when it fails to load.
| Prop | Type | Default |
|---|---|---|
delayMs | number | - |
mix | MixValue | - |
AvatarBadge
A badge indicator at the bottom right of the avatar.
| Prop | Type | Default |
|---|---|---|
mix | MixValue | - |
AvatarGroup
A group of overlapping avatars.
| Prop | Type | Default |
|---|---|---|
mix | MixValue | - |
AvatarGroupCount
A count indicator in an avatar group.
| Prop | Type | Default |
|---|---|---|
mix | MixValue | - |