화면이 무언가를 불러오는 동안 사용자는 이게 멈춘 건지 도는 건지 알고 싶어 합니다. 그 짧은 공백을 어떻게 채우느냐가 대기 경험을 좌우합니다. 아래 예제는 모두 그대로 복사해 브라우저에서 확인할 수 있습니다.
25.1 스켈레톤 스크린
텅 빈 화면 대신 콘텐츠가 들어올 자리를 회색 덩어리로 미리 그려 두면 사용자는 곧 무언가 나온다고 느낍니다. 여기에 빛이 훑고 지나가는 shimmer 효과를 얹어 살아 있는 느낌을 줍니다.
.skeleton {
background: #e5e7eb;
background-image: linear-gradient(90deg, #e5e7eb 0%, #f3f4f6 50%, #e5e7eb 100%);
background-size: 200% 100%;
border-radius: 6px;
animation: shimmer 1.2s infinite;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
배경 그라디언트를 실제 폭의 두 배로 늘려 두고 background-position 을 옆으로 흘려보내면, 밝은 띠가 왼쪽에서 오른쪽으로 반복해 지나갑니다. 회색 판이 숨을 쉬는 듯 보여 로딩이 살아 있다고 읽힙니다.
25.2 회전 스피너
불러올 내용의 모양을 알 수 없을 때는 도는 원 하나가 가장 무난합니다. 테두리 한쪽 색만 다르게 준 원을 회전시키면 됩니다.
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
테두리 네 변 중 위쪽만 파랗게 칠하고 border-radius: 50% 로 원을 만든 뒤 계속 돌리면, 파란 조각이 궤도를 그리며 도는 스피너가 됩니다. linear 를 줘야 멈칫거림 없이 일정하게 돕니다.
25.3 진행 바로 남은 양 알리기
파일 업로드처럼 얼마나 남았는지 셀 수 있는 작업은 도는 원보다 채워지는 막대가 정직합니다. 사용자는 끝이 보이면 훨씬 잘 기다립니다.
<div class="progress">
<div class="bar" style="width: 40%"></div>
</div>
바깥 트랙과 안쪽 채움을 겹쳐 두고 진행도만큼 안쪽 폭을 넓혀 줍니다.
.progress {
height: 8px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.progress .bar {
height: 100%;
background: #2563eb;
border-radius: inherit;
transition: width .3s ease;
}
width 에 transition 을 걸어 두면 자바스크립트로 퍼센트만 바꿔도 막대가 부드럽게 차오릅니다. overflow: hidden 덕에 둥근 트랙 밖으로 채움이 삐져나오지 않습니다.
25.4 버튼 로딩 상태
저장 버튼을 누른 뒤 아무 변화가 없으면 사용자는 또 누릅니다. 눌린 버튼을 그 자리에서 처리 중으로 바꿔 이중 제출을 막습니다.
<button class="btn" aria-busy="true" disabled>
<span class="spinner"></span>
저장 중
</button>
여기서 aria-busy="true" 는 이 요소가 지금 바쁘다는 것을 스크린 리더에 알려 주고, disabled 는 재클릭을 막습니다.
.btn[aria-busy="true"] {
pointer-events: none;
opacity: .8;
}
.btn .spinner {
width: 16px;
height: 16px;
border-width: 2px;
vertical-align: -3px;
}
속성 선택자로 로딩 상태만 골라 흐리게 만들고 클릭을 잠급니다. 버튼 안에 작은 스피너를 함께 두면 지금 일하고 있다는 신호가 손끝에 전해집니다.
25.5 자리를 미리 잡아 밀림 막기
이미지가 늦게 도착하면서 아래 내용이 갑자기 밀려나면 사용자는 엉뚱한 곳을 누릅니다. 이 레이아웃 이동은 미리 자리를 확보해 두면 사라집니다.
.thumb {
width: 100%;
aspect-ratio: 16 / 9;
background: #f3f4f6;
}
.card-body {
min-height: 96px;
}
aspect-ratio 로 이미지가 오기 전에도 16 대 9 크기의 빈 상자를 잡아 두면, 사진이 도착해도 주변이 흔들리지 않습니다. 본문에도 min-height 로 바닥을 깔아 두면 텍스트가 채워질 때 카드 높이가 튀지 않습니다.
25.6 지연 임계와 늦게 등장하기
사람은 0.1초 안의 반응은 즉각으로 느끼고 1초쯤부터 기다린다고 인식합니다. 그래서 아주 빨리 끝나는 작업에까지 스피너를 번쩍 띄우면 오히려 화면이 깜빡여 어수선합니다. 로더를 잠깐 늦게 등장시켜 순식간에 끝나는 경우엔 아예 안 보이게 합니다.
.loader {
opacity: 0;
animation: appear 0s linear 1s forwards;
}
@keyframes appear {
to { opacity: 1; }
}
로더를 처음엔 투명하게 두고 animation-delay 로 1초 뒤에 나타나게 하면, 1초 안에 응답이 오는 경우엔 스피너가 등장하기도 전에 콘텐츠로 교체됩니다. 진짜 느릴 때만 로더가 보이니 불필요한 깜빡임이 사라집니다.
25.7 움직임을 줄여야 하는 사용자
화면 위 반복 애니메이션이 어지럼이나 불편을 주는 사용자가 있습니다. 브라우저 설정으로 그 뜻을 밝힌 경우 shimmer 와 회전을 멈추고 정적인 표시로 바꿔 줍니다.
@media (prefers-reduced-motion: reduce) {
.skeleton,
.spinner,
.loader {
animation: none;
}
.skeleton {
background: #e5e7eb;
}
.loader {
opacity: 1;
}
}
이 미디어 쿼리 안에서 애니메이션을 전부 꺼도 로딩의 의미는 그대로 남습니다. 회색 자리와 정지한 표시가 여전히 무언가 준비 중임을 말해 주니까요. 움직임은 덜어 내되 정보는 지킨 셈입니다.
25.8 조각을 하나로 모으기
스켈레톤 카드 하나를 마크업으로 완성하면 이렇게 됩니다. 제목 자리와 본문 두 줄 자리를 각각의 회색 막대로 잡아 둔 모습입니다.
<div class="card" aria-busy="true">
<div class="skeleton thumb"></div>
<div class="card-body">
<div class="skeleton line" style="width: 60%"></div>
<div class="skeleton line" style="width: 90%"></div>
</div>
</div>
.skeleton.line {
height: 12px;
margin: 10px 0;
}
썸네일 자리는 aspect-ratio 로, 글자 자리는 폭이 다른 두 막대로 흉내 내면 진짜 카드가 도착하기 전에도 화면의 골격이 서 있습니다. 데이터가 오면 이 자리를 그대로 진짜 내용이 채우니 밀림 없이 자연스럽게 넘어갑니다. 잘 만든 대기 화면은 기다림을 짧게 느끼게 하는 작은 속임수인 셈입니다.