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
string
지정 안 함
number
0.3
number
2
"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" |
circle은 boxSize를 생략해도 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/image의 placeholder="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);
}