---
title: "스타일과 테마 — zenit Zig UI 문서"
description: "Token, 이름 있는 스타일 함수, Recipe를 사용해 시각 요소와 비즈니스 로직을 서로 독립적으로 유지합니다."
url: https://zenit.z.express/ko/docs/guide/styling
language: ko
alternate_en: https://zenit.z.express/docs/guide/styling.md
alternate_zh: https://zenit.z.express/zh/docs/guide/styling.md
alternate_es: https://zenit.z.express/es/docs/guide/styling.md
alternate_ja: https://zenit.z.express/ja/docs/guide/styling.md
alternate_fr: https://zenit.z.express/fr/docs/guide/styling.md
alternate_de: https://zenit.z.express/de/docs/guide/styling.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 스타일과 테마

Token, 이름 있는 스타일 함수, Recipe를 사용해 시각 요소와 비즈니스 로직을 서로 독립적으로 유지합니다.

## 3계층 스타일 모델

**01 · ThemeTokens**

색상, 글자 크기, 간격, 모서리 반경, 컨트롤 치수의 단일 출처.

**02 · styles.zig**

이름을 붙이고, 재사용하고, 테스트할 수 있으며 테마가 바뀌면 다시 실행되는 순수 함수.

**03 · Recipe**

변형, 상호작용 조건 상태, 여러 파트로 된 컴포넌트를 처리합니다.

STYLE LAYERS

각 계층은 바로 아래 계층만 읽습니다. 뷰는 색상을 하드코딩하지 않으므로 테마 전환은 가장 아래 계층만 바꾸면 됩니다.

