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

Stat

수치 통계를 레이블, 값, 트렌드와 함께 표시하는 컴포넌트

목차

대시보드나 리포트에서 주요 수치를 레이블, 값과 단위, 변동 지표, 설명 줄과 함께 표시하는 통계 컴포넌트입니다. Flutter / Web 양쪽에서 동일한 Stat API 를 사용합니다.

기본 사용법 (Basic Usage)#

// 기본 통계
Stat(
  label: '총 사용자',
  value: '128,450',
  unit: '명',
)

// 상승 트렌드 + 설명 줄
Stat(
  label: '이번 달 매출',
  value: '12,450,000',
  unit: '원',
  change: '+12.5%',
  changeType: CoreStatChangeType.positive,
  description: '환불 전 금액',
)

// 하락 트렌드
Stat(
  label: '이탈률',
  value: '3.2%',
  change: '-0.8%',
  changeType: CoreStatChangeType.negative,
)

// 중립 트렌드
Stat(
  label: '평균 응답 시간',
  value: '245ms',
  change: '변동 없음',
  changeType: CoreStatChangeType.neutral,
)
// 기본 통계
Stat(
  label: '총 사용자',
  value: '128,450',
  unit: '명',
)

// 상승 트렌드 + 설명 줄
Stat(
  label: '이번 달 매출',
  value: '12,450,000',
  unit: '원',
  change: '+12.5%',
  changeType: CoreStatChangeType.positive,
  description: '환불 전 금액',
)

// 하락 트렌드
Stat(
  label: '이탈률',
  value: '3.2%',
  change: '-0.8%',
  changeType: CoreStatChangeType.negative,
)

// 중립 상태
Stat(
  label: '평균 응답 시간',
  value: '245ms',
  change: '변동 없음',
  changeType: CoreStatChangeType.neutral,
)

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 대시보드의 KPI(핵심 성과 지표)를 카드 형태로 표시할 때
  • 매출, 사용자 수, 방문자 수 등 주요 지표를 강조하여 표시할 때
  • 이전 기간 대비 증감 트렌드를 함께 보여줄 때
  • 분석 리포트의 요약 수치를 그리드로 나열할 때

대신 다른 컴포넌트를 사용하세요:

  • Progress: 완료율이나 목표 달성률을 시각적으로 표현할 때
  • NumberTicker: 단순히 애니메이션 숫자만 필요할 때 (레이블, 트렌드 불필요)
  • Text: 레이블 없이 수치만 간단히 표시할 때

Props / Parameters#

W는 icon 합성 슬롯 타입으로 Flutter의 Widget, Web의 Component입니다. value · unit · change · description 은 이미 포맷된 문자열이며, 숫자 계산과 포맷은 호출자가 담당합니다. 단위는 value 에 붙여 쓰지 않고 unit 으로 넘기면 값과 같은 글자 바닥선에 한 단계 작게 그려집니다.

속성타입기본값설명
labelString필수통계 항목 레이블
value String? null 표시할 수치 텍스트. null 이면 값 자리에 CoreStatContract.defaultEmptyValue ( — )를 emptyValueColor 로 그립니다
unit String? null 값 뒤에 같은 글자 바닥선으로 붙는 단위 ( 명 · % · 원 ). 값이 null 이면 그리지 않습니다
labelMaxLines int? null 라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다
labelOverflow CoreTextOverflow? null labelMaxLines 에 도달한 라벨의 넘침 처리 ( clip / fade / ellipsis / visible )
variant CoreStatVariant outlined 카드 변형 — outlined (페이지 면 + 1px outline-variant 선) · filled (톤으로 구분하는 면, 선 없음)
size CoreComponentSize md 카드 크기 — md · 대시보드의 좁은 칸용 sm (여백 14 · 16, 줄 간격 4, 값 titleLarge — 20 · SemiBold). 적지 않은 크기는 그 아래의 가장 가까운 크기로 그려집니다
changeString?null변동 텍스트
changeType CoreStatChangeType? null 변동 종류 (positive / negative / neutral) — 색상과 화살표를 결정
description String? null 변동 줄 아래의 설명 줄 — 수치의 맥락(기간 · 기준). 화살표와 추세 색이 없습니다
icon W? null 우상단 trailing 아이콘
statStyle CoreStatStyle? null per-instance chrome 오버라이드 (padding / borderRadius / colours / typography 등)

