Repository navigation
UI Layout and Primitives
Containers for node bodies and panels, plus buttons and icons. Part of
@nynxz/zenkit-ui.
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. |
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.
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.
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) |
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) |
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.
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.
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.
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 |
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.
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.
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.
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.
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 |
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 |
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.
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.
Getting started
Guides
- Panels and workspaces
- Apps and routing
- Themes
- Backgrounds
- Channels and media
- Jobs
- Capabilities
- Storage and bus
Node packs
UI components
Reference