docs/guide/styling
핵심 개념 · Design system

스타일과 테마

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

약 12분 소요

3계층 스타일 모델

01
01 · ThemeTokens

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

02
02 · styles.zig

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

03
03 · Recipe

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

STYLE LAYERS
각 계층은 바로 아래 계층만 읽습니다. 뷰는 색상을 하드코딩하지 않으므로 테마 전환은 가장 아래 계층만 바꾸면 됩니다.
Button의 variant × size, 아이콘, loading, disabled, block 매트릭스. 스타일은 recipe로 해석됩니다.
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),

이름 있는 스타일 함수

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

styles.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
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
// 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);

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

브랜드 색상을 적용하거나 자신만의 라이트 / 다크 테마를 만들려면 커스텀 테마를 참고하십시오.

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가 받는 스타일 함수로 만들 수 있습니다.

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

resolve.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 / invalidui.ConditionalStyle
라이트 / 다크완전한 token 팔레트. 꼭 필요하면 t.scheme를 읽음

현재의 한계

  • ✓

    컴포넌트는 대부분 마운트 시 token을 읽습니다. ui.widgets.* 중 token을 다시 읽는 hook을 등록하지 않은 부분은 마운트 시점의 스냅숏을 유지합니다. 런타임에 테마를 전환하면 앱 레벨의 styled 노드는 다시 실행되지만, 컴포넌트 라이브러리 노드는 트리를 재구성해야 할 수 있습니다.

  • ✓

    재실행은 cx.root 서브트리만 대상입니다. 창 오버레이 portal은 그 안에 있습니다. cx.root에서 분리된 트리는 갱신되지 않습니다.

  • ✓

    themeSignal은 자신을 만든 Scope를 따릅니다. 모든 구독자보다 오래 살아 있도록 앱의 루트 Scope에서 만드십시오.

각 컴포넌트의 variant와 크기 매트릭스는 컴포넌트 사이트에서 확인하십시오.

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