빠른 오버라이드 (Chain)#

이미 만든 Stat 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class StatChainExample extends StatefulWidget {
  const StatChainExample({super.key});

  @override
  State<StatChainExample> createState() => _StatChainExampleState();
}

class _StatChainExampleState extends State<StatChainExample> {
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        const Stat(
          label: 'Total Revenue',
          value: '\$45,231.89',
          change: '+20.1%',
          changeType: CoreStatChangeType.positive,
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
        const Stat(
          label: 'Total Revenue',
          value: '\$45,231.89',
          change: '+20.1%',
          changeType: CoreStatChangeType.positive,
        ).withStyle(
          const CoreStatStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space24,
              vertical: CoreSpace.space8,
            ),
          ),
        ),
      ],
    );
  }
}
class StatChainExample extends StatefulComponent {
  const StatChainExample({super.key});

  @override
  State<StatChainExample> createState() => _StatChainExampleState();
}

class _StatChainExampleState extends State<StatChainExample> {
  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Stat(
          label: 'Total Revenue',
          value: '\$45,231.89',
          change: '+20.1%',
          changeType: CoreStatChangeType.positive,
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
        Stat(
          label: 'Total Revenue',
          value: '\$45,231.89',
          change: '+20.1%',
          changeType: CoreStatChangeType.positive,
        ).withStyle(
          const CoreStatStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space24,
              vertical: CoreSpace.space8,
            ),
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

마케팅 KPI — display 타이포#

값의 기본 역할은 headlineLarge(32 / bold)입니다. 홈 KPI 처럼 더 큰 숫자가 필요하면 valueTextStyle 에 기존 스케일 토큰을 넣습니다. 별도 display-size 축은 없습니다. CoreTextStyles.displaySmall 은 44 / bold / h140 / 자간 −2% 이고, Web 클래스는 text-display-small 입니다.

Stat(
  label: '활성 사용자',
  value: '128,450',
  statStyle: const CoreStatStyle(
    valueTextStyle: CoreTextStyle.token(CoreTextStyles.displaySmall),
  ),
)

text-headline-large 가 값 요소에 붙어 있으면, 부모에 거는 제품 CSS 의 font-size 는 상속이 끊겨 !important 로만 이깁니다. 슬롯은 그 역할 클래스를 text-display-small 로 바꿉니다.

단위와 설명 줄#

unit 은 값 뒤에 4 간격(valueUnitGapStyle)을 두고 값과 같은 글자 바닥선에 붙습니다. 역할은 titleMedium(18)에 굵기 500 이고 색은 onSurfaceVariant 입니다 — 값(32 · 700 · onSurface)보다 한 단계 아래에서 수치를 꾸밉니다. 라벨(bodyMedium)과 description(bodySmall)은 Regular 이고 값 · 단위 · 변동은 그보다 무겁게 그려 위계를 만듭니다. description 은 변동 줄 아래의 각주로 onSurfaceSubtle 로 그립니다. 기간이나 기준처럼 수치의 맥락을 담고, change 와 달리 화살표와 추세 색이 붙지 않습니다.

Stat(
  label: '활성 사용자',
  value: '128,450',
  unit: '명',
  change: '+5.2%',
  changeType: CoreStatChangeType.positive,
  description: '지난 30일',
)

줄은 위에서부터 레이블 → 값(+ 단위) → 변동 → 설명 순서이고, 사이 간격은 모두 contentGapStyle(10)입니다. 빈 줄은 간격까지 함께 빠집니다.

긴 값은 카드 안에서 줄바꿈되고, 단위는 값의 첫 줄과 기준선을 맞춰 그 줄 오른쪽에 놓입니다(마지막 줄이 아닙니다) — Flutter 는 값이 Flexible 이고, Web 은 값 요소가 min-w-0 break-words 이며, 두 플랫폼 모두 첫 기준선으로 정렬합니다.

빈 값#

수치를 아직 모르거나 없으면 value 를 생략합니다. 값 자리에 CoreStatContract.defaultEmptyValue(—)가 값의 역할 그대로, 색만 emptyValueColor(onSurfaceVariant)로 그려지고 단위는 붙지 않습니다. 카드 전체를 흐리게 하지 않으므로 레이블과 설명은 그대로 읽힙니다.

Stat(
  label: '이번 달 매출',
  unit: '원',
  description: '아직 집계되지 않았습니다',
)

카드 변형 — outlined · filled#

기본 variant 는 outlined 입니다 — surface 면에 outline-variant 1px 선을 둡니다(입력 필드와 같은 단계). filled 는 surfaceContainerLow 면에 선이 없고, 카드는 페이지보다 한 단계 올린 면의 톤으로 구분됩니다 — 그림자가 있는 카드라 한 단계면 충분합니다. 카드 상자는 두 변형 모두 OutlinedContainer 가 그리고, 반경·선·채움은 스타일 슬롯이 그 상자로 전달됩니다.

떠 있는 판(Dialog · Popup 의 surfaceBright) 안에서는 filled 카드가 판보다 어두운 면으로 놓입니다. 판 안에서는 기본 outlined 를 쓰세요.

Stat(
  label: '활성 사용자',
  value: '128,450',
  unit: '명',
  variant: CoreStatVariant.filled,
)

면까지 없앤 카드가 필요하면 스타일 슬롯으로 채움과 반경을 지웁니다.

Stat(
  label: '활성 사용자',
  value: '128,450',
  statStyle: const CoreStatStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius0),
    backgroundColor: CoreColor.transparent,
    valueTextStyle: CoreTextStyle.token(CoreTextStyles.displaySmall),
  ),
)

