Skeleton
Vue feedbackAnimated 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 ->Installation
$ 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.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/skeleton
Examples
Props
| 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 |
Dependencies
Used by
Files (6)
-
app/components/ui/skeleton/Skeleton.vue 1.9 kB
<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-[120px]', '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" :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> -
app/components/ui/skeleton/SkeletonGroup.vue 0.4 kB
<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> -
app/components/ui/skeleton/SkeletonLoader.vue 5.1 kB
<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> -
app/components/ui/skeleton/SkeletonText.vue 0.8 kB
<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" :class="cn('space-y-2', props.class)"> <div v-for="(width, i) in lineWidths" :key="i" class="bg-primary/10 h-4 animate-pulse rounded" :style="{ width }" /> </div> </template> -
app/components/ui/skeleton/skeleton.variants.ts 1.6 kB
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> -
app/components/ui/skeleton/index.ts 0.5 kB
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