[Video](https://zenit.z.express/media/stories/button.mp4?v=6203a01415)

Button의 variant × size, 아이콘, loading, disabled, block 매트릭스. 스타일은 recipe로 해석됩니다.

[Video](https://zenit.z.express/media/stories/glassbox.mp4?v=570227ca3d)

GlassBox의 regular, interactive, clear 모드와 실제 backdrop blur.

## Token 우선

스타일 값은 `cx.tokens`(또는 스타일 함수가 받는 `t`)에서 읽습니다. 정말로 스케일에서 벗어난 디자인 값은 `ui.arb`로 명시합니다. 런타임에는 비용이 없는 항등 함수이며, 가치는 의미에 있습니다. 읽는 사람이 ‘의도된 임의 값’과 ‘token으로 옮겨야 할 대충 쓴 리터럴’을 한눈에 구분할 수 있습니다.

```zig
// Preferred: follows the theme
.background = cx.tokens.color.bg_primary,
.padding = ui.Padding.all(cx.tokens.space._4),
.font_size = cx.tokens.font_size.xl,

// Legitimate escape hatch: states that the value is intentionally off-scale
.font_size = ui.arb.px(18),
.background = ui.arb.hex(0x316ff6),
.border_color = ui.arb.hexA(0x000000, 0.12),
```

> WARNING
> 
> **맨 리터럴은 피하십시오.** 뷰 안의 `Color.hex(...)`와 고정된 `font_size` 값은 테마를 우회합니다. `scripts/check_style_literals.sh`는 맨 리터럴만 잡아내고 `ui.arb`는 통과시키므로, `ui.arb`를 grep하면 token 밖의 값을 모두 파악할 수 있습니다. 같은 arb 값이 세 번 이상 나오면 token으로 승격하는 것을 고려하십시오.

## 이름 있는 스타일 함수

스타일 함수의 시그니처는 `fn (*const ui.ThemeTokens) ui.BoxStyle`(또는 `ui.TextStyle`)입니다. `styles.zig`에 모아 두고 뷰에서는 이름으로 참조합니다.

`styles.zig`

```zig
const ui = @import("ui");

pub fn card(t: *const ui.ThemeTokens) ui.BoxStyle {
    return .{
        .background = t.color.bg_secondary,
        .padding = ui.Padding.all(t.space._6),
        .gap = t.space._3,
    };
}

pub fn row(t: *const ui.ThemeTokens) ui.BoxStyle {
    return .{ .gap = t.space._2 };
}

pub fn title(t: *const ui.ThemeTokens) ui.TextStyle {
    return .{
        .font_size = t.font_size.xxl,
        .font_weight = 600,
        .color = t.color.fg_primary,
    };
}
```

`view.zig`

```zig
const S = @import("styles.zig");

const card = try ui.boxStyled(cx, S.card, .{});
try card.appendChild(
    cx.allocator,
    try ui.textStyled(cx, S.title, "Workspace"),
);

// hstackStyled / vstackStyled force direction = .row / .column.
const actions = try ui.hstackStyled(cx, S.row, .{});
try card.appendChild(cx.allocator, actions);
```

`boxStyled`, `textStyled`, `hstackStyled`, `vstackStyled`는 노드에 `on_theme` hook을 등록해 스타일 함수 자체를 기억합니다. 테마가 바뀌면 노드는 새 token으로 스타일을 다시 계산합니다. 일반 `ui.box` 안에서 읽은 token은 마운트 시점의 스냅숏일 뿐입니다.

## 테마 전환

기본 제공 테마는 `ui.theme.light`, `ui.theme.dark`, `ui.theme.high_contrast` 세 가지입니다. `cx.setTheme`는 token 포인터를 교체하고, `cx.root` 서브트리를 순회하며 각 노드의 `on_theme`와 `before_render` hook을 다시 실행한 뒤 dirty로 표시합니다. 현재 테마를 반응형으로 읽으려면 `cx.themeSignal(scope)`를 사용합니다.

`theme.zig`

```zig
// Built-in palettes: ui.theme.light, ui.theme.dark, ui.theme.high_contrast
cx.setTheme(&ui.theme.dark);

// Reactive access: a Signal that setTheme updates.
const theme = try cx.themeSignal(scope);
try scope.createEffect(.{ .theme = theme }, struct {
    fn run(ctx: anytype) void {
        const t = ctx.theme.get();
        std.log.info("theme: {s} ({s})", .{ t.name, @tagName(t.scheme) });
    }
}.run);
```

> NOTE
> 
> **재실행 범위는 cx.root 서브트리입니다.** 컴포넌트 오버레이(Modal, Popover, Menu, Tooltip)는 `cx.root` 아래의 `WindowOverlayPortal`에 있으므로 `setTheme` 때 함께 다시 실행됩니다. `cx.root`에서 분리된 노드 트리만 범위 밖입니다. 다시 실행되는 것은 styled 노드가 등록한 hook이며, 대부분의 `ui.widgets.*`는 마운트 시 token을 스냅숏하므로 여전히 재구성이 필요합니다.

라이트/다크 차이는 완전한 token 팔레트로 표현해야 합니다. 스타일 함수 안에서 정말로 분기가 필요할 때만 `t.scheme`(`.light` / `.dark`)를 읽습니다.

브랜드 색상을 적용하거나 자신만의 라이트 / 다크 테마를 만들려면 [커스텀 테마](https://zenit.z.express/ko/docs/guide/theming)를 참고하십시오.

## Recipe와 조건 상태

`ui.recipe`는 네임스페이스입니다. `ui.recipe.recipe(Config)`는 단일 노드 recipe를, `ui.recipe.slotRecipe(Config)`는 여러 파트로 된 recipe를 정의합니다. 해석 시 **base → variants → derived → compounds** 순으로 병합되며, 나중에 쓴 null이 아닌 필드가 우선합니다. 컴포넌트의 외부 style override는 마지막에 적용됩니다.

RECIPE MERGE

각 단계는 자신이 설정한 필드만 덮어씁니다. 관례상 derived는 기하 정보(padding, 모서리 반경, 크기)만 만들고 background는 건드리지 않습니다.

| Config 멤버 | 역할 |
| --- | --- |
| `Variants` | 필수. 각 필드는 기본값을 가진 변형 차원(enum 또는 bool) |
| `base(t)` | 선택. 기본 ConditionalStyle |
| `variants` | 선택. 차원마다 resolver 하나: fn (value, t) ConditionalStyle |
| `derived(v, t)` | 선택. 전체 Variants를 받아 차원을 넘나드는 연속적 로직을 표현(예: padding = f(size, 아이콘 모드)) |
| `compounds` | 선택. { matches(v), style(t) }. 여러 차원이 동시에 일치할 때 겹쳐 적용 |

다음은 저장소의 `examples/hello_button/styles.zig`에 있는 실제 코드입니다. 앱에서도 자체 recipe를 정의한 뒤, `resolveBase`로 변형을 고정해 `ui.boxStyled`가 받는 스타일 함수로 만들 수 있습니다.

`examples/hello_button/styles.zig`

```zig
pub const PanelEmphasis = enum { neutral, highlight };

/// CVA-style: base + one variant dimension, all from tokens.
const PanelRecipe = ui.recipe.recipe(struct {
    pub const Variants = struct {
        emphasis: PanelEmphasis = .neutral,
    };

    pub fn base(t: *const ui.ThemeTokens) ui.ConditionalStyle {
        return .{ .base = .{
            .padding = ui.Padding.symmetric(t.space._2, t.space._4),
            .corner_radius = t.radius.md,
        } };
    }

    pub const variants = .{
        .emphasis = struct {
            fn resolve(e: PanelEmphasis, t: *const ui.ThemeTokens) ui.ConditionalStyle {
                return switch (e) {
                    .neutral => .{ .base = .{ .background = t.color.bg_secondary } },
                    .highlight => .{ .base = .{
                        .background = t.color.bg_tertiary,
                        .border = .{ .width = 1, .color = t.color.accent, .radius = 0 },
                    } },
                };
            }
        }.resolve,
    };
});

/// Freeze a variant into a theme-safe style function for ui.boxStyled.
pub fn panel(comptime emphasis: PanelEmphasis) fn (*const ui.ThemeTokens) ui.BoxStyle {
    return struct {
        fn f(t: *const ui.ThemeTokens) ui.BoxStyle {
            return PanelRecipe.resolveBase(.{ .emphasis = emphasis }, t);
        }
    }.f;
}
```

`ConditionalStyle`은 `base` 외에 일곱 개의 조건 슬롯을 가집니다: `selected`, `expanded`, `hover`, `active`, `focus`, `invalid`, `disabled`. `resolve(state)`는 selected → expanded → hover → active → focus → invalid 순서로 겹칩니다. `disabled`는 단락되므로 비활성화된 노드에는 다른 조건이 겹치지 않습니다.

`resolve.zig`

```zig
// Full ConditionalStyle for one variant combination.
const cs = PanelRecipe.resolve(.{ .emphasis = .highlight }, cx.tokens);

// A component's style props are applied last, on top of the recipe output.
const merged = cs.override(.{
    .style = .{ .background = cx.tokens.color.accent },
});

// Pick the final style for the current interaction state.
const final: ui.BoxStyle = merged.resolve(.{ .is_hovered = true });
```

| 상황 | 도구 |
| --- | --- |
| 단일 정적 스타일 | 이름 있는 스타일 함수 |
| 크기 / 시각 변형 | `ui.recipe.recipe` |
| 여러 파트로 된 컴포넌트(입력 필드, 메뉴) | `ui.recipe.slotRecipe` |
| hover / disabled / invalid | `ui.ConditionalStyle` |
| 라이트 / 다크 | 완전한 token 팔레트. 꼭 필요하면 `t.scheme`를 읽음 |

## 현재의 한계

-   **컴포넌트는 대부분 마운트 시 token을 읽습니다.** `ui.widgets.*` 중 token을 다시 읽는 hook을 등록하지 않은 부분은 마운트 시점의 스냅숏을 유지합니다. 런타임에 테마를 전환하면 앱 레벨의 styled 노드는 다시 실행되지만, 컴포넌트 라이브러리 노드는 트리를 재구성해야 할 수 있습니다.
    
-   **재실행은 cx.root 서브트리만 대상입니다.** 창 오버레이 portal은 그 안에 있습니다. `cx.root`에서 분리된 트리는 갱신되지 않습니다.
    
-   **themeSignal은 자신을 만든 Scope를 따릅니다.** 모든 구독자보다 오래 살아 있도록 앱의 루트 Scope에서 만드십시오.
    

각 컴포넌트의 variant와 크기 매트릭스는 [컴포넌트 사이트](https://zenit.z.express/ko/components)에서 확인하십시오.
