Loading
작업 진행 중을 표시하는 로딩 인디케이터 컴포넌트
목차
비동기 작업이나 데이터 로딩 중임을 사용자에게 알리는 로딩 인디케이터 컴포넌트입니다. spinner, dots, bar, pulse 변형을 지원합니다.
기본 사용법 (Basic Usage)#
// 기본 스피너
Loading()
// 크기 지정
Loading(loadingStyle: CoreLoadingStyle(size: 32))
// 색상 지정
Loading(
loadingStyle: CoreLoadingStyle(
size: 24,
color: CoreColor.token(CoreColors.primary),
),
)
// variant 지정
Loading(variant: CoreLoadingVariant.dots)
// 레이블 포함 패턴
Column(
mainAxisSize: MainAxisSize.min,
children: [
Loading(loadingStyle: CoreLoadingStyle(size: 32)),
const Gap.space8(),
Text('데이터를 불러오는 중...').bodySmall.onSurfaceVariant,
],
)
// 기본 스피너
Loading()
// 크기 지정 (픽셀 단위)
Loading(loadingStyle: CoreLoadingStyle(size: 32))
// 색상 지정
Loading(
loadingStyle: CoreLoadingStyle(
size: 24,
color: CoreColor.token(CoreColors.primary),
),
)
// variant 지정
Loading(variant: CoreLoadingVariant.dots)
// 레이블 포함 (flex 레이아웃으로 구성)
div(
classes: 'flex flex-col items-center gap-${CoreSpace.scale.space8}',
[
Loading(loadingStyle: CoreLoadingStyle(size: 32)),
Text('데이터를 불러오는 중...').bodySmall.onSurfaceVariant,
],
)
Live Preview#
코드 보기
class LoadingDefaultExample extends StatelessComponent {
const LoadingDefaultExample({super.key});
@override
Component build(BuildContext context) {
return const Loading();
}
}
class LoadingDefaultExample extends StatelessWidget {
const LoadingDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return const Loading();
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 데이터를 불러오거나 저장하는 중에 사용자에게 진행 중임을 알릴 때
- 콘텐츠 레이아웃을 예측할 수 없는 비동기 작업 중에 표시할 때
- 버튼 클릭 후 처리가 완료될 때까지 기다리는 상태를 표시할 때
- 페이지 전체 로딩 오버레이가 필요할 때
대신 다른 컴포넌트를 사용하세요:
- RefreshTrigger: 단순 진행 표시 대신 pull-to-refresh 제스처·수명주기를 관리할 때
- Skeleton: 로딩 중 실제 값 대신 최종 콘텐츠 형태의 대역을 그려야 할 때. 호출부가 그 자리의 트리를 구성합니다
- Progress: 진행값이 알려져 있고 완료율을 표시해야 할 때. Loading은 작업이 진행 중이라는 표시만 제공합니다
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
variant |
CoreLoadingVariant |
spinner |
인디케이터 변형 스타일 (spinner, dots, bar, pulse) |
componentSize |
CoreComponentSize? |
null |
사이즈 토큰 (xs, sm, md, lg, xl). |
loadingStyle |
CoreLoadingStyle? |
null |
인스턴스별 chrome / dimensional 오버라이드 — 아래 슬롯 표 참조. |
CoreLoadingStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
color |
CoreColor? |
Indicator colour override (fill of
dots
/
pulse
/
bar
fill, arc of
spinner
).
|
trackColor |
CoreColor? |
Explicit track colour override for
spinner
track ring and
bar
background.
null
(default) → renderer fades [color] by [trackAlpha] (falling back to [defaultTrackAlpha]) so the track inherits the indicator hue with a low-opacity tint.
|
trackAlpha |
double? |
Track fade alpha override (
0.0
–
1.0
). Applied to [color] to derive the implicit track tint when [trackColor] is null.
null
falls back to [defaultTrackAlpha].
|
size |
double? |
Explicit pixel size override (logical px). Raw double — single-axis pixel value (verify.md u-1 exception). |
strokeWidth |
double? |
Spinner ring stroke width override (logical px). Only the
spinner
variant draws a ring; other variants ignore this. Raw double — border-width style (verify.md u-1 exception).
|
dotSize |
double? |
dots
diameter override (logical px).
null
takes the size step's dot ([defaultsBySize]), else [defaultDotSize] / [defaultDotSizeRatio].
|
barHeight |
double? |
bar
track thickness override (logical px).
null
takes the size step's track ([defaultsBySize]), else [defaultBarHeight] / [defaultBarHeightRatio].
|
cornerRadius |
double? |
Indicator corner radius override (logical px).
null
falls back to [defaultCornerRadius] (
CoreRadius.radius9999
→ fully round). Raw double — single-axis corner radius (verify.md u-1 exception).
|
duration |
Duration? |
Per-instance animation cycle duration override.
null
falls back to [defaultsByVariant] for the active variant.
|
CoreLoadingStyle 변형별 기본값 (CoreLoadingVariantStyle)#
| 필드 | spinner |
dots |
bar |
pulse |
|---|---|---|---|---|
duration |
Duration(milliseconds: CoreDuration.ms1000) | Duration(milliseconds: CoreDuration.ms1000) | Duration(milliseconds: CoreDuration.indeterminate) | Duration(milliseconds: CoreDuration.ms1000) |
빠른 오버라이드 (Chain)#
이미 만든 Loading 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class LoadingChainExample extends StatelessWidget {
const LoadingChainExample({super.key});
@override
Widget build(BuildContext context) {
return const Loading().withStyle(
const CoreLoadingStyle(
color: CoreColor.token(CoreColors.tertiary),
trackColor: CoreColor.token(CoreColors.surfaceContainerHighest),
size: CoreSize.size48,
strokeWidth: CoreStrokeWidth.stroke4,
),
);
}
}
class LoadingChainExample extends StatelessComponent {
const LoadingChainExample({super.key});
@override
Component build(BuildContext context) {
return const Loading().withStyle(
const CoreLoadingStyle(
color: CoreColor.token(CoreColors.tertiary),
trackColor: CoreColor.token(CoreColors.surfaceContainerHighest),
size: CoreSize.size48,
strokeWidth: CoreStrokeWidth.stroke4,
),
);
}
}
변형 (Variants)#
Spinner#
원형으로 회전하는 기본 스피너입니다.
Loading(variant: CoreLoadingVariant.spinner)
Dots#
세 개의 점이 순서대로 강조되는 형태입니다.
Loading(variant: CoreLoadingVariant.dots)
Bar#
가로 바가 좌우로 움직이는 형태입니다.
Loading(variant: CoreLoadingVariant.bar)
Pulse#
요소가 맥박처럼 커졌다 작아지는 형태입니다.
Loading(variant: CoreLoadingVariant.pulse)
톤 (Tone)#
tone 이 지시자 색을 정합니다. 트랙(Spinner 의 배경 링 · Bar 의 바탕)도 톤이 함께 정합니다.
| 톤 | tone | 지시자 | 트랙 | 쓰는 곳 |
|---|---|---|---|---|
| 강조 | CoreLoadingTone.primary (기본) |
primary |
중립 면 (surfaceContainerHigh) |
일반 면 위 |
| 중립 | CoreLoadingTone.neutral |
onSurfaceVariant |
중립 면 | 본문 옆의 조용한 로딩 |
| 반전 | CoreLoadingTone.inverse |
onPrimary |
지시자 색의 25% | 채워진 버튼 · 어두운 면 위 |
Loading(tone: CoreLoadingTone.inverse)
Spinner 의 호는 링의 사분원(90°) 입니다. 링 두께는 크기를 따라 굵어집니다 — xs · sm
· md 2px, lg · xl 3px, xl2 4px.
크기 (Sizes)#
| 크기 | componentSize | 픽셀 |
|---|---|---|
| Extra Small | CoreComponentSize.xs | 12px |
| Small | CoreComponentSize.sm | 16px |
| Medium | CoreComponentSize.md | 24px |
| Large | CoreComponentSize.lg | 32px |
| Extra Large | CoreComponentSize.xl | 48px |
loadingStyle: CoreLoadingStyle(size: 32) 처럼 픽셀 값을 직접 지정할 수도 있습니다.
동작 스펙 (Behavior)#
인터랙션#
- Loading은 표시 전용 컴포넌트입니다. 인터랙션이 없습니다.
- 작업 시작·완료·진행값·타이머 콜백을 받지 않으며 자체 배경 차단·오버레이·비활성 상태도 없습니다. 부모가 표시 여부를 결정합니다.
- 로딩 중에는 배경 컨텐츠 상호작용을 차단하는 오버레이와 함께 사용하는 경우가 많음
상태 전환#
- 로딩 시작: 즉시 표시 (애니메이션 시작)
- 로딩 완료: 부모에서 조건부 렌더링으로 제거
애니메이션#
variant 별 1주기 길이는 CoreLoadingStyle.defaultsByVariant 가 단일 출처이며 loadingStyle: CoreLoadingStyle(duration: …)
로 덮을 수 있습니다.
| Variant | 1주기 | 기본 토큰 |
|---|---|---|
| Spinner | 360도 회전 무한 반복 | CoreDuration.ms1000 (1s) |
| Dots | 세 점이 1 · 0.6 · 0.3 불투명도의 물결로 순환 (점마다 1/3 주기씩 늦음, 점 간격은 점 지름의 절반) | CoreDuration.ms1000 (1s) |
| Bar | 좌우 왕복 | CoreDuration.indeterminate (1.5s) |
| Pulse | 크기 확대/축소 | CoreDuration.ms1000 (1s) |
이 duration 들은 "얼마나 오래 머무르나"가 아니라 순환 표시라서 reduced-motion 에서도 보존됩니다 — 멈춘 스피너는 "아직 작업 중"이라고 말하지 못하기 때문입니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
로딩 인디케이터로 진행 중인 작업 안내
Column(
mainAxisSize: MainAxisSize.min,
children: [
Loading(
variant: CoreLoadingVariant.spinner,
loadingStyle: CoreLoadingStyle(size: 40),
),
const Gap.space8(),
Text('파일을 업로드하는 중... (2/5)'),
],
)
로딩 옆/아래에 텍스트로 어떤 작업이 진행 중인지 알려주면 사용자의 불안감을 줄일 수 있습니다.
❌ Don't#
레이아웃을 예측할 수 있는 경우 Loading 대신 Skeleton 사용
// ❌ 카드 목록 로딩에 단순 스피너 사용
if (isLoading) {
return Center(child: Loading()); // 레이아웃 점프 발생
}
return CardList(items: items);
카드 목록처럼 구조가 예측 가능한 경우 Skeleton을 사용하면 레이아웃 점프 없이 더 나은 경험을 제공합니다.
✅ Do#
버튼 내 인라인 로딩으로 즉각적 피드백 제공
Button(
variant: CoreButtonVariant.primary,
onPressed: isSubmitting ? null : handleSubmit,
child: isSubmitting
? Row(mainAxisSize: MainAxisSize.min, children: [
Loading(
loadingStyle: CoreLoadingStyle(
size: 16,
color: CoreColor.token(CoreColors.onPrimary),
),
),
const Gap.space8(),
Text('제출 중...'),
])
: Text('제출하기'),
)
버튼 내 인라인 로딩은 사용자에게 클릭이 처리되고 있음을 즉각적으로 알려주고 중복 클릭을 방지합니다.
❌ Don't#
페이지 전체에 로딩 오버레이를 과도하게 사용 금지
// ❌ 작은 데이터 업데이트에 전체 페이지 오버레이
Stacks(
children: [
MainContent(),
if (isLoading) FullPageLoadingOverlay(), // 단순 항목 추가에 전체 블로킹
],
)
작은 업데이트에 전체 페이지를 블로킹하면 사용성이 크게 저하됩니다. 변경되는 영역에만 로딩을 표시하세요.
✅ Do#
로딩 시간이 길 때 진행률(Progress)을 함께 표시하세요.
// 진행률을 알 수 있을 때는 Progress 컴포넌트 사용
Progress(value: uploadProgress) // 0.0 ~ 1.0
진행률을 보여주면 사용자가 얼마나 기다려야 하는지 알 수 있어 이탈률을 낮출 수 있습니다.
❌ Don't#
짧은 작업(1초 미만)에 로딩 인디케이터를 표시하지 마세요.
// ❌ 매우 빠른 작업에도 로딩 스피너 표시
FutureBuilder(
future: fastLocalOperation(), // 50ms 작업
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Loading(); // 깜빡임 현상 발생
}
return content;
},
)
너무 짧은 작업에 로딩을 보여주면 UI가 깜빡이는 플래시 효과가 발생합니다. 300ms 이상 걸리는 작업에만 표시하세요.
접근성 (Accessibility)#
역할#
-
Web — 루트
<span>에role="status"와aria-label이 붙습니다.aria-live="polite"는 이 role 의 암묵값이며 별도 속성으로 emit 되지 않습니다. -
Flutter —
Semantics(liveRegion: true, label: ...). Flutter 에는 status 역할의 표현이 없어 live region 하나로만 전달됩니다. -
이 두 속성은 호출자가 덮어쓸 수 없습니다. Web 은
role/aria-label을 callerattributes보다 나중에 병합하고, Flutter 에는 라벨 파라미터가 없습니다.
키보드#
해당 없음. Loading 은 포커스를 받지 않고 tab stop 을 만들지 않습니다.
스크린 리더#
-
이름은 로케일에서 옵니다 —
CoUILocalizations.loadingLabel/CouiLocalizations.loadingLabel(영어Loading, 한국어로딩 중). 두 플랫폼 모두 렌더 시점에 조회하므로 활성 로케일을 따라갑니다. -
라이브 리전 안에 텍스트 노드가 없습니다 — 이름은
aria-label(Web) /Semantics.label(Flutter) 로만 실립니다. -
진행률은 알리지 않습니다.
Loading은 값이 없는 표시이므로, 값을 읽혀야 하면Progress를 쓰세요.
포커스 관리#
-
진입 / 이탈 / 트랩 — 전부 없음. 오버레이가 아니고 포커스를 옮기지 않습니다.
로딩이 끝나
Loading이 사라져도 포커스는 그대로 있습니다.
알려진 제약#
-
라벨을 인스턴스별로 바꿀 수 없습니다. "저장 중…" 같은 문구가 필요하면 이름을
Loading밖에서 만들어야 합니다. 대안: 로케일 클래스의loadingLabel을 바꾸면 앱 전체에 적용되고, 인스턴스별로는 호출자가 별도 라이브 리전을 둡니다. -
'동작 줄이기'를 켜도 멈추지 않습니다. 네 variant 의 기본 주기가 전부 표시
tier(1000 ms ·
indeterminate)라 억제 대상이 아닙니다 — 멈춘 스피너는 "아직 작업 중"을 말하지 못하므로 의도된 보존입니다. 대안 없음. -
duration을 500 ms 이하로 override 하면 그 보존이 깨집니다. 전환 tier 로 분류되어 '동작 줄이기' 환경에서 억제됩니다(Flutter 는Duration.zero, Web 은--coui-duration-*를 0 으로 덮는 media query). 대안: 1000 ms 이상 값을 씁니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| Spinner 구현 | CustomPainter 기반 자체 회전 |
CSS animation: spin |
| Pulse 구현 | AnimationController + Transform.scale |
CSS @keyframes scale |
| 색상 기본값 | Theme.of(context).colorScheme.primary |
var(--coui-primary) |
관련 컴포넌트 (Related Components)#
- Skeleton: 콘텐츠 구조를 예측할 수 있을 때 더 나은 로딩 표시자
- Progress: 진행률을 퍼센트로 표시할 때
- EmptyState: 로딩 완료 후 데이터가 없을 때
조합 예제#
// Loading + 조건부 렌더링 + EmptyState 패턴
Widget buildContent() {
return AnimatedSwitcher(
duration: Duration(milliseconds: CoreDuration.normal),
child: isLoading
? Center(key: Key('loading'), child: Loading())
: items.isEmpty
? EmptyState(
key: Key('empty'),
icon: Icon(LucideIcons.searchX),
title: '결과가 없습니다',
)
: ListView.builder(
key: Key('list'),
itemCount: items.length,
itemBuilder: (context, index) => ItemTile(item: items[index]),
),
);
}