UIPackage

Skeleton

Vue feedback
Edit on GitHub

Animated 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

$ npx 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