테마 | CoUI
LogoCoUI

테마

CoUI 테마 시스템 가이드

테마#

Figma Foundation · 00. Overview · 값은 coui_core 가 정본입니다. 정본 파일은 Figma 라이브러리. 반경·모션 스케일은 형태 · 애니메이션.

CoUI는 Flutter와 Web 양쪽에서 일관된 테마 시스템을 제공합니다.

Flutter 테마#

CoreComponentTheme#

프로젝트 전체에 컴포넌트 테마를 주려면 CoreComponentTheme 을 만들어 ThemeData.fromCore 에 넘기고, 그 ThemeDataCoUIApp 에 준다.

CoUIApp(
  theme: ThemeData.fromCore(
    CoreThemePresets.light,
    coreComponentTheme: const CoreComponentTheme(
      button: CoreButtonTheme(
        style: CoreButtonStyle(
          borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
        ),
      ),
      badge: CoreBadgeTheme(
        style: CoreBadgeStyle(
          padding: CoreEdgeInsets.symmetric(
            horizontal: CoreSpace.space8,
            vertical: CoreSpace.space2,
          ),
        ),
      ),
    ),
  ),
  home: MyHomePage(),
)

CoreComponentTheme 은 컴포넌트마다 슬롯 하나(button / badge / …)를 갖고, 각 CoreXxxThemestylevariantStyles 두 슬롯만 갖는다. chrome 을 Theme 에 평면 필드로 얹지 않는 이유는 그 값이 위젯의 xxxStyle 슬롯과 두 경로로 들어와 우선순위가 모호해지기 때문이다.

variant 별 오버라이드#

variantStyles 로 특정 variant 만 다르게 줄 수 있다.

const CoreCardTheme(
  style: CoreCardStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
  ),
  variantStyles: {
    CoreCardVariant.filled: CoreCardStyle(
      backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
    ),
  },
)

우선순위#

chrome 값은 항상 한 방향으로 흐른다 — 아래로 갈수록 이긴다.

CoreXxxStyle.defaultsByVariant[variant]   // 디자인 시스템 default
  → CoreXxxStyle.defaultX
  → CoreXxxTheme.style                    // 프로젝트 공통
  → CoreXxxTheme.variantStyles[variant]   // 프로젝트 · variant 별
  → widget.xxxStyle                       // 인스턴스별

이 머지는 컴포넌트의 resolveXxx 안에서 한 번에 일어나고, 위젯은 그 결과만 읽는다.

Web 테마#

앱 루트#

Web 의 앱 루트는 CoUIWeb 이고, Flutter CoUIApp 과 같은 CoreComponentTheme 슬롯을 받습니다:

CoUIWeb(
  theme: ThemeData.fromCore(
    CoreThemePresets.light,
    coreComponentTheme: const CoreComponentTheme(
      button: CoreButtonTheme(
        style: CoreButtonStyle(
          borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
        ),
      ),
    ),
  ),
  child: MyApp(),
)

CoUI 테마#

theme: 은 컴포넌트 테마와 타이포그래피를 정합니다. 색 팔레트를 라이트/다크로 뒤집는 것은 theme: 이 아니라 data-theme 속성입니다ThemeConfig[data-theme="dark"]--coui-* 오버라이드를 정의해 두기 때문입니다:

<html data-theme="light">
<!-- 또는 -->
<html data-theme="dark">

Named Properties 패턴#

Web 컴포넌트는 Flutter와 동일한 named properties API를 씁니다 — 시맨틱 값은 위젯 파라미터로, chrome/dimensional 미세 조정은 xxxStyle 슬롯으로 분리됩니다 (위 button.md의 "시맨틱 vs 스타일" 참조).

Button(
  variant: CoreButtonVariant.primary,
  size: CoreComponentSize.lg,
  onPressed: () {},
  child: Text('Large Primary Button'),
)

커스텀 테마#

