목록 UI를 구현하다 보면 데이터 로딩 동안 화면이 비거나 레이아웃이 밀리는 상황을 자주 마주친다.
스켈레톤 UI(Skeleton UI)는 그 순간을 채우기 위한 로딩 패턴으로, 실제 콘텐츠와 비슷한 형태의 회색 블록을 먼저 보여준다.
뼈대(skeleton)만 먼저 그려두고, 데이터가 준비되면 실제 콘텐츠로 교체하는 방식이다.
[데이터 로딩 중] → 빈 화면 or 스피너 → [콘텐츠 등장]
빈 화면이나 스피너는 사용자 입장에서 "뭔가 고장난 건가?" 하는 불안감을 준다.
또한 콘텐츠가 갑자기 나타나면서 레이아웃이 툭툭 밀리는 현상(Layout Shift)이 발생한다.
[데이터 로딩 중] → 스켈레톤(뼈대) → [콘텐츠 등장]
| 항목 | 효과 |
|---|---|
| 체감 속도 | 실제보다 빠르게 느껴짐 |
| 레이아웃 안정성 | 자리를 미리 잡아두어 Layout Shift 없음 |
| 사용자 불안감 | "로딩 중"임을 시각적으로 인지시킴 |
| UX 완성도 | 서비스 전체의 완성도가 올라 보임 |
스켈레톤은 모든 로딩에 쓰는 게 아니다. 상황에 맞게 선택해야 한다.
| 상황 | 권장 방식 |
|---|---|
| 로딩이 300ms 이상 걸릴 때 | 스켈레톤 |
| 목록, 카드, 랭킹 등 반복 구조 | 스켈레톤 |
| 버튼 클릭 후 즉시 응답 | 스피너 or 비활성화 처리 |
| 페이지 전체 전환 | 스피너 or 프로그레스바 |
스켈레톤 전용 HTML을 따로 만들어두고, 로딩 시작 시 삽입 → 완료 시 실제 콘텐츠로 교체하는 방식.
흐름
API 호출 시작 → 스켈레톤 HTML 삽입 → 데이터 수신 → 실제 콘텐츠 HTML로 교체
HTML (스켈레톤 전용)
<div class="card-skeleton">
<div class="skeleton skeleton--thumbnail"></div>
<div class="skeleton skeleton--title"></div>
<div class="skeleton skeleton--desc"></div>
</div>
CSS
.skeleton {
position: relative;
display: block;
overflow: hidden;
background: #ececec;
border-radius: 4px;
}
/* shimmer 애니메이션 */
.skeleton::after {
content: "";
position: absolute;
top: 0;
left: -120px;
width: 80px;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.7) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { left: -120px; }
100% { left: 100%; }
}
.skeleton--thumbnail { width: 100%; height: 180px; }
.skeleton--title { width: 70%; height: 16px; margin-top: 12px; }
.skeleton--desc { width: 50%; height: 12px; margin-top: 8px; }
JS
async function loadCard() {
// 스켈레톤 삽입
container.innerHTML = skeletonHTML;
const data = await fetchData();
// 실제 콘텐츠로 교체
container.innerHTML = renderCard(data);
}
장점
단점
기존 실제 마크업에 .skeleton 클래스만 추가/제거하는 방식.
별도 스켈레톤 HTML이 필요 없다.
흐름
페이지 렌더링 → section에 .skeleton 클래스 추가 → 데이터 수신 → .skeleton 클래스 제거
HTML (실제 마크업 그대로 사용)
<!-- 로딩 중: skeleton 클래스 있음 -->
<section class="card-section skeleton">
<h2 class="card-title">인기 상품</h2>
<ul class="card-list">
<li class="card-item">
<span class="card-rank">1</span>
<span class="card-name">상품명</span>
</li>
</ul>
</section>
<!-- 로딩 완료: skeleton 클래스 제거 -->
<section class="card-section">
...
</section>
CSS
/* 스켈레톤 상태일 때 하위 요소를 덮어씀 */
.skeleton .card-title,
.skeleton .card-rank,
.skeleton .card-name {
position: relative;
overflow: hidden;
background: #ececec;
color: transparent; /* 텍스트 숨기기 */
border-radius: 4px;
pointer-events: none; /* 클릭 막기 */
}
.skeleton .card-title { width: 80px; height: 18px; }
.skeleton .card-rank { width: 16px; height: 14px; }
.skeleton .card-name { width: 74%; height: 14px; }
/* shimmer */
.skeleton .card-title::after,
.skeleton .card-rank::after,
.skeleton .card-name::after {
content: "";
position: absolute;
top: 0; left: -120px;
width: 80px; height: 100%;
background: linear-gradient(
90deg,
rgba(255,255,255,0) 0%,
rgba(255,255,255,0.7) 50%,
rgba(255,255,255,0) 100%
);
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { left: -120px; }
100% { left: 100%; }
}
JS
async function loadCard() {
section.classList.add('skeleton');
const data = await fetchData();
renderCard(data);
section.classList.remove('skeleton');
}
장점
단점
color: transparent, pointer-events: none 등 예외 처리가 많아진다| 상황 | 추천 방법 |
|---|---|
| AJAX로 콘텐츠 전체를 동적으로 삽입 | 방법 A (마크업 교체) |
| PHP 등 서버에서 마크업을 미리 렌더링 | 방법 B (클래스 토글) |
| 컴포넌트 구조가 자주 바뀜 | 방법 B |
| 스켈레톤 모양을 세밀하게 커스텀해야 함 | 방법 A |
스켈레톤의 핵심인 shimmer(반짝임) 효과는 ::after 가상 요소로 구현한다.
[회색 박스 - overflow: hidden]
↑
[흰색 빛줄기 - position: absolute]
left: -120px → left: 100% 로 이동 (animation)
overflow: hidden 덕분에 빛줄기가 박스 밖으로 나가면 잘려서 보이지 않는다.
이 빛줄기가 왼쪽에서 오른쪽으로 반복 이동하면서 shimmer 효과가 만들어진다.
키워드 너비를 다르게 줘서 자연스럽게 만들기
모든 항목의 너비가 똑같으면 부자연스러워 보인다.
nth-child로 항목마다 너비를 다르게 주면 실제 텍스트처럼 보인다.
방법 B(클래스 토글)에서는 요소에 color: transparent와 background가 이미 적용되어 있으므로, width를 직접 덮어쓰면 된다.
.skeleton .card-name { width: 74%; }
.skeleton .card-item:nth-child(2n) .card-name { width: 62%; }
.skeleton .card-item:nth-child(3n) .card-name { width: 84%; }
.skeleton .card-item:nth-child(5n) .card-name { width: 56%; }
로딩 완료 후 부드럽게 전환
스켈레톤에서 실제 콘텐츠로 바뀔 때 transition을 주면 덜 튀어보인다.
.card-section {
transition: opacity 0.2s ease;
}
.card-section.skeleton {
opacity: 0.8;
}
접근성 처리
스켈레톤 UI에서 접근성의 핵심은 스켈레톤 요소 하나하나를 스크린 리더에게 설명하는 게 아니라,
컨테이너가 현재 로딩 중이라는 상태만 전달하는 것이다.
aria-busy="true"는 "이 영역은 아직 준비 중"이라는 신호를 스크린 리더에 보내고, 내부 콘텐츠 읽기를 보류하게 한다.
<section aria-busy="true" aria-label="콘텐츠 로딩 중">
// 완료 후: 로딩 끝났음을 알림
section.setAttribute('aria-busy', 'false');
section.removeAttribute('aria-label');
| 항목 | 내용 |
|---|---|
| 정의 | 로딩 중 실제 콘텐츠 형태를 흉내낸 회색 플레이스홀더 |
| 목적 | 체감 속도 향상, Layout Shift 방지, UX 완성도 |
| 핵심 기술 | ::after 가상 요소 + shimmer 애니메이션 |
| 구현 방식 | 별도 마크업 교체 / CSS 클래스 토글 |
| 선택 기준 | 동적 삽입이면 마크업 교체, 서버 렌더링이면 클래스 토글 |
스켈레톤 UI는 단순한 로딩 효과가 아니다.
화면을 미리 채워두는 것 자체가 사용자에게 "지금 잘 되고 있다"는 신호를 주는 UX 장치다.