페이지 로딩 애니메이션 9종 — 화면이 바뀌는 동안
페이지 로딩 애니메이션은 화면이 다음 화면으로 바뀌는 짧은 동안 기다림을 눈에 보이게 만드는 장치입니다. 여기 아홉 개는 한가운데서 빙글 도는 스피너가 아니라 진행 바·색 띠·자리표시·낙관적 이동처럼 화면을 통째로 갈아 끼우는 순간을 다루는 코드입니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 화면 위를 가로지르는 라우트 진행 바
- 02 색 띠가 훑고 지나가며 내용을 갈아 끼우는 전환
- 03 빈 테두리 자리가 카드로 하나씩 채워지는 목록
- 04 첫 진입에서 숨 쉬는 브랜드 마크 스플래시
- 05 제목만 먼저 바꾸고 본문은 뒤따르는 낙관적 이동
- 06 목록 끝에서 다음 장을 물어 오는 바닥 로더
- 07 탭을 옮길 때 높이까지 따라 늘어나는 패널
- 08 비율 상자가 자리를 잡아 두고 늦게 떠오르는 그림
- 09 끊긴 연결을 세어 가며 다시 붙는 상태 띠
아홉 개는 인기순이 아니라 한 번의 이동이 거치는 순서대로 놓았습니다. 누르기 직후가 먼저입니다. 맨 위 띠가 조금씩 자라고(01) 색 띠가 화면을 훑고(02) 목록 자리가 하나씩 채워집니다(03). 이어 첫 방문과 누른 직후가 옵니다. 브랜드 마크가 숨 쉬는 스플래시(04)와 제목만 먼저 바꾸는 낙관적 이동(05)입니다. 그다음은 계속 읽는 화면의 차례입니다. 목록 바닥이 다음 장을 물어 오고(06) 탭을 옮기면 높이까지 따라 늘어나며(07) 그림은 자리를 지킨 채 늦게 떠오릅니다(08). 마지막은 이동이 실패했을 때입니다. 끊긴 연결을 세어 가며 다시 붙는 상태 띠(09)가 이 순서를 닫습니다. 스피너 축의 이웃은 로딩 스피너 9종과 스켈레톤 UI 9종에, 전환 자체의 축은 페이지 전환 애니메이션과 로딩페이지 디자인에 따로 묶어 두었습니다.
01화면 위를 가로지르는 라우트 진행 바
링크를 누르면 화면 맨 위의 얇은 띠가 12 → 38 → 66 → 88 → 100 다섯 단계로 자라며 다음 화면을 기다리는 시간을 보여 줍니다. 채움은 transform: scaleX()라 레이아웃을 다시 계산하지 않고, 단계마다 aria-valuenow가 실제로 갱신됩니다. 목록에서 상세로 넘어가기가 잦은 매거진 사이트에 맞으며 2초 루프에서 열아홉 프레임이 움직입니다.
var rpSteps = [12, 38, 66, 88, 100];
function rpSet(v) {
rpFill.style.transform = 'scaleX(' + v / 100 + ')';
rpFill.setAttribute('aria-valuenow', String(v));
}
function rpGo(btn) {
rp.classList.remove('is-demo');
rpView.setAttribute('aria-busy', 'true');
rpSet(0);
var k = 0;
rpTimer = setInterval(function () {
rpSet(rpSteps[k]);
k += 1;
if (k < rpSteps.length) return;
clearInterval(rpTimer);
rpView.setAttribute('aria-busy', 'false');
}, 240);
}
02색 띠가 훑고 지나가며 내용을 갈아 끼우는 전환
한 장의 색 띠가 왼쪽 밖에서 들어와 화면을 완전히 덮었다가 오른쪽으로 빠져나가며 뒤의 새 화면을 드러냅니다. 본문 두 장은 같은 칸에 겹쳐 두고 띠가 덮은 구간 안에서만 steps(1, end) 컷으로 갈아 끼우므로 반투명 유령이 겹치지 않습니다. 포트폴리오처럼 화면 하나하나가 작품인 사이트의 메뉴 이동에 씁니다.
@keyframes ws-sweep {
0% { transform: translateX(-101%); }
22%, 25% { transform: translateX(0); }
33% { transform: translateX(101%); animation-timing-function: steps(1, end); }
46% { transform: translateX(-101%); }
68%, 71% { transform: translateX(0); }
79%, 100% { transform: translateX(101%); }
}
@keyframes ws-b {
0%, 22% { opacity: 0; }
23%, 68% { opacity: 1; }
69%, 100% { opacity: 0; }
}
wsBand.classList.remove('is-run');
void wsBand.offsetWidth;
wsBand.classList.add('is-run');
setTimeout(function () { wsPages.classList.toggle('is-b'); }, 360);
setTimeout(function () {
wsBand.classList.remove('is-run');
wsPages.setAttribute('aria-busy', 'false');
}, 740);
03빈 테두리 자리가 카드로 하나씩 채워지는 목록
반짝이는 회색 막대 대신 점선 테두리 빈 칸이 먼저 자리를 잡고, 카드가 0ms·160ms·320ms animation-delay 시차로 들어찹니다. 칸은 display: grid라 점선과 카드가 같은 자리에 겹치므로 카드가 들어와도 아래가 밀리지 않고, 다 차면 aria-busy가 false로 떨어집니다. 필터를 바꿀 때마다 목록 전체를 다시 불러오는 검색 결과 화면에 맞습니다.
.of__slot {
display: grid;
min-height: 36px;
}
.of__ghost {
grid-area: 1 / 1;
border: 2px dashed rgba(255, 247, 230, .62);
border-radius: $r-control;
}
.of__card {
grid-area: 1 / 1;
display: flex;
align-items: center;
transform: translateY(0);
transition: transform $duration $easing;
}
.of__list.is-loading .of__card {
transform: translateY(10px);
opacity: 0;
}
.of__list.is-loading .of__slot.is-in .of__card {
transform: translateY(0);
opacity: 1;
}
04첫 진입에서 숨 쉬는 브랜드 마크 스플래시
동그라미와 둥근 사각형 두 도형만으로 만든 브랜드 마크가 transform: scale로 천천히 부풀었다 줄어들고, 그 아래 점 세 개가 차례를 넘깁니다. 안내 문구는 aria-live="polite" 영역에 있고 준비 완료는 버튼을 눌렀을 때만 옵니다. 첫 화면을 통째로 준비해야 하는 웹앱의 최초 진입에 맞습니다.
spEnter.addEventListener('click', function () {
sp.classList.remove('is-demo');
sp.classList.add('is-ready');
spMsg.textContent = '준비가 끝났어요';
spEnter.disabled = true;
});
.sp__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color;
opacity: .35;
}
05제목만 먼저 바꾸고 본문은 뒤따르는 낙관적 이동
탭을 누르는 즉시 큰 제목이 새 제목으로 컷 교체되고 aria-selected가 이동하지만, 본문은 아직 오지 않아 aria-busy를 켠 채 자리만 지키다가 600ms 뒤 translateY로 올라옵니다. 자리표시는 교체가 시작되는 그 프레임에 함께 켜지므로 중간에 빈 구멍이 생기지 않습니다. 탭 사이를 자주 오가는 대시보드에 맞습니다.
function ohGo(i) {
oh.classList.remove('is-demo');
oh.setAttribute('data-tab', String(i));
oh.classList.add('is-loading');
ohBody.setAttribute('aria-busy', 'true');
for (var k = 0; k < ohTabs.length; k++) {
ohTabs[k].setAttribute('aria-selected', k === i - 1 ? 'true' : 'false');
}
clearTimeout(ohTimer);
ohTimer = setTimeout(function () {
oh.classList.remove('is-loading');
ohBody.setAttribute('aria-busy', 'false');
}, 600);
}
06목록 끝에서 다음 장을 물어 오는 바닥 로더
스크롤 상자 맨 아래의 로더 카드가 화면에 90% 들어오면 IntersectionObserver가 잡아 카드 세 장을 실제 DOM에 붙입니다. 새 카드는 90ms 간격 시차로 올라오고 붙은 장수는 aria-live 영역이 알립니다. 끝없는 피드로 읽히는 커뮤니티 목록에 맞으며 스물한 프레임이 움직입니다.
function infWake() {
if (infIo) return;
inf.classList.remove('is-demo');
infIo = new IntersectionObserver(function (es) {
if (es[0] && es[0].isIntersecting) infAdd();
}, { root: infBox, threshold: 0.9 });
infIo.observe(infLoader);
}
['wheel', 'pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
infBox.addEventListener(ev, infWake);
});
07탭을 옮길 때 높이까지 따라 늘어나는 패널
패널을 grid-template-rows의 0fr과 1fr 사이에서 보간해 내용 높이를 몰라도 부드럽게 늘립니다. 내용 교체는 컷, 높이는 이징으로 분리했고 선택 상태는 aria-selected가 들고 있습니다. 설명 길이가 제각각인 요금제 탭에 맞습니다.
.th__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.th__panel.is-open { grid-template-rows: 1fr; }
.th__clip { min-height: 0; overflow: hidden; }
.th__body {
padding: $sp-2 0 0;
opacity: 0;
transition: opacity $duration steps(1, end);
}
.th__tab[aria-selected="true"] { background: $subject-ink; color: $color; }
08비율 상자가 자리를 잡아 두고 늦게 떠오르는 그림
그림이 오기 전에 aspect-ratio가 같은 크기의 빈 상자를 먼저 잡아 글이 밀리지 않게 하고, 도착한 그림은 흐림 없이 opacity로만 떠오릅니다. loading="lazy"와 decoding="async"를 실제로 단 그림은 데이터 URI 로 박아 넣은 SVG라 외부 요청이 없습니다. 사진이 많아 스크롤 중 글이 밀리기 쉬운 기사 본문에 맞습니다.
.lz__box {
display: grid;
aspect-ratio: 16 / 9;
border-radius: $r-sm;
overflow: hidden;
}
.lz__ph,
.lz__img {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.lz__img {
display: block;
object-fit: cover;
opacity: 1;
transition: opacity $duration $easing;
}
09끊긴 연결을 세어 가며 다시 붙는 상태 띠
연결이 끊기면 띠가 오류색으로 바뀌고 3 → 2 → 1 초 카운트다운이 컷으로 내려가며, 0이 되면 다시 연결됨으로 전환됩니다. 실제 판정은 navigator.onLine과 offline·online 이벤트가 맡고 바뀐 문구는 role="status" 영역에 담겨 읽힙니다. 지하철에서 연결이 자주 끊기는 모바일 웹앱에 맞습니다.
function ofDrop() {
ofState('off');
var n = 3;
ofSec(n);
clearInterval(ofTimer);
ofTimer = setInterval(function () {
n -= 1;
if (n > 0) { ofSec(n); return; }
clearInterval(ofTimer);
ofSec(0);
ofState('back');
}, 1000);
}
if (!navigator.onLine) ofDrop();
window.addEventListener('offline', ofDrop);
window.addEventListener('online', ofUp);
어디서 깨지는가 — 함정
가장 먼저 무너지는 자리는 미리보기 대표 장면입니다. 렌더러는 앞 프레임과 가장 크게 달라진 장을 대표 이미지로 뽑는데, 진행 바가 완료되어 사라지거나 상태 띠가 닫히는 장면은 퇴장 면적이 등장보다 커서 빈 화면이 대표로 남습니다. 그래서 01은 루프에서 88%까지만 채우고 완료 소멸은 실제 클릭 경로에만 두었고, 09는 띠가 사라지지 않은 채 문구와 색만 바꿉니다. 둘 다 복귀는 2초의 90% 안쪽에서 컷 한 프레임으로 끝냅니다. 24장 샘플의 마지막이 95.8% 지점이라 그 뒤는 아예 잡히지 않기 때문입니다. 다음으로 무너지는 자리는 교체입니다. 02의 띠 교체를 opacity 크로스페이드로 하면 샘플 간격 4.17% 사이에 반투명 유령이 찍히므로 컷으로 잘랐고, 03의 시차는 animation: 단축 속성으로 쓰면 delay가 0s로 리셋되어 세 칸이 동시에 움직입니다. 낱개 속성으로 풀어 쓰고 animation-fill-mode: backwards를 붙이니 열두 프레임이 확보됐습니다. 07은 접히는 칸에 세로 패딩을 주면 0fr에서도 그만큼이 남아 칸이 끝까지 닫히지 않는 것을 직접 보고 패딩을 안쪽 래퍼로 옮겼습니다. 08은 다시 받기로 페이드를 재시작할 때 클래스를 붙여 그림을 지운 다음 requestAnimationFrame을 두 번 거쳐 뗐는데, 한 번만 거치면 같은 프레임에 묶여 재시작이 무시됩니다. 06은 미리보기에서 스크롤 상자를 맨 바닥으로 내려 놓아야 로더 카드가 첫 프레임에 보입니다. 01의 균등 칸은 flex 1 1 0 대신 grid 1fr 셋으로 짰습니다. scaleX 로 채우는 막대를 flex 1 1 0 칸 위에 얹으면 실제 렌더 폭과 어긋나 표시가 딴 칸에 걸리기 때문입니다. 09의 남은 시간 막대도 같은 이유로 grid 겹침으로 짰습니다. 칸 폭이 정확히 균등해지는 것도 이 짜임이 주는 덤입니다. 02의 훑기는 구간을 일부러 어긋나게 잡았습니다. 빠져나가는 구간을 8%, 들어오는 구간을 22%로 두어야 프레임당 변화가 가장 큰 장면이 띠가 빠지며 새 내용이 드러나는 순간에 걸리고, 내용 교체는 띠가 완전히 덮은 22~25%와 68~71% 구간 안에서 한 프레임 컷으로 끝납니다. 이 띠를 clip-path 로 깎아 보기도 했는데, steps 타이밍 안에서는 clip-path 가 보간되지 않아 훑기 자체가 컷으로 죽었습니다. 결국 화면 한 장 크기 그대로 두고 translateX 로만 밀었습니다. 05는 탭을 누른 그 프레임인 10%에 제목을 컷으로 갈고 본문은 40%에 도착하도록 두 시점을 떼어 놓았습니다. 둘을 한 프레임에 붙이면 낙관적이라는 말 자체가 사라지고, 그 사이가 빈 구멍이 되지 않도록 자리표시는 제목이 바뀌는 같은 프레임에 켭니다. 06의 복귀 폭은 6px 이라 카드 사이 간격 8px 보다 작습니다. 되돌아가는 한 프레임에도 카드끼리 붙어 보이지 않게 하려고 고른 값입니다. 측정값도 같은 이야기를 합니다. 같은 2초 루프인데 07은 누적 변화 면적이 36.456%로 가장 크고 04는 0.917%로 가장 작습니다. 높이를 보간하는 07은 화면 전체를 흔들고, 맥박 치는 04는 마크 한 장만 건드리기 때문입니다. 움직인 프레임도 07이 11장, 03이 12장으로 가장 적은 축인데, 컷이 많을수록 프레임 수가 줄어드는 대신 09처럼 애니메이션 13개를 겹쳐 세워 사이를 메우게 됩니다. 이 아홉 개 파일의 압축 비밀번호는 wbq4cn6w 이고, zip 안에는 각 항목의 측정값과 React 판까지 함께 들어 있습니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce에서 장식 루프만 끄고 상태는 남겨 두었습니다. 04의 점은 기본 opacity를 .35로 두어 움직임을 꺼도 그대로 보이고, 03의 카드는 컷 없이 곧장 채워진 모습으로 둡니다. 기다림의 의미는 눈이 아니라 ARIA가 지킵니다. 01의 진행 값은 aria-valuenow에 숫자로 적히고, 06의 붙은 장수와 09의 문구 교체는 aria-live 영역을 통해 스크린리더로 읽힙니다. 05와 07은 좌우 화살표로 탭을 옮길 수 있고, 초점은 focus({ preventScroll: true })로 옮깁니다.
| 항목 | 글자·바탕 | 앞색 | 뒷색 | 대비 |
|---|---|---|---|---|
| 01 본문 글자 / 패널 | 크림 / 잉크 패널 | #fff7e6 |
#2a262a |
13.98:1 |
| 02 제목 / 카드 | 먹 / 크림 | #17141a |
#fff7e6 |
17.11:1 |
| 03 제목 / 오렌지 무대 | 먹 / 오렌지 | #17141a |
#ff4d1f |
5.5:1 |
| 04 문구 / 흰 무대 | 먹 / 흰 | #17141a |
#ffffff |
18.24:1 |
| 07 선택 탭 글자 / 먹 알약 | 크림 / 먹 | #fff7e6 |
#17141a |
17.11:1 |
| 09 끊김 문구 / 오류색 띠 | 크림 / 오류 | #fff7e6 |
#c2361a |
5.13:1 |
| 마우스 | 키보드 | 상태가 적히는 곳 | |
|---|---|---|---|
| 01 | 링크를 누른다 | 탭 초점으로 이동 | 채움 aria-valuenow, 본문 aria-busy |
| 02 | 다음 화면을 누른다 | 버튼 초점 후 Enter | 본문 aria-busy |
| 03 | 다시 불러오기를 누른다 | 버튼 초점 | 목록 aria-busy |
| 04 | 들어가기를 누른다 | 버튼 초점 | 문구 aria-live |
| 05 | 탭을 누른다 | 좌우 화살표 이동 | 탭 aria-selected, 본문 aria-busy |
| 06 | 상자를 스크롤한다 | 상자 초점 후 화살표 | aria-live 장수 문구 |
| 07 | 탭을 누른다 | 좌우 화살표 + tabIndex 로밍 | 탭 aria-selected |
| 08 | 처음부터 받기를 누른다 | 버튼 초점 | 그림 alt, 상자 크기 고정 |
| 09 | 끊어 보기를 누른다 | 버튼 초점 | 띠 role="status" |
판정 기준은 MDN 의 aria-busy 문서와 IntersectionObserver 문서를 따랐습니다. 접힘 축의 이웃으로 탭 메뉴 전환과 화면 전환 효과가 있습니다.
FAQ
진행 바는 언제까지 믿어도 되나요
가짜 단계는 실제 다운로드 양과 무관하므로 88%에서 오래 머물면 오히려 거짓말이 됩니다. 그래서 01도 격자 루프에서는 88%까지만 보여 주고, 100과 완료 소멸은 실제 클릭 경로에만 둡니다. 실제 서비스에서는 응답 스트림으로 내려받은 양을 잴 수 있을 때 그 값을 aria-valuenow에 넘기는 것이 정직한 버전이고, 없다면 무한 대기가 아니라 재시도로 넘어가는 09의 형태가 낫습니다.
스켈레톤과 점선 자리표시는 무엇이 다른가요
스켈레톤은 콘텐츠의 모양을 회색 막대로 미리 그려 보여 주는 것이고, 03의 점선 칸은 자리의 존재만 알리고 모양은 그리지 않는 것입니다. 모양을 그리면 실제 콘텐츠와 어긋날 때 부정확해 보이지만, 자리만 잡으면 어떤 내용이 와도 어긋나지 않습니다. 목록의 칸 수조차 알 수 없을 때는 점선 대신 09처럼 상태 문구로 알리는 쪽이 맞습니다.
낙관적 이동은 실패하면 어떻게 되나요
돌아갈 수 있어야 합니다. 05는 제목을 먼저 바꾸지만 본문이 600ms 안에 오지 않으면 aria-busy가 켜진 채 남으므로 화면이 거짓 완성으로 보이지 않습니다. 실제 서비스에서는 여기에 이전 화면으로 되돌리는 안내를 얹습니다. 실패를 숨기는 것이 아니라 기다림의 주인을 누른 사람에게 돌려 주는 것이 낙관적 이동의 조건입니다.