CoUI Web의 색은 PrimitiveColors.generate(...)로 만든 팔레트에서 파생되고, ThemeConfig가 그 팔레트를 Tailwind config + --coui-* CSS 변수로 굽는다. [data-theme="custom"] { --p: ...; } 처럼 DaisyUI 변수(--p/--s/--a/ --n/--b1)를 직접 정의하는 방식은 작동하지 않는다 — 이 변수들은 primitive_css_generator.dartgenerateDaisyUiAliases()가 반대 방향으로 (예: --p: var(--coui-primary)) 이미 채워 넣는 deprecated 하위호환 별칭 이고, 실제 팔레트를 정의하는 진입점이 아니다.

import 'package:coui_web/coui_web.dart';

final config = ThemeConfig(
  PrimitiveColors.generate(
    primaryHSL: (h: 262.0, s: 0.80, l: 0.50),
    secondaryHSL: (h: 314.0, s: 0.72, l: 0.45),
    tertiaryHSL: (h: 174.0, s: 0.72, l: 0.56),
    successHSL: (h: 140.0, s: 0.70, l: 0.42),
    infoHSL: (h: 200.0, s: 0.75, l: 0.50),
    warningHSL: (h: 45.0, s: 0.85, l: 0.50),
    errorHSL: (h: 0.0, s: 0.75, l: 0.50),
    neutralHue: 218.0,
  ),
);

// <head> 안에서:
// <script>{config.tailwindConfig}</script>
// <style>{config.cssVariables}</style>

디자인 토큰#

CoUI는 coui_core의 디자인 토큰으로 시각적 일관성을 보장합니다. 축 전체 맵은 디자인 토큰 을 먼저 보세요.

CoreRadius#

Border radius 는 숫자 primitive 만 쓴다 (radius0 ~ radius64, radius9999). 소비자 가이드는 형태 입니다.

CoreRadius.radius4      // 4px
CoreRadius.radius8      // 8px
CoreRadius.radius16     // 16px
CoreRadius.radius9999   // pill

field / selector / box 같은 글로벌 시맨틱 radius 토큰은 의도적으로 없다 — 컴포넌트가 자기 CoreXxxStyle.defaultBorderRadius 에서 숫자 primitive 를 직접 고른다. 그래서 "input 은 어느 radius 인가" 는 그 컴포넌트의 Style 을 보면 답이 나오고, 전역 별칭을 바꿔 의도치 않은 컴포넌트까지 움직이는 일이 없다.

Flutter 는 BorderRadius.circular(CoreRadius.radiusN), Web 은 rounded-${CoreRadius.scale.radiusN} Tailwind 키로 같은 값을 참조한다.

CoreDuration#

애니메이션 타이밍을 통일합니다:

// 시맨틱 alias tier — 컴포넌트가 참조하는 것
CoreDuration.instant   // 100ms — 체크 표시, 토글
CoreDuration.fast      // 150ms — 툴팁 등장, 버튼 hover, 입력 포커스
CoreDuration.normal    // 200ms — 드롭다운, 카드 hover, 아코디언
CoreDuration.moderate  // 300ms — 모달 진입, 패널 슬라이드
CoreDuration.slow      // 500ms — 페이지 레벨 전환

// raw 스케일 — 위 alias 가 가리키는 값 (직접 참조 대신 alias 사용)
CoreDuration.ms0 … CoreDuration.ms500     // 전환 tier (50ms 단위)
CoreDuration.ms1000 … CoreDuration.ms5000 // 표시 tier (alias 없음)

alias 를 거치는 이유는 이름이 값의 근거를 말해주기 때문이기도 하지만, 무엇보다 전역 모션 오버라이드(reduced-motion)가 걸 수 있는 단일 어휘가 되기 때문입니다. 컴포넌트가 raw ms150 을 직접 쓰면 그 오버라이드가 그 컴포넌트만 비껴갑니다.

