
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/avatar.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/avatar.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/avatar.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/avatar.jsonnpx shadcn-vue@latest add @uipkge/avatarInstalls to:app/components/ui/avatar/| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
size | 'xs''sm''default''lg''xl''2xl' | — | optional |
rounded | 'none''sm''default''md''lg''xl''2xl''3xl''full' | — | optional |
color | 'default''primary''secondary''destructive''success''warning''info''error''muted' | — | optional |
variant | 'default''outlined''soft' | — | optional |
tile | boolean | false | optional |
disabled | boolean | false | optional |
loading | boolean | false | optional |
Type aliases from this item's source — use them to shape the data you pass in.
AvatarContextinterface AvatarContext {
imageStatus: Ref<AvatarImageStatus>
setImageStatus: (status: AvatarImageStatus) => void
}<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, provide, ref } from 'vue'
import { cn } from '@/lib/utils'
import { avatarVariants } from './avatar.variants'
import { AVATAR_INJECTION_KEY, type AvatarImageStatus } from './context'
// Inlined unions: SFC compiler can't extract runtime props from
// `AvatarVariants['size']` etc. indexed-access types.
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | '2xl'
rounded?: 'none' | 'sm' | 'default' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
color?: 'default' | 'primary' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'error' | 'muted'
variant?: 'default' | 'outlined' | 'soft'
tile?: boolean
disabled?: boolean
loading?: boolean
}>(),
{
tile: false,
disabled: false,
loading: false,
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
function handleClick(event: MouseEvent) {
if (!props.disabled) {
emit('click', event)
}
}
const imageStatus = ref<AvatarImageStatus>('idle')
provide(AVATAR_INJECTION_KEY, {
imageStatus,
setImageStatus: (status) => {
imageStatus.value = status
},
})
const rootClasses = computed(() =>
cn(
avatarVariants({ size: props.size, rounded: props.rounded, color: props.color, variant: props.variant }),
props.tile ? 'rounded-none' : '',
props.disabled ? 'cursor-not-allowed opacity-50' : '',
props.loading ? 'animate-pulse' : '',
props.class,
),
)
</script>
<template>
<span :class="rootClasses" data-uipkge data-slot="avatar" @click="handleClick">
<slot />
</span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, inject } from 'vue'
import { cn } from '@/lib/utils'
import { avatarFallbackVariants } from './avatar.variants'
import { AVATAR_INJECTION_KEY } from './context'
// Inlined unions: SFC compiler can't extract runtime props from
// indexed-access types.
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | '2xl'
color?: 'default' | 'primary' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'error' | 'muted'
text?: string
}>(),
{
size: 'default',
color: 'default',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
function handleClick(event: MouseEvent) {
emit('click', event)
}
// Show fallback until a sibling AvatarImage reports loaded (Radix composition).
const ctx = inject(AVATAR_INJECTION_KEY, null)
const visible = computed(() => !ctx || ctx.imageStatus.value !== 'loaded')
const rootClasses = computed(() => cn(avatarFallbackVariants({ size: props.size, color: props.color }), props.class))
</script>
<template>
<span v-if="visible" :class="rootClasses" data-uipkge data-slot="avatar-fallback" @click="handleClick">
<template v-if="text">{{ text }}</template>
<slot v-else />
</span>
</template>
<script setup lang="ts">
import type { HTMLAttributes, VNode } from 'vue'
import { Comment, Fragment, Text, computed, defineComponent, useSlots } from 'vue'
import { cn } from '@/lib/utils'
export interface AvatarGroupProps {
class?: HTMLAttributes['class']
max?: number
overlap?: boolean
size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | '2xl'
/** Override the total used for +N when only a subset of avatars is rendered. */
total?: number
}
const props = withDefaults(defineProps<AvatarGroupProps>(), {
overlap: true,
size: 'default',
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
function handleClick(event: MouseEvent) {
emit('click', event)
}
const slots = useSlots()
function flattenVNodes(nodes: VNode[] | undefined): VNode[] {
const out: VNode[] = []
for (const node of nodes ?? []) {
if (!node) continue
if (node.type === Comment) continue
if (node.type === Text && !String(node.children ?? '').trim()) continue
if (node.type === Fragment) {
out.push(...flattenVNodes(node.children as VNode[]))
} else {
out.push(node)
}
}
return out
}
const allChildren = computed(() => flattenVNodes(slots.default?.() as VNode[] | undefined))
const totalCount = computed(() => props.total ?? allChildren.value.length)
// When overflowing, reserve one slot for the +N chip so max includes the badge.
const isOverflowing = computed(() => props.max != null && totalCount.value > props.max)
const visibleLimit = computed(() =>
isOverflowing.value ? Math.max((props.max ?? 0) - 1, 0) : allChildren.value.length,
)
const overflowCount = computed(() => (isOverflowing.value ? totalCount.value - visibleLimit.value : 0))
const visibleChildren = computed(() => allChildren.value.slice(0, visibleLimit.value))
const overflowSizeClass = computed(() => {
switch (props.size) {
case 'xs':
return 'size-4 text-xs'
case 'sm':
return 'size-6 text-xs'
case 'lg':
return 'size-12 text-base'
case 'xl':
return 'size-16 text-lg'
case '2xl':
return 'size-20 text-xl'
default:
return 'size-8 text-sm'
}
})
// Render pre-flattened VNodes from the default slot.
const VisibleAvatars = defineComponent({
name: 'AvatarGroupVisible',
setup() {
return () => visibleChildren.value
},
})
</script>
<template>
<div
:class="cn('flex items-center', overlap ? '-space-x-2' : 'gap-1', props.class)"
data-uipkge
data-slot="avatar-group"
@click="handleClick"
>
<VisibleAvatars />
<div
v-if="isOverflowing"
:class="
cn('bg-muted ring-background relative flex shrink-0 overflow-hidden rounded-full ring-2', overflowSizeClass)
"
>
<slot name="overflow" :count="overflowCount">
<span class="flex size-full items-center justify-center font-medium">+{{ overflowCount }}</span>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { AVATAR_INJECTION_KEY } from './context'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
src?: string
alt?: string
loading?: 'eager' | 'lazy'
referrerpolicy?:
| 'no-referrer'
| 'no-referrer-when-downgrade'
| 'origin'
| 'origin-when-cross-origin'
| 'same-origin'
| 'strict-origin'
| 'strict-origin-when-cross-origin'
| 'unsafe-url'
| ''
crossorigin?: 'anonymous' | 'use-credentials'
}>(),
{
// Radix's React AvatarImage has no lazy default; lazily-loaded avatars below
// the fold would sit on their fallback initials and diverge from the React
// mirror, so eager is the parity-correct default.
loading: 'eager',
},
)
const emit = defineEmits<{
error: [event: Event]
load: [event: Event]
}>()
// Sibling AvatarFallback shows when image is not loaded — match Radix composition.
const ctx = inject(AVATAR_INJECTION_KEY, null)
const status = computed(() => ctx?.imageStatus.value ?? 'idle')
watch(
() => props.src,
(src) => {
ctx?.setImageStatus(src ? 'loading' : 'idle')
},
{ immediate: true },
)
const imageRef = ref<HTMLImageElement | null>(null)
// An image that finished before this component attached its listener never
// fires `load`/`error`, so reconcile from the element state on mount: a
// complete image with pixels is loaded; a complete image without them failed.
onMounted(() => {
const el = imageRef.value
if (!el?.complete) return
ctx?.setImageStatus(el.naturalWidth > 0 ? 'loaded' : 'error')
})
onBeforeUnmount(() => {
ctx?.setImageStatus('idle')
})
function handleError(event: Event) {
ctx?.setImageStatus('error')
emit('error', event)
}
function handleLoad(event: Event) {
ctx?.setImageStatus('loaded')
emit('load', event)
}
</script>
<template>
<img
v-if="src && status !== 'error'"
ref="imageRef"
:src="src"
:alt="alt"
:loading="loading"
:referrerpolicy="referrerpolicy"
:crossorigin="crossorigin"
:class="cn('aspect-square size-full object-cover', props.class)"
data-uipkge
data-slot="avatar-image"
@error="handleError"
@load="handleLoad"
/>
</template>
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>
import type { InjectionKey, Ref } from 'vue'
export type AvatarImageStatus = 'idle' | 'loading' | 'loaded' | 'error'
export interface AvatarContext {
imageStatus: Ref<AvatarImageStatus>
setImageStatus: (status: AvatarImageStatus) => void
}
export const AVATAR_INJECTION_KEY: InjectionKey<AvatarContext> = Symbol('uipkge-avatar')
export { default as Avatar } from './Avatar.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export { default as AvatarGroup } from './AvatarGroup.vue'
// Re-export variant API from the sibling file (kept separate to avoid the
// Avatar.vue <-> index.ts circular import that broke dev SSR).
export {
avatarVariants,
avatarFallbackVariants,
type AvatarVariants,
type AvatarFallbackVariants,
} from './avatar.variants'
Raw manifest:https://uipkge.dev/r/vue/avatar.json