스켈레톤 (Skeleton)
데이터가 오기 전에 표 · 목록 · 카드의 자리를 회색 면으로 미리 잡아, 화면에 들어갈 때 내용이 덜컹거리지 않게 합니다.
CSS 전용 · Web / Mobile · 2026-10-21 신설
규칙 (강제) · 코딩 에이전트용
- 기존 로딩 표시를 대체하지 않습니다. 스피너 · 「불러오는 중…」 한 줄은 그대로 두고, 그 아래 자리를 스켈레톤이 잡습니다. 목적은 도착 순간의 덜컹(높이 변화 · 밀림)을 없애는 것입니다.
- 「첫 로딩」에만 그립니다. 이미 그려진 목록을 필터 · 새로고침으로 다시 부를 때는 이전 내용을 그대로 둡니다. 기준 상태는 「첫 응답이 왔다(성공 · 실패 무관)」 한 가지 —
employeesLoaded·rosterSettled·leaveBalanceSettled처럼finally에서 켭니다. 실패 때 켜지 않으면 스켈레톤이 영원히 남습니다. - 빈 상태 문구는 로딩이 끝난 뒤에만.
x-if="items.length === 0"만 보고 「…없어요」를 그리면 로딩 중에 빈 상태가 먼저 뜹니다.x-if="loaded && items.length === 0"로 씁니다. - 「있을 때만 나오는」 카드에는 그리지 않습니다 — 없는 사람에게 스켈레톤을 그리면 카드가 떴다 사라지는 새 덜컹이 생깁니다. 꼭 필요하면 지난번 결과를 기억해 있던 사람에게만 그립니다(직원 앱 홈 「지금 할 일」
readHadTodos). - 색은 토큰 한 가지 —
--color-skeleton(라이트 neutral-75, 다크는 면보다 한 단계 밝은 회색). 면에 따라 변형을 붙입니다.- 흰 면(카드 · 표 · 흰 화면) — 기본, 변형 없음.
- 회색 면(
bg-bg-alternative카드 · 직원 앱 셸 바탕) —ds-skeleton--on-sunken. neutral-75 는 회색 바탕(neutral-50)과 4 차이라 사라집니다. - 떠 있는 면(모달 · 팝오버 · 메뉴) —
ds-skeleton--on-elevated..ds-menu·.ds-modal· 데스크톱 채팅 창 안은 DS 가 자동으로 바꿔 줍니다. - 팔레트 · hex · 인라인 색으로 회색을 칠하지 않습니다(
bg-neutral-100 animate-pulse손수 만든 블록 금지).
- 폭은 Tailwind
w-*, 높이 · 모서리 · 원 지름은 변수로..ds-skeleton은 width 를 선언하지 않아w-24·w-3/4가 그대로 먹습니다. 높이는 이 파일이 레이어 밖이라h-*유틸이 지므로--ds-skeleton-h·--ds-skeleton-radius·--ds-skeleton-size를 씁니다(스피너와 같은 확장 규칙). - 표는
<tr class="ds-skeleton-row">에 칸마다.ds-skeleton하나. 칸 · 행마다 폭이 조금씩 달라지는 규칙이 DS 에 있으니 폭을 손으로 주지 않습니다. 고정 열(tbl-v2-sticky-*) 클래스는 실제 행과 똑같이 붙입니다. 체크박스 칸은ds-skeleton--box. - 움직임은 페이드 한 가지(옅어졌다 돌아옴, 1.6초). 반짝임(shimmer) · 그라데이션 띠를 만들지 않습니다.
prefers-reduced-motion이면 멈춥니다. - 반응형 숨김(
hidden md:block)은 감싸는 요소에..ds-skeleton의display: block이 레이어 밖이라 같은 요소의hidden유틸을 이겨, 모바일에서도 보입니다(2026-10-21 직원 앱 도입 화면에서 발견). - 스켈레톤 묶음에는
aria-hidden="true"— 화면 읽기 도구에는 기존 「불러오는 중」 문구가 상태를 알립니다. - 새 모양은 변수만 덮습니다.
design-system.css에.ds-skeleton--<이름>을 추가해--ds-skeleton-*만 바꾸고, 이 페이지 변형 표에 행을 더합니다. 앱main.css에 정의하지 않습니다.
Interactive playground
실제 DS CSS 로 렌더해요. 색은 라이트 neutral-75, 다크는 면보다 한 단계 밝은 회색이에요(미리보기 테마로 비교). 회색 면 위에서는
--on-sunken, 모달 · 메뉴 같은 떠 있는 면 안에서는 --on-elevated(.ds-menu · .ds-modal · 데스크톱 채팅 창 안은 자동)를 씁니다. <!-- 첫 응답 전에만 — 빈 상태 행은 로딩이 끝난 뒤에 그린다 -->
<template x-if="!rowsLoaded">
<template x-for="i in 4" :key="'sk-' + i">
<tr class="ds-skeleton-row">
<td class="cell"><span class="ds-skeleton ds-skeleton--box"></span></td>
<template x-for="c in 5" :key="c"><td class="cell"><span class="ds-skeleton"></span></td></template>
</tr>
</template>
</template>
<template x-if="rowsLoaded && rows.length === 0">…빈 상태…</template>흰 면 (기본)
회색 면 (--on-sunken)
떠 있는 면 (--on-elevated)
같은 모양을 세 면 위에 놓았어요. 면마다 변형을 맞춰야 회색이 사라지거나 튀지 않아요.
언제 쓰나
| 상황 | 쓰는 것 |
|---|---|
| 표 · 카드 · 목록의 모양을 미리 알고, 데이터 전후로 높이가 바뀜 | 스켈레톤 + (있으면) 기존 스피너 |
| 「없어요」 빈 상태가 로딩 중에 먼저 뜸 | 스켈레톤 + 빈 상태 조건에 로딩 끝남 추가 |
| 모양을 모르는 영역 전체가 빔(앱 부팅 · iframe 임베드) | 영역 링 — 스켈레톤 아님 |
| 버튼 · 컨트롤만 바쁨 | 인라인 아이콘 스피너 |
| 진행률을 앎(N/M) | 카운트 배너 |
| 내용이 몇 줄인 짧은 팝업 | 스피너 한 번으로 충분 — 스켈레톤 불필요 |
사용법
표 (Table 1) — <tbody> 안, 실제 행 바로 아래.
html
<template x-if="!employeesLoaded">
<template x-for="i in 12" :key="'emp-sk-' + i">
<tr class="ds-skeleton-row">
<td class="cell tbl-v2-sticky-1"><span class="ds-skeleton ds-skeleton--box"></span></td>
<td class="cell tbl-v2-sticky-2"><span class="ds-skeleton"></span></td>
<template x-for="c in 9" :key="c"><td class="cell"><span class="ds-skeleton"></span></td></template>
</tr>
</template>
</template>
<template x-if="employeesLoaded && employees.length === 0">
<tr><td colspan="12" class="cell text-center text-label-muted">직원 데이터가 없어요</td></tr>
</template>목록 (아바타 + 두 줄) — 메신저 대화 목록.
html
<template x-if="!loaded && conversations.length === 0">
<div aria-hidden="true">
<template x-for="i in 8" :key="'conv-sk-' + i">
<div class="flex items-center gap-3 py-2.5">
<span class="ds-skeleton ds-skeleton--circle"></span>
<div class="flex-1 min-w-0 flex flex-col gap-2">
<span class="ds-skeleton ds-skeleton--title w-24"></span>
<span class="ds-skeleton w-3/4"></span>
</div>
</div>
</template>
</div>
</template>회색 카드 안 — 결재 탭 연차현황(bg-bg-alternative).
html
<template x-if="!leaveBalanceSettled">
<div class="bg-bg-alternative rounded-xl p-4 flex flex-col gap-2.5" aria-hidden="true">
<span class="ds-skeleton ds-skeleton--on-sunken w-16"></span>
<span class="ds-skeleton ds-skeleton--title ds-skeleton--on-sunken w-3/5"></span>
</div>
</template>기존 로딩 표시와 함께 — 직원 카드 팝오버. 링 + 「불러오는 중…」은 그대로, 아래 자리만 스켈레톤.
html
<div x-show="state === 'loading'">
<div class="flex items-center gap-2"><span class="ds-spinner ds-spinner--sm"></span><span>불러오는 중…</span></div>
<div class="flex flex-col gap-3" aria-hidden="true">
<span class="ds-skeleton w-10"></span>
<div class="flex justify-between"><span class="ds-skeleton w-20"></span><span class="ds-skeleton w-24"></span></div>
</div>
</div>적용 현황
2026-10-21 1차(P1) — 진입마다 겪는 큰 덜컹 9곳.
| 앱 | 화면 | 자리 | 기준 상태 |
|---|---|---|---|
| admin | 직원 관리 | 표 12행 · 통계 핀 숫자 | employeesLoaded · statsReady |
| admin | 팀 관리 | 팀 트리 8줄 · 명단 표 10행(두 표) | teamsLoaded · rosterSettled |
| admin | 근태 현황 › 연차 | 표 10행 · 요약 숫자 3개 | leaveBalancesLoaded |
| 공용(admin · team-manager · 직원 앱) | 직원 카드 팝오버 | 소속 팀 · 병동 배정 줄 | $store.employeeCard.state |
| 직원 앱 | 홈 | 「지금 할 일」 카드(지난번에 있던 계정만) | loaded · expectTodos |
| 직원 앱 | 결재 › 신청·결재 | 연차현황 카드(회색 면) | leaveBalanceSettled |
| 직원 앱 | 결재 › 내 신청·결재 현황 | 묶음 1개(제목 + 신청 줄 3개) | loaded |
| 공용(직원 앱 · 채팅 위젯) | 메신저 대화 목록 | 대화 줄 8개 | loaded |
| 직원 앱 | 급여명세서 | 연도 줄 + 월 카드 4장(화면을 먼저 열도록 바꿈) | payslipsLoading |
2026-10-21 2차(P2) — 자주 열지만 덜컹이 중간이거나 이미 스피너가 있던 18곳.
| 앱 | 화면 | 자리 | 기준 상태 |
|---|---|---|---|
| admin | 역할 관리 | 줄 카드 8개(.ds-list-card 껍데기) | loadedOnce.roles |
| admin | 병동 관리 | 좌측 목록 6줄 · 요약 숫자 · 표 5행 | loadedOnce.wards |
| admin | 근태 현황 › 외근 · 근로시간 마감 · 정정 요청 | 표 행 | loadedOnce.offsite · .closings · .corrections |
| admin | 설정 › 직무 · 근무 그룹 · 근로형태 | 표 카드(근무 그룹은 그룹 카드 2장) | loadedOnce.jobDuties · .shiftGroups · .workPatterns |
| admin | 설정 › 조직 설정 | 「불러오는 중」 아래 설정 카드 2장(실제 머리 · 본문 껍데기) | orgInfo |
| admin | 스케줄러 | 링 아래 근무표 격자(이름 열 + 14칸 × 10줄) | loading |
| admin | 연차 잔여 상세 모달 | 「불러오는 중…」 아래 표 4행 | leaveDetailLoading |
| 직원 앱 | 근무 기록 | 월 합계 숫자 4칸 · 일별 카드 2장 | workLog · workLogDayLoading |
| 직원 앱 | 팀 근태 | 링 아래 팀원 줄 6개 | loading |
| 공용(직원 앱 · 웹 앱 모달) | 내 정보 | 라벨 · 값 줄(화면을 먼저 열도록 바꿈) | myInfoLoading |
| 직원 앱 | 공지(소식 전체) · 드라이브 · 알림 | 링 · 「로딩 중」 아래 목록 줄 | noticesLoading · drive.loading · notificationsLoading |
| 공용(admin · team-manager · 직원 앱) | 결재선 패널 | 안내 한 줄 아래 단계 줄 3개 | apsLoading |
2026-10-21 3차(P3) — 5곳.
| 앱 | 화면 | 자리 | 기준 상태 |
|---|---|---|---|
| admin | 예약된 변경 | 링 아래 표 8행(768 이상) · 카드 4장(768 미만) | loading && rows.length === 0 |
| admin | 직원 앱 도입 | 링 아래 「도입 흐름」 단계 카드 · 「지금 할 일」 카드 | loading && !summary |
| admin | 전자 서명 | 연동 상태 전 목록 머리 + 표, 목록 전 표 행 | loadedOnce.esignStatus · .esign |
| admin | 결재선 › 팀별 결재선 | 링(첫 줄) 아래 팀 줄 6개 | teamLineLoading && !teamLineOverview |
| 공용(admin 등) | 직원 정보 수정 모달 | 「편집」 즉시 같은 크기 껍데기 + 메뉴 · 입력칸 자리, 준비되면 실제 패널로 | editOpening |
공통 상태 하나 — admin 은 목록별 「첫 응답이 왔다」를 loadedOnce.<목록> 한 객체에 모았어요. 로더의 finally 에서 켭니다.
변형
| 클래스 | 모양 | 값 |
|---|---|---|
.ds-skeleton | 줄(본문 한 줄) | 높이 12 · 모서리 4 |
.ds-skeleton--title | 제목 줄 · 이름 | 높이 18 |
.ds-skeleton--block | 버튼 · 큰 숫자 · 입력칸 | 높이 44 · 모서리 8 |
.ds-skeleton--circle | 아바타 · 썸네일 | 지름 --ds-skeleton-size(기본 40) |
.ds-skeleton--box | 체크박스 칸 | 16 × 16 · 가운데 |
.ds-skeleton--on-sunken | 회색 면 위 | --color-skeleton-sunken |
.ds-skeleton--on-elevated | 떠 있는 면 위 | --color-skeleton-elevated |
tr.ds-skeleton-row | 표 행 | 칸 · 행마다 폭 40~76% 로 흔듦 |
색
| 토큰 | 라이트 | 다크 |
|---|---|---|
--color-skeleton | neutral-75 #f1f1f1 (흰 면과 14 차이) | neutral-600 40% + neutral-800 ≈ #343434 (면 #252525 과 15 차이) |
--color-skeleton-sunken | neutral-100 #e5e5e5 (회색 면 #f5f5f5 위) | neutral-700 #2c2c2c (면 #1b1b1b 위) |
--color-skeleton-elevated | neutral-75 (떠 있는 면도 흰색) | neutral-600 50% + neutral-700 ≈ #3b3b3b (면 #2c2c2c 위) |
다크에서 라이트처럼 「한 단계 어둡게」 하면 면과 구분이 안 됩니다. 다크는 면보다 밝게, 차이는 라이트와 비슷하게(약 15) 맞췄어요. 700 단계를 그대로 쓰면 카드 면과 7 차이라 거의 보이지 않았습니다.
규격
| 변수 | 기본 | 쓰는 곳 |
|---|---|---|
--ds-skeleton-h | 12px | 높이 — h-* 유틸 대신 |
--ds-skeleton-radius | --radius-4 | 모서리 |
--ds-skeleton-size | 40px | --circle 지름 |
--ds-skeleton-bg | --color-skeleton | 면 색 — 변형이 바꾼다. 화면에서 직접 덮지 않는다 |
단일 출처
- CSS · 토큰:
packages/static/styles/design-system.css의.ds-skeleton블록과--color-skeleton*(라이트@theme static· 다크[data-theme="dark"]). - 앱
main.css에 복사본을 두지 않습니다.
변경 이력
- 2026-10-21 — 컴포넌트 · 토큰 신설, P1 9곳 적용, 이 페이지 신설 → 변경 이력