Skeleton
React 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 Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/skeleton.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/skeleton.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/skeleton.json$ bunx 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