Skip to content

UI Layout and Primitives

github-actions[bot] edited this page Oct 18, 2026 · 5 revisions

UI: layout and primitives

Containers for node bodies and panels, plus buttons and icons. Part of @nynxz/zenkit-ui.

ZenWidget

The outer shell of a node body: a flex column with standard padding, gap, 12px text, and a cursor: default reset so ComfyUI's grab cursor doesn't leak into the content.

<ZenWidget fill ref="shell">
  <ZenRow>…</ZenRow>
  <template #footer><ZenButton block>Run</ZenButton></template>
</ZenWidget>
Prop Type Default Description
fill boolean false Fill the node body (height 100%). Match the widget's fill: true registration.
gap number 7 px between sections
pad boolean | number | string true true = 6px 2px, false = none, a number = Npx 2px, a string = raw CSS padding
dragThrough boolean false Keep the node's grab cursor, for dragThrough widgets
Slot Description
default Content
footer Pinned to the bottom (a data-zen-spacer spacer pushes it down)
Exposed Type Description
el Ref<HTMLElement | null> The root element (a component ref otherwise gives the instance). Pass shell.value?.el to addNodeHeaderButton.

ZenRow

A row of controls that wraps onto new lines instead of squashing them, for narrow node bodies. Children keep their natural size unless they carry data-grow.

<ZenRow :min="140">
  <ZenSelect data-grow v-model="preset" :options="presets" />
  <ZenIconButton icon="mdi mdi-plus" title="Add" />
</ZenRow>
Prop Type Default Description
min number 120 Flex basis in px for data-grow children; they wrap rather than shrink below it
gap number 6 px
align 'center' | 'start' | 'end' | 'stretch' 'center' Cross-axis alignment
wrap boolean true false keeps one line

Slots: default. It forces width: auto on direct children so width: 100% controls don't take the whole row.

ZenField

A caption beside (or above) a control, with optional hint text.

<ZenField label="Steps" stack hint="1–150">
  <ZenNumber v-model="steps" :step="1" />
</ZenField>
Prop Type Default Description
label string — Caption
stack boolean false Label above the control instead of beside it
hint string — Dim helper text under the control

Slots: default (the control).

The hint sits on its own line under the label and control.

ZenSection

A titled, collapsible block: chevron, title, optional meta text and actions, and a body that folds away. plain is a header over a hairline divider (stack them for an inspector); card is a bordered box.

<ZenSection
  v-model:open="settingsOpen"
  title="Scene settings"
  variant="card"
  storage-key="mypack.scene"
>
  <template #meta>3 keyframes</template>
  <template #actions><ZenIconButton icon="mdi mdi-restore" title="Reset" @click="reset" /></template>
  <ZenField label="Length"><ZenNumber v-model="seconds" /></ZenField>
