docs/guide/components
핵심 개념 · Components

내장 컴포넌트

zenit 컴포넌트가 무엇을 약속하는지, 언제 하위 레이어로 내려가야 하는지, 그리고 마운트·오버레이·테스트라는 세 가지 계약을 설명합니다. 컴포넌트별 녹화, props, 결과 타입은 컴포넌트 사이트에 있습니다.

10분 분량

여기서 말하는 ‘컴포넌트’란

zenit 컴포넌트는 색상과 모서리 반경을 이어 붙이는 helper가 아닙니다. 공개 컴포넌트는 보통 노드 구조, 영속 상태, 이벤트 라우팅, 포커스와 키보드 시맨틱, 접근성 속성, 테마 token, 오버레이 수명, 그리고 harness가 찾아내고 다시 읽을 수 있는 테스트 경계를 함께 캡슐화합니다. 모두 ui.widgets에서 export됩니다.

51컴포넌트 사이트의 Story
6사용 사례 분류 (Labs 별도)
1:1Story 대 E2E test_id
ANATOMY
mount는 먼저 자식 Scope를 만듭니다. 자식 Scope가 소유한 모든 것은 부모 Scope와 함께 해제됩니다.

먼저 올바른 레이어 선택하기

요구레이어여전히 직접 맡는 것
표준 제품 컨트롤ui.widgets.*비즈니스 상태, 문구, 콜백, 배치
맞춤 시각, 검증된 인터랙션ui.hooks · ui.interaction · ui.select_headless그리기, token, a11y 레이블, 합성 경계
새로운 인터랙션 패턴ui.Node + ui.events히트 테스트, 키보드, 포커스, IME, a11y, 테스트 계약

컴포넌트 카탈로그

모든 컴포넌트는 컴포넌트 사이트에 전용 페이지가 있습니다. 실제 zenit Storybook.app의 녹화, 소스에서 생성한 props와 결과 타입, 그리고 녹화가 증명하는 동작입니다. harness는 test_id로 각 story를 찾고, 가상 커서나 입력을 구동하며, Metal drawable을 직접 녹화합니다.

분류개수대표 예
액션과 선택7Button · Checkbox · Switch · RadioGroup · Slider …
입력과 폼10Input · Textarea · Select · Input · Select · Button · ComboBox …
데이터 표시17Badge · Tag · Chip · Card · Alert …
내비게이션4Tabs · Accordion · Menu · DropdownMenu
오버레이와 피드백5Notifier · Tooltip · Popover · Modal · Sheet
레이아웃과 대용량 데이터8GlassBox · Divider · HStack / VStack · ui.box · VirtualList …
실험과 기능 검증13glasslab · glassislands · glassmotion · glasschrome · canvasevents …
Button story: variant × size 매트릭스를 훑은 뒤 Primary에서 hover와 press를 보여 줍니다. 자세한 내용은 /components/button.

Mount, 상태, 정리

대부분의 시각 컴포넌트는 builder 형식 Config.mount(scope, cx)를 사용합니다. ui.widgets.Button(props)는 builder를 반환하고, 실제로 트리를 만드는 것은 mount입니다. 반환값은 루트 *Node이거나 wrapper, trigger, body, panel, state 같은 핸들을 담은 결과 구조체입니다.

mount_form.zig
const EditorActions = struct {
    document: *Document,

    fn save(self: *EditorActions) void {
        self.document.save();
    }
};

const bindings = try cx.bindState(EditorActions, .{ .document = document });

const save = try ui.widgets.Button(.{
    .label = "Save",
    .variant = .primary,
    .on_click = cx.on(EditorActions, bindings, EditorActions.save),
}).mount(scope, cx);

const name = try ui.widgets.Input(.{
    .label_text = "Project name",
    .placeholder = "Untitled",
    .required = true,
    .width = 320,
}).mount(scope, cx);

try form.appendChild(cx.allocator, name);
try form.appendChild(cx.allocator, save);

일부 컴포넌트는 함수 형식으로, props, scope, cx를 한 번에 전달합니다. mountScrollArea, mountGrid, 그리고 Select, ComboBox, TagsInput, NumberStepper, FileUpload, DataTable입니다(이들은 ui.widgets에서 mountX 함수의 별칭이며 ui.widgets.Select(props, scope, cx)처럼 호출합니다).

scroll_area.zig
// Function form: props, scope and cx in one call; returns a handle struct.
const area = try ui.widgets.mountScrollArea(.{ .height = 320 }, scope, cx);
try area.content.appendChild(cx.allocator, list);
try root.appendChild(cx.allocator, area.container);
컴포넌트반환값
Button · Input*Node
ModalModalResult{ overlay, dialog, body, portaled }
TooltipTooltipResult{ wrapper, trigger, content }
SelectSelectMount{ wrapper, trigger, panel, state, is_open }
mountScrollAreaScrollAreaResult{ container, content, state }
mountGridGridResult{ root, state }

