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
intentfor buttons; usedsVariant/dsTone/dsSizewhen you need finer control - Use
variant: "heading"/"caption"/"label"for text roles - Prefer
ui.box({ preset: "card" })(orui.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
idprop 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: