GODRICH

로딩페이지 디자인 9종 — 복붙하는 예쁜 CSS 로딩 화면

로딩페이지 디자인은 서비스가 켜지는 첫 순간에 사용자에게 "기다리고 있네"라고 말해주는 화면입니다. 기본 스피너 하나로도 되지만, 파스텔 링이나 차오르는 물처럼 예쁜 로딩 화면은 복붙 몇 줄로 사이트 인상을 바꿔 놓습니다.

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

아홉 개는 인기순이 아니라 로딩 화면을 고르는 순서로 놓았습니다. 가장 무난한 첫 선택인 캔디 링(01)과 익숙한 점 세 개(02)를 지나 개성이 들어가는 블롭(03)과 공(04)으로 넘어가고, 정교한 궤도(05)와 차오르는 물(06)로 기다림에 의미를 붙입니다. 숫자 카운트(07)와 쌓이는 큐브(08)로 진행을 알려주다가, 로딩이 끝나는 순간을 연출하는 커튼(09)으로 마무리합니다.

01캔디 링 회전

파스텔 세 색을 섞은 conic-gradient 링을 mask 로 도넛 모양으로 깎아 1초에 한 바퀴 돌립니다. 바깥 래퍼가 2초 주기로 1.12배까지 숨을 쉬어, 돌기만 하는 링보다 훨씬 살아 있습니다. 어두운 첫 화면에서 사탕처럼 눈에 띄어 다크 테마 서비스의 대표 로딩으로 좋습니다.

conic-gradientmask$duration
.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개

가운데 점 하나만 좌우로 오가며 양옆에 가만히 있는 점에 닿을 때마다 늘어붙었다 떨어집니다. 처음엔 blurcontrast 필터 조합으로 만들었는데 노란 배경에서 점 둘레에 붉은 테두리가 남아서, 색 채널이 아니라 알파(불투명도)만 증폭하는 SVG feColorMatrix 방식으로 갈아탔습니다. 필터 종류만 바꿨을 뿐인데 테두리 흔적이 사라졌습니다.

filter: blur + contrasttranslateX$duration
<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초)에 정확히 제자리로 돌아오므로 루프 이음새가 안 보인다. 정지 상태에서도 블롭 모양이 남게 기본 반경을 따로 뒀다.

border-radiusrotate$duration
.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) 를 써서 중력감을 냈다.

translateYscale(1.28, .55)transform-origin
.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도씩 어긋난 위상에서 출발해 한 곳에 뭉치지 않는다. 회전시키는 대상은 커다란 팔 박스가 아니라 점 각각이다 — 이유는 아래 함정에서.

rotatetransform-origin$duration
.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초 루프라 기다림이 길어도 지루하지 않다.

translate: 0 -60pxrotateoverflow: hidden
.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" 를 달아 보조기기가 그 문구로 진행 상황을 읽게 했다.

@propertycounter()linear
@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 이 서로 덮어쓰지 않게 했다. 모듈을 하나씩 불러오는 화면의 리듬 그대로다.

scalestagger$easing
.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커튼 열고 닫기

로딩이 끝나는 순간을 연출하는 마무리다. 좌우 커튼 두 장이 바깥으로 밀리며 뒤에 숨겨둔 콘텐츠 자리(제목줄·본문줄·썸네일)를 드러내고, 잠시 열린 채로 있다가 다시 닫혀 처음으로 돌아간다. 실서비스에서는 로딩이 끝나는 시점에 커튼을 열고 클래스를 떼어 그대로 두면 페이지 전환 커버가 된다.

translateXoverflow: hidden$duration
.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번 커튼이 맞다.

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

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