Skip to content

Widget Authoring Guide

How to build consistent, design-system-compliant widgets in Rezi.

Overview

Every Rezi widget should consume the design system rather than specifying raw colors. This ensures:

  • Consistency across all widgets and themes
  • Automatic theming when users switch themes
  • Capability tier adaptation (16/256/truecolor)
  • Accessibility via validated contrast ratios

Design System Integration

Protocol Registry (Required for New Kinds)

When creating a new widget kind, register it in:

  • packages/core/src/widgets/protocol.ts

Do not add new widget-kind checks to hardcoded lists in commit.ts, hitTest.ts, or widgetMeta.ts. Capability detection should stay centralized in the protocol registry.

Using Recipes

Recipes are the primary API for computing widget styles. They take design tokens (from the theme) and return TextStyle objects.

import { recipe, type ColorTokens } from "@rezi-ui/core";

function renderMyWidget(colors: ColorTokens, isFocused: boolean) {
  const style = recipe.button(colors, {
    variant: "solid",
    tone: "primary",
    state: isFocused ? "focus" : "default",
    size: "md",
  });

  // style.label: TextStyle for the button text
  // style.bg: TextStyle for the background fill
  // style.border: BorderVariant to use
  // style.px: horizontal padding
}

Available Recipes

Recipe Use Case
recipe.button Button-like interactive controls
recipe.input Text input fields
recipe.surface Panels, cards, containers
recipe.select Dropdown selects
recipe.table Data table cells and headers
recipe.modal Modal dialogs
recipe.badge Inline badges
recipe.text Typography roles
recipe.divider Divider lines
recipe.checkbox Checkbox/radio indicators
recipe.progress Progress bars
recipe.callout Alert/info callout boxes
recipe.scrollbar Scrollbar track/thumb

Using Design System Props

Interactive widgets support ds* props (and intent shorthands) that customize recipe-based styling.

Core interactive widgets are now recipe-styled by default when the active theme provides semantic color tokens (see Design System). Manual style props remain available to override specific attributes (they do not disable recipes).

// Recipe-styled button (default) + intent shorthand
ui.button({
  id: "save",
  label: "Save",
  intent: "primary",
  onPress: handleSave,
});

// Manual override (merged on top of recipes)
ui.button({
  id: "save",
  label: "Save",
  style: { fg: rgb(255, 180, 84) },
  px: 2,
  onPress: handleSave,
});

When recipe styling is active, the widget adapts to theme changes, capability tiers, and focus/disabled states without manual styling.

Building Custom Widgets

Stateless Widget

import { ui, type VNode } from "@rezi-ui/core";

function StatusCard(title: string, value: string, tone: "success" | "danger"): VNode {
  const badgeVariant = tone === "success" ? "success" : "error";
  return ui.box({ preset: "card" }, [
    ui.text(title, { variant: "label" }),
    ui.text(value, { style: { bold: true } }),
    ui.badge(tone === "success" ? "Success" : "Danger", { variant: badgeVariant }),
  ]);
}

Stateful Widget with defineWidget

import { defineWidget, ui, type VNode } from "@rezi-ui/core";

const Counter = defineWidget<{ initial: number }>((props, ctx) => {
  const [count, setCount] = ctx.useState(props.initial);

  return ui.column({ gap: 1 }, [
    ui.text(`Count: ${count}`),
    ui.button({
      id: ctx.id("inc"),
      label: "+1",
      intent: "primary",
      onPress: () => setCount((c) => c + 1),
    }),
    ui.button({
      id: ctx.id("dec"),
      label: "-1",
      intent: "danger",
      onPress: () => setCount((c) => c - 1),
    }),
  ]);
});

Design Rules

DO

  • Prefer intent for buttons; use dsVariant / dsTone / dsSize when you need finer control
  • Use variant: "heading" / "caption" / "label" for text roles
  • Prefer ui.box({ preset: "card" }) (or ui.card(...)) for cards/panels
  • Use semantic colors from theme tokens (not raw RGB)
  • Ensure all interactive widgets have a unique id
  • Test your widget across at least 2 themes

DON'T

  • Don't use raw rgb() for widget chrome (borders, backgrounds)
  • Don't hardcode focus styles (the design system handles them)
  • Don't mix ds-styled and manually-styled buttons in the same row
  • Don't create custom color constants — use theme tokens
  • Don't skip the id prop on interactive widgets

Capability Tiers

Your widget automatically works across all terminal capability tiers:

Tier What Happens
A (256-color) Colors mapped to nearest palette entry
B (truecolor) Full RGB color support
C (enhanced) Images, sub-cell rendering available

You don't need to handle tier differences in most widgets — the theme and recipe system handles color mapping. Only use tier detection if your widget offers enhanced features (e.g., image rendering):

import { getCapabilityTier, type TerminalCaps, ui, type VNode } from "@rezi-ui/core";

function MyImageWidget(caps: TerminalCaps): VNode {
  const tier = getCapabilityTier(caps);
  if (tier === "C") {
    return ui.text("Enhanced mode enabled (image/canvas features available)");
  }
  return ui.text("[Image placeholder]");
}

Testing

Use createTestRenderer for deterministic testing:

import { createTestRenderer, coerceToLegacyTheme, darkTheme, ui } from "@rezi-ui/core";

const theme = coerceToLegacyTheme(darkTheme);
const renderer = createTestRenderer({ viewport: { cols: 80, rows: 24 }, theme });

const result = renderer.render(MyWidget({ title: "Test" }));
assert.ok(result.findById("my-button"));
assert.ok(result.toText().includes("Test"));

For snapshot testing:

import { captureSnapshot, serializeSnapshot } from "@rezi-ui/core";

const snapshot = captureSnapshot("my-widget", MyWidget(props), { viewport, theme }, "dark");
const serialized = serializeSnapshot(snapshot);
// Compare with stored snapshot