이전 API 에서 옮기기 (Migration)#

  • 기본 변형이 filled 에서 outlined 로 바뀌었습니다. variant 를 적지 않은 Stat 은 이제 surface 면에 outline-variant 선으로 그려집니다. 옛 모습을 그대로 두려면 variant: CoreStatVariant.filled 를 적습니다.
  • size 파라미터(md · sm)가 생겼습니다. 기본 md 는 이전 크기 그대로이고, 라벨 · 설명 글자만 Regular 로 내려갔고 단위는 Medium 으로 한 단계 가벼워졌습니다.
  • CoreStatVariant 에 filled 값이 생겼습니다. 이 enum 을 빠짐없이 나누는 switch 는 filled 갈래가 필요합니다.
  • value 가 String? 이 됐습니다. 넘기는 쪽은 그대로 동작하고, CoreStatContract 를 직접 구현하거나 stat.value 를 읽는 코드는 null 을 다뤄야 합니다. 계약에는 unit · description 이 더해졌습니다.
  • 단위를 value 문자열에 붙여 쓰던 자리('12,480명')는 value: '12,480', unit: '명' 으로 나누면 단위가 한 단계 작게 바닥선에 맞춰 그려집니다. 붙여 쓴 문자열도 그대로 동작합니다.

스타일 시스템 (Style System)#

CoreStatStyle 필드#

필드타입설명
padding CoreEdgeInsets? Inner padding around the stat content.
borderRadius CoreBorderRadius? Outer border radius of the stat card.
borderColorCoreColor?Outer border colour.
borderWidth double? Outer border width (logical px).
backgroundColor CoreColor? Card background colour.
contentGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the 1-off gap between the content rows of the card (label-row ↔ value-row ↔ change-row ↔ description). Forwarded straight to the Gap widget that separates the rows. null defers to [defaultContentGapStyle].
changeIconGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the 1-off inner gap between the change arrow glyph and the change text inside the change row. Forwarded straight to the Gap widget. null defers to [defaultChangeIconGapStyle].
valueUnitGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the 1-off gap between the value and its unit inside the value row. Forwarded straight to the Gap widget. null defers to [defaultValueUnitGapStyle].
labelColorCoreColor?Label text colour.
valueColorCoreColor?Value text colour.
unitColorCoreColor?Unit text colour.
emptyValueColor CoreColor? Colour of the empty-value placeholder drawn when the stat has no value. The placeholder keeps the value's text style; only the colour steps down.
positiveColor CoreColor? Colour of the change indicator when changeType == positive.
negativeColor CoreColor? Colour of the change indicator when changeType == negative.
neutralColor CoreColor? Colour of the change indicator when changeType == neutral.
descriptionColor CoreColor? Description (footnote) text colour.
labelTextStyle CoreTextStyle? Label text style override.
valueTextStyle CoreTextStyle? Value text style override.
unitTextStyle CoreTextStyle? Unit text style override.
changeTextStyle CoreTextStyle? Change indicator text style override.
descriptionTextStyle CoreTextStyle? Description (footnote) text style override.

