
JavaScript 없는 CSS 로더 — 순수 CSS 스피너 6가지와 상황별 선택 가이드
📷 Pixabay / PexelsJavaScript 없는 CSS 로더 — 순수 CSS 스피너 6가지와 상황별 선택 가이드
순수 CSS만으로 만드는 로딩 인디케이터. 실제로 쓰는 6가지 패턴, 사람들이 자주 빠뜨리는 접근성 포인트, 그리고 스피너를 보여주지 말아야 할 때의 규칙까지.
새 프로젝트를 시작할 때 가장 먼저 만드는 게 로딩 상태입니다. 레이아웃도, 폼도, 데이터 페치도 아니고 — 데이터가 도착하는 동안 떠 있을 스피너 말이죠. 이게 미루기처럼 느껴진 적도 있고 아마 지금도 그런 면이 있긴 합니다만, 실용적인 이유가 있습니다. 사용자에게 보여 줄 무언가가 준비되었을 때쯤이면 곧 나타날 콘텐츠의 자리를 잡아 줄 자리표시자가 필요한데, 그걸 먼저 만들어 두면 배포 전날 새벽 3시에 급하게 끼워 넣을 일이 없어집니다.
또 다른 이유: 로더는 의외로 제대로 만드는 게 어렵습니다. 스타일·색·크기·속도·접근성·폴백·언제 보여줄지까지 7~8가지 결정이 따로따로 있고, 각각에 잘못된 답이 있습니다. 잘못된 로더는 경험을 적극적으로 해칩니다. 스피너 없을 때보다 페이지를 느려 보이게 만들고, 접근성을 망가뜨릴 수 있고, 이런 처리를 가장 견디기 어려운 기기의 프레임 예산을 갉아먹기도 합니다.
이 글은 너무 많은 사이드 프로젝트와 실제 프로덕트에서 모은 로더 규칙들입니다. 짝꿍 도구는 CSS 로더 생성기 — 색·크기·속도·선 두께 노브가 있는 6가지 순수 CSS 스피너로, 그대로 복사해 프로젝트에 붙여 넣을 수 있는 HTML과 CSS를 출력합니다.
거의 모든 경우에 CSS 전용 로더가 이기는 이유
처음 로더를 만들 때 쉬운 길은 무료 아이콘 사이트에서 GIF를 받거나 JS 라이브러리를 끌어오는 것입니다. 둘 다 합리적으로 보이지만, 보통은 잘못된 선택입니다.
64×64 픽셀짜리 로더 GIF는 작은 것도 20KB 정도, 30fps로 부드러워 보이게 만들면 50~100KB까지 갑니다. 평범한 SaaS 대시보드에 들어가는 로더 수만큼 곱해 보면 — 내비게이션에 3개, 테이블 행마다 1개, 비동기 버튼마다 1개 — 사용자가 아무것도 하기 전에 1MB짜리 춤추는 점들을 이미 보낸 셈이 됩니다.
CSS 스피너는 몇 백 바이트, 기존 스타일시트로 한 번만 파싱되고, 이후 페이지가 살아있는 내내 GPU 컴포지터에서 돕니다. 초기 파싱 이후엔 비용이 사실상 0입니다. CSS 변수로 테마를 입혀 다크 모드를 공짜로 얻을 수도 있습니다. 크기를 키워도 픽셀이 깨지지 않는 건 브라우저가 픽셀이 아닌 수학으로 그리기 때문입니다.
Lottie나 SVG를 쓰자는 주장은 복잡한 모션 패스를 작성하기 더 쉽다는 점에서 옳습니다. 다만 로더의 90%는 도형 하나가 회전·확대·페이드하는 패턴이고, 그 정도는 CSS가 Lottie 보일러플레이트보다 적은 글자 수로 잘 처리합니다.
실제로 쓰는 6가지 패턴
오랜 시간 만들다 보니 같은 몇 가지를 계속 쓴다는 걸 깨달았습니다. 위의 생성기는 6가지 모두 제공합니다. 각 스타일이 빛을 보는 상황을 정리합니다.
클래식 스피너 (테두리에 한 색의 호)
로더계의 검정 티셔츠. 어떤 제품, 어떤 색에 두어도 어색하지 않습니다. 레시피는 원에 테두리를 두르고 한 면만 브랜드 컬러, 나머지는 옅은 회색 트랙으로. 1초 주기로 회전. 끝.
저는 이걸 버튼, 테이블 셀, '저장'을 누른 폼 필드의 인라인 상태에 씁니다. 무엇을 원하는지 확신이 없을 때 기본값으로도 좋습니다 — 클래식 스피너로 틀리는 건 불가능에 가깝고 그저 평범할 뿐인데, 가장 좋은 로더는 아무도 알아채지 못하는 로더입니다.
한 가지 팁: 색이 들어간 호가 원 둘레의 25% 정도가 되도록. 둘레의 절반이 색칠된 스피너는 둔하고 느려 보입니다. 25% 근처가 시각적으로 가장 좋습니다.
듀얼 링 (반대 방향 두 호)
클래식이 너무 조용할 때 손이 가는 스피너. 두 호가 서로 다른 방향으로 함께 회전합니다. 클래식보다 더 의도적이고 더 집중된 느낌. 저는 '시간이 좀 걸릴 수 있는' 작업 — 파일 업로드, 보고서 내보내기, 사용자가 2초 이상 응시할 가능성이 있는 모든 것에 씁니다.
듀얼 링은 작은 크기에서도 회전이 잘 보입니다. 두 호 사이의 대비가 24px 너비에서도 회전을 시각적으로 살려 줍니다.
점 3개 바운스
친근하고 캐주얼함. '시스템이 바쁨'보다 '생각 중'으로 읽힙니다. 채팅 인터페이스, AI 어시스턴트, 페이지의 부담을 낮추고 싶은 온보딩 흐름에 씁니다.
모든 곳에 어울리진 않습니다. 진지한 관리자 도구에선 가벼워 보이고, 결제 페이지에 두면 주문이 사라질지 걱정하게 만듭니다. 톤을 맥락에 맞추세요. 클래식 스피너는 보편적이고, 점 3개 바운스는 캐릭터가 있습니다.
막대 이퀄라이저
수직 막대 4~5개가 위아래로 오르내리며 이퀄라이저처럼 보이는 스타일. 미디어 인터페이스에 특화된 로더입니다 — 오디오 플레이어, 비디오 편집기, 소리나 리듬과 관련된 것이라면 무엇이든. 시각적 은유가 그대로 통합니다. 목록을 쭉 훑는 듯한 좌우 움직임이 '동기화'나 '인덱싱' 작업에도 잘 어울립니다.
펄스
원 하나가 시간에 맞춰 확대되며 페이드하는 가장 미니멀한 로더. UI가 이미 바쁠 때 다중 도형 스피너가 주의를 분산시킬 수 있는 상황에 씁니다. 데이터 대시보드처럼 밀도 높은 화면이나, 콘텐츠가 로드되는 즉시 시각적으로 사라져야 하는 모든 경우. 펄스는 메인 CTA 컬러로 색을 맞추면 별개 위젯이 아니라 '버튼이 숨 쉬는 것'처럼 보여 더 우아합니다.
리플 (동심원이 바깥으로 퍼짐)
6개 중 가장 화려하고 가장 적게 쓰는 스타일. 리플은 시선을 끕니다. 그게 핵심입니다. 사용자가 로더를 봐 주길 바랄 때 쓰세요 — 사실 그런 경우는 드뭅니다. 스플래시 화면, 페이지 전체 라우트 전환, 계정 생성 직후 대시보드가 뜨기 전. 그 외엔 과합니다.
크기 정하기, 절반의 싸움
크기가 잘못된 로더는 잘 작동해도 망가져 보입니다. 제가 따르는 규칙:
- 버튼 안: 16~20px. 버튼 텍스트 옆에 자연스럽게 들어갈 만큼. 아이콘을 대체하세요. 아이콘 위에 스피너를 겹치지 마세요.
- 폼 필드 옆이나 테이블 행 안의 인라인: 24~32px. 분명히 보이지만 행을 압도하지 않을 정도.
- 페이지 레벨: 48~64px. 사용자의 시선이 페이지에 있고 로더가 초점입니다. 이보다 작으면 묻히고, 크면 다급해 보입니다.
- 풀스크린 스플래시: 80~120px. 거대한 스피너가 통하는 유일한 맥락. 페이지 전체가 곧 로더이고 다른 볼 게 없을 때.
가장 흔한 실수는 32px 높이의 버튼 안에 64px짜리 스피너를 넣는 것 — 버튼이 토하는 것처럼 보입니다. 컨테이너에 맞추세요.
속도: 가장 저평가된 설정
스피너 속도는 페이지가 빠르게 느껴질지 망가져 보일지에 가장 크게 기여하는 요소인데 거의 아무도 조정하지 않습니다. 복붙 스니펫의 기본값은 보통 0.6~0.8초/회전인데, 이건 너무 빠릅니다. 빠르다고 더 민첩해 보이는 게 아닙니다. 0.4초/회전으로 도는 스피너는 불안하게 — 시스템이 곤란한 것처럼 — 읽힙니다. 2초/회전으로 기어가는 스피너는 멈춘 것처럼 보입니다.
스위트 스폿은 1회 사이클 0.91.2초입니다. '작동 중'으로 읽히면서 '패닉'으로 보이지 않는 박자. 곧 교체될 로더라면 0.7초까지 내려도 괜찮고, 사용자가 1020초간 응시할 로더는 1.4초로 늘려야 합니다 — 그 길이에서는 더 빠르면 짜증이 납니다.
위에 링크한 생성기는 1초가 기본값이며, 거의 모든 경우에 맞습니다.
접근성, 진심으로
세 가지는 타협하지 마세요.
하나: 보조 기술에 로더를 알리세요. 로더 요소에 role="status"를 추가하세요. 스크린리더가 라이브 영역으로 인식해 변화를 읽어 줍니다. aria-label="Loading"(또는 aria-label="검색 결과를 불러오는 중"처럼 구체적으로) 페어링해서 스크린리더가 읽을 거리를 만들어 주세요.
둘: prefers-reduced-motion을 존중하세요. 회전하는 도형에 멀미를 일으키는 사용자가 적지만 분명히 존재합니다. OS 레벨의 '동작 줄이기' 설정이 CSS로 노출됩니다. 이렇게 쓰면 됩니다:
@media (prefers-reduced-motion: no-preference) {
.loader { animation: spin 1s linear infinite; }
}
@media (prefers-reduced-motion: reduce) {
.loader::after {
content: "Loading…";
/* 정적 폴백으로 교체 */
}
}
셋: 색상만으로 상태를 전달하지 마세요. 주변 UI와 색만 다른 스피너는 색맹 사용자에게 '로딩 중'을 전하지 못합니다. 모션이 신호이고, 색은 장식입니다.
스피너를 아예 보여주지 말아야 할 때
거의 아무도 따르지 않는 규칙. 300ms 미만에 끝나는 작업에는 로딩 인디케이터를 띄우지 마세요. 스피너가 깜빡 켜졌다 꺼지면 프레임 변화로 인식되고, 시각적으로는 페이지가 점프한 것처럼 — 사실은 더 느린 인상으로 — 다가옵니다.
해법은 디바운싱. 일정 시간 후에만 스피너를 노출하세요 — 보통 200ms. 그 안에 끝나면 스피너 없음. 넘어가면 표시.
let spinnerTimeout = setTimeout(() => showSpinner(), 200);
fetch('/api/data').then(() => {
clearTimeout(spinnerTimeout);
hideSpinner();
});
스피너를 건너뛰어야 하는 또 다른 경우는 스켈레톤 스크린이 더 정보를 잘 전달할 때입니다. 스켈레톤은 곧 나타날 콘텐츠의 구조를 미리 보여주는데, 연구에 따르면 일반 스피너 대비 체감 대기 시간을 30~50% 줄입니다. 글 목록·프로필 페이지·대시보드처럼 구조가 예측 가능한 레이아웃에서는 스켈레톤이 이깁니다. 스피너는 결과가 무경계이고 예측할 수 없는 대기에서 가장 좋습니다.
로더를 빌딩 블록으로 만드는 CSS 변수 트릭
스니펫을 빌딩 블록으로 바꿔주는 패턴. CSS 변수로 로더를 정의해 한 곳에서 테마를 입히세요:
.loader {
--loader-color: hsl(240 80% 60%);
--loader-size: 48px;
--loader-speed: 1s;
width: var(--loader-size);
height: var(--loader-size);
border: 4px solid color-mix(in srgb, var(--loader-color) 20%, transparent);
border-top-color: var(--loader-color);
border-radius: 50%;
animation: spin var(--loader-speed) linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
이제 변수를 덮어쓰는 것만으로 어디서든 색을 바꿀 수 있습니다:
.dark-mode .loader { --loader-color: hsl(180 80% 70%); }
.danger .loader { --loader-color: hsl(0 80% 60%); }
.compact .loader { --loader-size: 24px; }
이게 제가 실제로 출고하는 버전입니다. 디자인 시스템 확장에 추가 비용이 거의 없고, 디자인의 나머지가 CSS 변수를 쓰면 다크 모드는 공짜로 따라오며, 단일 값 교체로 A/B 테스트도 가능합니다.
자주 만나는 버그와 해결
반복적으로 나타나는 이슈 몇 가지.
Safari에서 스피너가 흐릿하거나 떨립니다. 회전 요소에 transform: translateZ(0)을 추가해 컴포지터 레이어를 강제하세요. 서브픽셀 렌더링이 안정됩니다. 약간의 꼼수지만 효과적입니다.
스피너가 나타날 때 레이아웃이 흔들립니다. 스피너가 아니라 컨테이너에 width와 height를 예약하세요. 그렇지 않으면 스피너가 마운트될 때 주변이 리플로우합니다.
모바일 Safari에서 스피너가 안 보입니다. 트리 어딘가에 display: inline-block이 걸려 있는지 확인하세요 — Safari는 절대 위치 자식을 감싸는 inline-block의 치수를 잃을 때가 있습니다. display: inline-flex나 display: block으로 바꾸면 해결됩니다.
Firefox에서 속도가 이상합니다. Firefox는 prefers-reduced-motion을 통해 시스템의 '동작 줄이기' 설정을 존중합니다. CSS에서 그 케이스를 처리하지 않으면 속도가 0(즉, 안 돌아감)이 될 수 있습니다. 위의 @media 블록을 추가하면 사라집니다.
솔직한 결론
대부분의 경우 스피너는 페이지에서 가장 중요한 요소가 아닙니다. 사용자는 멋진 로딩 애니메이션을 보러 사이트에 들어온 게 아닙니다. 무언가를 하기 위해 들어왔고, 로딩 상태는 그 사이의 마찰일 뿐입니다.
가장 좋은 로더는 사용자가 기억하지 않는 로더입니다. 잠깐 나타나 작업 진행 중임을 알리고 인상을 남기지 않은 채 사라집니다. 클래식 스피너 — 브랜드와 매칭된 색, 컨테이너에 맞는 크기, 1초/회전 속도, role="status"와 prefers-reduced-motion 폴백 — 가 90%의 경우를 커버하고 시간이 지나도 잘 늙습니다.
생성기 링크는 글 위에 있습니다. 스타일을 골라 맥락에 맞게 튜닝하고 CSS를 붙여 넣은 뒤 다음 일로 넘어가세요. 로더가 가리고 있는 진짜 작업이 우리가 보수를 받는 부분입니다.
관련 도구
- CSS 애니메이션 생성기 — 로더를 넘는 키프레임 애니메이션
- CSS 큐빅 베지어 생성기 — 모션을 자연스럽게 만드는 이징 곡선
- CSS 박스 섀도 생성기 — 대부분의 로더 스타일과 잘 어울리는 그림자
- 플레이스홀더 이미지 생성기 — 스피너 대신 자주 쓰는 스켈레톤 스크린