
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
skeletonuiAnimated placeholder rectangles for loading states — drop one in shape of the content that’s about to render. Variants for text lines, avatars, rounded rectangles, and circles.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/skeleton.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/skeleton.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/skeleton.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/skeleton.jsonnpx shadcn-vue@latest add @uipkge/skeletonInstalls to:app/components/ui/skeleton/| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
variant | 'rectangular''rounded''circular''text''avatar''image''card''table-row' | 'rectangular' | optional |
width | string | — | optional |
height | string | — | optional |
loading | boolean | true | optional |
<script setup lang="ts">
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
variant?: 'rectangular' | 'rounded' | 'circular' | 'text' | 'avatar' | 'image' | 'card' | 'table-row'
width?: string
height?: string
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'rectangular',
loading: true,
})
const variantClasses = {
rectangular: '',
rounded: 'rounded-md',
circular: 'rounded-full',
text: 'rounded h-4 w-full',
avatar: 'rounded-full size-10',
image: 'rounded-lg size-24',
card: 'rounded-xl size-full min-h-30',
'table-row': 'rounded h-10 w-full',
}
const variantStyles = computed(() => {
const base: Record<string, string> = {}
if (props.width) base.width = props.width
if (props.height) base.height = props.height
return Object.keys(base).length > 0 ? base : undefined
})
</script>
<template>
<div
v-if="loading"
data-uipkge
data-slot="skeleton"
aria-hidden="true"
:class="cn('skeleton-shimmer', variantClasses[variant || 'rectangular'], props.class)"
:style="variantStyles"
/>
<slot v-else />
</template>
<style scoped>
/* Slow left-to-right gradient sweep. Per NN/g, slow shimmer reads as
faster loading than a pulse. 1.8s loop is the documented sweet spot. */
.skeleton-shimmer {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--muted) 100%, transparent) 0%,
color-mix(in srgb, var(--muted) 60%, var(--foreground) 8%) 50%,
color-mix(in srgb, var(--muted) 100%, transparent) 100%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.8s linear infinite;
}
@keyframes skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
background: var(--muted);
}
}
</style>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
tag?: string
}
const props = withDefaults(defineProps<Props>(), {
tag: 'div',
})
</script>
<template>
<component :is="props.tag" data-uipkge data-slot="skeleton-group" :class="cn('space-y-2', props.class)">
<slot />
</component>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { skeletonLoaderVariants } from './skeleton.variants'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
variant?:
| 'text'
| 'chip'
| 'chip-icon'
| 'article'
| 'avatar'
| 'avatar-small'
| 'avatar-large'
| 'heading'
| 'heading-medium'
| 'heading-small'
| 'image'
| 'image-small'
| 'image-large'
| 'card'
| 'card-avatar'
| 'actions'
| 'table'
| 'table-row'
| 'button'
| 'button-icon'
| 'badge'
| 'tab'
| 'date-picker'
| 'list-item'
| 'list-item-two-line'
| 'list-item-three-line'
loading?: boolean
rows?: number
boilerplate?: boolean
}>(),
{
variant: 'text',
loading: true,
rows: 1,
boilerplate: false,
},
)
</script>
<template>
<div data-uipkge data-slot="skeleton-loader" class="space-y-2">
<!-- Single item -->
<template v-if="rows === 1">
<div v-if="loading" :class="cn(skeletonLoaderVariants({ variant }), props.class)" />
<slot v-else />
</template>
<!-- Multiple rows -->
<template v-else>
<template v-if="loading">
<!-- Article variant with multiple elements -->
<template v-if="variant === 'article'">
<div :class="cn(skeletonLoaderVariants({ variant: 'heading' }), 'mb-4')" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }), 'mb-2')" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }), 'mb-2')" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }), 'w-3/4')" />
</template>
<!-- Card variant -->
<template v-else-if="variant === 'card'">
<div :class="cn(skeletonLoaderVariants({ variant: 'image-large' }), 'mb-4')" />
<div :class="cn(skeletonLoaderVariants({ variant: 'heading-small' }), 'mb-2')" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }), 'mb-2')" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }), 'w-1/2')" />
</template>
<!-- Card avatar variant -->
<template v-else-if="variant === 'card-avatar'">
<div class="mb-4 flex items-center gap-4">
<div :class="cn(skeletonLoaderVariants({ variant: 'avatar-large' }))" />
<div class="flex-1 space-y-2">
<div :class="cn(skeletonLoaderVariants({ variant: 'heading-small' }))" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }), 'w-1/2')" />
</div>
</div>
</template>
<!-- Actions variant -->
<template v-else-if="variant === 'actions'">
<div class="flex gap-2">
<div :class="cn(skeletonLoaderVariants({ variant: 'button' }))" />
<div :class="cn(skeletonLoaderVariants({ variant: 'button' }))" />
</div>
</template>
<!-- Table variant -->
<template v-else-if="variant === 'table'">
<div v-for="i in rows" :key="i" :class="cn(skeletonLoaderVariants({ variant: 'table-row' }), 'mb-2')" />
</template>
<!-- List item variants -->
<template v-else-if="variant === 'list-item'">
<div v-for="i in rows" :key="i" class="mb-2 flex items-center gap-3">
<div :class="cn(skeletonLoaderVariants({ variant: 'avatar-small' }))" />
<div class="flex-1">
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }))" />
</div>
</div>
</template>
<!-- List item two line -->
<template v-else-if="variant === 'list-item-two-line'">
<div v-for="i in rows" :key="i" class="mb-2 flex items-center gap-3">
<div :class="cn(skeletonLoaderVariants({ variant: 'avatar' }))" />
<div class="flex-1 space-y-2">
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }))" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }), 'w-3/4')" />
</div>
</div>
</template>
<!-- List item three line -->
<template v-else-if="variant === 'list-item-three-line'">
<div v-for="i in rows" :key="i" class="mb-2 flex items-start gap-3">
<div :class="cn(skeletonLoaderVariants({ variant: 'avatar' }))" />
<div class="flex-1 space-y-2">
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }))" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }))" />
<div :class="cn(skeletonLoaderVariants({ variant: 'text' }), 'w-2/3')" />
</div>
</div>
</template>
<!-- Default: repeat rows -->
<template v-else>
<div v-for="i in rows" :key="i" :class="cn(skeletonLoaderVariants({ variant }), 'mb-2')" />
</template>
</template>
<slot v-else />
</template>
<!-- Boilerplate mode: dim the content -->
<div v-if="boilerplate && !loading" class="bg-muted/50 absolute inset-0" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
lines?: number
lastLineWidth?: string
firstLineWidth?: string
}
const props = withDefaults(defineProps<Props>(), {
lines: 3,
lastLineWidth: '80%',
firstLineWidth: '100%',
})
const lineWidths = computed(() => {
return Array.from({ length: props.lines }, (_, i) => {
if (i === 0) return props.firstLineWidth
if (i === props.lines - 1) return props.lastLineWidth
return '100%'
})
})
</script>
<template>
<div data-uipkge data-slot="skeleton-text" aria-hidden="true" :class="cn('space-y-2', props.class)">
<div v-for="(width, i) in lineWidths" :key="i" class="skeleton-shimmer h-4 rounded" :style="{ width }" />
</div>
</template>
<style scoped>
/* Match Skeleton.vue shimmer so text lines don't flash a different motion language. */
.skeleton-shimmer {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--muted) 100%, transparent) 0%,
color-mix(in srgb, var(--muted) 60%, var(--foreground) 8%) 50%,
color-mix(in srgb, var(--muted) 100%, transparent) 100%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.8s linear infinite;
}
@keyframes skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
background: var(--muted);
}
}
</style>
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
/**
* Variant definitions live in their own file (rather than inline in the
* SFC) so `SkeletonLoader.vue` can import them without the cva runtime
* coupling that breaks SSR when several `<SkeletonLoader>` instances
* render before the module graph fully resolves. Sibling pattern to
* `card/card.variants.ts`.
*/
export const skeletonLoaderVariants = cva('bg-muted animate-pulse rounded', {
variants: {
variant: {
text: 'h-4 w-full',
chip: 'h-8 w-24 rounded-full',
'chip-icon': 'h-8 w-8 rounded-full',
article: '',
avatar: 'size-12 rounded-full',
'avatar-small': 'size-8 rounded-full',
'avatar-large': 'size-16 rounded-full',
heading: 'h-6 w-3/4',
'heading-medium': 'h-5 w-1/2',
'heading-small': 'h-4 w-1/3',
image: 'aspect-video w-full rounded-lg',
'image-small': 'h-32 w-32 rounded-lg',
'image-large': 'h-64 w-full rounded-lg',
card: '',
'card-avatar': '',
actions: '',
table: 'h-4 w-full',
'table-row': 'h-12 w-full',
button: 'h-10 w-24 rounded-md',
'button-icon': 'h-10 w-10 rounded-md',
badge: 'h-6 w-16 rounded-full',
tab: 'h-8 w-24 rounded-md',
'date-picker': 'h-10 w-full',
'list-item': 'h-16 w-full',
'list-item-two-line': 'h-20 w-full',
'list-item-three-line': 'h-24 w-full',
},
},
defaultVariants: {
variant: 'text',
},
})
export type SkeletonLoaderVariants = VariantProps<typeof skeletonLoaderVariants>
export { default as Skeleton } from './Skeleton.vue'
export { default as SkeletonText } from './SkeletonText.vue'
export { default as SkeletonGroup } from './SkeletonGroup.vue'
export { default as SkeletonLoader } from './SkeletonLoader.vue'
// Re-export variant API from the sibling file (kept separate to avoid the
// SkeletonLoader.vue <-> index.ts circular import that broke dev SSR).
export { skeletonLoaderVariants, type SkeletonLoaderVariants } from './skeleton.variants'
Raw manifest:https://uipkge.dev/r/vue/skeleton.json