// CoreDuration 토큰을 Duration으로 경유
AnimatedContainer(
  duration: Duration(milliseconds: CoreDuration.normal),
  curve: Curves.easeInOut,
  child: content,
)
// Tailwind duration 클래스로 접근
// transition-colors duration-150
// transition-shadow duration-200

Light/Dark 모드#

CoUIApp(
  theme: const ThemeData(),
  darkTheme: const ThemeData.dark(),
  themeMode: CoreThemeMode.system,
  // 사용자가 OS 에서 고대비를 켰을 때 쓰일 세트 — 같은 팔레트에서 만든다.
  // 주지 않으면 위 두 테마로 폴백한다 (검토하지 않은 세트를 렌더하는 것보다 낫다).
  highContrastTheme: ThemeData.fromCore(
    CoreThemeData(
      brightness: CoreBrightness.light,
      designSystem: DesignSystem.coui,
      colorScheme: CoreSemanticMapping.highContrastLight(palette),
    ),
  ),
  highContrastDarkTheme: ThemeData.fromCore(
    CoreThemeData(
      brightness: CoreBrightness.dark,
      designSystem: DesignSystem.coui,
      colorScheme: CoreSemanticMapping.highContrastDark(palette),
    ),
  ),
  home: MyHomePage(),
)

paletteCouiColorPresets.defaultPalette(기본) 또는 앱이 PrimitiveColors.generate(...)로 만든 커스텀 팔레트입니다 — 위 Web 커스텀 테마 절의 PrimitiveColors.generate 와 같은 값을 공유해야 Flutter/Web 고대비 세트가 같은 팔레트에서 파생됩니다.

Web은 Flutter처럼 테마 객체를 갈아끼우지 않습니다. 색을 뒤집는 것은 위 CoUI 테마 절의 <html data-theme> 속성이고, ThemeConfig[data-theme="dark"]--coui-* 오버라이드를 정의해 둡니다. 그래서 프로그래밍 방식 전환은 그 속성을 쓰는 것입니다:

CoUIThemeController.setTheme('dark');

사용자가 직접 고르게 하려면 ThemeController를 씁니다 — 앱을 감싸는 래퍼가 아니라 선택 UI입니다:

ThemeController(
  themes: const [
    CoreThemeOption(
      name: 'light',
      primaryColor: CoreColor.token(CoreColors.primary),
    ),
    CoreThemeOption(
      name: 'dark',
      primaryColor: CoreColor.token(CoreColors.primary),
    ),
  ],
  selectedTheme: 'light',
  onThemeChanged: CoUIThemeController.setTheme,
)

CSS 변수가 정의된 테마만 동작합니다 — 현재 ThemeConfig가 값을 정의하는 것은 lightdark 둘뿐입니다. data-theme에 다른 이름을 넣어도 대응하는 --coui-* 정의가 없어 아무 일도 일어나지 않습니다.

서페이스 스타일#

밝기와 직교하는 두 번째 테마 축입니다. 한 번 고르면 전 컴포넌트가 같이 움직이고, light/dark 어느 쪽에서도 켤 수 있어 둘은 목록이 아니라 매트릭스로 조합됩니다.

스타일무엇을 진술하나
defaultStyle기본 표면 — 불투명, 선으로 경계, 평평
neoBrutalism두꺼운 테두리, 흐림 없는 대각 그림자, 굵은 글씨
liquidGlass반투명 + 배경 블러
claymorphism둥글고 부푼 표면 + 안쪽 하이라이트 림
neumorphism페이지와 같은 색 표면, 양방향 그림자만으로 경계
CoUIApp(
  theme: ThemeData.fromCore(CoreThemePresets.light),
  surfaceStyle: CoreSurfaceStyleId.neoBrutalism,
  home: MyHomePage(),
)

문서에 data-coui-style 속성을 쓰면 전환됩니다 — 트리를 다시 빌드하지 않습니다.

