docs/guide/theming
핵심 개념 · 테마

커스텀 테마

테마는 하나의 ThemeTokens 값입니다. 색상, 간격, 모서리 반경, 글자 크기, 컨트롤 치수가 모두 들어 있습니다. 기본 제공 프리셋을 복사해 브랜드 관련 필드를 바꾸고 cx.setTheme로 설치하면 그것이 여러분의 테마입니다.

약 8분 소요 · 실제 창에서 찍은 스크린숏

이 페이지는 테마를 정의하고 설치하는 방법을 다룹니다. 뷰가 token을 소비하는 방식(스타일 함수와 recipe)은 스타일과 테마에서 설명합니다.

기본 테마

각 창의 Cx는 처음에 ui.theme.dark를 가리킵니다. setTheme를 한 번도 호출하지 않는 앱은 다크 테마입니다 — Hello Button이 그렇습니다. 다른 테마를 쓰려면 노드가 생기기 전, 마운트 함수의 첫 줄에서 설정합니다:

main.zig
fn mountUI(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    // Cx starts on ui.theme.dark. Pick the theme before building any node,
    // so every component reads the right tokens when it mounts.
    cx.setTheme(&ui.theme.light);

    const root = try ui.boxStyled(cx, S.root, .{});
    // …
    return root;
}

창이 여러 개면 각 창이 자신의 Cx를 가지므로 각 마운트 함수에서 테마를 설정합니다. zenit은 아직 시스템 외관을 따라가지 않습니다. 시스템의 라이트/다크 전환은 다시 그리기만 한 번 일으키며, 시스템 외관을 읽는 공개 API도 없습니다. 대신 사용자에게 전환 스위치를 제공하십시오(“런타임 전환” 참고).

ThemeTokens의 구조

src/ui/theme.zig (발췌)
pub const ThemeTokens = struct {
    name: []const u8 = "unnamed",
    scheme: ColorScheme = .light,      // .light / .dark — the one flag for "is this dark?"
    color: ColorTokens,                // 54 colors, no defaults: you must provide all of them
    space: SpaceScale = .{},           // _0 … _12  (0 … 48 px, 4 px grid)
    radius: RadiusScale = .{},         // none sm md lg xl full
    font_size: FontSizeScale = .{},    // xxs … xxxl (9 … 24)
    border_width: BorderWidthScale = .{},
    size: SizeTokens = .{},            // checkbox, switch, badge, dot, close button
    control: ControlScale = .{},       // xs / sm / md / lg metrics for every control
    duration: DurationTokens = .{},    // fast / normal / slow (seconds)
    shadow: ShadowTokens = .{},        // sm / md / lg
};

color를 제외한 모든 필드에는 기본값이 있습니다. ColorTokens의 54개 색상에는 기본값이 없으므로 struct 리터럴을 처음부터 쓰지 마십시오. ui.theme.light, ui.theme.dark, ui.theme.high_contrast 중 하나를 복사해 필요한 필드만 바꿉니다.

브랜드 테마 파생

테마는 컨테이너 수준의 const로 작성합니다. 초기화 블록은 컴파일 타임에 평가되고 결과는 정적 저장소에 놓이므로, cx.setTheme(&brand)가 보관하는 포인터는 프로그램이 실행되는 동안 계속 유효합니다. 함수의 지역 변수에 둔 테마의 주소를 취하지 마십시오.

theme.zig
// theme.zig — your app's themes. Container-level consts: they are
// evaluated at compile time and live for the whole program, which is
// what cx.setTheme(&brand) needs (it keeps the pointer).
const ui = @import("ui");

pub const brand: ui.ThemeTokens = blk: {
    var t = ui.theme.light; // copy a complete preset (every color is required)
    t.name = "brand";
    t.scheme = .light;

    const blue = ui.Color.hex(0x1144AA);
    t.color.accent = blue;                       // links, checked states, active tabs…
    t.color.accent_hover = ui.Color.hex(0x0D3688);
    t.color.accent_subtle = ui.Color.hex(0xE6ECF7);
    t.color.button_primary_bg = blue;            // primary buttons read these two,
    t.color.button_primary_fg = ui.Color.WHITE;  //   not accent
    t.color.border_focus = blue;
    t.color.selection_bg = ui.Color.hex(0xC9D6F0);
    t.color.list_selection_bg = ui.Color.hex(0xE6ECF7);

    t.radius.lg = 10;
    t.control.md.radius = 10;
    t.control.md.padding_h = 14;
    break :blk t;
};

짝을 이루는 다크 테마

다크 버전은 라이트 브랜드 테마의 배경을 바꾸는 대신 ui.theme.dark에서 파생시킵니다. 다크 프리셋의 수십 개 배경, 텍스트, 테두리 값은 한 세트로 조정되어 있기 때문입니다. 어두운 배경에서는 브랜드 색상을 보통 밝게 해야 하며, primary 버튼의 텍스트는 어두운 색으로 바뀝니다.

