데이터를 불러오는 동안 사용자를 기다리게 만드는 스피너. JS 없이 CSS만으로 만드는 6가지 스피너 유형을 코드와 함께 정복해봐요.
사용자는 아무 반응이 없는 화면을 가장 답답해합니다. "지금 처리 중이에요"라는 시각적 피드백만 있어도 이탈률이 크게 줄어요.
UX 연구 결과: 100ms 이내 응답은 즉각적으로 느껴지지만, 1초가 넘어가면 사용자는 "뭔가 문제가 있나?" 하고 의심하기 시작해요. 3초 이상이면 약 40%가 페이지를 떠납니다. 로딩 스피너는 "지금 열심히 하고 있어요"라는 신뢰 신호입니다.
스피너가 돌아가는 것을 보면 사용자는 "앱이 살아있구나"라고 인식해요. 빈 화면보다 같은 대기 시간이 훨씬 짧게 느껴집니다.
가능하다면 확정 스피너(진행 %) vs 불확정 스피너(무한 회전) 중 적합한 것을 골라요. 진행률을 알 수 있으면 확정이 훨씬 좋습니다.
복잡한 라이브러리 없이 border와 @keyframes 몇 줄이면 충분히 매끄러운 스피너를 만들 수 있어요. 번들 크기가 늘지 않아요.
버튼 안 → 아주 작은 인라인 스피너. 페이지 전체 → 오버레이 스피너. 콘텐츠 영역 → 스켈레톤 로딩이 가장 좋은 UX예요.
스켈레톤 vs 스피너: 콘텐츠의 레이아웃을 미리 보여주는 스켈레톤 로딩은 사용자가 로드될 내용을 예측할 수 있어 스피너보다 체감 속도가 더 빠릅니다. 페이스북, YouTube 등 대형 서비스가 모두 이 방식을 사용하는 이유예요.
실제로 작동하는 스피너 6가지예요. 각 카드의 "코드 보기" 버튼을 누르면 해당 스피너의 CSS 코드를 바로 확인할 수 있습니다.
border 트릭으로 만드는 가장 기본적인 스피너. 어디에나 무난하게 어울려요.
.sp-circle { width: 44px; height: 44px; border: 4px solid #e5e7eb; border-top-color: #4f46e5; border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
순차적으로 튀어나오는 점 3개. 채팅 앱의 "타이핑 중" 표시에 자주 쓰여요.
.sp-dots { display: flex; gap: 8px; } .sp-dot { width: 12px; height: 12px; background: #4f46e5; border-radius: 50%; animation: dotPulse 1.4s ease-in-out infinite; } .sp-dot:nth-child(2) { animation-delay: .2s; } .sp-dot:nth-child(3) { animation-delay: .4s; } @keyframes dotPulse { 0%,80%,100% { transform: scale(0); opacity: .3; } 40% { transform: scale(1); opacity: 1; } }
원이 커졌다 작아지는 효과. 심박수처럼 살아있다는 느낌을 줘요.
.sp-pulse { width: 48px; height: 48px; background: #4f46e5; border-radius: 50%; animation: scalePulse 1.2s ease-in-out infinite; } @keyframes scalePulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: .5; } }
빛이 흘러가는 플레이스홀더. 텍스트·이미지 로딩 전 레이아웃을 미리 보여줘요.
.sk-line { height: 10px; border-radius: 5px; background: linear-gradient( 90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75% ); background-size: 600px 100%; animation: shimmer 1.4s infinite linear; } @keyframes shimmer { 0% { background-position: -600px 0; } 100% { background-position: 600px 0; } }
바깥·안쪽 링이 반대로 도는 효과. 정교한 느낌을 줘야 할 때 좋아요.
.sp-dual { width: 48px; height: 48px; position: relative; } .sp-dual-outer { position: absolute; inset: 0; border: 4px solid transparent; border-top-color: #4f46e5; border-radius: 50%; animation: spin .9s linear infinite; } .sp-dual-inner { position: absolute; inset: 8px; border: 3px solid transparent; border-top-color: #a5b4fc; border-radius: 50%; animation: spin .7s linear infinite reverse; }
점이 위아래로 통통 튀는 효과. 귀엽고 발랄한 느낌의 서비스에 잘 맞아요.
.sp-bounce { display: flex; gap: 7px; align-items: flex-end; } .sp-b { width: 11px; height: 11px; background: #4f46e5; border-radius: 50%; animation: bounce .8s ease-in-out infinite; } .sp-b:nth-child(2) { animation-delay: .15s; } .sp-b:nth-child(3) { animation-delay: .3s; } @keyframes bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-16px); } }
6가지 스피너 CSS를 모두 담은 완성 코드예요. 필요한 것만 골라서 쓰거나 전부 포함해도 용량은 매우 작습니다.
/* ① 원형 스피너 */ @keyframes spin { to { transform: rotate(360deg); } } .sp-circle { width: 44px; height: 44px; border: 4px solid #e5e7eb; border-top-color: #4f46e5; border-radius: 50%; animation: spin .8s linear infinite; } /* ② 점 세 개 */ @keyframes dotPulse { 0%,80%,100% { transform: scale(0); opacity: .3; } 40% { transform: scale(1); opacity: 1; } } .sp-dots { display: flex; gap: 8px; align-items: center; } .sp-dot { width: 12px; height: 12px; background: #4f46e5; border-radius: 50%; animation: dotPulse 1.4s ease-in-out infinite; } .sp-dot:nth-child(2) { animation-delay: .2s; } .sp-dot:nth-child(3) { animation-delay: .4s; } /* ③ 펄스 */ @keyframes scalePulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: .5; } } .sp-pulse { width: 48px; height: 48px; background: #4f46e5; border-radius: 50%; animation: scalePulse 1.2s ease-in-out infinite; } /* ④ 스켈레톤 */ @keyframes shimmer { 0% { background-position: -600px 0; } 100% { background-position: 600px 0; } } .sk-line { height: 10px; border-radius: 5px; background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 600px 100%; animation: shimmer 1.4s infinite linear; } /* ⑤ 이중 링 */ .sp-dual { width: 48px; height: 48px; position: relative; } .sp-dual-outer, .sp-dual-inner { position: absolute; border-radius: 50%; border: 4px solid transparent; border-top-color: #4f46e5; animation: spin .9s linear infinite; } .sp-dual-outer { inset: 0; } .sp-dual-inner { inset: 8px; border-width: 3px; border-top-color: #a5b4fc; animation-direction: reverse; animation-duration: .7s; } /* ⑥ 바운스 */ @keyframes bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-16px); } } .sp-bounce { display: flex; gap: 7px; align-items: flex-end; } .sp-b { width: 11px; height: 11px; background: #4f46e5; border-radius: 50%; animation: bounce .8s ease-in-out infinite; } .sp-b:nth-child(2) { animation-delay: .15s; } .sp-b:nth-child(3) { animation-delay: .3s; }
스피너를 어디에 붙이냐에 따라 UX가 달라져요. 버튼 인라인, 전체 오버레이, Fetch API 연동 예시를 준비했습니다.
버튼을 누르면 2초 후 완료됩니다
fetch 요청 전에 스피너를 보여주고, 완료 후 숨기는 패턴이에요.
async function loadData() { const spinner = document.getElementById('spinner'); const content = document.getElementById('content'); // 1. 스피너 표시, 콘텐츠 숨김 spinner.style.display = 'flex'; content.style.display = 'none'; try { const res = await fetch('/api/data'); const data = await res.json(); // 2. 콘텐츠 렌더링 content.innerHTML = renderData(data); content.style.display = 'block'; } catch (err) { content.innerHTML = '<p>불러오기 실패. 다시 시도해주세요.</p>'; content.style.display = 'block'; } finally { // 3. 스피너 항상 숨김 (성공·실패 무관) spinner.style.display = 'none'; } }
접근성 팁: 스피너 요소에 role="status"와 aria-label="로딩 중"을 추가하면 스크린 리더가 로딩 상태를 읽어줍니다. 시각적으로만 표현하면 시각장애 사용자는 아무런 피드백을 받지 못해요.
AI에게 스피너를 요청할 때 사용하기 좋은 프롬프트 예시예요. 어떤 유형·색상·크기를 원하는지 구체적으로 쓸수록 정확한 결과가 나와요.
아래 프롬프트를 Claude, ChatGPT, Cursor에 붙여넣어 보세요.
내 웹페이지에 로딩 스피너를 추가해줘. 상황별로 3가지가 필요해: 1. 버튼 안 인라인 스피너 - 버튼 클릭 시 텍스트 대신 작은 원형 스피너가 표시됨 - 버튼 비활성화(disabled) 처리 - 완료 시 원래 텍스트로 복원 2. 카드/섹션 전체 오버레이 스피너 - 반투명 흰 배경 위에 원형 스피너 + "로딩 중..." 텍스트 - position: absolute로 컨테이너 위에 레이어링 3. 목록 로딩용 스켈레톤 스피너 - 3개 줄 shimmer 효과 (회색 → 밝은 회색 → 회색 반복) 조건: - CSS만으로 구현 (JS는 show/hide 제어만) - 색상은 #4f46e5 (인디고) - role="status" aria-label="로딩 중" 접근성 포함 - 기존 HTML/CSS를 최대한 유지
CSS 스피너를 처음 쓸 때 자주 만나는 질문들이에요.
position: relative, 스피너에 position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)를 주면 돼요. 전체 화면 중앙이라면 부모를 position: fixed; inset: 0; display: flex; align-items: center; justify-content: center로 설정하면 가장 간단합니다.
border-top-color만 바꾸면 됩니다. 점·바운스 스피너는 background 속성을 변경하세요. CSS 변수(--brand-color: #your-color)를 선언하고 모든 스피너에서 참조하면 한 곳만 바꿔도 전부 바뀌어서 관리가 편해요.
setTimeout으로 간단하게 구현할 수 있어요.