Skip to content

Components

The design system's components, each with a live example and its key props.

Every component below comes from zidane-ui and paints from the theme tokens, so it matches the desktop app in every theme. To import one module alone, see Entry points.

TSX
import { buttonClass, IconButton, Menu, MenuRow, Segmented } from 'zidane-ui'

Buttons#

buttonClass({ variant, size }) returns a class string, so the same look fits a <button>, an <a> or a router link.

buttonClass, size xs
buttonClass, size sm
buttonClass, size md
disabled
with icon, as a link
OptionValuesDescription
variant'secondary' (default), 'primary', 'solid', 'ghost', 'danger', 'danger-solid'solid: accent fill, one per view (send, save). primary: accent wash. danger: red text, for the step that asks (Remove). danger-solid: red fill, for the destructive commit (Delete, Deny)
size'xs', 'sm', 'md' (default), 'lg'xs toolbars (24px), sm rows (28px), md card actions, form submits, dialog footers (32px), lg page-level calls to action (36px)

Icon buttons and toggles#

IconButton is the square icon control. IconToggle is its on/off variant for toolbars, placed in a .zg-segment.

sizes xs, sm, md, lg
active, danger, disabled
node icon, badge, render as link
IconToggle in a segment
IconButton propTypeDescription
iconstring | ReactNodeAn icon class (i-ph-gear) or a node (a SwapIcon)
labelstringAccessible name, and title unless you pass one
size'xs' | 'sm' | 'md' | 'lg'20, 24, 28, 32px. Default md
activebooleanPressed or open state
tone'neutral' | 'danger'
childrenReactNodeOverlays on the glyph: a badge, a dot
render(props) => ReactElementRender another element (a link) with the button's props

IconToggle takes active, onClick, icon (a class), label, badge? and size? ('xs' | 'sm', default sm).

Segments and tabs#

Segmented is a single choice on one track. TabPill is a closable tab in a .zg-segment, with a CountBadge in its trailing slot.

Segmented md
Segmented sm, one disabled
TabPill + CountBadge in a track
Segmented propTypeDescription
value, onChangeT, (v: T) => voidControlled. T is a string union
optionsSegmentedOption<T>[]{ value, label, icon?, title?, tooltip?, disabled? }
size'sm' | 'md'
ariaLabelstringName of the radio group
TabPill propTypeDescription
label, selectedReactNode, boolean
onSelect, onClose, closeLabel() => void, () => void, stringClose button shows on hover (always with closeAlways). Delete or Backspace closes the focused tab
leading, trailing, markerReactNodeAn icon, a CountBadge (text, tone: 'attention' | 'info' | 'neutral'), a status mark
size'sm' | 'md'

Surfaces and empty states#

Tray is the bed. Raised is a card or control lifted off it. Both take <div> props plus radius ('sm' | 'md' | 'lg' | 'xl', default lg). Raised adds live (one step up while a run streams).

Lighter surfaces are classes: .zg-track-chip, .zg-well, .zg-fill.

Tray > Raised (the depth ladder)
A raised card in a tray
Raised live
Lifted one step while a run streams
track chip, well, fill
12.4k · $0.08
SectionLabel, Meta
3 files · 18ms
EmptyState
No sessions yet
Start one to see it here.
PanelEmpty, warn, loading
Nothing changed
Edits the agent makes show here.
Could not read the folder
Permission denied.
ComponentPropsDescription
SectionLabelchildrenThe section label (sentence case)
Metachildren, title?Metadata voice: mono, tabular, small, muted
EmptyStateicon?, title, hint?, action?, framed?A panel's empty view, in a tray unless framed={false}
PanelEmptyicon?, title, hint?, warn?Compact empty or error line for a side panel. PanelLoading is its loading state

Error boundaries#

ErrorBoundary keeps a throw in one region from blanking the others: <ErrorBoundary region="Sidebar" resetKey={sessionId}>. The region shows ErrorFallback (what failed, the message, Try again) until Try again or a new resetKey. fallback replaces it (null renders nothing), actions adds buttons, framed={false} drops the tray.

Each distinct error is logged once to the console through reportRendererError(error, { source }). installRendererErrorHandlers() does the same for the window's uncaught errors and unhandled rejections. configureErrorReports({ sink, showStacks }) at boot sends every report to your log too, and shows the JS and component stacks in the fallback (a development build).