theme.zig
pub const brand_dark: ui.ThemeTokens = blk: {
    var t = ui.theme.dark; // start from the dark preset, not from brand
    t.name = "brand_dark";
    t.scheme = .dark;      // Notification palettes and DevTools key off this

    const blue = ui.Color.hex(0x7FA2F0); // lighter on dark backgrounds
    t.color.accent = blue;
    t.color.button_primary_bg = blue;
    t.color.button_primary_fg = ui.Color.hex(0x0D1119);
    t.color.border_focus = blue;
    break :blk t;
};
brand(라이트)brand(라이트)
brand_darkbrand_dark
ui.theme.dark(기본)ui.theme.dark(기본)
같은 Hello Button에서 setTheme 인자만 바꾼 것입니다. 세 장 모두 E2E harness가 실제 창에서 캡처했습니다.

어떤 token이 무엇에 영향을 주는가

컴포넌트가 실제로 읽는 필드 기준으로 정리했습니다(v0.1.0-alpha 소스). 테마를 만들 때는 이것부터 바꾸십시오:

Token주요 사용처
fg_primary · fg_secondary · fg_tertiary · fg_disabled거의 모든 텍스트와 아이콘
bg_primary · bg_secondary · bg_tertiary패널, 카드, Calendar, Chip, Progress / Slider 트랙, Skeleton
bg_hover · bg_active클릭 가능한 컨트롤의 호버·눌림 상태
accent선택된 Checkbox / Switch, 링크, Tabs, Breadcrumb, 선택된 날짜, Badge
button_primary_bg · button_primary_fgprimary Button(hover / 눌림은 배경에서 파생). fg는 Badge, Chip, Tabs의 반전 텍스트에도 사용
border · border_strong · separator테두리와 구분선
border_focus · input_bg · input_border · selection_bgInput, Select, NumberStepper, DatePicker 등 입력 컨트롤
success · warning · danger · info · status_fgAlert, Badge, Tag, Progress, danger Button
list_hover_bg · list_selection_bgMenu, DropdownMenu, ComboBox, Table, Tree, DataTable
checkbox_* · switch_thumb · switch_track_offCheckbox / Switch의 미선택·비활성 상태
tooltip_bg · tooltip_fg · overlay · scrollbar_thumbTooltip, Modal / Sheet 스크림, 스크롤바

일부 컴포넌트는 ColorTokens를 읽지 않고 자체 색상을 가집니다. Notifier는 scheme에 따라 내장된 라이트 / 다크 팔레트 중 하나를 고르고, GlassBox의 유리 색조와 Progress, Select의 일부 세부 요소도 고정값입니다. 라이트/다크는 따라가지만 브랜드 색상으로 바뀌지는 않습니다.

모서리 반경, 간격, 컨트롤 치수

색상이 아닌 token은 필드 단위로 덮어쓸 수 있습니다. 가장 유용한 것은 control입니다. Button, Input, Select, ComboBox, DatePicker, DateRangePicker, Tabs, Chip은 xs / sm / md / lg 네 단계의 치수를 공유합니다. 컨트롤의 높이는 token이 아니라 계산됩니다:

크기기본 높이 = padding_y × 2 + font_size × line_height
xs2.5 × 2 + 12 × 1.25 = 20
sm4.5 × 2 + 12 × 1.25 = 24
md7.25 × 2 + 14 × 1.25 = 32
lg10 × 2 + 16 × 1.25 = 40

따라서 md 컨트롤을 더 높게 하려면 t.control.md.padding_y나 글자 크기를 바꾸면 되고, 같은 단계의 모든 컨트롤이 함께 바뀝니다. 컨트롤에 height를 하드코딩하지 마십시오. icon_size는 줄 높이보다 크지 않게 유지합니다.

런타임 전환

cx.setTheme는 언제든 호출할 수 있습니다. token 포인터를 교체하고, cx.root 서브트리(오버레이 portal 포함)에서 on_theme / before_render hook을 다시 실행한 뒤 다시 그리기를 요청합니다.

prefs.zig
const Prefs = struct {
    cx: *ui.Cx,
    dark: bool = false,

    pub fn toggle(self: *Prefs) void {
        self.dark = !self.dark;
        self.cx.setTheme(if (self.dark) &themes.brand_dark else &themes.brand);
    }
};

// In mountUI:
const prefs = try cx.bindState(Prefs, .{ .cx = cx });
cx.setTheme(&themes.brand);
// …
.on_click = cx.on(Prefs, prefs, Prefs.toggle),
reactive read
// A reactive read of the current theme (lives as long as `scope`).
const theme_sig = try cx.themeSignal(scope);
const is_dark = theme_sig.get().scheme == .dark;

배포 전 점검

  • ✓

    scheme이 배경의 명암과 일치합니다(어두운 배경이면 반드시 .dark).

  • ✓

    button_primary_fg와 button_primary_bg, status_fg와 danger의 대비가 최소 4.5:1입니다 — 기본 제공 프리셋의 단위 테스트와 같은 기준입니다.

  • ✓

    border가 bg_primary와 여전히 구분됩니다(프리셋 테스트는 대비 ≥ 1.2를 요구).

  • ✓

    Storybook을 여러분의 테마로 바꾸고 자주 쓰는 컴포넌트를 모두 눌러 봅니다(Component Wall story부터 시작하면 좋습니다).

zenit · 이중 라이선스오픈 소스 프로젝트는 GPL-3.0-only로 무료 사용할 수 있으며, 비공개 소스나 상용 제품에는 상용 라이선스가 필요합니다.작성자 연락처: zongyi.xzy#gmail.com (#을 @로 바꾸세요)zenit 5f9add5+wip 2026-09-30