feat: add Skeleton primitive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 11:58:40 +01:00
parent ba48f677d9
commit b6fcdada8a
3 changed files with 201 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
@keyframes shimmer {
from {
background-position: -200% center;
}
to {
background-position: 200% center;
}
}
.skeleton {
background-color: var(--bg-inset);
display: block;
}
.shimmer {
background-image: linear-gradient(
90deg,
var(--bg-inset) 25%,
color-mix(in srgb, var(--bg-inset) 60%, transparent) 50%,
var(--bg-inset) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
.text {
height: 12px;
border-radius: 4px;
width: 100%;
}
.textGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.circular {
border-radius: 50%;
width: 40px;
height: 40px;
}
.rectangular {
border-radius: var(--radius-sm);
width: 100%;
height: 80px;
}

View File

@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Skeleton } from './Skeleton'
describe('Skeleton', () => {
it('renders rectangular variant by default', () => {
const { container } = render(<Skeleton />)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('rectangular')
expect(el).toHaveClass('shimmer')
})
it('renders text variant with single bar', () => {
const { container } = render(<Skeleton variant="text" />)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('text')
expect(el).toHaveClass('shimmer')
})
it('renders circular variant', () => {
const { container } = render(<Skeleton variant="circular" />)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('circular')
expect(el).toHaveClass('shimmer')
})
it('renders multiple lines for text variant when lines > 1', () => {
const { container } = render(<Skeleton variant="text" lines={3} />)
const group = container.firstChild as HTMLElement
expect(group).toHaveClass('textGroup')
const bars = group.querySelectorAll('.text')
expect(bars).toHaveLength(3)
})
it('renders last bar at 70% width when lines > 1', () => {
const { container } = render(<Skeleton variant="text" lines={3} />)
const group = container.firstChild as HTMLElement
const bars = group.querySelectorAll('.text')
const lastBar = bars[bars.length - 1] as HTMLElement
expect(lastBar.style.width).toBe('70%')
})
it('applies custom width and height', () => {
const { container } = render(<Skeleton variant="rectangular" width={200} height={50} />)
const el = container.firstChild as HTMLElement
expect(el.style.width).toBe('200px')
expect(el.style.height).toBe('50px')
})
it('applies custom width and height as strings', () => {
const { container } = render(<Skeleton variant="circular" width="80px" height="80px" />)
const el = container.firstChild as HTMLElement
expect(el.style.width).toBe('80px')
expect(el.style.height).toBe('80px')
})
it('applies extra className', () => {
const { container } = render(<Skeleton className="custom-class" />)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('custom-class')
})
it('circular has default 40x40 dimensions', () => {
const { container } = render(<Skeleton variant="circular" />)
const el = container.firstChild as HTMLElement
expect(el.style.width).toBe('40px')
expect(el.style.height).toBe('40px')
})
it('rectangular has default 80px height and 100% width', () => {
const { container } = render(<Skeleton variant="rectangular" />)
const el = container.firstChild as HTMLElement
expect(el.style.width).toBe('100%')
expect(el.style.height).toBe('80px')
})
it('is aria-hidden', () => {
const { container } = render(<Skeleton />)
const el = container.firstChild as HTMLElement
expect(el).toHaveAttribute('aria-hidden', 'true')
})
})

View File

@@ -0,0 +1,71 @@
import styles from './Skeleton.module.css'
interface SkeletonProps {
variant?: 'text' | 'circular' | 'rectangular'
width?: string | number
height?: string | number
lines?: number
className?: string
}
function normalizeSize(value: string | number | undefined): string | undefined {
if (value === undefined) return undefined
return typeof value === 'number' ? `${value}px` : value
}
export function Skeleton({
variant = 'rectangular',
width,
height,
lines,
className,
}: SkeletonProps) {
const w = normalizeSize(width)
const h = normalizeSize(height)
if (variant === 'text') {
const lineCount = lines && lines > 1 ? lines : 1
if (lineCount === 1) {
return (
<div
className={[styles.skeleton, styles.text, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
style={{ width: w, height: h }}
aria-hidden="true"
/>
)
}
return (
<div className={[styles.textGroup, className ?? ''].filter(Boolean).join(' ')} aria-hidden="true">
{Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className={[styles.skeleton, styles.text, styles.shimmer].join(' ')}
style={{
width: i === lineCount - 1 ? '70%' : (w ?? '100%'),
height: h,
}}
/>
))}
</div>
)
}
if (variant === 'circular') {
return (
<div
className={[styles.skeleton, styles.circular, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
style={{ width: w ?? '40px', height: h ?? '40px' }}
aria-hidden="true"
/>
)
}
// rectangular (default)
return (
<div
className={[styles.skeleton, styles.rectangular, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
style={{ width: w ?? '100%', height: h ?? '80px' }}
aria-hidden="true"
/>
)
}