v1.0

Skeleton

Preview

Basic

Loading…

Preview

Code

ts
import Skeleton from '@/components/data/Skeleton';

src/components/data/Skeleton.tsx

AI prompt

text
Build a loading skeleton placeholder component in React + TypeScript + Tailwind CSS.

## Look
- Every shape is `animate-pulse bg-slate-200 dark:bg-slate-700`, rendered as block `<span>`s so it can sit inside inline contexts.
- `circle`: `rounded-full`, default size `h-9 w-9`.
- `rect`: `rounded-lg`, default size `h-24 w-full`.
- `text` (default): `lines` bars stacked `space-y-1.5`, each `h-3 rounded w-full`. With more than one line, the last is `w-2/3`: a block of equal-length bars reads as a table, not as text.
- On circle and rect, `className` replaces the default size. On text, it is added to every line.

## Accessibility
Every shape is `aria-hidden`. The surrounding region is responsible for announcing that it is busy.

## API
`variant?: 'text' | 'circle' | 'rect'` (text), `lines?: number` (1), `className?`.

## Demo
At max-w-md: a circle beside a two-line text block (an avatar row), a rect, then a four-line paragraph.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { cn } from '@/lib/cn';

/**
 * A loading placeholder shaped like the thing it replaces.
 *
 * `lines > 1` staggers the last line short, because a block of equal-length bars
 * reads as a table, not as text.
 */
export default function Skeleton({
  variant = 'text',
  lines = 1,
  className,
}: {
  variant?: 'text' | 'circle' | 'rect';
  lines?: number;
  className?: string;
}) {
  const base = 'animate-pulse bg-slate-200 dark:bg-slate-700';
  if (variant === 'circle') return <span className={cn(base, 'block rounded-full', className ?? 'h-9 w-9')} aria-hidden />;
  if (variant === 'rect') return <span className={cn(base, 'block rounded-lg', className ?? 'h-24 w-full')} aria-hidden />;
  return (
    <span className="block space-y-1.5" aria-hidden>
      {Array.from({ length: lines }, (_, i) => (
        <span key={i} className={cn(base, 'block h-3 rounded', i === lines - 1 && lines > 1 ? 'w-2/3' : 'w-full', className)} />
      ))}
    </span>
  );
}

Props

PropTypeDefaultDescription
variant'text' | 'circle' | 'rect''text'
linesnumber1
classNamestring—