Loading | CoUI
본문으로 건너뛰기
LogoCoUI

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#

사용 시기 (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 SmallCoreComponentSize.xs12px
SmallCoreComponentSize.sm16px
MediumCoreComponentSize.md24px
LargeCoreComponentSize.lg32px
Extra LargeCoreComponentSize.xl48px

loadingStyle: CoreLoadingStyle(size: 32) 처럼 픽셀 값을 직접 지정할 수도 있습니다.

동작 스펙 (Behavior)#

인터랙션#

  • Loading은 표시 전용 컴포넌트입니다. 인터랙션이 없습니다.
  • 작업 시작·완료·진행값·타이머 콜백을 받지 않으며 자체 배경 차단·오버레이·비활성 상태도 없습니다. 부모가 표시 여부를 결정합니다.
  • 로딩 중에는 배경 컨텐츠 상호작용을 차단하는 오버레이와 함께 사용하는 경우가 많음

상태 전환#

  • 로딩 시작: 즉시 표시 (애니메이션 시작)
  • 로딩 완료: 부모에서 조건부 렌더링으로 제거

애니메이션#

variant 별 1주기 길이는 CoreLoadingStyle.defaultsByVariant 가 단일 출처이며 loadingStyle: CoreLoadingStyle(duration: …) 로 덮을 수 있습니다.

Variant1주기기본 토큰
Spinner360도 회전 무한 반복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 을 caller attributes 보다 나중에 병합하고, 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)#

항목FlutterWeb
Spinner 구현 CustomPainter 기반 자체 회전 CSS animation: spin
Pulse 구현 AnimationController + Transform.scale CSS @keyframes scale
색상 기본값 Theme.of(context).colorScheme.primary var(--coui-primary)
  • 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]),
          ),
  );
}