UIPackage

Skeleton

React 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 Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/skeleton.json

Or with the named registry: npx shadcn@latest add @uipkge-react/skeleton

Examples

Props

Name Type / Values Default Required
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'
text optional
width string optional
height string optional
loading boolean true optional

Dependencies

Used by

Files (3)

  • components/ui/skeleton/skeleton.tsx 9.3 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import { skeletonLoaderVariants } from './skeleton.variants'
    
    /* ------------------------------------------------------------------ */
    /* Shimmer keyframes                                                   */
    /* Ported from Skeleton.vue's <style scoped> block. Injected once so    */
    /* the component ships self-contained (Tailwind has no equivalent      */
    /* color-mix gradient sweep utility). Slow 1.8s sweep per NN/g.        */
    /* ------------------------------------------------------------------ */
    const shimmerCss = `
    .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);
      }
    }
    `
    
    function ShimmerStyle() {
      return <style dangerouslySetInnerHTML={{ __html: shimmerCss }} />
    }
    
    /* ------------------------------------------------------------------ */
    /* Skeleton                                                            */
    /* ------------------------------------------------------------------ */
    
    const skeletonVariantClasses: Record<NonNullable<SkeletonProps['variant']>, string> = {
      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',
    }
    
    export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
      variant?: 'rectangular' | 'rounded' | 'circular' | 'text' | 'avatar' | 'image' | 'card' | 'table-row'
      width?: string
      height?: string
      loading?: boolean
    }
    
    const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
      ({ className, variant = 'rectangular', width, height, loading = true, style, children, ...props }, ref) => {
        if (!loading) return <>{children}</>
    
        const variantStyle: React.CSSProperties = { ...style }
        if (width) variantStyle.width = width
        if (height) variantStyle.height = height
    
        return (
          <>
            <ShimmerStyle />
            <div
              ref={ref}
              data-uipkge=""
              data-slot="skeleton"
              className={cn('skeleton-shimmer', skeletonVariantClasses[variant ?? 'rectangular'], className)}
              style={variantStyle}
              {...props}
            />
          </>
        )
      },
    )
    Skeleton.displayName = 'Skeleton'
    
    /* ------------------------------------------------------------------ */
    /* SkeletonGroup                                                       */
    /* ------------------------------------------------------------------ */
    
    export interface SkeletonGroupProps extends React.HTMLAttributes<HTMLElement> {
      tag?: keyof React.JSX.IntrinsicElements
    }
    
    const SkeletonGroup = React.forwardRef<HTMLElement, SkeletonGroupProps>(
      ({ className, tag = 'div', children, ...props }, ref) => {
        const Tag = tag as React.ElementType
        return (
          <Tag
            ref={ref}
            data-uipkge=""
            data-slot="skeleton-group"
            className={cn('space-y-2', className)}
            {...props}
          >
            {children}
          </Tag>
        )
      },
    )
    SkeletonGroup.displayName = 'SkeletonGroup'
    
    /* ------------------------------------------------------------------ */
    /* SkeletonText                                                        */
    /* ------------------------------------------------------------------ */
    
    export interface SkeletonTextProps extends React.HTMLAttributes<HTMLDivElement> {
      lines?: number
      lastLineWidth?: string
      firstLineWidth?: string
    }
    
    const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(
      ({ className, lines = 3, lastLineWidth = '80%', firstLineWidth = '100%', ...props }, ref) => {
        const lineWidths = Array.from({ length: lines }, (_, i) => {
          if (i === 0) return firstLineWidth
          if (i === lines - 1) return lastLineWidth
          return '100%'
        })
    
        return (
          <div ref={ref} data-uipkge="" data-slot="skeleton-text" className={cn('space-y-2', className)} {...props}>
            {lineWidths.map((width, i) => (
              <div key={i} className="bg-primary/10 h-4 animate-pulse rounded" style={{ width }} />
            ))}
          </div>
        )
      },
    )
    SkeletonText.displayName = 'SkeletonText'
    
    /* ------------------------------------------------------------------ */
    /* SkeletonLoader                                                      */
    /* ------------------------------------------------------------------ */
    
    export interface SkeletonLoaderProps {
      className?: string
      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
      children?: React.ReactNode
    }
    
    function SkeletonLoader({
      className,
      variant = 'text',
      loading = true,
      rows = 1,
      boilerplate = false,
      children,
    }: SkeletonLoaderProps) {
      let body: React.ReactNode
    
      if (rows === 1) {
        body = loading ? <div className={cn(skeletonLoaderVariants({ variant }), className)} /> : children
      } else if (!loading) {
        body = children
      } else if (variant === 'article') {
        body = (
          <>
            <div className={cn(skeletonLoaderVariants({ variant: 'heading' }), 'mb-4')} />
            <div className={cn(skeletonLoaderVariants({ variant: 'text' }), 'mb-2')} />
            <div className={cn(skeletonLoaderVariants({ variant: 'text' }), 'mb-2')} />
            <div className={cn(skeletonLoaderVariants({ variant: 'text' }), 'w-3/4')} />
          </>
        )
      } else if (variant === 'card') {
        body = (
          <>
            <div className={cn(skeletonLoaderVariants({ variant: 'image-large' }), 'mb-4')} />
            <div className={cn(skeletonLoaderVariants({ variant: 'heading-small' }), 'mb-2')} />
            <div className={cn(skeletonLoaderVariants({ variant: 'text' }), 'mb-2')} />
            <div className={cn(skeletonLoaderVariants({ variant: 'text' }), 'w-1/2')} />
          </>
        )
      } else if (variant === 'card-avatar') {
        body = (
          <div className="mb-4 flex items-center gap-4">
            <div className={cn(skeletonLoaderVariants({ variant: 'avatar-large' }))} />
            <div className="flex-1 space-y-2">
              <div className={cn(skeletonLoaderVariants({ variant: 'heading-small' }))} />
              <div className={cn(skeletonLoaderVariants({ variant: 'text' }), 'w-1/2')} />
            </div>
          </div>
        )
      } else if (variant === 'actions') {
        body = (
          <div className="flex gap-2">
            <div className={cn(skeletonLoaderVariants({ variant: 'button' }))} />
            <div className={cn(skeletonLoaderVariants({ variant: 'button' }))} />
          </div>
        )
      } else if (variant === 'table') {
        body = Array.from({ length: rows }, (_, i) => (
          <div key={i} className={cn(skeletonLoaderVariants({ variant: 'table-row' }), 'mb-2')} />
        ))
      } else if (variant === 'list-item') {
        body = Array.from({ length: rows }, (_, i) => (
          <div key={i} className="mb-2 flex items-center gap-3">
            <div className={cn(skeletonLoaderVariants({ variant: 'avatar-small' }))} />
            <div className="flex-1">
              <div className={cn(skeletonLoaderVariants({ variant: 'text' }))} />
            </div>
          </div>
        ))
      } else if (variant === 'list-item-two-line') {
        body = Array.from({ length: rows }, (_, i) => (
          <div key={i} className="mb-2 flex items-center gap-3">
            <div className={cn(skeletonLoaderVariants({ variant: 'avatar' }))} />
            <div className="flex-1 space-y-2">
              <div className={cn(skeletonLoaderVariants({ variant: 'text' }))} />
              <div className={cn(skeletonLoaderVariants({ variant: 'text' }), 'w-3/4')} />
            </div>
          </div>
        ))
      } else if (variant === 'list-item-three-line') {
        body = Array.from({ length: rows }, (_, i) => (
          <div key={i} className="mb-2 flex items-start gap-3">
            <div className={cn(skeletonLoaderVariants({ variant: 'avatar' }))} />
            <div className="flex-1 space-y-2">
              <div className={cn(skeletonLoaderVariants({ variant: 'text' }))} />
              <div className={cn(skeletonLoaderVariants({ variant: 'text' }))} />
              <div className={cn(skeletonLoaderVariants({ variant: 'text' }), 'w-2/3')} />
            </div>
          </div>
        ))
      } else {
        body = Array.from({ length: rows }, (_, i) => (
          <div key={i} className={cn(skeletonLoaderVariants({ variant }), 'mb-2')} />
        ))
      }
    
      return (
        <div data-uipkge="" data-slot="skeleton-loader" className="space-y-2">
          {body}
          {boilerplate && !loading ? <div className="bg-muted/50 absolute inset-0" /> : null}
        </div>
      )
    }
    SkeletonLoader.displayName = 'SkeletonLoader'
    
    export { Skeleton, SkeletonGroup, SkeletonText, SkeletonLoader }
  • 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>
  • components/ui/skeleton/index.ts 0.4 kB
    export {
      Skeleton,
      SkeletonGroup,
      SkeletonText,
      SkeletonLoader,
      type SkeletonProps,
      type SkeletonGroupProps,
      type SkeletonTextProps,
      type SkeletonLoaderProps,
    } from './skeleton'
    
    // Re-export variant API from the sibling file (kept separate to mirror the
    // Vue registry convention and avoid a component <-> index circular import).
    export { skeletonLoaderVariants, type SkeletonLoaderVariants } from './skeleton.variants'

Raw manifest: https://react.uipkge.dev/r/react/skeleton.json