CoreStatStyle 변형별 기본값 (CoreStatVariantStyle)#

필드outlinedfilled
backgroundColor surface surfaceContainerLow (fallback: surface)
borderColoroutlineVarianttransparent
borderWidthstroke10

변형 (Variants)#

상승 트렌드#

Stat(
  label: '신규 가입',
  value: '1,284',
  change: '+8.2%',
  changeType: CoreStatChangeType.positive,
)

하락 트렌드#

Stat(
  label: '취소율',
  value: '1.4%',
  change: '-0.3%',
  changeType: CoreStatChangeType.negative,
)

중립#

Stat(
  label: '활성 세션',
  value: '892',
  change: '±0%',
  changeType: CoreStatChangeType.neutral,
)

그리드 레이아웃#

여러 Stat 을 그리드로 배치하여 대시보드를 구성합니다.

GridView.count(
  crossAxisCount: 3,
  children: [
    Stat(
      label: '총 사용자',
      value: '128,450',
      change: '+5%',
      changeType: CoreStatChangeType.positive,
    ),
    Stat(
      label: '매출',
      value: '₩12.4M',
      change: '+12%',
      changeType: CoreStatChangeType.positive,
    ),
    Stat(
      label: '이탈률',
      value: '3.2%',
      change: '-0.8%',
      changeType: CoreStatChangeType.negative,
    ),
  ],
)

동작 스펙 (Behavior)#

인터랙션#

  • Stat 은 기본적으로 표시 전용 컴포넌트입니다.

표시#

  • 줄 순서는 레이블 → 값(+ 단위) → 변동 → 설명이고, 줄 사이는 contentGapStyle 입니다. 넘기지 않은 줄은 간격과 함께 빠집니다.
  • 단위는 값과 같은 글자 바닥선(alphabetic baseline)에 valueUnitGapStyle 만큼 떨어져 붙습니다. 값이 null 이면 단위를 그리지 않습니다.
  • value 가 null 이면 — 를 값의 역할 · emptyValueColor 로 그립니다.
  • 변동 줄은 change 와 changeType 이 둘 다 있을 때만 그립니다.

상태 전환#

  • change / changeType 변경 시 아이콘과 색이 즉시 갱신됩니다.
  • changeType.positive → 색 = onSuccessContainer, 화살표 = ↑
  • changeType.negative → 색 = onErrorContainer, 화살표 = ↓
  • changeType.neutral → 색 = onSurfaceVariant, 화살표 = →

애니메이션#

기본 동작 없음. value 슬롯에 다른 컴포넌트 (예: 숫자 ticker) 를 합성해 표현하려면 호출처에서 별도 위젯과 조합합니다.

사용 가이드라인 (Usage Guidelines)#

지표 이름·값·변동량을 카드로 묶으려면 Stat을 사용합니다. 숫자 보간만 필요하면 NumberTicker, 임의의 미디어·본문·액션을 조합하려면 Card, 상태 이력의 여러 구간은 Tracker를 선택합니다. Stat은 전달한 문자열을 표시하며 변동률을 계산하지 않습니다.

✅ Do#

의미 있는 비교 기간을 함께 표시

Stat(
  label: '월간 매출',
  value: '₩12,450,000',
  change: '+11.7% (지난 달 대비)',
  changeType: CoreStatChangeType.positive,
)

어느 기간과 비교한 변화율인지 명시하면 수치의 의미를 정확히 전달할 수 있습니다.


❌ Don't#

changeType 과 change 의미가 어긋나지 않게 하세요

// ❌ change 는 양수 표기인데 changeType 은 negative
Stat(
  label: '이탈률',
  value: '3.2%',
  change: '+0.5%',
  changeType: CoreStatChangeType.negative,
)

방향이 일치하지 않으면 사용자가 혼란스러워합니다. 이탈률이 증가했다면 보통 부정적이므로 change: '+0.5%' + changeType.negative 로 일치시키거나, 맥락에 따라 change: '-0.5%' + changeType.negative 로 명확히 합니다.

❌ Don't#

너무 많은 Stat 을 한 화면에 나열 금지

// ❌ 15 개 KPI 한 화면에 나열
GridView.count(
  crossAxisCount: 5,
  children: List.generate(15, (i) => Stat(...)),
)

