Skeleton

Feedback & Status

로딩 중 콘텐츠 자리를 회색 도형으로 대신하는 플레이스홀더.

Usage

콘텐츠 구조는 알지만 데이터가 도착하기 전 레이아웃 자리를 유지할 때 사용한다.

import

import

import { Skeleton } from '@mildang/design-system/Skeleton';

예제를 복사해 쓸 때 필요한 준비

@mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.

API Reference

Skeleton Props

Prop

Type

Default

className

string

지정 안 함

delay

number

0.3

duration

number

2

variant

"text" | "circle" | "rect"

text

설계 원칙

로딩 중 실제 콘텐츠의 자리(레이아웃)를 회색 도형으로 근사해 대기감을 줄이고, 로드 후 레이아웃 점프(CLS)를 없앤다.

목표는 완벽한 미러링이 아니라 레이아웃 근사다 — 상태점·보더·아이콘 같은 장식은 재현하지 않는다. 같은 이유로 컴포넌트별 스켈레톤(Avatar.Skeleton 같은 이름)을 따로 만들지 않는다 — 아래 조합표대로 Skeleton 프리미티브를 조립해서 쓴다.

픽셀 크기는 raw px 로 명시한다(boxSize="40px"처럼 단위를 붙인다) — sizes 스케일 토큰 이름(boxSize="40")을 쓰지 않는다. 훗날 sizes.40 토큰이 생기면 그 값이 조용히 끼어들 수 있어서다.

기본 사용

프로필 카드가 로딩 중일 때의 자리를 circle·text·rect 조합으로 만듭니다. 확정 폭 부모가 있어야 자리가 보입니다.

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, HStack, Box } from '@mildang/styled-system/jsx';

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonCardCompositionExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack gap="8" alignItems="stretch" width="360px">
      <Box textStyle="caption-md-medium" color="primary.text.low">
        circle(아바타 40px) + text(제목) + Skeleton.Text(본문 2줄) 조합
      </Box>
      <HStack gap="12" padding="12" alignItems="flex-start">
        <Skeleton {...args} variant="circle" boxSize="40px" />
        <VStack gap="12" alignItems="flex-start" flex={1}>
          <Skeleton {...args} textStyle="title-md" width="60%" />
          <Skeleton.Text
            delay={args.delay}
            duration={args.duration}
            textStyle="caption-md"
            lines={2}
            gap="8"
          />
        </VStack>
      </HStack>
    </VStack>
  );

export default function SkeletonCardCompositionExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonCardCompositionExampleRender(mergedProps);
}

어떤 모양이든 이 프리미티브로

거의 모든 스켈레톤은 아래 조합으로 표현된다. 폼 필드·칩·썸네일 등을 위해 컴포넌트별 스켈레톤을 따로 만들지 않고, 호출자가 프리미티브를 조립한다.

실제 모양프리미티브 표현
텍스트 줄variant="text" textStyle="title-2xl"
여러 줄 텍스트Skeleton.Text lines={3}
원형(아바타 등)variant="circle" boxSize="40px"
필드·박스·체크박스variant="rect" height="40px"
칩·배지(둥근 알약)variant="rect" borderRadius="full"
썸네일·이미지variant="rect" width="full" aspectRatio="16:9"

circleboxSize를 생략해도 40px(아바타 lg 크기)이 기본값이라, 위 표의 40px은 자리를 보여주는 예시지 필수 지정이 아니다.

기본 사용

프로필 카드가 로딩 중일 때의 자리를 circle·text·rect 조합으로 만듭니다. 확정 폭 부모가 있어야 자리가 보입니다.

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, HStack, Box } from '@mildang/styled-system/jsx';

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonCardCompositionExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack gap="8" alignItems="stretch" width="360px">
      <Box textStyle="caption-md-medium" color="primary.text.low">
        circle(아바타 40px) + text(제목) + Skeleton.Text(본문 2줄) 조합
      </Box>
      <HStack gap="12" padding="12" alignItems="flex-start">
        <Skeleton {...args} variant="circle" boxSize="40px" />
        <VStack gap="12" alignItems="flex-start" flex={1}>
          <Skeleton {...args} textStyle="title-md" width="60%" />
          <Skeleton.Text
            delay={args.delay}
            duration={args.duration}
            textStyle="caption-md"
            lines={2}
            gap="8"
          />
        </VStack>
      </HStack>
    </VStack>
  );

export default function SkeletonCardCompositionExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonCardCompositionExampleRender(mergedProps);
}

텍스트 막대의 모서리

