---
title: "커스텀 테마 — zenit Zig UI 문서"
description: "테마는 하나의 ThemeTokens 값입니다. 색상, 간격, 모서리 반경, 글자 크기, 컨트롤 치수가 모두 들어 있습니다."
url: https://zenit.z.express/ko/docs/guide/theming
language: ko
alternate_en: https://zenit.z.express/docs/guide/theming.md
alternate_zh: https://zenit.z.express/zh/docs/guide/theming.md
alternate_es: https://zenit.z.express/es/docs/guide/theming.md
alternate_ja: https://zenit.z.express/ja/docs/guide/theming.md
alternate_fr: https://zenit.z.express/fr/docs/guide/theming.md
alternate_de: https://zenit.z.express/de/docs/guide/theming.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 커스텀 테마

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

이 페이지는 테마를 정의하고 설치하는 방법을 다룹니다. 뷰가 token을 소비하는 방식(스타일 함수와 recipe)은 [스타일과 테마](https://zenit.z.express/ko/docs/guide/styling)에서 설명합니다.

## 기본 테마

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

`main.zig`

```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 (발췌)`

```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` 중 하나를 복사해 필요한 필드만 바꿉니다.

> NOTE
> 
> **라이트/다크 판별은 scheme만 보십시오.** `scheme`은 유일한 라이트/다크 플래그입니다. Notifier는 이것으로 팔레트를 고르고, DevTools는 이것에서 자신의 색상을 도출합니다. `name`에 “dark”가 들어 있는지로 판단하지 마십시오. 고대비 테마는 순수한 검정 배경이지만 이름에 그런 말이 없습니다.

## 브랜드 테마 파생

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

`theme.zig`

```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;
};
```

> WARNING
> 
> **컴포넌트 token은 accent를 따라가지 않습니다.** 프리셋에서 `accent`와 `button_primary_bg`는 서로 독립된 값이며 파생 관계가 없습니다. 브랜드 색상을 바꿀 때는 `accent`, `button_primary_bg` / `button_primary_fg`, `border_focus`를 함께 바꾸십시오. 그렇지 않으면 primary 버튼은 프리셋 색상에 머뭅니다.

## 짝을 이루는 다크 테마

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

`theme.zig`

```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(라이트)](https://zenit.z.express/media/theme-brand.webp?v=588f003545)brand(라이트)

![brand_dark](https://zenit.z.express/media/theme-brand-dark.webp?v=854df7b29f)brand\_dark

![ui.theme.dark(기본)](https://zenit.z.express/media/theme-default-dark.webp?v=3b5b953dc1)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_fg` | primary Button(hover / 눌림은 배경에서 파생). fg는 Badge, Chip, Tabs의 반전 텍스트에도 사용 |
| `border · border_strong · separator` | 테두리와 구분선 |
| `border_focus · input_bg · input_border · selection_bg` | Input, Select, NumberStepper, DatePicker 등 입력 컨트롤 |
| `success · warning · danger · info · status_fg` | Alert, Badge, Tag, Progress, danger Button |
| `list_hover_bg · list_selection_bg` | Menu, DropdownMenu, ComboBox, Table, Tree, DataTable |
| `checkbox_* · switch_thumb · switch_track_off` | Checkbox / Switch의 미선택·비활성 상태 |
| `tooltip_bg · tooltip_fg · overlay · scrollbar_thumb` | Tooltip, Modal / Sheet 스크림, 스크롤바 |

> WARNING
> 
> **아직 이 token들을 읽는 컴포넌트가 없습니다.** `accent_muted`, `bg_inset`, `button_secondary_bg`, `button_secondary_fg`, `switch_track_on`(켜진 Switch는 `accent`를 사용), `table_header_bg`, `list_selection_hover_bg`, 그리고 `success_subtle` / `warning_subtle` / `danger_subtle` / `info_subtle`. 지금은 이 값을 바꿔도 눈에 보이는 변화가 없습니다. 직접 만든 스타일 함수에서는 그대로 사용할 수 있습니다.

일부 컴포넌트는 `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 |
| --- | --- |
| `xs` | 2.5 × 2 + 12 × 1.25 = 20 |
| `sm` | 4.5 × 2 + 12 × 1.25 = 24 |
| `md` | 7.25 × 2 + 14 × 1.25 = 32 |
| `lg` | 10 × 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`

```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),
```

```zig
// 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;
```

> NOTE
> 
> **런타임 전환이 미치는 범위.** `boxStyled` / `textStyled`로 만든 노드와 테마 hook을 등록한 컴포넌트는 즉시 색이 바뀝니다. 대부분의 `ui.widgets.*`는 마운트 시 token을 읽으므로, 완전히 전환하려면 해당 서브트리를 재구성해야 합니다. 시작할 때 한 번만 테마를 고르는 앱은 영향을 받지 않습니다.

## 배포 전 점검

-   `scheme`이 배경의 명암과 일치합니다(어두운 배경이면 반드시 `.dark`).
    
-   `button_primary_fg`와 `button_primary_bg`, `status_fg`와 `danger`의 대비가 최소 4.5:1입니다 — 기본 제공 프리셋의 단위 테스트와 같은 기준입니다.
    
-   `border`가 `bg_primary`와 여전히 구분됩니다(프리셋 테스트는 대비 ≥ 1.2를 요구).
    
-   Storybook을 여러분의 테마로 바꾸고 자주 쓰는 컴포넌트를 모두 눌러 봅니다(Component Wall story부터 시작하면 좋습니다).