MenuRow is the row of every menu, list, picker and nav in the app. Menu gives rows their padding and role ('menu' | 'listbox' | 'group'). Put it in a .zg-float surface or a Popover. Your menu code handles the keyboard. Move active with the arrow keys.

Menu: rows, sections, states
MenuRow lg with description, long title
MenuRow as a link, current page
MenuSwitch
MenuRow propTypeDescription
titleReactNodeThe label
icon, description, trailingReactNodeLeading glyph, second line, trailing keycap or value
size'sm' | 'md' | 'lg'lg stacks the description (two lines max)
activebooleanKeyboard cursor
checkedbooleanCurrent choice: a trailing check
togglebooleanOn/off option: a trailing switch (use role="menuitemcheckbox")
destructivebooleanDanger tone
render(props) => ReactElementRender a link with the row's look. aria-current="page" marks the current page

MenuSection (label, trailing) groups rows and MenuSeparator divides them. MenuNote (icon) is a hint or empty state inside a menu. MenuSwitch (on) is the switch glyph alone.

Floating surfaces#

Floating surfaces use the overlay tone, position against their anchor (flipping and clamping to the viewport) and close on Escape or an outside click.

Popover (click)
HoverCard (hover)
Hover me
RowActionsMenu (3-dots)
Tooltip, inline and portaled
ComponentKey propsDescription
Popovertrigger(props, open), children(close), ariaLabel, side, align, width, roleOpens on click. Spread props on your trigger
HoverCardtrigger(props, open), children, ariaLabel, side, align, widthOpens on hover. Escape closes it
Tooltiplabel, side ('top' | 'bottom'), align ('center' | 'end'), portal, maxWidthWraps its trigger. portal escapes a clipping scroller
RowActionsMenuopen, onClose, items, anchorRef or positionThe 3-dots and right-click menu

side: 'top' | 'bottom' | 'left' | 'right'. align: 'start' | 'center' | 'end'. RowActionsMenu items: { id, label, onSelect, destructive?, disabled?, shortcut? }.

To build your own, use the parts of Popover: FloatingSurface, usePopover and useAnchoredPosition.

Dialogs and overlays#

Overlay owns the layer: portal, scrim, enter and exit, Escape, focus return, scroll lock. Put the state it passes on your surface's data-state. ConfirmDialog is a confirmation surface for it that focuses its confirm button on mount.

ConfirmDialog in an Overlay
Overlay + zg-float dialog
Lightbox
Overlay propTypeDescription
open, onCloseboolean, () => void
children(state: 'open' | 'closed') => ReactNodeRendered through the exit animation
className, scrimClassNamestringPosition the layer (fixed inset-0 …), style the scrim
closeOnEscape, trapFocusboolean, booleanBoth default to true. trapFocus moves focus in, keeps Tab inside and makes the rest of body inert. exitMs is deprecated: the exit lasts as long as its motion
inertSelectorstringElements made inert while open (main, footer)
ConfirmDialog propTypeDescription
title, messagestring, ReactNodeThe title has id CONFIRM_TITLE_ID, for your layer's aria-labelledby
onConfirm, onCancel() => void
confirmLabel, cancelLabelstringDefault to the confirm and cancel labels
destructivebooleanDanger-toned confirm button

useLightbox() returns { open(items, index, origin?), close, element }. Render element once. Call open with { key, name, src?, body? } items to page through images or text previews. The image grows out of the origin element.

An image in the lightbox shows in ZoomableImage, the kit's image viewer, which you can also use on its own. It fills its parent. The wheel and a trackpad pinch zoom toward the pointer, from fit up to 8× the image's real size. A drag pans while zoomed in, and a double-click switches between fit and the real size. While zoomed, a chip shows the share of the real size; click it to go back to fit. Right-click the image to copy it as an image (PNG), as Base64 or as a data URL. Its ref (ZoomableImageHandle) has zoomIn, zoomOut, reset and zoomed, for an owner that handles the keys: the lightbox maps + / − / 0 to them, and the arrow keys still step. MediaNav draws the lightbox's ‹ › chips (and an optional "n / N" chip) over any box, for your own media sets.

Code, copy and keys#

