Skip to content

Spinner (로딩 스피너)

"지금 뭔가 진행 중"을 회전으로 알리는 표시입니다. 종류는 두 개뿐이고, 무엇을 쓸지는 취향이 아니라 그 자리가 비어 있는지로 정해집니다 — 영역 전체가 아직 그릴 게 없으면 링(.ds-spinner), 버튼·컨트롤 안에서 그 컨트롤만 바쁘면 아이콘(ic-spinner).

규칙 (강제) · 코딩 에이전트용 — 사람은 접힌 채로 두어도 됩니다

Spinner 강제 규칙. 상세·미리보기는 아래 본문.

두 종류뿐이고, 자리로 정해집니다 — 세 번째를 만들지 마세요

  • 영역 로딩 = .ds-spinner (div 링). 화면·패널·그리드가 아직 아무것도 못 그리는 동안(앱 부팅, iframe 임베드, 시트 첫 페이지, 처리 중 화면 잠금).
  • 인라인 로딩 = $icon('ic-spinner') + animate-spin (SVG 아이콘). 버튼 안이나 컨트롤 바로 옆에서 그 컨트롤 하나만 바쁠 때. 크기는 h-3~h-5 급.
  • 두 용도를 섞지 마세요. 영역 로딩에 아이콘을 쓰면 회전이 끊기고(아래 이유), 버튼 안에 링을 넣으면 두께·정렬이 버튼 텍스트와 안 맞습니다.