config를 나중에 쓸 수 있는 live props로 취급하지 마세요. 컴포넌트는 mount 시점에 config를 읽어 노드와 상태를 만듭니다. config 안의 signal.get()은 스냅숏으로 한 번만 읽힙니다. 계속 바뀌는 데이터는 반환된 state, Signal(예: Modal의 .visible(sig)) 또는 컴포넌트의 공개 메서드를 통해 전달하세요. 임시 config에 대한 포인터를 프레임 너머로 보관해서도 안 됩니다. 컴포넌트의 내부 상태는 mount가 만든 자식 Scope에 있으며, 부모 Scope가 dispose될 때 함께 해제됩니다.

오버레이가 컴포넌트여야 하는 이유

Tooltip, Popover, Menu, Modal, Sheet, 그리고 앱 내 알림 Notifier는 모두 OverlayStack에 레이어를 등록합니다. barrier가 있는 레이어(Modal / Sheet)는 창 수준 portal(cx.root 아래의 WindowOverlayPortal)로 옮겨져 조상의 overflow 클리핑을 벗어납니다. z 값은 시맨틱 tier로 정해집니다. overlay 100, dialog 1000, toast 2000(Notifier가 있는 층), tooltip 3000이며 devtools overlay는 32000으로 고정입니다. 오버레이 안에서 연 오버레이는 항상 호스트 위에 쌓입니다. box에 absolute와 큰 z-index를 준다고 이런 동작을 얻을 수는 없습니다.

modal.zig
const visible = try scope.createSignal(bool, false);

const modal = try ui.widgets.Modal(.{
    .title = "Delete document?",
    .width = 420,
}).visible(visible).mount(scope, cx);

try modal.body.appendChild(cx.allocator, confirm_content);
// modal.portaled == true: the barrier already lives under the window-level
// portal. Do not append modal.overlay to the current tree.

// Open it from any handler:
visible.set(true);
OVERLAY PORTAL
mount를 호출한 위치가 오버레이가 그려지는 위치를 정하지 않습니다. barrier는 portal로 가고, z는 tier가 정하며, Escape는 스택 맨 위에서 아래로 내려가며 처음 닫을 수 있는 레이어를 찾습니다.
  • ✓

    내부 클릭은 dialog / panel 안에 머물며 outside-dismiss를 일으키지 않습니다. barrier를 클릭하면 close_on_overlay에 따라 닫힙니다.

  • ✓

    Escape는 최상위 오버레이만 닫고, 중첩된 레이어는 한 단계씩 해제됩니다.

  • ✓

    포커스: 열린 Modal은 포커스를 trap하고 자동으로 포커스하며, 닫히면 원래 위치로 되돌립니다.

  • ✓

    Scope dispose는 barrier 서브트리를 portal에서 능동적으로 떼어 내므로, 페이지를 바꿔도 보이지 않는 히트 레이어가 남지 않습니다.

  • ✓

    퇴장 트랜지션 동안 오버레이는 보이는 상태를 유지하고, 애니메이션이 확정된 뒤에 중단됩니다. 끝 상태로 바로 건너뛰지 않습니다.

차단하지 않는 앱 전역 알림에는 ui.widgets.Notifier(이전 ToastManager 대체)를 사용합니다. Notifier.init(scope, cx, .{})는 toast tier에 레이어를 등록하고, 창 portal이 있으면 창 전체 컨테이너를 그곳에 마운트합니다(portaled가 false이면 container를 직접 루트에 추가하세요). 그다음 show(.{ .tone = .success, .title = "Saved" })가 id를 반환하고, update는 카드를 그 자리에서 바꾸며, dismiss는 카드를 거둡니다. 닫기, 만료, 버튼 동작은 Listener 이벤트로 앱에 돌아옵니다. 기록, 방해 금지 같은 알림 센터 로직은 앱이 직접 구현합니다.

harness가 가운데 dialog를 열고 내부 클릭으로 닫히지 않는지 검증합니다. Modal
가상 커서가 trigger에 hover하면 말풍선이 레이아웃을 건드리지 않고 tooltip tier로 들어갑니다. Tooltip

Showcase는 회귀 테스트 모음이기도 합니다

e2e/storybook.test.ts는 nav.<key>로 각 story로 전환하고, 서브트리의 텍스트를 모아 기대한 데이터 값이 모두 있는지 단언한 뒤 스크린숏을 찍습니다. 인터랙티브 컴포넌트는 상호작용 후의 상태, 지오메트리, 픽셀도 단언합니다. 컴포넌트 사이트의 녹화는 같은 앱과 같은 시맨틱 id를 재사용하므로 ‘데모’와 ‘테스트’가 두 구현으로 갈라지지 않습니다.

게이트잡아내는 문제
텍스트 / 상태 다시 읽기빈 패널, 값을 되쓰지 않는 콜백, 잘못된 필터 / 페이지 값
지오메트리 단언padding box, portal 가운데 정렬, Sheet 가장자리 정렬, 드래그 이동량
픽셀 단언빈 GPU 레이어, blend 회귀, 흑백이 된 이모지, 잘못된 z 순서
녹화hover, press, 커서, IME, 스크롤, 등장 애니메이션의 타이밍
zenit · 이중 라이선스오픈 소스 프로젝트는 GPL-3.0-only로 무료 사용할 수 있으며, 비공개 소스나 상용 제품에는 상용 라이선스가 필요합니다.작성자 연락처: zongyi.xzy#gmail.com (#을 @로 바꾸세요)zenit 5f9add5+wip 2026-09-30