⏳ CSS 애니메이션 패턴

로딩 스피너
(Loading Spinner)

데이터를 불러오는 동안 사용자를 기다리게 만드는 스피너. JS 없이 CSS만으로 만드는 6가지 스피너 유형을 코드와 함께 정복해봐요.

⏳ Pure CSS ⚡ JS 불필요 🎨 6가지 디자인 📋 복사 가능한 코드
① Concept

로딩 스피너가 필요한 이유

사용자는 아무 반응이 없는 화면을 가장 답답해합니다. "지금 처리 중이에요"라는 시각적 피드백만 있어도 이탈률이 크게 줄어요.

💡

UX 연구 결과: 100ms 이내 응답은 즉각적으로 느껴지지만, 1초가 넘어가면 사용자는 "뭔가 문제가 있나?" 하고 의심하기 시작해요. 3초 이상이면 약 40%가 페이지를 떠납니다. 로딩 스피너는 "지금 열심히 하고 있어요"라는 신뢰 신호입니다.

🔄

인지적 안심

스피너가 돌아가는 것을 보면 사용자는 "앱이 살아있구나"라고 인식해요. 빈 화면보다 같은 대기 시간이 훨씬 짧게 느껴집니다.

📊

진행 피드백

가능하다면 확정 스피너(진행 %) vs 불확정 스피너(무한 회전) 중 적합한 것을 골라요. 진행률을 알 수 있으면 확정이 훨씬 좋습니다.

CSS만으로 충분

복잡한 라이브러리 없이 border@keyframes 몇 줄이면 충분히 매끄러운 스피너를 만들 수 있어요. 번들 크기가 늘지 않아요.

🎯

상황에 맞게 선택

버튼 안 → 아주 작은 인라인 스피너. 페이지 전체 → 오버레이 스피너. 콘텐츠 영역 → 스켈레톤 로딩이 가장 좋은 UX예요.

스켈레톤 vs 스피너: 콘텐츠의 레이아웃을 미리 보여주는 스켈레톤 로딩은 사용자가 로드될 내용을 예측할 수 있어 스피너보다 체감 속도가 더 빠릅니다. 페이스북, YouTube 등 대형 서비스가 모두 이 방식을 사용하는 이유예요.

② Types

6가지 스피너 갤러리

실제로 작동하는 스피너 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); }
}
③ Code

전체 CSS 한 번에 복사하기

6가지 스피너 CSS를 모두 담은 완성 코드예요. 필요한 것만 골라서 쓰거나 전부 포함해도 용량은 매우 작습니다.

spinners.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; }
④ Usage

실전 활용 패턴

스피너를 어디에 붙이냐에 따라 UX가 달라져요. 버튼 인라인, 전체 오버레이, Fetch API 연동 예시를 준비했습니다.

① 버튼 로딩 상태

버튼을 누르면 2초 후 완료됩니다

② 오버레이 로딩

콘텐츠 영역
불러오는 중...

③ Fetch API 연동 패턴

fetch 요청 전에 스피너를 보여주고, 완료 후 숨기는 패턴이에요.

script.js — 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="로딩 중"을 추가하면 스크린 리더가 로딩 상태를 읽어줍니다. 시각적으로만 표현하면 시각장애 사용자는 아무런 피드백을 받지 못해요.

⑤ Prompt Tip

AI 프롬프트 팁

AI에게 스피너를 요청할 때 사용하기 좋은 프롬프트 예시예요. 어떤 유형·색상·크기를 원하는지 구체적으로 쓸수록 정확한 결과가 나와요.

✦ 바이브코더 프롬프트

아래 프롬프트를 Claude, ChatGPT, Cursor에 붙여넣어 보세요.

내 웹페이지에 로딩 스피너를 추가해줘.

상황별로 3가지가 필요해:

1. 버튼 안 인라인 스피너
   - 버튼 클릭 시 텍스트 대신 작은 원형 스피너가 표시됨
   - 버튼 비활성화(disabled) 처리
   - 완료 시 원래 텍스트로 복원

2. 카드/섹션 전체 오버레이 스피너
   - 반투명 흰 배경 위에 원형 스피너 + "로딩 중..." 텍스트
   - position: absolute로 컨테이너 위에 레이어링

3. 목록 로딩용 스켈레톤 스피너
   - 3개 줄 shimmer 효과 (회색 → 밝은 회색 → 회색 반복)

조건:
- CSS만으로 구현 (JS는 show/hide 제어만)
- 색상은 #4f46e5 (인디고)
- role="status" aria-label="로딩 중" 접근성 포함
- 기존 HTML/CSS를 최대한 유지
⑥ FAQ

자주 묻는 질문

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)를 선언하고 모든 스피너에서 참조하면 한 곳만 바꿔도 전부 바뀌어서 관리가 편해요.
10초 이상 스피너가 돌고 있으면 사용자는 오류인지 헷갈려요. 타임아웃 처리를 꼭 추가하세요. 예를 들어 10초가 지나면 "응답이 없습니다. 새로고침해주세요."라는 오류 메시지를 보여주는 것이 좋습니다. setTimeout으로 간단하게 구현할 수 있어요.