GODRICH

스켈레톤 UI CSS 9종 — 복붙하면 되는 로딩 화면

스켈레톤 UI CSS는 실제 글자·이미지가 도착하기 전에 그 자리를 회색 막대나 동그라미로 미리 그려 두는 로딩 방식입니다. 빈 화면이나 뱅글뱅글 도는 스피너보다 "곧 뭔가 나온다"는 확신을 먼저 줍니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 화면 하나에서 겪는 순서로 놓았습니다. 글·카드·목록·표처럼 정적인 자리부터 채우고(01~04), 사진처럼 무거운 자리(05), 상자 크기에 따라 모양이 바뀌는 경우(06), 데이터가 도착한 뒤의 전환(07), 긴 목록에서의 리듬(08), 배색까지 자동으로 맞추는 경우(09) 순입니다.

01글줄 반짝임

글 한 줄이 들어갈 자리를 폭이 다른 회색 막대 네 개로 대신하고, 그 위로 빛이 왼쪽에서 오른쪽으로 훑고 지나갑니다. 댓글·목록처럼 텍스트 줄 수가 대충 예상되는 곳에 씁니다.

1.4s느리게 2s빛 없이
.line {
  position: relative; overflow: hidden;
  height: 14px; border-radius: 12px; background: rgba($color, .14);
  &::after {
    content: ""; position: absolute; inset: 0;
    background: linear-gradient(90deg, transparent, rgba($color, .45), transparent);
    transform: translateX(-120%);
    animation: shimmer $duration $easing infinite;
  }
}
@keyframes shimmer { to { transform: translateX(120%); } }

02카드 스켈레톤

이미지·제목·설명 자리를 가진 카드 한 장이고, 세 자리 모두 같은 빛이 훑습니다. 상품 목록이나 글 카드처럼 이미지와 텍스트가 함께 오는 곳에 씁니다.

3장가로형둥글게
%bar {
  position: relative; overflow: hidden;
  border-radius: $r-control; background: rgba($color, .16);
  &::after {
    content: ""; position: absolute; inset: 0;
    background: linear-gradient(90deg, transparent, rgba($stage-paper, .85), transparent);
    transform: translateX(-120%);
    animation: shimmer $duration $easing infinite;
  }
}
.thumb { @extend %bar; aspect-ratio: 2 / 1; }
.title { @extend %bar; height: 16px; width: 70%; }

03아바타 목록

동그란 아바타와 두 줄 글이 다섯 번 반복되고, 반짝임은 막대마다가 아니라 목록 전체 위로 한 줄기만 지나갑니다. 채팅방이나 팔로워 목록처럼 줄 수가 많은 곳에 씁니다.

5줄작게글 한 줄
.avatar { width: 28px; height: 28px; border-radius: $r-pill; background: rgba($color, .22); }
.sheen {
  position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent, rgba($stage-paper, .4), transparent);
  transform: translateX(-120%);
  animation: shimmer $duration $easing infinite;
  pointer-events: none;
}
@keyframes shimmer { to { transform: translateX(120%); } }

04표 스켈레톤

머리글 글자는 그대로 두고 몸통 셀만 막대로 바꾸며, 헤더와 몸통이 같은 grid-template-columns를 써서 열 폭이 흔들리지 않습니다. 대시보드 표처럼 열이 의미를 가진 곳에 씁니다.

6행열 폭 유지줄무늬
.row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 8px; padding: 8px 12px; }
.row.head { border-bottom: 1px solid rgba($color, .12); }
.cell { height: 11px; border-radius: $r-control; background: rgba($color, .13); }
.sheen {
  position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent, rgba($subject-blue, .45), transparent);
  animation: shimmer $duration $easing infinite;
}

05이미지 블러업

아주 작은 원본을 20px 흐리게 먼저 깔고, 그 위에 선명한 판을 겹쳐 opacity로 나타났다 사라지길 반복합니다. 나타나는 순간 scale(1.04→1)로 확대가 살짝 풀립니다. 히어로 이미지나 갤러리처럼 사진이 무거운 곳에 씁니다.

20px400ms확대 섞기
.placeholder { filter: blur(20px); transform: scale(1.15); }
.real {
  opacity: 0; transform: scale(1.04);
  animation: reveal $duration $easing infinite;
}
@keyframes reveal {
  0%   { opacity: 0; transform: scale(1.04); }
  50%  { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.04); }
}

06반응형 스켈레톤

카드 세 장을 담는 상자에 container-type: inline-size를 걸어 두면, 뷰포트가 아니라 이 상자 자신의 폭에 따라 3열·2열·1열이 바뀝니다. 사이드바에 넣든 본문에 넣든 파일 하나로 끝납니다.

