로딩페이지 디자인 9종 — 복붙하는 예쁜 CSS 로딩 화면
로딩페이지 디자인은 서비스가 켜지는 첫 순간에 사용자에게 "기다리고 있네"라고 말해주는 화면입니다. 기본 스피너 하나로도 되지만, 파스텔 링이나 차오르는 물처럼 예쁜 로딩 화면은 복붙 몇 줄로 사이트 인상을 바꿔 놓습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기순이 아니라 로딩 화면을 고르는 순서로 놓았습니다. 가장 무난한 첫 선택인 캔디 링(01)과 익숙한 점 세 개(02)를 지나 개성이 들어가는 블롭(03)과 공(04)으로 넘어가고, 정교한 궤도(05)와 차오르는 물(06)로 기다림에 의미를 붙입니다. 숫자 카운트(07)와 쌓이는 큐브(08)로 진행을 알려주다가, 로딩이 끝나는 순간을 연출하는 커튼(09)으로 마무리합니다.
01캔디 링 회전
파스텔 세 색을 섞은 conic-gradient 링을 mask 로 도넛 모양으로 깎아 1초에 한 바퀴 돌립니다. 바깥 래퍼가 2초 주기로 1.12배까지 숨을 쉬어, 돌기만 하는 링보다 훨씬 살아 있습니다. 어두운 첫 화면에서 사탕처럼 눈에 띄어 다크 테마 서비스의 대표 로딩으로 좋습니다.
.cr-ring {
width: 148px;
height: 148px;
border-radius: 50%;
background: conic-gradient(from 0deg, $color, $subject-lilac, $subject-sky, $color);
mask: radial-gradient(farthest-side, #0000 calc(100% - 22px), #000 calc(100% - 21px));
}
.cr-wrap.is-demo {
.cr-ring { animation: cr-spin $duration linear infinite; }
animation: cr-breathe 2s $easing infinite;
}
@keyframes cr-spin { to { transform: rotate(360deg); } }
@keyframes cr-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
02끈적한 점 3개
가운데 점 하나만 좌우로 오가며 양옆에 가만히 있는 점에 닿을 때마다 늘어붙었다 떨어집니다. 처음엔 blur 와 contrast 필터 조합으로 만들었는데 노란 배경에서 점 둘레에 붉은 테두리가 남아서, 색 채널이 아니라 알파(불투명도)만 증폭하는 SVG feColorMatrix 방식으로 갈아탔습니다. 필터 종류만 바꿨을 뿐인데 테두리 흔적이 사라졌습니다.
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="goo-filter">
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
<feColorMatrix in="blur" mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/>
</filter>
</defs>
</svg>
<div class="goo">
<span class="goo-dot"></span>
<span class="goo-dot gd-b"></span>
<span class="goo-dot"></span>
</div>
.goo {
display: flex;
align-items: center;
padding: $sp-6 $sp-8;
filter: url(#goo-filter);
}
.gd-b { animation: goo-slide 2s $ease-spring infinite; }
@keyframes goo-slide {
0%, 50%, 100% { transform: translateX(0); }
25% { transform: translateX(-68px); }
75% { transform: translateX(68px); }
}
03형태 바꾸는 블롭
border-radius 여덟 값을 순서대로 바꾸면서 90도씩 회전해, 방석 같은 형태가 쉼 없이 꿈틀댄다. 반경 값 네 쌍이 한 바퀴(2초)에 정확히 제자리로 돌아오므로 루프 이음새가 안 보인다. 정지 상태에서도 블롭 모양이 남게 기본 반경을 따로 뒀다.
.mb-blob {
width: 112px;
height: 112px;
border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%;
background: $color;
}
.mb-blob.is-demo { animation: mb-morph $duration linear infinite; }
@keyframes mb-morph {
0% { border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; transform: rotate(0deg) scale(1); }
25% { border-radius: 45% 55% 60% 40% / 52% 42% 58% 48%; transform: rotate(90deg) scale(1.06); }
50% { border-radius: 57% 43% 40% 60% / 55% 47% 53% 45%; transform: rotate(180deg) scale(.96); }
75% { border-radius: 42% 58% 57% 43% / 48% 55% 45% 52%; transform: rotate(270deg) scale(1.04); }
100% { border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; transform: rotate(360deg) scale(1); }
}
04쿵 찍는 공
공이 떨어져 바닥에 닿는 순간 가로로 퍼지며 납작해지고(.55배), 한 박자 쉬었다가 탄성으로 되튕는다. transform-origin 을 바닥 중앙에 두어 찍히는 순간 공의 아랫면이 바닥에 붙어 있고, 그림자는 공이 높을 때 작고 옅어졌다가 착지 때 커진다. 떨어지는 구간엔 가속되는 cubic-bezier(.45, 0, .8, .6) 를 써서 중력감을 냈다.
.sq-ball {
position: absolute;
left: 50%;
bottom: 2px;
width: 56px;
height: 56px;
margin-left: -28px;
border-radius: 50%;
background: $color;
transform-origin: 50% 100%;
}
.sq-ball.is-demo { animation: sq-bounce 1s cubic-bezier(.45, 0, .8, .6) infinite; }
@keyframes sq-bounce {
0% { transform: translateY(-98px) scale(1, 1); }
55% { transform: translateY(0) scale(1, 1); }
68% { transform: translateY(0) scale(1.28, .55); }
80% { transform: translateY(0) scale(.92, 1.08); }
100% { transform: translateY(-98px) scale(1, 1); }
}
05궤도 도는 점들
크기가 다른 점 세 개가 같은 중심을 두고 반지름 36·52·68px 궤도를 각자 돈다. 작은 점일수록 각속도가 빨라(1초 vs 2초) 행성이 도는 듯한 깊이가 생기고, 세 점은 120도씩 어긋난 위상에서 출발해 한 곳에 뭉치지 않는다. 회전시키는 대상은 커다란 팔 박스가 아니라 점 각각이다 — 이유는 아래 함정에서.
.od-s { // 작은 점: 반지름 52
top: 26px;
left: calc(50% - 6px);
width: 12px;
height: 12px;
transform-origin: 50% 58px; // 점에서 계통 중심까지 거리
animation: ob-spin-a 1s linear infinite;
}
@keyframes ob-spin-a {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
06차오르는 물 원
둥근 테두리 원 안에 물이 차오르되, 수면은 둥근 사각형(반경 36%)을 회전시켜 만든다. 상승은 translate 속성으로만 움직여 회전(transform)과 충돌하지 않게 분리했고, 사각형의 모서리가 쓸 때마다 잔물결이 진다. 올라갔다 내려오는 2초 루프라 기다림이 길어도 지루하지 않다.
.lq-tube {
position: relative;
width: 150px;
height: 150px;
border: 4px solid $subject-ink;
border-radius: 50%;
overflow: hidden;
}
.lq-water {
position: absolute;
top: 100%;
left: calc(50% - 55px);
width: 110px;
height: 110px;
border-radius: 36%;
background: $color;
animation: lq-wave 2s linear infinite, lq-rise $duration $ease-spring infinite;
}
@keyframes lq-wave { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes lq-rise {
0% { translate: 0 0; }
50% { translate: 0 -60px; }
100% { translate: 0 0; }
}
07숫자 카운트 로딩
자바스크립트 없이 @property 로 등록한 정수 변수 --n 이 0에서 100까지 올라가고 counter() 로 화면에 찍힌다. 같은 변수를 읽는 얇은 conic-gradient 링이 정확히 같은 속도로 차올라 숫자와 게이지가 어긋나지 않는다. 화면에 보이는 숫자 자체는 장식이라 aria-hidden 으로 숨기고, 대신 눈에 안 보이는 문구에 role="status" 를 달아 보조기기가 그 문구로 진행 상황을 읽게 했다.
@property --n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.cu-num {
counter-reset: n var(--n);
}
.cu-num::after { content: counter(n) "%"; }
.cu-box.is-demo { animation: cu-count $duration linear infinite; }
@keyframes cu-count {
from { --n: 0; }
to { --n: 100; }
}
08쌓이는 큐브
정사각 네 개가 왼쪽 위부터 오른쪽 아래까지(그리드 순서대로) 120ms 간격으로 튀어올라 자리를 잡고, 한 박자 뒤 네 칸이 같은 타이밍에 쿵 내려앉는다. 등장 애니메이션(안쪽 큐브의 scale)과 착지 애니메이션(바깥 셀의 translateY)을 요소 두 겹으로 나눠 transform 이 서로 덮어쓰지 않게 했다. 모듈을 하나씩 불러오는 화면의 리듬 그대로다.
.cs-cell { animation: cs-settle $duration $ease-pop infinite; }
.cs-cube { animation: cs-pop $duration $ease-pop infinite backwards; }
.cs-cell:nth-child(2) .cs-cube { animation-delay: 120ms; }
.cs-cell:nth-child(3) .cs-cube { animation-delay: 240ms; }
.cs-cell:nth-child(4) .cs-cube { animation-delay: 360ms; }
@keyframes cs-pop {
0% { transform: scale(0); opacity: 0; }
16% { transform: scale(1.18); opacity: 1; }
22%, 68% { transform: scale(1); opacity: 1; }
90%, 100% { transform: scale(0); opacity: 0; }
}
@keyframes cs-settle {
0%, 60% { transform: translateY(0); }
70% { transform: translateY(8px); }
80%, 100% { transform: translateY(0); }
}
09커튼 열고 닫기
로딩이 끝나는 순간을 연출하는 마무리다. 좌우 커튼 두 장이 바깥으로 밀리며 뒤에 숨겨둔 콘텐츠 자리(제목줄·본문줄·썸네일)를 드러내고, 잠시 열린 채로 있다가 다시 닫혀 처음으로 돌아간다. 실서비스에서는 로딩이 끝나는 시점에 커튼을 열고 클래스를 떼어 그대로 두면 페이지 전환 커버가 된다.
.ct-left { left: 0; background: $color; }
.ct-right { right: 0; background: $subject-pink; }
.ct-scene.is-demo .ct-left { animation: ct-open-left $duration $ease-spring infinite; }
.ct-scene.is-demo .ct-right { animation: ct-open-right $duration $ease-spring infinite; }
@keyframes ct-open-left {
0%, 12% { transform: translateX(0); }
38%, 72% { transform: translateX(-101%); }
94%, 100% { transform: translateX(0); }
}
@keyframes ct-open-right {
0%, 12% { transform: translateX(0); }
38%, 72% { transform: translateX(101%); }
94%, 100% { transform: translateX(0); }
}
어디서 깨지는가 — 회전하는 박스는 폰 화면을 넘친다
05번을 처음엔 점을 담은 커다란 사각 팔을 통째로 회전시키는 흔한 방식으로 만들었다. 데스크톱에선 멀쩡했는데 320px 폰 폭 검사에서 세로 넘침이 잡혔다 — 눈으로 보이는 점은 작은데, 168px 정사각형이 45도로 돌아가는 순간 경계 상자가 대각선 길이만큼(약 237px)로 커지고, 스크롤 높이 계산은 그 커진 상자를 그대로 세기 때문이다. 실측해보니 body 의 스크롤 높이가 화면 200px 인데 218px 로 잡혔다. overflow: hidden 이든 clip 이든 이 계산을 막지 못한다. 해결은 회전 대상을 줄이는 것이다 — 점 하나가 자기 transform-origin 을 계통 중심에 두고 제자리에서 공전하게 하면, 경계 상자는 점이 지나는 원(지름 138px) 안에만 머문다. 회전 요소가 하나 더 있는 01번 링은 원형이라 회전해도 경계 상자가 그대로인 것과 대비된다. 압축 파일을 풀 때 묻는 비밀번호는 3eqz6dw4 이고, 입력하면 아홉 개 폴더가 그대로 열린다. 02번도 비슷하게 처음 설계를 버렸다 — blur+contrast 필터는 노란 배경 위에서 점 가장자리를 붉게 물들였고, 알파만 증폭하는 SVG 행렬 필터로 바꾸니 흔적 없이 깨끗해졌다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce 에서 애니메이션이 꺼진다. 그냥 사라지는 게 아니라 각자 쓸 모양을 남긴다 — 03번은 블롭 형태, 07번은 100% 찬 링과 숫자, 09번은 열린 커튼, 04번은 공중에 뜬 공이다. 로딩 표시는 장식이므로 07번을 빼고 전부 aria-hidden="true" 로 보조기기에서 숨겼고, 07번은 반대로 role="status" 와 "로딩 중" 문구를 넣어 진행 상황이 읽히게 했다. 무한 반복 애니메이션이라 WCAG 2.2.2 의 일시정지 기준 과 만나면 — 실서비스에서는 로딩이 끝나면 반드시 화면에서 치우는 조건이 붙어야 안전하다. 이 설정은 운영체제의 움직임 줄이기 옵션과 연동되며 자세한 값은 MDN 문서 에 정리돼 있다.
FAQ
로딩 화면을 CSS만으로 만들 수 있나요?
네, 이 9개는 전부 자바스크립트 0줄로 만들었다. 07번 숫자 카운트조차 @property 로 변수를 등록해 CSS 문법 안에서 돈다. 실서비스에 붙일 때만 로딩 시작·끝에 .is-loading 클래스를 붙이고 떼는 자바스크립트 한 줄이 필요하다. 기본 스피너 모음은 로딩 스피너 CSS 9종 에, 스켈레톤 화면은 스켈레톤 로딩 UI 9종 에 따로 있다.
색을 우리 브랜드 색으로 바꾸고 싶어요.
각 데모의 SCSS 맨 위 변수 3개($duration · $easing · $color) 중 $color 하나만 바꾸면 된다. 01번 캔디 링은 두 보조 색($subject-lilac, $subject-sky)이 그라디언트에 같이 들어가니 그 두 값도 함께 바꾼다. React 버전은 props 로 같은 값을 넘긴다.
실제 로딩 시간을 모르는데 퍼센트 숫자를 보여도 되나요?
모르면 가짜 퍼센트는 보여주지 않는 게 낫다 — 숫자가 90에서 멈춰 있으면 사용자는 늦어진다고 느낀다. 그럴 땐 01·02·03번처럼 끝나는 시점이 없는 인디케이터를 쓰고, 07번은 진행률을 실제로 알 수 있을 때(파일 업로드처럼)만 쓴다. 끝나는 순간의 연출만 필요하면 09번 커튼이 맞다.