기다림은 없앨 수 없어도 어떻게 보이느냐는 우리가 정할 수 있습니다. 텅 빈 화면 대신 무언가 돌아가고 있다는 기척을 CSS로 어떻게 만드는지, 스피너부터 진행 막대와 뼈대 화면까지 코드로 하나씩 보여드리겠습니다.

79.1 원형 스피너: 테두리 한쪽만 색을 준다

가장 익숙한 스피너는 원형 테두리에서 한쪽 변만 색을 넣고 통째로 회전시킨 것입니다. 트릭은 border는 연한 회색으로 두고 border-top-color 하나만 진한 색으로 바꾸는 것입니다.


.spinner {
width: 36px;
height: 36px;
border: 4px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}


연한 원에서 파란 조각 하나만 돌기에 회전이 눈에 잡힙니다. 속도는 linear로 일정하게 두어야 자연스럽고, 0.8초쯤이 조급하지도 굼뜨지도 않은 지점입니다.

79.2 점 세 개가 순서대로 뛰기

점 세 개를 시차를 두고 뛰게 하려면 같은 애니메이션을 걸되 :nth-childanimation-delay만 다르게 줍니다. 지연이 파도처럼 번지며 순서가 생깁니다.


.dots span {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 3px;
background: #6b7280;
border-radius: 50%;
animation: bounce 1.2s ease-in-out infinite;
}
.dots span:nth-child(2) {
animation-delay: 0.15s;
}
.dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-8px);
opacity: 1;
}
}


마크업은 점 세 개를 나란히 두기만 하면 됩니다.


<span class="dots"><span></span><span></span><span></span></span>


첫 점이 뛰어오르는 사이 둘째와 셋째가 0.15초씩 뒤따라 오릅니다. 딜레이 두 줄이 정지한 점들을 흐르는 리듬으로 바꿉니다.

79.3 스켈레톤 셔머: 그라디언트를 흘려보내기

뼈대 화면의 반짝이는 결은 넓은 linear-gradient 배경을 만들고 background-position을 옆으로 흘려서 만듭니다. 배경 자체가 요소보다 두 배 넓어야 이음매 없이 지나갑니다.


.skeleton {
height: 16px;
border-radius: 4px;
background: linear-gradient(
90deg,
#eeeeee 25%,
#f5f5f5 50%,
#eeeeee 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}


밝은 띠가 회색 막대 위를 왼쪽으로 훑고 지나갑니다. 곧 내용이 채워질 자리를 미리 그려 두는 셈이라, 같은 시간을 기다려도 더 빨라 보입니다.

79.4 결정형 진행 막대: width에 transition

얼마나 왔는지 알 수 있는 작업이라면 채워지는 막대가 낫습니다. 진행률을 안쪽 요소의 width로 표현하고 transition을 걸면, 값이 바뀔 때마다 부드럽게 차오릅니다.


.bar {
height: 8px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.bar > .fill {
height: 100%;
width: 0;
background: #2563eb;
border-radius: inherit;
transition: width 0.3s ease;
}


진행률은 안쪽 요소의 인라인 값으로 넣습니다.


<div class="bar"><div class="fill" style="width: 60%"></div></div>


60에서 80으로 값이 바뀌면 막대가 툭 뛰지 않고 0.3초에 걸쳐 미끄러져 채워집니다. 바깥의 overflow: hidden이 채움을 둥근 모서리 안에 가둬 줍니다.

79.5 비결정형 막대: 끝을 모를 땐 흐르게

진행률을 잴 수 없을 때는 짧은 조각이 막대 위를 계속 오가게 합니다. 폭은 고정해 두고 transform: translateX로 좌우를 흐르게 하면 됩니다.


.bar.indeterminate .fill {
width: 40%;
animation: slide 1.2s ease-in-out infinite;
}
@keyframes slide {
from {
transform: translateX(-100%);
}
to {
transform: translateX(250%);
}
}


파란 조각이 왼쪽 밖에서 나와 오른쪽 밖으로 빠져나가기를 반복합니다. 끝이 어디인지 약속하지 않으면서도 무언가 진행 중임은 분명히 전합니다.

79.6 펄스: 불투명도로 숨 쉬게

가장 담백한 로딩은 요소를 은은하게 깜빡이는 펄스입니다. 위치나 크기를 건드리지 않고 opacity만 오르내리므로 어떤 자리에도 조용히 얹힙니다.


.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}


회색 자리 표시자에 이 클래스만 얹으면 천천히 밝아졌다 흐려지기를 반복합니다. 뼈대 화면 전체를 한 호흡으로 묶고 싶을 때 특히 잘 어울립니다.

79.7 움직임을 줄이는 사용자 배려하기

회전이나 튀는 움직임은 어지럼을 느끼는 사람이 있습니다. 움직임 줄이기를 켠 사용자에게는 회전을 걷어 내고, 상태만은 전하도록 부드러운 불투명도 페이드로 갈아 끼웁니다.


@media (prefers-reduced-motion: reduce) {
.spinner {
animation-name: fade;
animation-duration: 1.4s;
}
.dots span,
.skeleton,
.bar.indeterminate .fill,
.pulse {
animation: fade 1.4s ease-in-out infinite;
}
}
@keyframes fade {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}


스피너는 더 이상 돌지 않고 대신 은은하게 밝아졌다 흐려집니다. 회전하던 조각도, 흐르던 막대도 모두 같은 페이드로 대체되어 움직임은 사라지되 로딩 중이라는 신호는 남습니다.

79.8 정리

로딩은 피할 수 없는 기다림에 옷을 입히는 일입니다. 가늠할 수 없는 짧은 기다림엔 회전 스피너나 뛰는 점을, 잴 수 있는 작업엔 width가 차오르는 막대를, 콘텐츠를 불러올 땐 셔머가 흐르는 뼈대 화면을 골라 입히면 됩니다. 그리고 어떤 움직임을 넣든 prefers-reduced-motion 대체를 함께 마련해 두는 것, 그것이 기다림을 다루는 기본 예의입니다.