// 스타일별 CSS 규칙은 ThemeConfig 가 굽습니다.
ThemeConfig(CouiColorPresets.defaultPalette).surfaceStyleVariableBlocks

브랜드 색은 프리셋이 건드리지 않습니다#

프리셋은 팔레트를 대체하지 않고 굽힙니다. 그중 hue 는 절대 건드리지 않습니다 — 채도와 명도만 움직입니다. 앱이 가져온 파랑은 어떤 프리셋에서도 그 파랑으로 남고, 그 프리셋다운 파랑이 될 뿐입니다.

그래서 "강조색을 바꾸고 싶다" 는 서페이스 스타일이 아니라 팔레트의 일입니다:

final palette = PrimitiveColors.generate(
  primaryHSL: (h: 220, s: 0.9, l: 0.6),   // 여기가 강조색을 정합니다
  // …
);

프리셋 튜닝#

프리셋이 저작한 값을 앱이 정할 수 있습니다. 모서리·그림자 캐스트·테두리 두께가 대상입니다.

CoUIApp(
  surfaceStyle: CoreSurfaceStyleId.neoBrutalism,
  surfaceTuning: const CoreSurfaceTuning(
    radiusCap: CoreRadius.radius0,        // 각진 모서리
    shadowOffsetX: 6,
    shadowOffsetY: 6,                     // 더 멀리 던지는 캐스트
    strokeFloor: CoreStrokeWidth.stroke3,
    stageTintChroma: 0.06,                // 페이지가 강조색 hue 를 받는다
  ),
  home: MyHomePage(),
)
ThemeConfig(
  CouiColorPresets.defaultPalette,
  surfaceTunings: const {
    CoreSurfaceStyleId.neoBrutalism: CoreSurfaceTuning(
      radiusCap: CoreRadius.radius0,
      shadowOffsetX: 6,
      shadowOffsetY: 6,
      strokeFloor: CoreStrokeWidth.stroke3,
      stageTintChroma: 0.06,
    ),
  },
)

이름 짓지 않은 축은 프리셋의 답을 그대로 씁니다 — radiusCap 만 적으면 캐스트와 두께는 프리셋 값입니다.

빌드 시점 결정입니다. 스타일 전환은 Web 에서 속성 하나로 되지만 튜닝은 연속값이라, 속성으로 고르려면 값마다 선택자가 하나씩 필요합니다.

눌림 피드백이 캐스트에서 파생됩니다shadowOffset 을 키우면 눌렀을 때 물러나는 거리도 같이 커집니다. 두 값을 따로 맞출 필요가 없습니다.

범위

범위
radiusFloor / radiusCap ≥ 0, floor ≤ cap. 사다리 밖 값은 변환이 알아서 스냅합니다
shadowOffsetX / shadowOffsetY ≥ 0 . 상한 없음 — 큰 캐스트가 이웃을 침범하는 건 앱이 소유한 미감이지 계약 위반이 아닙니다. 음수만 막습니다(아래-오른쪽이 이 스타일의 진술입니다)
strokeFloor CoreStrokeWidth.stroke2 이상, 그 프리셋의 포커스 링 폭 미만
stageTintChroma 0 ~ CoreSurfaceTuning.maxStageTintChroma. 아래 절 참조 — 이 상한은 대비 한계가 아닙니다

범위를 벗어난 값은 대부분 컴파일 에러입니다(const 호출부 기준). strokeFloor 상한만 프리셋마다 다른 값에서 파생되므로 디버그 첫 fold 에서 걸립니다.

테두리가 포커스 링과 같아지면 링이 테두리의 일부로 읽혀 키보드 사용자가 자기 위치를 잃습니다. 그래서 상한이 링 폭에서 파생되고, 링이 바뀌면 상한도 따라갑니다.

배경 틴트 — 실제로 결정하는 건 페이지의 밝기입니다