CodeFrame with caption
agent.ts
const agent = createAgent({ ...basic, provider })
await agent.run({ prompt: 'build a REST API' })
CodeFrame plain (copy on hover), long line
cat packages/server/src/handlers/very-long-folder-name/another-nested-directory/session-title-generator.ts | head -n 200 # a long line scrolls inside the frame
CodePanel
$ bun test
41 passed
CopyButton inline, Keycaps
Command KCommand Shift Returnesc
ComponentKey propsDescription
CodeFramecode, children, caption?, icon?, copyLabel?Code panel with a copy button. code is the copied text. children is the highlighted markup, with no background of its own: the frame draws the code's tone and its edge, and a background would cover that edge
CodePanelchildren, wrap?, maxHeight?The bare panel, for raw output
CopyButtontext, label?, variant? ('overlay' | 'inline')Copy with a check swap
KeyCombospec, size? ('sm' | 'md')One chord from a spec (cmd+shift+enter), with platform glyphs
KeycapchildrenA single key

Forms#

The desktop app's settings layout: sections of rows, each row a label, an optional description and one control.

FormSection: rows
4 settings
Transcript density
Font size
14px
FormSection: cards
Office server
wss://zidane.example.com
A server with a long name that has to truncate somewhere sensible
packages/server/src/handlers/very-long-folder-name/another-nested-directory/session-title-generator.ts
Controls
mutedtextaccentsuccesswarndangerinfo
Notes
Installing…
Installed 3 skills.
ComponentKey propsDescription
FormSectiontitle?, trailing?, layout? ('rows' | 'cards')A titled group of rows or cards
FormCard<div> propsA raised card in a cards section
SwitchRowlabel, description?, on, onToggleThe whole row toggles
SegmentedRowlabel, description?, value, onChange, optionsA Segmented on the right
SliderRowlabel, description?, value, min, max, step, format, onChange, onReset?
NavRowaction: { label, description, icon, onSelect }Opens another page
RowTextlabel, description?The label column, for your own rows
ToggleSwitchon, onChange, label, disabled?Standalone role="switch"
Switch, Checkboxon / checkedGlyphs only. The surrounding control owns the state
MetaChiptone? (muted, text, accent, success, warn, danger, info)A value set into a surface
ErrorNote, OpLinechildren. OpLine adds kind ('busy' | 'notice' | 'error')Inline outcome lines

For your own fields: INPUT_CLASS and TEXTAREA_CLASS.

List furniture#

Breadcrumb (long path truncates left)
EditableTitle (double-click)
Rate limit the chat endpoint
DiffCounts, RowBadge, Highlighted
+14−2+120−8mergeddraft3 filessession-title.ts
SegmentSpinner
ZidaneMark
WorkingWorkingWorkingWorking
ComponentKey propsDescription
Breadcrumbpath, onFolder(dir), onFile()File path that truncates from the left
EditableTitletext, editing, onStartEdit, onChange, onCommit, onCancelRename in place. Enter commits, Escape cancels
DiffCountsadded, removed+14 −2
RowBadgetext, tone? ('neutral' | 'diff' | 'success' | 'warn')Small badge after a row title
Highlightedtext, indicesFuzzy-match highlighting
SegmentSpinnersize? ('xs' | 'sm' | 'md'), tone? ('inherit' | 'accent' | 'warn' | 'muted'), label?Working indicator
ZidaneMarksize? (px), speed? (loops per second, 0 still), tone? ('inherit' | 'accent' | 'warn' | 'muted'), label?, decorative?The logo as the agent's working indicator: its bands flow across the sphere; still under reduced motion

Motion#

Helpers for a status glyph swapping, a count rolling, a chip growing. All hold still when html[data-motion] is off or reduced (motion switch).

Crossfade (scale): spinner → check → x
Crossfade (text)
Running tests…
Presence
new
RollingText in SmoothWidth
HeldWidth
1s
SwapIcon
HelperKey propsDescription
CrossfadeactiveKey, children, variant? ('scale' | 'text'), align?Swaps content when activeKey changes
Presenceshow, children, variant?, display? ('inline' | 'block')Fades and scales content in and out
SwapIconicons (key → icon class), currentSwaps glyphs (copy → check)
RollingTexttext, value?Rolls changed characters. value sets the direction
SmoothWidthchildrenEases its width to its content's
HeldWidthholdKey, childrenKeeps its widest width until holdKey changes
Growappear, as?, childrenGrows in and shrinks out, inside GrowPresence
useReducedMotion()True when motion is off or reduced. Skip your own JS animation then

Lower-level motion modules, also exported from zidane-ui, for your own components:

