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-motion | 2.4s 로 느려짐 | 대응 없음 |
판단이 애매할 때 — "이 스피너를 지우면 그 자리에 뭐가 보이나?"를 물어보세요. 아무것도 안 보이면 영역 링, 버튼이나 값이 그대로 보이면 인라인 아이콘입니다.
사용법
영역 링 — 화면 가운데 오버레이
<!-- 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>영역 링 — 처리 중 화면 잠금 (흰 딤 위, 받침 없음)
<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>인라인 아이콘 — 버튼 안
<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-size | 2rem (32px) |
| 링 두께 | --ds-spinner-thickness | 3px |
| 도는 배경 원 색 | --ds-spinner-track | --color-neutral-200 |
| 도는 부분 색 | --ds-spinner-indicator | --color-orange-500 |
| 받침 지름 | --ds-spinner-badge-size | 4rem (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) | ✅ 사용 중 | 변형이 아니라 감싸개 — 링과 조합 |
지금은 종류가 하나입니다. 다만 구조는 열려 있어서, 새 종류는 아래 절차로 변수만 덮어 추가합니다.
확장 — 새 종류를 추가할 때
모양을 정하는 값을 전부 변수로 빼 두었기 때문에, 새 종류는 링을 다시 그리는 게 아니라 변수를 덮는 일입니다.
/* design-system.css — .ds-spinner 블록 바로 아래에 추가 */
.ds-spinner--sm {
--ds-spinner-size: 1.25rem;
--ds-spinner-thickness: 2px;
}<span class="ds-spinner ds-spinner--sm"></span>지켜야 할 것
- 변수만 덮습니다.
width·border·animation·will-change를 다시 선언하지 마세요. 회전 메커니즘을 공유해야 GPU 합성 이점과 reduced-motion 대응이 새 종류에도 자동으로 따라옵니다. - 정본은
design-system.css한 곳입니다. 앱main.css에 정의하면 그 앱에서만 존재하는 유령 클래스가 됩니다(.ds-modal__desc가 이렇게 어긋났습니다). - 베이스와 함께 씁니다 —
.ds-spinner--sm단독은 아무것도 아닙니다..ds-spinner .ds-spinner--sm두 클래스를 같이 답니다. - 추가했으면 위 변형 표에 행을 넣고 변경 이력에 기록합니다.
두께는 지름에 딸려 오지 않습니다 — 지름만 줄이면 두께 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.ts의ic-spinner항목입니다.
적용 현황
영역 링 (.ds-spinner) — 4곳
| 자리 | 파일 | 받침 |
|---|---|---|
| 앱 부팅 (임베드) | packages/components/html/loading.html | O |
| admin iframe 임베드 | apps/admin/src/views/tabs/embed.html | O |
| 시트 첫 페이지 로딩 | apps/hr-system/src/views/tabs/sheets/grid.html | O |
| 시트 [설정 완료] 처리 중 | apps/hr-system/.../modals/setup-complete.html | X (흰 딤 위) |
네 자리가 같은 링을 쓰는 게 의도입니다 — admin 에서 시트를 열면 부팅 → iframe → 시트 로딩으로 이어지는 동안 스피너 하나가 계속 도는 것처럼 보입니다. 여기서 하나만 모양이 달라지면 로딩이 세 번 재생되는 것처럼 끊겨 보여요.
인라인 아이콘 (ic-spinner) — 36곳. 대부분 정당한 버튼 안 사용(h-3~h-5)입니다.
손으로 그린 링 — 3곳 (pnpm check:spinners:audit)
.ds-spinner 도 ic-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곳 정리 · 이 페이지 신설 → 변경 이력