Widgets & Registry
Choose built-in widgets, derive defaults, and register custom renderer components.
A Widget is a named field component kind such as text, textarea, select, date, number, or checkbox. A Control can name the widget it wants:
{
type: "Control",
scope: "#/properties/notes",
widget: "textarea",
}The widget name is stored in the UI Schema because it changes presentation, not data validity. The JSON Schema still owns the data shape and validation constraints for the field.
Default widgets
The widget property is optional. When a Control does not name a widget, Core derives one deterministic default from the field's JSON Schema:
| JSON Schema | Default widget |
|---|---|
{ type: "string" } | text |
{ type: "string", format: "date" } | date |
{ type: "number" } | number |
{ type: "integer" } | integer |
{ type: "boolean" } | checkbox |
{ enum: [...] } | select |
{ type: "array", items: { enum: [...] } } | multiselect |
Explicit widget names always win. That is how a plain JSON Schema string can render as a textarea, radio group, or custom component without changing the submitted data contract.
Some widgets are intentionally never chosen as defaults. For example, textarea and radio are presentation choices, so they appear only when a Control names them.
The registry
A Registry is a Renderer-owned map from widget names to concrete React components:
import { defaultRegistry } from "@mackehansson/schemaform-shadcn";
const registry = {
...defaultRegistry,
};The ShadCN Renderer resolves each Control's final widget name through this map. If a Control asks for widget: "textarea", the Renderer uses the textarea entry. If a Control omits widget, Core derives the default first, and the Renderer resolves that derived name through the same Registry.
This keeps Core headless. Core decides that a Control wants the date widget; the Renderer decides which React component implements date.
The default ShadCN registry includes:
{
text,
textarea,
number,
integer,
checkbox,
select,
radio,
multiselect,
date,
}The Registry is also the Form Builder's widget palette. Passing a custom registry to <FormBuilder /> changes the field widgets users can add, because the palette is built from the registry's keys.
Register a custom widget
Custom widgets are normal React components registered under a widget name. A widget receives the resolved Control, revealed field errors, blur handling, pending state, and translation hook through WidgetProps.
The ShadCN package exports FieldShell and useRegisteredField for custom widgets that should behave like the built-in single-input widgets:
import {
defaultRegistry,
FieldShell,
SchemaForm,
useRegisteredField,
type WidgetProps,
type WidgetRegistry,
} from "@mackehansson/schemaform";
function ColorSwatchWidget(props: WidgetProps) {
const { invalid, inputProps } = useRegisteredField(props);
return (
<FieldShell {...props} htmlFor={props.control.path}>
<input
type="color"
{...inputProps}
className={invalid ? "border-destructive" : "border-input"}
/>
</FieldShell>
);
}
const registry = {
...defaultRegistry,
"color-swatch": ColorSwatchWidget,
} satisfies WidgetRegistry;Then point a Control at the new widget name:
{
type: "Control",
scope: "#/properties/accentColor",
widget: "color-swatch",
}Pass the registry to the Renderer:
<SchemaForm definition={form} registry={registry} onSubmit={console.log} />To override a built-in, register the same name:
const registry = {
...defaultRegistry,
text: MyTextWidget,
};Live example
This example renders the built-in widget set and one custom color-swatch widget. The name, role, startDate, seats, budget, enabled, and notifications Controls use default-widget fallback. The notes, billingCycle, and accentColor Controls name widgets explicitly.
Live form
Source
import type { FormDefinition } from "@mackehansson/schemaform";import { defaultRegistry, FieldShell, SchemaForm, useRegisteredField, type WidgetProps, type WidgetRegistry,} from "@mackehansson/schemaform";function ColorSwatchWidget(props: WidgetProps) { const { invalid, inputProps } = useRegisteredField(props); return ( <FieldShell {...props} htmlFor={props.control.path}> <input type="color" {...inputProps} className={invalid ? "border-destructive" : "border-input"} /> </FieldShell> );}const registry = { ...defaultRegistry, "color-swatch": ColorSwatchWidget,} satisfies WidgetRegistry;const form = { formatVersion: 1, schema: { type: "object", required: ["name", "role"], properties: { name: { type: "string", title: "Name" }, notes: { type: "string", title: "Notes" }, role: { type: "string", title: "Role", enum: ["Admin", "Editor", "Viewer"], }, startDate: { type: "string", title: "Start date", format: "date", }, seats: { type: "integer", title: "Seats" }, budget: { type: "number", title: "Monthly budget" }, enabled: { type: "boolean", title: "Enabled" }, billingCycle: { type: "string", title: "Billing cycle", enum: ["Monthly", "Annual"], }, notifications: { type: "array", title: "Notifications", items: { enum: ["Product", "Billing", "Security"] }, }, accentColor: { type: "string", title: "Accent color" }, }, }, uiSchema: { pages: [ { type: "Page", title: "Widgets", children: [ { type: "Control", scope: "#/properties/name" }, { type: "Control", scope: "#/properties/notes", widget: "textarea", }, { type: "Control", scope: "#/properties/role" }, { type: "Control", scope: "#/properties/startDate" }, { type: "Control", scope: "#/properties/seats" }, { type: "Control", scope: "#/properties/budget" }, { type: "Control", scope: "#/properties/enabled" }, { type: "Control", scope: "#/properties/billingCycle", widget: "radio", }, { type: "Control", scope: "#/properties/notifications" }, { type: "Control", scope: "#/properties/accentColor", widget: "color-swatch", }, ], }, ], }, rules: [],} satisfies FormDefinition;export function App() { return <SchemaForm definition={form} registry={registry} onSubmit={console.log} />;}Modeling guide
Use a widget when the data shape is the same but the presentation should differ. A multiline comment is still a JSON Schema string; widget: "textarea" belongs on the Control. A preferred accent color is still a JSON Schema string; widget: "color-swatch" belongs on the Control and resolves through the Registry.
Use meta when the widget needs renderer-owned hints that are not part of Schemaform's core contract:
field.email({
title: "Email",
meta: {
iconText: "Please provide an email",
},
});The resolved control exposes that as control.meta, so custom widgets can render it without adding new Schemaform fields.
Use JSON Schema when the data contract changes. Use UI Schema Controls when presentation changes. Use the Registry when a Renderer needs to map widget names to components.