너무 많은 통계는 인지 과부하를 유발합니다. 한 화면에는 가장 중요한 4 ~ 8 개의 KPI 만 표시하세요.

❌ Don't#

단위 없이 숫자만 표시하지 마세요

// ❌ 단위가 없어 의미를 알 수 없는 숫자
Stat(
  label: '방문자',
  value: '15234',
)

숫자만 있으면 무엇을 기준으로 한 값인지 알 수 없습니다. 단위(명·원·% 등)를 unit 으로 함께 표시하세요 — value: '15,234', unit: '명'.

접근성 (Accessibility)#

역할#

양 플랫폼 모두 없음. Flutter 위젯 파일에는 Semantics 호출이 하나도 없고, Web 은 루트부터 레이블 · 값 · 변동 행까지 전부 role / aria-* 없는 <div> · <span> 으로 렌더합니다. 카드 · 지표 · 트렌드라는 구조는 시각 배치로만 존재합니다.

키보드#

없음. Stat 은 값을 그리기만 하고 콜백을 받지 않습니다 — contract 에 onTap 류가 없고, 양 플랫폼 모두 포커스 가능한 자식이 없어 탭 정지가 생기지 않습니다. Web 생성자는 attributes 와 tag 는 노출하지만 이벤트 핸들러 파라미터는 노출하지 않아, 호출자가 키 조작을 직접 붙일 수도 없습니다.

스크린 리더#

  • 읽히는 순서 — 레이블 → (아이콘) → 값 → (단위) → (변동 화살표 → 변동 텍스트) → (설명). Flutter 위젯 트리와 Web DOM 순서가 같습니다.
  • 빈 값은 — 글자로 읽힙니다. "값 없음" 같은 텍스트 대안은 없습니다.
  • 접근 가능한 이름은 없습니다. 이름을 가질 역할 노드가 없기 때문입니다. 레이블과 값은 서로 무관한 두 텍스트로 읽히고, "이 레이블의 값" 이라는 관계는 발표되지 않습니다.
  • 변동 방향은 ↑ / ↓ / → 글리프와 색으로만 전달됩니다. 글리프는 aria-hidden 없이 일반 텍스트로 나가고, "상승" / "하락" 같은 텍스트 대안은 없습니다.
  • 라이브 리전이 없습니다. value 가 갱신돼도 통보되지 않아, 사용자가 다시 읽어야 바뀐 것을 압니다.

포커스 관리#

없음. 오버레이가 아니어서 진입 시 포커스 이동이 없고, 이탈·트랩도 해당하지 않습니다.

알려진 제약#

  • 레이블과 값이 시맨틱으로 묶이지 않습니다. Web 은 dl / dt / dd 가 아니라 평범한 <div> 중첩이고, Flutter 에는 둘을 한 노드로 합치는 Semantics 가 없습니다. 대안 — Web 은 컴포넌트가 자체 role 을 붙이지 않으므로 attributes 로 넘긴 role / aria-label 이 그대로 루트에 실리고 tag 도 바꿀 수 있습니다. Flutter 는 Stat 을 Semantics(...) 로 감싸는 것이 유일한 경로입니다.
  • 변동 방향에 텍스트 대안이 없습니다. 방향이 확실히 읽혀야 하는 자리라면 change 문자열 자체에 담으세요 — '+20.1%' 의 부호나 '20.1% 감소' 처럼.
  • 로딩 자리에 스켈레톤을 넣을 수 없습니다. label · value · unit · change · description 이 전부 String 이라 위젯을 담지 못하고, 위젯 슬롯은 icon 하나뿐입니다. 로딩 중에는 Stat 대신 Skeleton 트리를 직접 렌더하세요. 빈 값(—)은 값이 없다는 표시이지 로딩 표시가 아닙니다.

전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
클래스명StatStat
icon 타입Widget?Component?
변동 화살표 ↑ / ↓ / → 글리프 ↑ / ↓ / → 글리프 (동일)
값 · 단위 줄 Row(crossAxisAlignment: baseline) + 값 Flexible flex items-baseline + 값 min-w-0 break-words (같은 배치)
  • Card: Stat 의 외곽 chrome 을 더 다양하게 표현하고 싶을 때.
  • Progress: 목표 대비 진행률을 바 형태로 표시할 때.
  • NumberTicker: 단순히 애니메이션 숫자만 필요할 때.