Skeleton

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

Skeleton

데이터 로딩 중 표시할 placeholder. text · circle · rect 변형을 조합해 카드/리스트 모양을 즉석으로 만들 수 있습니다.

Variants

기본 도형 3종.

Text
<Skeleton variant="text" width={240} />
Live Preview
Circle
<Skeleton variant="circle" width={48} height={48} />
Live Preview
Rect
<Skeleton variant="rect" height={80} />
Live Preview

Multi-line text

lines props로 여러 줄을 한 번에. 마지막 줄은 자동으로 짧게.

Source Code
<Skeleton variant="text" lines={3} />
Live Preview

Card composition

기본 부품을 조합해 콘텐츠 카드 모양을 만든 예시. 실제 Card 골격이 들어갈 자리에 그대로 배치합니다.

Source Code
<div className="rounded-2xl border border-slate-200 p-5">
  <Skeleton variant="rect" height={140} radius={12} />
  <div className="mt-4 flex items-center gap-3">
    <Skeleton variant="circle" width={32} height={32} />
    <div className="flex-1 space-y-2">
      <Skeleton variant="text" width="60%" />
      <Skeleton variant="text" width="40%" />
    </div>
  </div>
</div>
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Skeletontypescript
'use client';

/**
 * Skeleton — 데이터 로딩 placeholder.
 *
 * - variant: text(여러 줄 행) · circle(아바타) · rect(박스)
 * - shimmer 애니메이션은 Tailwind `animate-pulse` 기반 (저비용)
 * - lines props로 text variant에서 여러 줄 동시 렌더 가능
 */

import { forwardRef, type CSSProperties, type HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';

export type SkeletonVariant = 'text' | 'circle' | 'rect';

export interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
  variant?: SkeletonVariant;
  /** width — 숫자(px) 또는 CSS 값 ('100%', '12rem'). default: 100% */
  width?: number | string;
  /** height — 숫자(px) 또는 CSS 값. default: variant별 적정값 */
  height?: number | string;
  /** rounded radius (text/rect만). default: variant별 */
  radius?: number | string;
  /** text variant 전용 — 여러 줄 동시 렌더 */
  lines?: number;
  /** 마지막 줄을 짧게 (text + lines>1일 때 default true) */
  lastLineShorter?: boolean;
}

function toCss(v: number | string | undefined): string | undefined {
  if (v == null) return undefined;
  return typeof v === 'number' ? `${v}px` : v;
}

const variantDefaults: Record<SkeletonVariant, { width: string; height: string; radius: string }> =
  {
    text: { width: '100%', height: '0.875rem', radius: '0.5rem' },
    circle: { width: '2.5rem', height: '2.5rem', radius: '9999px' },
    rect: { width: '100%', height: '6rem', radius: '0.75rem' },
  };

export const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(
  (
    {
      variant = 'text',
      width,
      height,
      radius,
      lines,
      lastLineShorter = true,
      className,
      style,
      ...rest
    },
    ref,
  ) => {
    const defaults = variantDefaults[variant];
    const w = toCss(width) ?? defaults.width;
    const h = toCss(height) ?? defaults.height;
    const r = toCss(radius) ?? defaults.radius;

    const baseClass = cn(
      'block animate-pulse bg-gradient-to-r from-slate-100 via-slate-200/70 to-slate-100',
      className,
    );
    const baseStyle: CSSProperties = {
      width: w,
      height: h,
      borderRadius: r,
      ...style,
    };

    if (variant === 'text' && lines && lines > 1) {
      return (
        <div ref={ref} className="flex flex-col gap-2" {...rest}>
          {Array.from({ length: lines }).map((_, i) => {
            const isLast = i === lines - 1;
            return (
              <span
                key={i}
                aria-hidden
                className={baseClass}
                style={{
                  ...baseStyle,
                  width: isLast && lastLineShorter ? '70%' : w,
                }}
              />
            );
          })}
        </div>
      );
    }

    return <div ref={ref} aria-hidden className={baseClass} style={baseStyle} {...rest} />;
  },
);

Skeleton.displayName = 'Skeleton';