Avatar
avataruiRound 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.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/avatar.json$npx shadcn@latest add https://uipkge.dev/r/lit/avatar.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/avatar.json$bunx shadcn@latest add https://uipkge.dev/r/lit/avatar.jsonManifest:
https://uipkge.dev/r/lit/avatar.jsonInstalls to:src/uip/components/avatar/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
size | 'xs''sm''default''lg''xl''2xl' | default | optional |
rounded | 'none''sm''default''md''lg''xl''2xl''3xl''full' | default | optional |
color | 'default''primary''secondary''destructive''success''warning''info''error''muted' | default | 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.
HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-avatar': UipAvatar
'uip-avatar-image': UipAvatarImage
'uip-avatar-fallback': UipAvatarFallback
'uip-avatar-group': UipAvatarGroup
}
}npm dependencies
Includes
Used by
activity-feedchat-threadchat-two-panecustomer-reviewsdata-table-pageenterprise-workspace-switcherevent-calendar-dashboardherohero-01inboxkanban-task-boardorder-management-pageprofile-menurecord-detail-pagesidebar-01sidebar-02sidebar-05sidebar-06sidebar-07team-sectiontestimonialtestimonial-marquee-scrolluser-profile-page
Files installed (2)
src/uip/components/avatar/avatar.ts10.6 kB
import { LitElement, css, html, isServer, nothing } from 'lit' import { cn } from '../../lib/utils' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { avatarVariants, avatarFallbackVariants, type AvatarVariants } from './avatar.variants' type Size = 'xs' | 'sm' | 'default' | 'lg' | 'xl' | '2xl' type Rounded = NonNullable<AvatarVariants['rounded']> type Color = NonNullable<AvatarVariants['color']> type Variant = 'default' | 'outlined' | 'soft' export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error' /** * <uip-avatar> — the registry Avatar root (Radix Avatar.Root). * * Children: <uip-avatar-image> and/or <uip-avatar-fallback>. As in Radix, the * image only renders once it has loaded; until then (or on error) the * fallback renders. The parts need no id references, so they are separate * elements coordinated through the root. * * Class strings are React's verbatim, on the inner `part="base"` span. React's * `className` overrides → style the part from outside, e.g. * `class="[&::part(base)]:size-10 [&::part(base)]:ring-2"`. */ export class UipAvatar extends LitElement { // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: inline-flex; }`] static properties = { size: { reflect: true }, rounded: { reflect: true }, color: { reflect: true }, variant: { reflect: true }, tile: { type: Boolean, reflect: true }, disabled: { type: Boolean, reflect: true }, loading: { type: Boolean, reflect: true }, } size?: Size rounded?: Rounded color?: Color variant?: Variant tile = false disabled = false loading = false /** Loading status of the child <uip-avatar-image> (Radix context). */ imageStatus: ImageLoadingStatus = 'idle' constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'avatar') } /** Called by <uip-avatar-image>; re-renders this avatar's fallbacks. */ setImageStatus(status: ImageLoadingStatus) { this.imageStatus = status this.querySelectorAll('uip-avatar-fallback').forEach((f) => { if (f.closest('uip-avatar') === this) f.requestUpdate() }) } render() { return html`<span part="base" data-uipkge="" data-slot="avatar" class=${cn( avatarVariants({ size: this.size, rounded: this.rounded, color: this.color, variant: this.variant }), this.tile ? 'rounded-none' : '', this.disabled ? 'cursor-not-allowed opacity-50' : '', this.loading ? 'animate-pulse' : '', )} ><slot></slot ></span>` } } /** * <uip-avatar-image> — Radix Avatar.Image. Preloads `src`; the <img> renders * only once loaded. * * Events: `loading-status-change` (detail: { status: 'idle' | 'loading' | * 'loaded' | 'error' }) — React's `onLoadingStatusChange`. */ export class UipAvatarImage extends LitElement { // display: contents so the <img> is laid out directly inside the avatar. static styles = [tailwind, css`:host { display: contents; }`] static properties = { src: {}, alt: {}, status: { state: true }, } src?: string alt = '' private status: ImageLoadingStatus = 'idle' private probe?: HTMLImageElement constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'avatar-image') } disconnectedCallback() { super.disconnectedCallback() this.probe = undefined } protected willUpdate(changed: Map<string, unknown>) { if (changed.has('src')) this.load() } private load() { if (isServer) return if (!this.src) return this.setStatus('idle') const img = new Image() this.probe = img const settle = (status: ImageLoadingStatus) => () => { if (this.probe === img) this.setStatus(status) } this.setStatus('loading') img.onload = settle('loaded') img.onerror = settle('error') img.src = this.src } private setStatus(status: ImageLoadingStatus) { if (this.status === status) return this.status = status this.closest('uip-avatar')?.setImageStatus(status) this.dispatchEvent(new CustomEvent('loading-status-change', { detail: { status }, bubbles: true, composed: true })) } render() { if (this.status !== 'loaded') return nothing return html`<img part="base" data-uipkge="" data-slot="avatar-image" class="aspect-square size-full object-cover" src=${this.src ?? nothing} alt=${this.alt} />` } } /** * <uip-avatar-fallback> — Radix Avatar.Fallback. Renders while the sibling * image is not loaded (after `delay-ms`, Radix's `delayMs`). Content: the * `text` attribute, else the default slot. */ export class UipAvatarFallback extends LitElement { // display: contents so the fallback span fills the avatar directly. static styles = [tailwind, css`:host { display: contents; }`] static properties = { size: { reflect: true }, color: { reflect: true }, text: {}, delayMs: { type: Number, attribute: 'delay-ms' }, delayed: { state: true }, } size: Size = 'default' color: Color = 'default' text?: string delayMs?: number private delayed = false private timer?: ReturnType<typeof setTimeout> constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'avatar-fallback') if (!isServer && this.delayMs) { this.delayed = true this.timer = setTimeout(() => (this.delayed = false), this.delayMs) } } disconnectedCallback() { super.disconnectedCallback() clearTimeout(this.timer) } render() { // The server DOM shim has no tree: render the fallback (Radix does too). const status = isServer ? 'idle' : (this.closest('uip-avatar')?.imageStatus ?? 'idle') if (this.delayed || status === 'loaded') return nothing return html`<span part="base" data-uipkge="" data-slot="avatar-fallback" class=${avatarFallbackVariants({ size: this.size, color: this.color })} >${this.text ?? html`<slot></slot>`}</span >` } } const overflowSizeClasses: Record<Size, string> = { xs: 'size-4 text-xs', sm: 'size-6 text-xs', default: 'size-8 text-sm', lg: 'size-12 text-base', xl: 'size-16 text-lg', '2xl': 'size-20 text-xl', } /** * <uip-avatar-group> — the registry AvatarGroup (custom layout wrapper). * * Children are <uip-avatar>s. With `max`, only the first `max - 1` render * (manual slot assignment — the rest stay in the DOM, unrendered) plus a `+N` * chip. React's `overflow` render prop → an element with `slot="overflow"` * replaces the chip; the hidden count is `.overflowCount` and the host's * `data-overflow-count`. * * Overlap: React's `-space-x-2` can't reach slotted children through the * <slot>, so the same negative margin is applied with `::slotted(…)`. */ export class UipAvatarGroup extends LitElement { static shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' } static styles = [tailwind, css`:host { display: block; }`] static properties = { max: { type: Number }, overlap: { type: Boolean, converter: { fromAttribute: (v: string | null) => v !== 'false' } }, size: { reflect: true }, total: { type: Number }, items: { state: true }, } max?: number overlap = true size: Size = 'default' total?: number private items: Element[] = [] private observer?: MutationObserver constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'avatar-group') this.readItems() if (isServer) return this.observer = new MutationObserver(() => this.readItems()) this.observer.observe(this, { childList: true }) } disconnectedCallback() { super.disconnectedCallback() this.observer?.disconnect() } private readItems() { this.items = [...this.children].filter((el) => el.slot !== 'overflow') } private get layout() { const count = this.total ?? this.items.length // When overflowing, reserve one slot for the +N chip so max includes the badge. const showOverflow = this.max != null && count > this.max const visibleLimit = showOverflow ? Math.max(this.max! - 1, 0) : this.items.length return { showOverflow, visibleLimit, overflowCount: showOverflow ? count - visibleLimit : 0 } } /** Number of avatars hidden behind the overflow indicator. */ get overflowCount() { return this.layout.overflowCount } protected updated() { const { visibleLimit, overflowCount } = this.layout this.setAttribute('data-overflow-count', String(overflowCount)) const root = this.renderRoot as ShadowRoot root.querySelector<HTMLSlotElement>('slot:not([name])')?.assign(...this.items.slice(0, visibleLimit)) root .querySelector<HTMLSlotElement>('slot[name=overflow]') ?.assign(...[...this.children].filter((el) => el.slot === 'overflow')) } render() { const { showOverflow, visibleLimit, overflowCount } = this.layout return html`<div part="base" data-uipkge="" data-slot="avatar-group" class=${cn( 'flex items-center', this.overlap ? '-space-x-2 [&::slotted(:not(:first-child))]:-ms-2' : 'gap-1', )} > <slot></slot> ${showOverflow ? html`<div class=${cn( 'bg-muted ring-background relative flex shrink-0 overflow-hidden rounded-full ring-2', overflowSizeClasses[this.size], this.overlap && visibleLimit > 0 ? '-ms-2' : '', )} > <slot name="overflow" ><span class="flex size-full items-center justify-center font-medium">+${overflowCount}</span></slot > </div>` : nothing} </div>` } } customElements.get('uip-avatar') || customElements.define('uip-avatar', UipAvatar) customElements.get('uip-avatar-image') || customElements.define('uip-avatar-image', UipAvatarImage) customElements.get('uip-avatar-fallback') || customElements.define('uip-avatar-fallback', UipAvatarFallback) customElements.get('uip-avatar-group') || customElements.define('uip-avatar-group', UipAvatarGroup) declare global { interface HTMLElementTagNameMap { 'uip-avatar': UipAvatar 'uip-avatar-image': UipAvatarImage 'uip-avatar-fallback': UipAvatarFallback 'uip-avatar-group': UipAvatarGroup } }src/uip/components/avatar/avatar.variants.ts3.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-xs', 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>
Raw manifest:https://uipkge.dev/r/lit/avatar.json