링을 마크업에서 흉내 내지 마세요 — 이게 이 페이지의 핵심 강제이고, 커밋에서 막힙니다

  • 회전 + 원형 테두리를 한 줄에서 조합하는 것 금지. 철자는 두 가지가 실제로 있었습니다: 인라인 style 형(class="animate-spin rounded-full" style="border:3px solid …; border-top-color: …")과 Tailwind 유틸 조합 형(class="w-6 h-6 border-2 border-orange-500 border-t-transparent rounded-full animate-spin"). 둘 다 사본입니다.
  • 겉보기는 같지만 .ds-spinner 가 주는 두 가지를 못 받습니다: ① --ds-spinner-* 변수(정본을 고쳐도 이 사본만 안 따라옴), ② prefers-reduced-motion 대응(다른 스피너는 2.4s로 느려지는데 이 사본만 1s로 빠르게 돔).
  • pnpm check:spinners (pre-commit 차단)추가된 줄만 보고 이 조합을 잡습니다. 아이콘 스피너(ds-ico / $icon( 가 같은 줄에 있음)는 대상이 아닙니다 — 버튼 안 인라인 스피너는 정상 사용법이에요. 로딩 표시가 아닌 회전 원(진행률 링·차트 조각)은 같은 줄이나 윗줄에 ds-spinner-exempt: <사유> 로 통과시키되, 사유 없이 예외를 늘리지 마세요.
  • 레거시는 유틸 조합 형 3곳(pnpm check:spinners:audit) — 마이그레이션 대상이지 위반이 아닙니다.

왜 영역 로딩은 아이콘이 아니라 div 링인가 (되돌리지 마세요)

  • SVG transform 애니메이션은 메인 스레드가 막히면 합성 레이어로 못 올라가 프레임이 튑니다. 영역 로딩은 정의상 메인 스레드가 바쁜 순간이라 정확히 그때 끊깁니다 — 시트 로딩에서 "뚝뚝 끊김"으로 실제 리포트된 현상입니다.
  • div 테두리 회전 + will-change: transform 은 GPU 합성이 안정적이라 로딩 중에도 부드럽게 돕니다.
  • 반대로 버튼 안 인라인 스피너는 그 순간 메인 스레드가 한가하므로 아이콘으로 충분합니다.

마크업 (강제)

  • 링은 <span class="ds-spinner"></span> 입니다. 자식도, 크기 유틸(w-8 h-8)도 붙이지 마세요 — 크기는 --ds-spinner-size 가 정합니다.
  • 콘텐츠 위에 띄울 때만 .ds-spinner-badge 로 감쌉니다(흰 원형 받침 + 그림자). 이미 흰 딤 위라면 받침을 씌우지 마세요 — 흰 바탕에 흰 원이 겹쳐 테두리만 보입니다.
  • 아이콘 쪽은 .ds-ico 래퍼가 필수입니다(없으면 display:inline 이라 w-4 h-4 가 안 먹고 SVG 가 컨테이너를 가득 채웁니다). 회전은 래퍼의 animate-spin, 색은 래퍼의 text-* 가 담당합니다.
  • 전체 화면 오버레이로 띄울 땐 pointer-events-none 을 붙여 클릭을 통과시킵니다(순수 시각 표시). 클릭을 막아야 하는 처리 중 잠금은 스피너가 아니라 화이트 딤의 역할입니다.

새 종류는 변수만 덮습니다 (확장 규칙)

  • 크기·두께·색이 다른 스피너가 필요하면 design-system.css.ds-spinner--<이름> 을 추가해 --ds-spinner-* 변수만 덮습니다. width/border/animation 을 다시 선언하거나 링을 새로 그리지 마세요 — 회전 메커니즘을 공유해야 GPU 합성과 reduced-motion 이 새 종류에도 따라옵니다.
  • 변수는 요소 자신에만 겁니다 — 조상에 걸어도 안 내려갑니다. 다섯 축은 @property { inherits: false } 로 등록돼 있어서, 패널에 --ds-spinner-size 를 걸어 그 안 스피너를 줄이는 식은 동작하지 않습니다(의도된 차단). 상속이 열려 있으면 그 설정이 안에 든 부팅·임베드 스피너까지 함께 끌고 가거든요.
  • 새 종류를 만들면 이 페이지의 변형 표에 행을 추가합니다. 앱 main.css 에는 정의하지 마세요(정본은 design-system.css 하나).

스피너가 아닌 것 — 여기에 밀어 넣지 마세요

  • 진행률을 아는 경우(N/M 로딩)는 스피너가 아니라 카운트 배너입니다 → 아래.
  • 표·카드 자리를 미리 잡아 두는 회색 자리표시(스켈레톤)는 아직 DS에 없습니다. 없는 걸 쓰라고 지시하지 마세요.
  • 진행률 바(.ds-progress)도 없습니다. 부팅 화면의 상단 바는 loading.html 안의 로컬 @keyframes loading-bar 이고 공용 컴포넌트가 아닙니다.

라이브 미리보기

아래는 실제 design-system.css.ds-spinner 규칙으로 렌더됩니다.

링 (기본)
링 + 받침
받침 (콘텐츠 위)

받침(.ds-spinner-badge)은 언제 씌우나 — 링이 콘텐츠 위에 겹쳐 뜰 때만입니다. 위 오른쪽처럼 배경에 무늬나 표가 깔려 있으면 링만으로는 묻히거든요. 반대로 이미 흰 딤이 깔린 위라면 받침 없이 링만 씁니다.

두 종류 — 자리가 정한다

영역 링 .ds-spinner인라인 아이콘 ic-spinner
언제영역이 아직 아무것도 못 그림그 컨트롤 하나만 바쁨
앱 부팅 · iframe 임베드 · 시트 첫 페이지 · 처리 중 화면 잠금[저장] 버튼 · 인라인 검증 · 카드 안 진행 표시
구현div 테두리 회전 (CSS 전용)SVG 아이콘 + Tailwind animate-spin
크기32px 기본 (--ds-spinner-size)12–20px (h-3~h-5)
메인 스레드막혀 있음 → GPU 합성 필수한가함 → 아이콘으로 충분
reduced-motion2.4s 로 느려짐대응 없음

판단이 애매할 때 — "이 스피너를 지우면 그 자리에 뭐가 보이나?"를 물어보세요. 아무것도 안 보이면 영역 링, 버튼이나 값이 그대로 보이면 인라인 아이콘입니다.

사용법

영역 링 — 화면 가운데 오버레이

html
<!-- pointer-events-none: 순수 시각 표시라 클릭은 통과시킨다 -->
<div x-show="loading" x-cloak
     class="fixed inset-0 z-40 flex items-center justify-center pointer-events-none">
  <span class="ds-spinner-badge"><span class="ds-spinner"></span></span>
</div>

영역 링 — 처리 중 화면 잠금 (흰 딤 위, 받침 없음)

html
<div x-show="processing" x-cloak
     class="ds-modal-backdrop ds-modal-backdrop--white flex-col gap-3" aria-live="polite">
  <span class="ds-spinner"></span>
  <span class="text-b5 font-medium text-neutral-700">시트를 설정 완료 합니다.</span>
</div>

인라인 아이콘 — 버튼 안

html
<button class="ds-btn ds-btn--solid ds-btn--primary ds-btn--md" :disabled="saving">
  <span x-show="saving" class="ds-ico animate-spin h-4 w-4" aria-hidden="true"
        x-html="$icon('ic-spinner')"></span>
  <span x-text="saving ? '저장 중…' : '저장'"></span>
</button>

접근성

링·아이콘 어느 쪽도 스스로는 아무 말도 하지 않습니다(장식 요소). 스크린리더에 상태를 알려야 하면 문구 쪽aria-live="polite" 를 겁니다 — 위 화이트 딤 예시가 그 형태입니다. 아이콘에는 aria-hidden="true" 를 붙이세요.

규격

변수기본값
링 지름--ds-spinner-size2rem (32px)
링 두께--ds-spinner-thickness3px
도는 배경 원 색--ds-spinner-track--color-neutral-200
도는 부분 색--ds-spinner-indicator--color-orange-500
받침 지름--ds-spinner-badge-size4rem (64px)

다섯 축 모두 @property { syntax: "*"; inherits: false } 로 등록돼 상속되지 않습니다 — 자세한 건 확장.

  • 회전 주기 1s linear infinite(@keyframes ds-spinner-rotate), prefers-reduced-motion: reduce 에서 2.4s.
  • 받침은 --color-bg-normal 배경 + --color-neutral-100 보더 + --ds-shadow-md.
  • 이 값들은 전부 토큰입니다 — 하드코딩 hex 나 인라인 그림자를 넣지 마세요.

변형

변형상태비고
기본 (.ds-spinner)✅ 사용 중32px · orange-500
받침 (.ds-spinner-badge)✅ 사용 중변형이 아니라 감싸개 — 링과 조합

지금은 종류가 하나입니다. 다만 구조는 열려 있어서, 새 종류는 아래 절차로 변수만 덮어 추가합니다.

확장 — 새 종류를 추가할 때

모양을 정하는 값을 전부 변수로 빼 두었기 때문에, 새 종류는 링을 다시 그리는 게 아니라 변수를 덮는 일입니다.

css
/* design-system.css — .ds-spinner 블록 바로 아래에 추가 */
.ds-spinner--sm {
  --ds-spinner-size: 1.25rem;
  --ds-spinner-thickness: 2px;
}
html
<span class="ds-spinner ds-spinner--sm"></span>

지켜야 할 것

  1. 변수만 덮습니다. width·border·animation·will-change 를 다시 선언하지 마세요. 회전 메커니즘을 공유해야 GPU 합성 이점과 reduced-motion 대응이 새 종류에도 자동으로 따라옵니다.
  2. 정본은 design-system.css 한 곳입니다. 앱 main.css 에 정의하면 그 앱에서만 존재하는 유령 클래스가 됩니다(.ds-modal__desc 가 이렇게 어긋났습니다).
  3. 베이스와 함께 씁니다.ds-spinner--sm 단독은 아무것도 아닙니다. .ds-spinner .ds-spinner--sm 두 클래스를 같이 답니다.
  4. 추가했으면 위 변형 표에 행을 넣고 변경 이력에 기록합니다.

두께는 지름에 딸려 오지 않습니다 — 지름만 줄이면 두께 3px 이 상대적으로 두꺼워져 링이 도넛처럼 보입니다. 크기를 바꾸는 변형은 두께도 같이 정해 주세요(위 예시가 그 형태).

변수는 상속되지 않습니다 (@property { inherits: false }). CSS 변수는 원래 상속이라, 조상 하나가 --ds-spinner-size 를 설정하면 그 아래 모든 스피너가 따라갑니다 — 패널 안 스피너 하나 줄이려던 설정이 거기 든 부팅·임베드 스피너까지 끌고 가는 사고죠. 다섯 축을 @property 로 등록해 그 경로를 막아 두었습니다.

어디에 거나먹나
변형 클래스 .ds-spinner--sm (요소 자신)
요소에 직접 style="--ds-spinner-size: 3rem"
조상--ds-spinner-size❌ 의도된 차단

@property 를 모르는 옛 브라우저는 이 블록을 무시합니다 — 변수가 예전처럼 상속될 뿐 렌더 결과는 같습니다(안전한 degradation).

스피너가 아닌 로딩 표시

로딩이라고 다 스피너가 아닙니다. 잘못 고르면 "얼마나 남았는지 알 수 있는데 안 알려주는" 화면이 됩니다.

상황쓰는 것실제 사례
진행률을 모름, 영역이 빔영역 링 .ds-spinner시트 첫 페이지 로딩
진행률을 모름, 컨트롤만 바쁨인라인 아이콘 ic-spinner[저장] 버튼
진행률을 (N/M)카운트 배너 — 스피너 금지시트 배경 스트리밍 「추가 행 로딩 중… 500 / 1000」
되돌릴 수 없는 처리로 화면을 잠금화이트 딤 + 링 + 한 줄시트 [설정 완료] → 모달 · 화이트 딤
패널·모달 안 짧은 목록 로딩텍스트 한 줄 「불러오는 중…」시트 사이드바 · 메모 이력
표·카드 자리 미리 잡기스켈레톤은 아직 없습니다

카운트 배너를 스피너로 바꾸지 마세요. 남은 양을 아는데 스피너를 돌리면 사용자는 끝을 가늠할 수 없습니다. 반대로 총량을 모르는 자리에 진행률을 만들어 내지도 마세요.

텍스트 한 줄로 충분한 자리에 스피너를 넣지 마세요. 이미 패널 뼈대가 보이고 그 안 목록만 채워지는 중이라면, 회전하는 원이 하나 더 도는 것보다 「불러오는 중…」 한 줄이 조용합니다.

단일 출처

  • 구현은 packages/static/styles/design-system.css.ds-spinner / .ds-spinner-badge / @keyframes ds-spinner-rotate 입니다. TS 컴포넌트도, 공유 HTML 파셜도 없어요 — 마크업을 직접 씁니다.
  • 복제본 없음 — 앱 main.css 어디에도 .ds-spinner 재정의가 없습니다(2026-08-13 확인). 모달·페이지네이션과 달리 copy trap 이 없어서 정본만 고치면 전 앱에 반영됩니다.
  • 아이콘 쪽 정본은 packages/components/src/icons.tsic-spinner 항목입니다.

적용 현황

영역 링 (.ds-spinner) — 4곳

자리파일받침
앱 부팅 (임베드)packages/components/html/loading.htmlO
admin iframe 임베드apps/admin/src/views/tabs/embed.htmlO
시트 첫 페이지 로딩apps/hr-system/src/views/tabs/sheets/grid.htmlO
시트 [설정 완료] 처리 중apps/hr-system/.../modals/setup-complete.htmlX (흰 딤 위)

네 자리가 같은 링을 쓰는 게 의도입니다 — admin 에서 시트를 열면 부팅 → iframe → 시트 로딩으로 이어지는 동안 스피너 하나가 계속 도는 것처럼 보입니다. 여기서 하나만 모양이 달라지면 로딩이 세 번 재생되는 것처럼 끊겨 보여요.

인라인 아이콘 (ic-spinner) — 36곳. 대부분 정당한 버튼 안 사용(h-3~h-5)입니다.

손으로 그린 링 — 3곳 (pnpm check:spinners:audit)

.ds-spinneric-spinner 도 아닌, Tailwind 유틸로 링을 조합한 사본입니다: apps/employee-app/src/views/modals/work-settings.html · work-type-change.html · apps/team-manager/src/views/dashboard.html. 전부 border-2 border-orange-500 border-t-transparent rounded-full animate-spin 형태라 두께 2px·색 orange-500 으로 공용 링(3px·orange-500)과 미세하게 다르고, reduced-motion 에서 혼자 빠르게 돕니다.

마이그레이션 대상 (위반 아님)

ic-spinner 를 영역 로딩에 쓰는 자리 11곳 — 전부 h-6 w-6 text-orange-600 정도로 빈 영역 한가운데 놓인 형태라, 규칙상 .ds-spinner 자리입니다: admin 스케줄러 4곳(schedule · profiles · scheduler · patient-count), team-manager 3곳(dashboard · profiles · schedule), inventory/dashboard · general-affairs/dashboard · module-manager/dashboard, 그리고 tools/system-admin/src/views/loading.html(부팅 화면인데 아이콘을 씀 — 공용 loading.html 과 어긋난 자리).

② 손으로 그린 링 3곳 — 위 목록. pnpm check:spinners추가된 줄만 보므로 이 3곳이 커밋을 막지는 않습니다.

일괄 스윕하지 마세요. 지금 고치고 있는 파일 안에서만 바꿉니다. 전 앱이 design-system.css 를 로드하므로(2026-08-13 확인) 치환 자체는 <span class="ds-spinner"></span> 한 줄로 끝납니다.

변경 이력

  • 2026-08-13 — 확장 축 5개 변수 개방 · 인라인 복제본 1곳 정리 · 이 페이지 신설 → 변경 이력