3열2열 @ 480px1열 @ 320px
.wrap { container-type: inline-size; container-name: skeleton; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
@container skeleton (max-width: 260px) {
  .grid { grid-template-columns: repeat(2, 1fr); }
}
@container skeleton (max-width: 150px) {
  .grid { grid-template-columns: 1fr; }
}

07스켈레톤 → 내용 전환

막대 세 개와 실제 문단을 같은 칸에 겹쳐 두고, 데이터가 도착하면 막대는 페이드로 사라지고 문단은 6px 위에서 페이드로 내려옵니다. 스켈레톤을 쓴 모든 화면의 마지막 장면입니다.

300ms위로 살짝페이드만
.skeleton, .content { grid-area: 1 / 1; }
.skeleton { animation: fade-out $duration $easing infinite; }
.content { animation: fade-in $duration $easing infinite; }
@keyframes fade-out { 0%, 40% { opacity: 1; } 55%, 85% { opacity: 0; } 100% { opacity: 1; } }
@keyframes fade-in {
  0%, 40%  { opacity: 0; transform: translateY(6px); }
  55%, 85% { opacity: 1; transform: translateY(0); }
  100%     { opacity: 0; transform: translateY(6px); }
}

08줄마다 시차

막대 여섯 개가 01과 같은 빛을 받지만, 줄마다 animation-delay를 60ms씩 늦춰 파도처럼 보이게 하고 짝수 줄은 반대 방향으로 훑습니다. 긴 목록을 스크롤할 때 리듬을 줍니다.

60ms역방향2열
@for $i from 1 through 6 {
  .bar:nth-child(#{$i})::after { animation-delay: -($i - 1) * 60ms; }
}
.bar:nth-child(even)::after { animation-name: shimmer-reverse; }
@keyframes shimmer { to { transform: translateX(120%); } }
@keyframes shimmer-reverse {
  from { transform: translateX(120%); }
  to   { transform: translateX(-120%); }
}

09다크모드 자동 색

카드와 막대 색을 light-dark() 함수 하나로 정의해 두면 사이트의 color-scheme이 바뀔 때 별도 클래스 없이 배경·막대 색이 저절로 맞춰집니다. 다크모드가 있는 모든 사이트에 씁니다.

light-dark()대비 높게회색 대신 파랑
.card {
  color-scheme: light dark;
  background: light-dark(#ffffff, #17141a);
}
.bar {
  background: light-dark(rgba(23, 20, 26, .16), rgba(255, 247, 230, .22));
}

어디서 깨지는가 — 함정 셋

아홉 개 대부분이 위치가 아니라 transformopacity만 움직이는 이유는, 이 둘만 브라우저가 레이아웃을 다시 재지 않고 합성 단계에서 처리하기 때문입니다. MDN의 CSS 애니메이션 성능 문서가 이 둘을 권하는 이유이기도 합니다. 06 반응형 스켈레톤만 예외인데, 컨테이너 쿼리는 요소의 실제 박스 크기에만 반응하고 transform으로 만든 시각적 확대·축소에는 반응하지 않아서 진짜 width를 바꿔야 열 수가 바뀝니다. 이 사실을 모르고 카드 안쪽 요소를 scale()로만 줄였다가 컨테이너 쿼리가 한 번도 안 걸린 채로 배포한 적이 있습니다.

같은 실수를 하나 더 겪었습니다. 05 이미지 블러업에서 흐린 판과 선명한 판에 똑같이 매끈한 그라데이션만 썼더니, 두 판의 색이 너무 비슷해서 크로스페이드가 거의 안 보였습니다. 결이 있는 무늬(가는 줄무늬)를 넣고 나서야 흐림이 지워버리는 디테일이 눈에 들어왔습니다. 04 표 스켈레톤에서는 훑는 빛 레이어에 position: relative를 몸통에 안 걸었더니 빛이 표 바깥 엉뚱한 자리에 그려졌는데, 이 아홉 개 파일은 이미 그 값이 맞게 들어간 채로 압축돼 있고 비밀번호는 a3rjf9rd이며 아래 버튼으로 받을 수 있습니다.

접근성

시스템의 "동작 줄이기"가 켜졌을 때 아홉 개 전부 prefers-reduced-motion: reduce를 처리하지만, 끄는 방식은 항목마다 다릅니다. 01·02·03·04·08의 훑는 빛은 멈추고 옅은 밝기만 남으며, 05는 흐림 없이 선명한 최종 상태 하나로 고정되고, 06은 폭이 가장 넓은 3열 상태에서 멈춥니다. 07은 스켈레톤을 아예 숨기고 내용만 보여주며, 09는 어두운 배색 한 장으로 고정됩니다. 어느 경우도 정보 자체가 사라지지는 않고, 반복되는 움직임만 빠집니다. 이 미디어 쿼리 자체의 지원 범위는 MDN prefers-reduced-motion 문서에 있습니다.

@media (prefers-reduced-motion: reduce) {
  .line::after, .sheen { animation: none !important; opacity: .4; }
}

다른 로딩·카드 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.

FAQ

스켈레톤 UI와 스피너 중 뭘 써야 하나요?

기다리는 시간이 1초를 넘고 화면 구조를 미리 보여줄 수 있으면 스켈레톤이 낫습니다. 사용자가 "무엇이 오는지" 미리 짐작할 수 있어 체감 대기 시간이 줄어듭니다. 반대로 구조가 매번 크게 달라지거나 대기 시간이 아주 짧으면 스피너 하나로 충분합니다.

아홉 개를 한 페이지에 다 써도 되나요?

한 페이지 안에서는 실제로 그 자리에 올 콘텐츠 모양과 맞는 것 한두 개만 쓰는 게 좋습니다. 04 표 스켈레톤과 02 카드 스켈레톤을 한 목록에 같이 쓰면 사용자가 어떤 화면이 오는지 헷갈립니다. 06 반응형 스켈레톤은 카드형 자리라면 여러 화면 폭에서 하나로 대신할 수 있습니다.

React에서는 로딩 상태를 어떻게 넘기나요?

zip의 react/ 폴더는 로딩 여부를 클래스 토글이 아니라 props로 받습니다. 07은 loading, 05는 이미지의 onLoad 콜백이 내부 상태를 바꿉니다. 06은 컨테이너 쿼리만으로 동작해서 별도 로딩 상태가 필요 없습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.