ModuleExportsUse
motion/presenceusePresence(open, { enter?, exitMs? }), transitionState, useScrollLock, readPanelMs, readExitMsKeeps a node mounted through its CSS exit. The phase goes entering, open, closing, closed. Map it to data-state with transitionState. Pass exitMs as readPanelMs or readExitMs so the motion setting applies
motion/collapseuseStaysMounted(open)True while open, or while the close still plays
motion/swapuseCadencedValue(value, ms)Holds a fast-changing value to one change per ms
motion/shimmersyncShimmer (a ref callback), SHIMMER_MSStarts a .zg-shimmer label's sweep on a shared clock, so a swapped label keeps the band where it was
motion/title-textTitleTextA title that writes itself in word by word when it changes
motion/springsuseMotionTransition(name), useWithFade, useMenuTransition, MOTIONSThe named curves that every animation reads (snap, pop, panel, slide, settle, exit, fade, fade-out, menu, menu-exit) as motion transitions. Movement springs. Opacity, color and the menus never spring
motion/providerMotionProvider (level?, springs?, panelMs?)One motion policy for an app: writes the motion variables on <html> and wraps the tree in motion's MotionConfig

The springs and provider modules import the motion peer, as do Grow, Overlay, FloatingSurface and Lightbox.

Hooks#

From zidane-ui:

HookReturnsUse
usePersistedState(key, fallback, parse, serialize?)[value, setValue]useState kept in localStorage: a stored value parse doesn't accept falls back, null removes the key, storage that throws is ignored. readPersisted / writePersisted are the same outside React; oneOf(members) and parseBoolean are ready-made parses
useMinuteTick()nothingRe-renders once a minute for relative times ("5m"), on one shared interval that runs only while read and the window is visible
useKeyedValue(store, key)the value or nullReads one key of a createKeyedStore(): a write to one key re-renders only that key's readers
useNearViewport(ref, margin?)booleanTurns true once the element comes within margin of its scroll container's viewport, and stays true. Use it for lazy work such as code highlighting. It shares one IntersectionObserver per scroll root and margin. Where IntersectionObserver is missing, it is true at once
useResizeDrag({ value, min, max, defaultValue, edge, axis?, target, onCommit }){ handleProps, active }Resizes a panel by dragging its edge. Each frame writes the width into target's CSS variable, with no React state. onCommit fires once on release. A double-click resets to defaultValue. edge is end for a panel on the left, start for one on the right. axis: 'y' resizes a height
TSX
import type { CSSProperties } from 'react'
import { useResizeDrag } from 'zidane-ui'
import { useRef, useState } from 'react'

export function Sidebar() {
  const el = useRef<HTMLDivElement>(null)
  const [width, setWidth] = useState(260)
  const drag = useResizeDrag({ value: width, min: 200, max: 480, defaultValue: 260, edge: 'end', target: { ref: el, cssVar: '--sidebar-w' }, onCommit: setWidth })
  return (
    <div ref={el} style={{ '--sidebar-w': `${width}px`, width: 'var(--sidebar-w)' } as CSSProperties}>
      <div {...drag.handleProps} data-active={drag.active} className="zg-resize-handle" />
    </div>
  )
}

Path helpers#

From zidane-ui, browser-safe (no node:path): basename(path), splitPath(path) ({ dir, base }), compactPath(path, maxChars?) (/…/agent/zidane: the start collapses, the last segments stay), formatPathForCwd(path, projectRoot, cwd) (relative below cwd, absolute above it, never ../), tildePath(path, home) (/Users/ada/app → ~/app) and tildeText(text, home) (the same for each path in a line, such as in /Users/ada/app).

The kit's components show paths with their machine's home folder as ~ when you give them the home folders. Wrap the app in PathDisplayProvider homes={{ local: '/Users/ada', [serverId]: '/home/ada' }}. Keys are host ids (HostView.id, 'local' for this computer). A component asks useDisplayPath().path(path, hostId), and PathDisplayScope hostId names the host for everything below it (one session's transcript). This is display only: tooltips, copies and what goes to the agent keep the real path. Without a provider, paths show in full. A zidane host reports its home folder in its ipc.hello (homeDir).

Reference#

components/button, components/IconButton, components/Menu, components/Surface, components/Segmented, components/TabPill, components/Form, components/floating/Popover, components/ConfirmDialog, components/CodeFrame.