</ZenSection>
Prop Type Default Description
open boolean true Use v-model:open. Ignored inside a ZenSections group.
title string —
icon string — MDI class or image URL
collapsible boolean true false = always open, no chevron
disabled boolean false Header can't be toggled
variant 'plain' | 'card' 'plain'
storageKey string — Remember open/closed in localStorage. Ignored inside a group.
value string — This section's id inside a ZenSections group
Slot Description
default Body
title Replaces the title text
meta Soft text on the right of the header
actions Header buttons (clicks don't toggle the section)

ZenSections

Groups ZenSection children so open/closed is decided in one place.

<ZenSections v-model="openId" accordion>
  <ZenSection value="transform" title="Transform">…</ZenSection>
  <ZenSection value="crop" title="Cropping">…</ZenSection>
</ZenSections>
Prop Type Default Description
modelValue string | string[] | null — Accordion: the open value or null. Otherwise: the array of open values. Unbound, it seeds from the sections' own open.
accordion boolean false One section open at a time
gap number 0 px between sections (0 suits plain, ~6 suits card)

ZenSplit

Resizable panes with draggable gutters (keyboard accessible, canvas-zoom aware).

<ZenSplit v-model:sizes="sizes" :panes="[{}, { size: 320, min: 260, collapsible: true }]">
  <template #pane-0><Monitor /></template>
  <template #pane-1><Inspector /></template>
</ZenSplit>
Prop Type Default Description
panes SplitPane[] required One entry per pane
direction 'horizontal' | 'vertical' 'horizontal' horizontal = side by side
step number 10 Arrow-key step in px (Shift = 5×)
gutter 'line' | 'gap' 'line' line: a 1px border between panes. gap: an 8px empty gap, for panes that draw their own panels; a grip shows on hover and while dragging
sizes number[] from panes Use v-model:sizes. Same units as each pane's size. Updated at the end of a drag, never per move, so it is safe to persist.

SplitPane: { size?: number; min?: number; max?: number; collapsible?: boolean }. A size above 1 is px; 0–1 is a fraction of the flexible space; omitted takes an even share. min and max are px.

Slots: pane-0, pane-1, … Exposed: el, reset() (back to the configured sizes), collapse(index, shut = true).

Gutters: double-click resets the pane; arrow keys resize (Shift = 5×); Home/End take a side to its min; Enter collapses or reopens a collapsible pane. Panes clip their content, so put a ZenScroll inside a pane that needs to scroll. Escape cancels a drag and restores its starting sizes.

ZenResizeHandle

The shared resize grip used by ZenSplit, workspace tiles and docks. It owns pointer capture, keyboard input and cleanup; the host owns sizes and layout geometry.

<ZenResizeHandle
  orientation="vertical"
  label="Resize inspector"
  :value="width"
  :min="200"
  :max="600"
  @nudge="(pixels) => (width = Math.min(600, Math.max(200, width + pixels)))"
/>

orientation: vertical (moves along x), horizontal (moves along y), or both for workspace junctions. variant: gap (default, 8px with a compact grip on hover/focus/drag) or line (1px with a wider hit area). The host can position and size it with CSS. label is required; value, min, max, valueText and controls describe the separator for assistive technology. step defaults to 10px; Shift multiplies it by five.

Events: dragStart(PointerEvent), dragMove(PointerEvent), dragEnd(cancelled), nudge(pixels, axis), limit('min' | 'max') for Home/End, toggle() for Enter, and reset() for double-click. A both handle also accepts Space for toggle(). Escape, pointer cancellation, lost capture and unmount emit dragEnd(true); release emits dragEnd(false). Hosts restore their starting sizes on cancellation and persist successful changes on release. The handle restores the previous document cursor and selection styles.

ZenView

The standard layout for a panel: toolbar, body (scrolling by default), footer.

<ZenView>
  <template #toolbar><ZenToolbar title="Assets" icon="mdi mdi-folder" /></template>
  <ul>…</ul>
  <template #footer>…</template>
</ZenView>
Prop Type Default Description
scroll boolean true Body is a ZenScroll. false = a flex container that fills
pad boolean true 10px body padding

Slots: toolbar, default (body), footer.

ZenToolbar

A panel header bar: icon and title on the left, actions on the right.

<ZenToolbar title="Queue" icon="mdi mdi-tray-full">
  <template #start><ZenInput v-model="q" sm placeholder="Filter" /></template>
  <ZenIconButton icon="mdi mdi-refresh" title="Refresh" @click="reload" />
</ZenToolbar>
Prop Type Default Description
title string —
icon string — Full MDI class
Slot Description
start After the title
default Right-aligned actions

ZenScroll

A scroll container with themed scrollbars (adds .zen-scroll).

<ZenScroll><ul>…</ul></ZenScroll>
Prop Type Default Description
horizontal boolean false Scroll on x instead of y

Slots: default. Native scroll listeners fall through to the root.

ZenButton

A themed text button.

<ZenButton variant="primary" icon="mdi mdi-play" @click="run">Run</ZenButton>
Prop Type Default Description
variant 'default' | 'primary' | 'ghost' | 'danger' 'default'
icon string — Full MDI class
block boolean false Full width
sm boolean false Same as size="sm"
size 'sm' | 'md' 'md' Control height: md 28px, sm 24px (--zen-control-h, --zen-control-h-sm) — the same in every ZenKit control
disabled boolean false

Slots: default (label). Events: native click falls through.

ZenIconButton

A compact 26×26 icon-only button.

<ZenIconButton icon="mdi mdi-close" title="Close" @click="close" />
Prop Type Default Description
icon string required Full MDI class
danger boolean false Red on hover
active boolean false Accent colour (toggled state)
disabled boolean false
size 'sm' | 'md' 'md' Square size: md 28px, sm 24px (--zen-control-h, --zen-control-h-sm) — the same in every ZenKit control

Events: native click. Always pass a title attribute: it is the tooltip and the button's only text.

ZenIcon

Renders an MDI class or an image URL (favicon, logo), falling back to a glyph if the image fails to load.

<ZenIcon :icon="plugin.icon" fallback="mdi-puzzle-outline" />
Prop Type Default Description
icon string | null — MDI class ('mdi-x' or 'mdi mdi-x') or a URL / data URI
fallback string 'mdi-application-outline' Glyph used when icon is empty or the image fails

isIconUrl(icon?: string | null): boolean is exported too: true for http(s):, data:, blob:, /… or an image extension, when the string has no space.

ZenEmpty

The "nothing here yet" state for a panel or list: a faint mark, an optional title, one line of guidance and optional actions. The mark is ZenKit's lotus in the current text colour unless you pass an icon. It centres itself in a flex or grid parent.

<ZenEmpty v-if="!rows.length" title="No runs yet">
  Queue a workflow and each node appears here with its run time.
  <template #actions><ZenButton @click="queue">Queue now</ZenButton></template>
</ZenEmpty>
Prop Type Default Description
title string — Bold line above the text
icon string lotus MDI class or image URL to use instead of the lotus
Slot Description
default The guidance line
actions Buttons under the text

ZenLotus

ZenKit's mark, the lotus, in one colour: it takes the text colour (currentColor) and lets opacity carry the petals' layers, so it sits on any theme. Size it with width (default 1.2em, so it matches the text beside it). ZenEmpty uses it.

<span class="brand"><ZenLotus class="mark" /> My plugin</span>
Prop Type Default Description
water boolean false Adds the ripple under the bloom

useWheelClaim

Hover to focus, with the wheel decided per event: a wheel over something that scrolls in that direction (a side panel, a list, a text box) is the node's; anywhere else it goes on to the graph to zoom or pan. For a node body with panels in it. (useFocusSurface below is the other policy: click to take the wheel, for a surface that uses the wheel itself.)

<script setup lang="ts">
import { useWheelClaim } from '@nynxz/zenkit-ui'

const wheel = useWheelClaim({
  // also claim the wheel where a control uses it itself
  ours: (e, target) => !!target.closest('.zoomable'),
  // send keystrokes to a canvas inside rather than the root
  focusTarget: () => canvasEl.value,
})
</script>

<template>
  <div :ref="(el) => (wheel.el.value = el as HTMLElement | null)" v-bind="wheel.attrs">…</div>
</template>

Hovering takes focus — never out of a text field the user is in — and leaving gives it back, so the body's keyboard shortcuts work without a click. scrollsUnder(e, target, root) is the scroll test on its own.

useFocusSurface

Click-to-focus for a node body that needs the mouse wheel (a zoomable picture, a scrolling list). ComfyUI's canvas only gives the wheel to a focused element with data-capture-wheel; this composable focuses the surface on press (not hover) and releases it on an outside press, focus loss or Escape.

<script setup lang="ts">
import { useFocusSurface } from '@nynxz/zenkit-ui'

const surface = useFocusSurface({ canFocus: () => images.value.length > 0 })
</script>

<template>
  <div
    :ref="surface.el"
    v-bind="surface.attrs"
    :class="{ focused: surface.focused.value }"
    @wheel.prevent="zoom"
  >
    …
  </div>
</template>
useFocusSurface(opts?: FocusSurfaceOptions): FocusSurface
Option Type Description
canFocus () => boolean Return false to ignore a press (the wheel stays with the canvas)
onFocus, onBlur () => void
Returned Type Description
el Ref<HTMLElement | null> Bind to the surface
focused Ref<boolean> True while focus is inside; style your focus ring from it
attrs { tabindex: '-1', 'data-capture-wheel': 'true' } Spread onto the surface
focus(), blur() () => void

It never takes focus away from an input, textarea, select or contenteditable element.

Clone this wiki locally