variant="text"의 모서리는 textStyle의 폰트 크기에 따라 자동으로 정해진다 — 20px 이상(타이틀급)이면 8px, 그 외에는 기본값인 4px다. title-xl처럼 md(960px) 경계에서 폰트 크기 자체가 갈리는 반응형 textStyle은 모서리도 함께 반응형으로 전환돼 데스크톱에서만 8px가 된다. 직접 정하고 싶으면 borderRadius로 덮어쓴다.

접근성

프리미티브는 의미 없는 장식이라 기본 aria-hidden이 붙는다(포커스 불가, role 없음). 필요하면 호출자가 직접 덮어쓸 수 있다.

aria-busy="true"는 스켈레톤 하나하나가 아니라 로딩 영역의 상위 컨테이너에 한 번만 붙인다 — 사용처의 책임이다.

OS의 '동작 줄이기(reduced-motion)'가 켜지면 wave 애니메이션이 멈추고 fadein 지연 없이 즉시 단색으로 노출된다(자동 적용, delay 값과 무관하다).

다루지 않는 것

단계별 노출 순서는 앱(화면) 책임이다. "헤더 먼저 → 리스트 → 필터" 처럼 영역을 순차적으로 드러내는 순서·타이밍(progressive loading)은 DS가 아니라 그 화면이 오케스트레이션한다. DS는 스켈레톤 조각(프리미티브)만 제공하며, 영역별로 조립해 두고 데이터가 도착하는 순서대로 실제 콘텐츠로 교체하는 일은 호출처의 몫이다.

이미지의 대표색·블러 미리보기(LQIP, next/imageplaceholder="blur" 등)는 스켈레톤과 다른 메커니즘이다. 로딩 순서상 회색 스켈레톤 다음 단계에 해당하며 이미지 컴포넌트 계층의 책임이다 — Skeleton이 이미지 색을 미리 흉내내려 하지 않는다.

사용 가이드

권장

  • 예상 로딩이 500ms 이상일 때만 스켈레톤을 노출한다(300ms 미만은 미표시나 짧은 펄스가 낫다).
  • 무한 대기가 예상되면 최대 8초 타임아웃을 두고, 이후 에러·재시도 화면으로 전환한다.
  • 데이터 도착 시 약 150ms 페이드로 실제 콘텐츠와 교체한다.
  • 모든 데이터가 준비된 뒤 한 번에 교체한다 — 일부만 채워진 상태와 혼용하지 않는다.
  • 리스트는 3~5개만 스켈레톤으로 채운다.

지양

  • 실제 콘텐츠와 전혀 다른 회색 박스를 나열하지 않는다.
  • 강한 색이나 높은 대비를 쓰지 않는다.
  • 스켈레톤을 클릭·포커스 가능하게 만들지 않는다.
  • 로딩 종료 시 레이아웃이 점프하게 두지 않는다.

예제

Variants

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, Box } from '@mildang/styled-system/jsx';
import { type ReactNode } from 'react';

/** 예시 한 칸: 위에 설명 라벨(caption) + 아래에 실제 스켈레톤. */
const Sample = ({ label, children }: { label: ReactNode; children: ReactNode }) => (
  <VStack gap="6" alignItems="stretch" width="100%">
    <Box textStyle="caption-md-medium" color="primary.text.low">
      {label}
    </Box>
    {children}
  </VStack>
);

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonVariantsExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack gap="24" alignItems="flex-start" width="360px">
      <Sample label='variant="text" · textStyle="title-2xl" — 제목 한 줄 (높이=lineHeight 자동, radius 8px)'>
        <Skeleton {...args} textStyle="title-2xl" width="64%" />
      </Sample>
      <Sample label='variant="text" · textStyle="body-lg" — 본문 한 줄 (높이 자동, radius 4px)'>
        <Skeleton {...args} textStyle="body-lg" />
      </Sample>
      <Sample label='variant="circle" · boxSize="40px" — 아바타/아이콘 자리 (40px 원형)'>
        <Skeleton {...args} variant="circle" boxSize="40px" />
      </Sample>
      <Sample label='variant="rect" · aspectRatio="16:9" — 썸네일/이미지 (가로 100%, 높이는 비율 유지)'>
        <Skeleton {...args} variant="rect" width="full" aspectRatio="16:9" />
      </Sample>
    </VStack>
  );

export default function SkeletonVariantsExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonVariantsExampleRender(mergedProps);
}

CircleSizes

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, Box } from '@mildang/styled-system/jsx';
import { type ReactNode } from 'react';

