Avatar
avataruiUser avatar with Radix image loading: the image renders only once it has loaded, the fallback (optionally delayed) shows until then. AvatarGroup overlaps avatars and collapses extras into a +N chip.
Installation
$pnpm dlx uipkge-ng@latest add avatar$npx uipkge-ng@latest add avatar$yarn dlx uipkge-ng@latest add avatar$bunx uipkge-ng@latest add avatarManifest:
https://uipkge.dev/r/angular/avatar.jsonInstalls to:src/app/components/ui/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 |
class | string | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
AvatarOverflowContextinterface AvatarOverflowContext {
$implicit: number
count: number
}npm dependencies
Used by
access-request-workflowactivity-feedagent-memory-inspectorai-code-review-assistantai-crm-pipeline-dashboardai-llm-chatai-saas-command-centerairbnb-host-earningsaudio-waveform-transcriptaudit-event-streamaudit-logauthor-bio-boxblog-post-card-gridboard-resolution-signoffcap-table-ownership-summarycase-studycase-study-featured-storycase-study-metric-cardschat-threadchat-two-panecode-diff-reviewcontent-calendar-matrixcontract-redline-diffcontractor-timesheet-approvalcourse-curriculum-outlinecrm-sales-call-workbenchcustomer-reviewsdaily-standup-summarydata-explorerdata-mesh-domain-catalogdata-product-carddata-table-pagedelivery-proof-carddeployment-pipelinedispatch-assignment-boarddoctor-appointment-scheduleremployee-org-chart-treeenterprise-workspace-switcherevent-calendar-dashboardexit-interview-surveyfaq-contact-sidebarfield-inspection-managerfreelance-work-for-hire-contractherohero-01inboxinfluencer-campaign-cardinvite-membersinvoices-listkanban-task-boardloyalty-rewards-tier-hubmeeting-agenda-notesmentionsorder-management-pagepatient-medical-recordpayroll-run-summarypeer-discussion-forumperformance-review-matrixpodcast-audio-playerpress-kit-brand-assetsprofile-menuproperty-listing-cardrecord-detail-pagerecruitment-candidate-pipelinereferral-program-widgetresources-featured-listresources-teaser-gridsecurity-incident-timelinesidebar-01sidebar-02sidebar-05sidebar-06sidebar-07skill-assessment-radarsmart-inbox-triagesocial-media-post-schedulerstore-pickup-curbsidestudent-gradebook-tablesubpoena-legal-hold-managersupport-ticket-detailteam-sectiontelehealth-video-roomtestimonialtestimonial-logo-quote-rowtestimonial-marquee-scrolltestimonial-single-featuredtestimonial-social-cardstime-off-portaltransaction-feedtriage-queue-dashboarduse-casesuse-cases-role-cardsuser-profile-pagevirtual-card-manager
Files installed (3)
src/app/components/ui/avatar/avatar.component.ts11.2 kB
import { AfterContentInit, Component, DestroyRef, Directive, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, TemplateRef, ViewContainerRef, booleanAttribute, computed, inject, signal, ChangeDetectionStrategy, } from '@angular/core' import { cn } from '@/lib/utils' import { avatarVariants, avatarFallbackVariants, type AvatarVariants, type AvatarFallbackVariants, } from './avatar.variants' export type AvatarSize = NonNullable<AvatarVariants['size']> export type AvatarRounded = NonNullable<AvatarVariants['rounded']> export type AvatarColor = NonNullable<AvatarVariants['color']> export type AvatarVariant = NonNullable<AvatarVariants['variant']> /** Radix `ImageLoadingStatus`. */ export type AvatarImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error' /** * Angular port of UIPKGE Avatar (React `Avatar` on Radix Avatar). Radix semantics: the root * holds the image loading status; `ui-avatar-image` preloads its `src` off-DOM and only * renders the `<img>` once it has loaded, while `ui-avatar-fallback` renders until then * (optionally after `delayMs`) and disappears once the image is shown. A broken or missing * src therefore shows just the fallback, never both side by side. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-avatar, [ui-avatar]', standalone: true, host: { '[attr.data-uipkge]': '""', '[attr.data-slot]': '"avatar"', '[class]': 'hostClass', }, template: `<ng-content />`, }) export class UiAvatarComponent { @Input() size?: AvatarSize @Input() rounded?: AvatarRounded @Input() color?: AvatarColor @Input() variant?: AvatarVariant @Input({ transform: booleanAttribute }) tile = false @Input({ transform: booleanAttribute }) disabled = false @Input({ transform: booleanAttribute }) loading = false @Input('class') className?: string /** Shared with the image / fallback parts (signal: set from load callbacks, zoneless-safe). */ readonly imageStatus = signal<AvatarImageLoadingStatus>('idle') get hostClass(): string { return 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' : '', this.className, ) } } /** * Radix `AvatarImage`: preloads `src` with `new Image()` (honouring `referrerPolicy` / * `crossOrigin`) and renders the `<img>` only when it loaded. Emits `loadingStatusChange` * (React `onLoadingStatusChange`) for every status after `idle`. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-avatar-image, [ui-avatar-image]', standalone: true, host: { class: 'contents' }, template: `@if (status() === 'loaded') { <img [src]="src" [attr.alt]="alt ?? null" [attr.loading]="loading ?? null" [attr.referrerpolicy]="referrerPolicy ?? null" [attr.crossorigin]="crossOrigin ?? null" data-uipkge="" data-slot="avatar-image" [class]="imgClass" /> }`, }) export class UiAvatarImageComponent implements OnChanges, OnDestroy { private readonly avatar = inject(UiAvatarComponent, { optional: true }) private readonly ownStatus = signal<AvatarImageLoadingStatus>('idle') private probe?: HTMLImageElement @Input() src?: string @Input() alt?: string @Input() loading?: 'eager' | 'lazy' @Input() referrerPolicy?: ReferrerPolicy @Input() crossOrigin?: 'anonymous' | 'use-credentials' | '' @Input('class') className?: string @Output() loadingStatusChange = new EventEmitter<AvatarImageLoadingStatus>() readonly status = computed(() => (this.avatar ? this.avatar.imageStatus() : this.ownStatus())) get imgClass(): string { return cn('aspect-square size-full object-cover', this.className) } ngOnChanges(): void { this.load() } ngOnDestroy(): void { this.detach() if (this.avatar) this.avatar.imageStatus.set('idle') } private setStatus(s: AvatarImageLoadingStatus): void { if (this.avatar) this.avatar.imageStatus.set(s) this.ownStatus.set(s) if (s !== 'idle') this.loadingStatusChange.emit(s) } private detach(): void { if (!this.probe) return this.probe.onload = this.probe.onerror = null this.probe = undefined } private load(): void { this.detach() if (!this.src || typeof window === 'undefined') { this.setStatus('error') return } const img = new window.Image() this.probe = img img.onload = () => this.probe === img && this.setStatus('loaded') img.onerror = () => this.probe === img && this.setStatus('error') if (this.referrerPolicy) img.referrerPolicy = this.referrerPolicy if (this.crossOrigin !== undefined) img.crossOrigin = this.crossOrigin this.setStatus('loading') img.src = this.src if (img.complete && img.naturalWidth > 0) this.setStatus('loaded') } } /** * Radix `AvatarFallback`: rendered while the image is not loaded; with `delayMs` it waits * that long first (avoids a flash of initials for fast-loading images). `text` wins over * projected content, like React `text ?? children`. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-avatar-fallback, [ui-avatar-fallback]', standalone: true, host: { '[attr.data-uipkge]': '""', '[attr.data-slot]': '"avatar-fallback"', '[class]': 'hostClass', }, template: `@if (visible()) { @if (text !== undefined) { {{ text }} } @else { <ng-content /> } }`, }) export class UiAvatarFallbackComponent implements OnInit { private readonly avatar = inject(UiAvatarComponent, { optional: true }) private readonly destroyRef = inject(DestroyRef) private readonly canRender = signal(true) @Input() size: NonNullable<AvatarFallbackVariants['size']> = 'default' @Input() color: NonNullable<AvatarFallbackVariants['color']> = 'default' @Input() text?: string @Input() delayMs?: number @Input('class') className?: string readonly visible = computed(() => this.canRender() && (this.avatar?.imageStatus() ?? 'idle') !== 'loaded') ngOnInit(): void { if (this.delayMs === undefined) return this.canRender.set(false) const id = setTimeout(() => this.canRender.set(true), this.delayMs) this.destroyRef.onDestroy(() => clearTimeout(id)) } get hostClass(): string { return this.visible() ? cn(avatarFallbackVariants({ size: this.size, color: this.color }), this.className) : 'hidden' } } /** Renders an `overflow` template with the hidden count (React `overflow(count)`). */ @Directive({ selector: '[uiAvatarOverflowOutlet]', standalone: true }) export class UiAvatarOverflowOutletDirective implements OnChanges, OnDestroy { @Input('uiAvatarOverflowOutlet') template: TemplateRef<AvatarOverflowContext> | null = null @Input() uiAvatarOverflowOutletCount = 0 private readonly vcr = inject(ViewContainerRef) private readonly ctx: AvatarOverflowContext = { $implicit: 0, count: 0 } ngOnChanges(): void { this.ctx.$implicit = this.ctx.count = this.uiAvatarOverflowOutletCount if (this.vcr.length === 0 && this.template) this.vcr.createEmbeddedView(this.template, this.ctx) } ngOnDestroy(): void { this.vcr.clear() } } export interface AvatarOverflowContext { $implicit: number count: number } const overflowSizeClasses: Record<AvatarSize, 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', } /** * React `AvatarGroup` (custom layout, not Radix): overlaps its children and, when there are * more than `max`, shows only `max - 1` of them plus a `+N` chip (so `max` includes the chip). * Children are counted from the DOM (and re-counted when they change); `total` overrides the * count when only a subset is projected. `overflow` replaces the `+N` chip with a template * that receives the hidden count (`let-count`). */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-avatar-group, [ui-avatar-group]', standalone: true, imports: [UiAvatarOverflowOutletDirective], host: { '[attr.data-uipkge]': '""', '[attr.data-slot]': '"avatar-group"', '[class]': 'hostClass', }, template: `<ng-content /> @if (showOverflow()) { <div data-avatar-group-overflow="" [class]="overflowClass"> @if (overflow) { <ng-container [uiAvatarOverflowOutlet]="overflow" [uiAvatarOverflowOutletCount]="overflowCount()" /> } @else { <span class="flex size-full items-center justify-center font-medium">+{{ overflowCount() }}</span> } </div> }`, }) export class UiAvatarGroupComponent implements AfterContentInit, OnChanges, OnDestroy { private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement private observer?: MutationObserver private readonly itemCount = signal(0) private readonly inputs = signal<{ max?: number; total?: number }>({}) /** Max visible avatars including the +N chip when overflowing. */ @Input() max?: number @Input({ transform: booleanAttribute }) overlap = true @Input() size: AvatarSize = 'default' /** Override the total used for +N when only a subset of avatars is projected. */ @Input() total?: number @Input() overflow?: TemplateRef<AvatarOverflowContext> @Input('class') className?: string private readonly count = computed(() => this.inputs().total ?? this.itemCount()) readonly showOverflow = computed(() => { const max = this.inputs().max return max != null && this.count() > max }) private readonly visibleLimit = computed(() => this.showOverflow() ? Math.max((this.inputs().max ?? 0) - 1, 0) : Number.POSITIVE_INFINITY, ) readonly overflowCount = computed(() => (this.showOverflow() ? this.count() - this.visibleLimit() : 0)) get overflowClass(): string { return cn( 'bg-muted ring-background relative flex shrink-0 overflow-hidden rounded-full ring-2', overflowSizeClasses[this.size] ?? overflowSizeClasses.default, ) } get hostClass(): string { return cn('flex items-center', this.overlap ? '-space-x-2' : 'gap-1', this.className) } ngOnChanges(): void { this.inputs.set({ max: this.max, total: this.total }) this.apply() } ngAfterContentInit(): void { this.apply() if (typeof MutationObserver === 'undefined') return this.observer = new MutationObserver(() => this.apply()) this.observer.observe(this.el, { childList: true }) } ngOnDestroy(): void { this.observer?.disconnect() } private items(): HTMLElement[] { return [...this.el.children].filter( (c): c is HTMLElement => c instanceof HTMLElement && !c.hasAttribute('data-avatar-group-overflow'), ) } /** Hide children beyond the visible limit (React simply does not render them). */ private apply(): void { const items = this.items() this.itemCount.set(items.length) const limit = this.visibleLimit() items.forEach((item, i) => { const hide = i >= limit item.toggleAttribute('hidden', hide) // The `hidden` attribute's UA display:none loses to the avatar's `flex` utility, so hide inline too. item.style.display = hide ? 'none' : '' }) } } export { avatarVariants, avatarFallbackVariants, type AvatarVariants, type AvatarFallbackVariants }src/app/components/ui/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>src/app/components/ui/avatar/index.ts0.4 kB
export { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent, UiAvatarGroupComponent, UiAvatarOverflowOutletDirective, avatarVariants, avatarFallbackVariants, type AvatarVariants, type AvatarFallbackVariants, type AvatarSize, type AvatarRounded, type AvatarColor, type AvatarVariant, type AvatarImageLoadingStatus, type AvatarOverflowContext, } from './avatar.component'
Raw manifest:https://uipkge.dev/r/angular/avatar.json