stageTintChroma 는 페이지(surface)가 강조색 hue 를 얼마나 받는지 정합니다. 페이지만 물듭니다 — 카드·입력·팝오버를 구분하는 컨테이너 톤은 그대로입니다. 같이 물들이면 그 층이 사라지고 화면이 한 덩어리가 됩니다.

페이지의 명도(lightness)는 움직이지 않습니다. 대비는 상대 휘도의 함수라, 명도를 열면 그 위의 모든 전경 쌍이 함께 움직입니다.

그리고 여기서 나오는 결과가 직관과 다릅니다 — sRGB 는 밝은 색일수록 담을 수 있는 채도가 적습니다. 명도를 고정한 채 요청한 채도가 게멋 밖이면, CoUI 는 clamp 하지 않고 게멋 안으로 줄여서 명도를 지킵니다. 그래서 실제로 실리는 틴트는 요청값이 아니라 페이지가 담을 수 있는 만큼입니다:

페이지 명도어떤 브랜드 hue 에서도 보장되는 채도
0.991 (CoUI 라이트 기본)0.006
0.950.027
0.920.043
0.213 (CoUI 다크 기본)0.038

브랜드 hue 는 앱이 고르므로 위 값은 최악 hue 기준입니다. hue 에 따라 더 담기기도 합니다 — 같은 라이트 페이지도 파랑 계열에서는 0.016 까지 갑니다.

숫자는 작지만 화면에서는 보입니다 — 페이지 전면 단색 영역에서는 작은 채도도 지각되기 때문입니다. 기본 라이트 팔레트에 상한 틴트를 걸고 실제로 렌더하면 페이지가 #fcfcfd 에서 #f6fdff 로 가고, 옅은 하늘색 wash 로 분명히 읽힙니다(측정 채도 0.008).

기대치만 맞추면 됩니다 — 레퍼런스 구현들의 뚜렷한 파스텔 배경과는 다릅니다. 그쪽은 대략 0.04 로 5배 진하고, 그 차이는 채도를 더 요청해서가 아니라 페이지가 더 어둡기 때문입니다. 다크 모드는 0.038 로 그 깊이에 거의 닿습니다.

레퍼런스 구현들의 뚜렷한 파스텔 배경은 채도를 더 요청해서가 아니라 덜 흰 페이지를 쓰기 때문입니다. 그런 배경이 필요하면 팔레트에서 페이지를 덜 희게 잡으세요 — L 0.95 면 0.027, 0.92 면 0.043 이 어떤 hue 에서도 보장됩니다.

maxStageTintChroma 는 그래서 대비 한계가 아니라 요청값의 sanity ceiling 입니다. 안전을 만드는 건 게멋이고, check_contrast.dart 의 sweep 이 "페이지가 담을 수 있는 최대 틴트"를 전 브랜드 hue × 두 모드에서 감사해 그걸 증명합니다.

고대비에서는 틴트가 꺼집니다. 가독성을 요청한 사용자가 앱이 고른 색조를 요청한 것은 아닙니다. 반대로 모서리·캐스트·두께는 고대비에서도 남습니다 — 대비 레버가 아니라서 버려도 이득이 없습니다.

튜닝할 수 없는 것

접근성 계약(a11y) · 스타일 id · 재질 · 글자 굵기 하한은 노출되지 않습니다.

boundaryMechanism 은 강제 색상 모드에서 경계가 살아남는다는 선언이고, 컴포넌트가 그 선언을 근거로 대체 경계를 그립니다. 앱이 그걸 바꿀 수 있으면 선언이 값이 아니라 거짓말이 되고, 그 손상은 컴포넌트 140개에 동시에 퍼지면서 각 컴포넌트 리뷰에서는 "원래 이렇게 생겼나 보다" 로 읽힙니다.

여기 없는 필드는 원칙이 아니라 오늘 없는 것이고, 축은 늘어날 수 있습니다.