/** 예시 한 칸: 위에 설명 라벨(caption) + 아래에 실제 스켈레톤. */
const Sample = ({ label, children }: { label: ReactNode; children: ReactNode }) => (
  <VStack gap="6" alignItems="stretch" width="100%">
    <Box textStyle="caption-md-medium" color="primary.text.low">
      {label}
    </Box>
    {children}
  </VStack>
);

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonCircleSizesExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack gap="12" alignItems="flex-end">
      <Sample label='boxSize="24px"'>
        <Skeleton {...args} variant="circle" boxSize="24px" />
      </Sample>
      <Sample label='boxSize="32px"'>
        <Skeleton {...args} variant="circle" boxSize="32px" />
      </Sample>
      <Sample label='boxSize="40px" (기본)'>
        <Skeleton {...args} variant="circle" boxSize="40px" />
      </Sample>
      <Sample label='boxSize="56px"'>
        <Skeleton {...args} variant="circle" boxSize="56px" />
      </Sample>
      <Sample label='boxSize="80px"'>
        <Skeleton {...args} variant="circle" boxSize="80px" />
      </Sample>
    </VStack>
  );

export default function SkeletonCircleSizesExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonCircleSizesExampleRender(mergedProps);
}

Thumbnails

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, Box } from '@mildang/styled-system/jsx';
import { type ReactNode } from 'react';

/** 예시 한 칸: 위에 설명 라벨(caption) + 아래에 실제 스켈레톤. */
const Sample = ({ label, children }: { label: ReactNode; children: ReactNode }) => (
  <VStack gap="6" alignItems="stretch" width="100%">
    <Box textStyle="caption-md-medium" color="primary.text.low">
      {label}
    </Box>
    {children}
  </VStack>
);

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonThumbnailsExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack gap="16" alignItems="flex-start" width="280px">
      <Sample label='aspectRatio="16:9" (기본)'>
        <Skeleton {...args} variant="rect" width="full" aspectRatio="16:9" />
      </Sample>
      <Sample label='aspectRatio="4:3"'>
        <Skeleton {...args} variant="rect" width="full" aspectRatio="4:3" />
      </Sample>
      <Sample label='aspectRatio="1:1" (정사각)'>
        <Skeleton {...args} variant="rect" width="full" aspectRatio="1:1" />
      </Sample>
    </VStack>
  );

export default function SkeletonThumbnailsExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonThumbnailsExampleRender(mergedProps);
}

TextStyleHeights

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, Box } from '@mildang/styled-system/jsx';
import { type ReactNode } from 'react';

/** 예시 한 칸: 위에 설명 라벨(caption) + 아래에 실제 스켈레톤. */
const Sample = ({ label, children }: { label: ReactNode; children: ReactNode }) => (
  <VStack gap="6" alignItems="stretch" width="100%">
    <Box textStyle="caption-md-medium" color="primary.text.low">
      {label}
    </Box>
    {children}
  </VStack>
);

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonTextStyleHeightsExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack gap="16" alignItems="flex-start" width="360px">
      {(
        [
          ['headline-lg', '헤드라인 32/40px (radius 8px) / 폭 70%'],
          ['headline-sm', '헤드라인 22/28px (radius 8px) / 폭 70%'],
          ['title-2xl', '섹션 타이틀 20/24px (radius 8px) / 폭 70%'],
          ['title-xl', '타이틀 18→20px (radius 4px→데스크톱 8px) / 폭 70%'],
          ['title-lg', '보조 타이틀 18px 고정 (radius 4px) / 폭 70%'],
          ['body-lg', '본문 (radius 4px) / 폭 70%'],
          ['caption-lg', '캡션 (radius 4px) / 폭 70%'],
        ] as const
      ).map(([ts, desc]) => (
        <Sample key={ts} label={`textStyle="${ts}" — ${desc}`}>
          <Skeleton {...args} textStyle={ts} width="70%" />
        </Sample>
      ))}
    </VStack>
  );

export default function SkeletonTextStyleHeightsExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonTextStyleHeightsExampleRender(mergedProps);
}

TextLines

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, Box } from '@mildang/styled-system/jsx';
import { type ReactNode } from 'react';

/** 예시 한 칸: 위에 설명 라벨(caption) + 아래에 실제 스켈레톤. */
const Sample = ({ label, children }: { label: ReactNode; children: ReactNode }) => (
  <VStack gap="6" alignItems="stretch" width="100%">
    <Box textStyle="caption-md-medium" color="primary.text.low">
      {label}
    </Box>
    {children}
  </VStack>
);

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonTextLinesExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack gap="24" alignItems="flex-start" width="360px">
      <Sample label="Skeleton.Text lines={3} — 본문 3줄 (폭 88→72→60%)">
        <Skeleton.Text delay={args.delay} duration={args.duration} textStyle="body-lg" lines={3} />
      </Sample>
      <Sample label='Skeleton.Text lines={1} width="40%" — 1줄, 폭 고정'>
        <Skeleton.Text
          delay={args.delay}
          duration={args.duration}
          textStyle="body-md"
          lines={1}
          width="40%"
        />
      </Sample>
    </VStack>
  );

export default function SkeletonTextLinesExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonTextLinesExampleRender(mergedProps);
}

ListWithAriaBusy

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, HStack, Box } from '@mildang/styled-system/jsx';

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonListWithAriaBusyExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack gap="12" alignItems="stretch" width="360px">
      <Box textStyle="caption-md-medium" color="primary.text.low">
        상위 컨테이너에 aria-busy="true" 1회 · 각 행 = circle(32px) + text
      </Box>
      <VStack gap="16" alignItems="stretch" aria-busy="true">
        {Array.from({ length: 4 }, (_, i) => (
          <HStack key={i} gap="12" alignItems="center">
            <Skeleton {...args} variant="circle" boxSize="32px" />
            <Skeleton {...args} textStyle="body-md" width="70%" />
          </HStack>
        ))}
      </VStack>
    </VStack>
  );

export default function SkeletonListWithAriaBusyExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonListWithAriaBusyExampleRender(mergedProps);
}

ReducedMotion

import { VStack, HStack, Box } from '@mildang/styled-system/jsx';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { css } from '@mildang/styled-system/css';
import { ReactNode } from 'react';

/** 예시 한 칸: 위에 설명 라벨(caption) + 아래에 실제 스켈레톤. */
const Sample = ({ label, children }: { label: ReactNode; children: ReactNode }) => (
  <VStack gap="6" alignItems="stretch" width="100%">
    <Box textStyle="caption-md-medium" color="primary.text.low">
      {label}
    </Box>
    {children}
  </VStack>
);

// ── reduced-motion 확인 (라이브 + 결과 미리보기) ────────────
// reduced-motion은 `@media (prefers-reduced-motion)`이라 .dark처럼 클래스로 토글 불가.
// ①은 실제 미디어쿼리를 따르는 라이브, ②는 그 결과를 재현한 미리보기(설정 무관 항상 정지).
// ※ sx가 아니라 css()로 재현 — 이 DS에서 sx는 정적 추출 대상이 아니라 CSS가 안 생기지만,
//    css({...}) 인라인 호출은 추출되고 utilities layer라 recipe의 animation/opacity를 덮어쓴다.
const reducedMotionPreview = css({ animation: 'none', opacity: 1, backgroundImage: 'none' });

const SkeletonReducedMotionExample = () => (
    <HStack gap="32" alignItems="flex-start">
      <Sample label="① 라이브 (OS/DevTools 설정 따름 — 켜면 정지)">
        <VStack gap="12" alignItems="flex-start" width="240px">
          <Skeleton textStyle="title-2xl" width="60%" />
          <Skeleton.Text textStyle="body-md" lines={2} />
        </VStack>
      </Sample>
      <Sample label="② reduced-motion 결과 미리보기 (항상 정지·단색)">
        <VStack gap="12" alignItems="flex-start" width="240px">
          <Skeleton textStyle="title-2xl" width="60%" className={reducedMotionPreview} />
          <Skeleton.Text textStyle="body-md" lines={2} className={reducedMotionPreview} />
        </VStack>
      </Sample>
    </HStack>
  );

export default SkeletonReducedMotionExample;

DarkMode

import type { ComponentProps } from 'react';
import { Skeleton } from '@mildang/design-system/Skeleton';
import { VStack, Box } from '@mildang/styled-system/jsx';

const STORY_DEFAULT_ARGS = { ...({ delay: 0 }), ...({}) } as ComponentProps<typeof Skeleton>;

const SkeletonDarkModeExampleRender = (args: ComponentProps<typeof Skeleton>) => (
    <VStack padding="24" gap="16" alignItems="flex-start" width="500px">
      <Box textStyle="caption-md-medium" color="primary.text.low">
        primary.bg.low 위 — 대비 확인용 (툴바 Color scheme로 Light/Dark 전환)
      </Box>
      <Skeleton {...args} textStyle="title-2xl" width="60%" />
      <Skeleton {...args} variant="circle" boxSize="40px" />
      <Skeleton.Text delay={args.delay} duration={args.duration} textStyle="body-md" lines={3} />
    </VStack>
  );

export default function SkeletonDarkModeExample(props: Partial<ComponentProps<typeof Skeleton>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Skeleton>;
  return SkeletonDarkModeExampleRender(mergedProps);
}