Building a Custom Field Widget
Extend StudioBrain’s entity editor with your own form inputs
Overview
Plugin field widgets let you create custom form inputs that appear alongside StudioBrain’s 48 builtin widgets. They render inside entity editor forms, are available in the Layout Designer, and can be used in any template’s field_config.
Use cases:
- Specialized color pickers (gradient, 3D color space, HSL wheel)
- Domain-specific inputs (map coordinate picker, music note selector)
- Integration widgets (external API lookup, AI-assisted input)
- Custom validation UI (password strength meter, regex tester)
Plugin Manifest
Add a field_widgets array to your plugin.json:
{
"id": "my-widget-plugin",
"name": "My Custom Widgets",
"version": "1.0.0",
"capabilities": {
"frontend": {
"field_widgets": [
{
"id": "gradient-picker",
"label": "Gradient Picker",
"description": "Pick or create CSS gradients",
"category": "color",
"accepts_options": false,
"value_type": "string",
"preview_url": "widgets/gradient-preview.png"
}
]
}
}
}field_widgets Properties
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Widget identifier (used in plugin:{pluginId}:{id}) |
label | string | Yes | Display name in Layout Designer dropdown |
description | string | No | Tooltip/help text |
category | string | Yes | Category group: text, number, selection, date, color, entity, array, media, document |
accepts_options | boolean | No | Whether the widget reads options from field_config |
value_type | string | No | Expected value type (string, number, object, array) |
preview_url | string | No | Thumbnail image shown in Layout Designer palette |
Widget HTML File
Create your widget as an HTML file in your plugin’s directory. The widget renders inside a sandboxed iframe.
File: widgets/gradient-picker.html
Receiving Data from Host
The host sends value updates via postMessage:
window.addEventListener('message', (event) => {
if (event.data.type === 'sb-field-widget-update') {
const { value, disabled, options } = event.data;
// Update your widget UI with the current value
renderGradient(value);
setDisabled(disabled);
}
});Sending Value Changes
When the user changes the value, send it back:
function onGradientChange(newGradient) {
window.parent.postMessage({
type: 'sb-field-widget-change',
value: newGradient // e.g., "linear-gradient(90deg, #ff0000, #0000ff)"
}, '*');
}Resizing
Tell the host how tall your widget needs to be (min 40px, max 300px):
function updateHeight() {
const height = document.body.scrollHeight;
window.parent.postMessage({
type: 'sb-field-widget-resize',
height: Math.min(300, Math.max(40, height))
}, '*');
}
// Call on load and after UI changes
window.addEventListener('load', updateHeight);
new ResizeObserver(updateHeight).observe(document.body);Theme Integration
Request the host’s CSS variables to match the design system:
// Request theme on load
window.parent.postMessage({ type: 'studiobrain-theme-request' }, '*');
// Receive theme variables
window.addEventListener('message', (event) => {
if (event.data.type === 'studiobrain-theme-response') {
const vars = event.data.variables;
// vars is { '--surface-input-bg': '#1a1a2e', '--surface-primary-bg': '#6366f1', ... }
Object.entries(vars).forEach(([key, val]) => {
document.documentElement.style.setProperty(key, val);
});
}
});Key CSS variables to use:
| Variable | Purpose |
|---|---|
--surface-input-bg | Input background |
--surface-input-border | Input border |
--surface-input-text | Input text color |
--surface-primary-bg | Accent/primary color |
--surface-elevated-bg | Card/dropdown background |
--surface-elevated-text | Body text |
--surface-elevated-text-secondary | Muted text |
Using in Templates
Reference your widget in any template’s field_config:
field_config:
background_gradient:
widget: "plugin:my-widget-plugin:gradient-picker"Or set it in the Layout Designer by selecting your widget from the “Plugin” category in the widget type dropdown.
Desktop vs Web Runtime
| Platform | Runtime | Notes |
|---|---|---|
| Web/Cloud | Sandboxed iframe | sandbox="allow-scripts" (no same-origin) |
| Desktop (Tauri) | WASM or iframe | WASM via sb-plugins crate for native performance |
For iframe widgets, the protocol is identical on both platforms. WASM widgets use the WIT interface defined in sb-plugins — see Migrating from Python to WASM for details.
Complete Example: Gradient Picker
plugin.json
{
"id": "gradient-tools",
"name": "Gradient Tools",
"version": "1.0.0",
"description": "CSS gradient picker for visual fields",
"capabilities": {
"frontend": {
"field_widgets": [
{
"id": "gradient-picker",
"label": "Gradient Picker",
"category": "color",
"accepts_options": false,
"value_type": "string"
}
]
}
}
}widgets/gradient-picker.html
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui; padding: 8px; }
.preview {
height: 40px;
border-radius: 8px;
border: 1px solid var(--surface-input-border, #333);
margin-bottom: 8px;
cursor: pointer;
}
.controls { display: flex; gap: 8px; }
.color-stop {
display: flex; align-items: center; gap: 4px;
}
input[type="color"] {
width: 32px; height: 32px; border: none; cursor: pointer;
}
input[type="range"] {
flex: 1;
}
.angle-input {
width: 60px; padding: 4px 8px;
background: var(--surface-input-bg, #1a1a2e);
color: var(--surface-input-text, #fff);
border: 1px solid var(--surface-input-border, #333);
border-radius: 6px;
}
</style>
</head>
<body>
<div class="preview" id="preview"></div>
<div class="controls">
<div class="color-stop">
<input type="color" id="color1" value="#ff0000">
</div>
<input type="range" id="angle" min="0" max="360" value="90">
<input type="number" class="angle-input" id="angleDeg" value="90" min="0" max="360">
<div class="color-stop">
<input type="color" id="color2" value="#0000ff">
</div>
</div>
<script>
let currentValue = '';
const preview = document.getElementById('preview');
const color1 = document.getElementById('color1');
const color2 = document.getElementById('color2');
const angle = document.getElementById('angle');
const angleDeg = document.getElementById('angleDeg');
function buildGradient() {
return `linear-gradient(${angle.value}deg, ${color1.value}, ${color2.value})`;
}
function update() {
const gradient = buildGradient();
preview.style.background = gradient;
if (gradient !== currentValue) {
currentValue = gradient;
window.parent.postMessage({ type: 'sb-field-widget-change', value: gradient }, '*');
}
}
function parseGradient(val) {
if (!val) return;
const match = val.match(/linear-gradient\((\d+)deg,\s*(#[0-9a-f]{6}),\s*(#[0-9a-f]{6})\)/i);
if (match) {
angle.value = angleDeg.value = match[1];
color1.value = match[2];
color2.value = match[3];
preview.style.background = val;
}
}
color1.addEventListener('input', update);
color2.addEventListener('input', update);
angle.addEventListener('input', () => { angleDeg.value = angle.value; update(); });
angleDeg.addEventListener('input', () => { angle.value = angleDeg.value; update(); });
window.addEventListener('message', (e) => {
if (e.data.type === 'sb-field-widget-update') {
parseGradient(e.data.value);
if (e.data.disabled) {
document.querySelectorAll('input').forEach(i => i.disabled = true);
}
}
if (e.data.type === 'studiobrain-theme-response') {
Object.entries(e.data.variables).forEach(([k, v]) => {
document.documentElement.style.setProperty(k, v);
});
}
});
// Request theme
window.parent.postMessage({ type: 'studiobrain-theme-request' }, '*');
// Report height
window.parent.postMessage({ type: 'sb-field-widget-resize', height: 100 }, '*');
// Initialize
update();
</script>
</body>
</html>Using in a Template
field_config:
sky_gradient:
widget: "plugin:gradient-tools:gradient-picker"
ground_gradient:
widget: "plugin:gradient-tools:gradient-picker"SDK Widget Registry Integration
When a plugin loads, the plugin loader registers its widgets with the SDK widget registry. This makes plugin widgets available for name-pattern inference and Layout Designer discovery.
How Registration Works
- The plugin loader parses
plugin.jsonand extracts thefield_widgetsarray - Each entry is mapped to a
WidgetDefinitionwithsource: 'plugin' - The loader calls
registerWidgets()from@biloxistudios/studiobrain-sdk - The registry rebuilds its inference patterns to include the new widgets
import { registerWidgets } from '@biloxistudios/studiobrain-sdk';
// Plugin loader does this automatically at load time
registerWidgets(pluginManifest.capabilities.frontend.field_widgets.map(w => ({
id: `plugin:${pluginManifest.id}:${w.id}`,
label: w.label,
description: w.description || '',
category: w.category,
source: 'plugin' as const,
value_type: w.value_type,
accepts_options: w.accepts_options,
name_patterns: w.name_patterns,
})));Adding Name Patterns for Inference
If your widget should be automatically suggested when a template field name matches a pattern, add name_patterns to your manifest:
{
"id": "recipe-timer",
"label": "Recipe Timer",
"description": "Duration input with cooking presets",
"category": "number",
"value_type": "number",
"name_patterns": ["cook_time", "prep_time", "bake_time", "timer"]
}With this registration, any template field named cook_time or prep_time will automatically infer recipe-timer as its widget without needing an explicit field_config entry.
Patterns are case-insensitive regex fragments. They are tested against the raw field name. Be specific to avoid false matches — timer is fine for a cooking plugin, but time would conflict with the builtin time widget’s patterns.
WASM Plugin Widgets
For desktop and mobile platforms, plugin widgets can be implemented as WASM modules instead of iframes for native performance.
WASM Widget Lifecycle
- Plugin declares
field_widgetsinplugin.json(same manifest format as iframe widgets) - Plugin ships a
.wasmbinary built against the StudioBrain WIT interface - At load time, the WASM module is instantiated via wasmtime (desktop/mobile) or extism (web)
- The host calls the widget’s
render()function with the current field value - Value changes are returned via the WIT
on_change()callback
WIT Interface (simplified)
interface sb-field-widget {
record field-context {
value: string,
disabled: bool,
options: option<list<string>>,
theme: list<tuple<string, string>>,
}
render: func(ctx: field-context) -> string
on-change: func(new-value: string)
get-height: func() -> u32
}For the full WIT spec and a working WASM widget example, see Migrating from Python to WASM.
Example: Recipe Timer Widget (Cooking Template Pack)
A cooking template pack might ship a recipe-timer widget that shows preset buttons (Quick, Medium, Long) alongside a manual minutes input.
plugin.json
{
"id": "cooking-tools",
"name": "Cooking Tools",
"version": "1.0.0",
"description": "Specialized widgets for recipe and cooking templates",
"capabilities": {
"frontend": {
"field_widgets": [
{
"id": "recipe-timer",
"label": "Recipe Timer",
"description": "Duration input with cooking time presets",
"category": "number",
"value_type": "number",
"name_patterns": ["cook_time", "prep_time", "bake_time", "rest_time"]
}
]
}
}
}Template usage
# _TEMPLATES/TEMPLATE.md frontmatter
entity_type: recipe
template_category: entity
field_config:
cook_time:
widget: "plugin:cooking-tools:recipe-timer"
prep_time:
widget: "plugin:cooking-tools:recipe-timer"Because the widget registers name_patterns, templates that omit field_config for cook_time or prep_time fields will still get the recipe-timer widget through inference (provided the plugin is installed).
Example: Musical Key Widget (Music Template Pack)
A music production template pack could provide a music-key widget that renders an interactive circle-of-fifths selector.
plugin.json
{
"id": "music-production",
"name": "Music Production Tools",
"version": "1.0.0",
"description": "Widgets for music production templates",
"capabilities": {
"frontend": {
"field_widgets": [
{
"id": "music-key",
"label": "Musical Key",
"description": "Key signature selector with circle-of-fifths visualization",
"category": "selection",
"value_type": "string",
"accepts_options": false,
"name_patterns": ["key_signature", "musical_key", "song_key"]
},
{
"id": "bpm-input",
"label": "BPM Input",
"description": "Tempo input with tap-to-set and genre presets",
"category": "number",
"value_type": "number",
"name_patterns": ["bpm", "tempo"]
}
]
}
}
}Template usage
# _TEMPLATES/TEMPLATE.md frontmatter for a Track entity type
entity_type: track
template_category: entity
field_config:
key_signature:
widget: "plugin:music-production:music-key"
bpm:
widget: "plugin:music-production:bpm-input"Related Documentation
- Widget System Architecture — resolution pipeline and registry internals
- Widget Registry Reference — SDK API signatures and full widget table
- Field Widgets Reference — builtin widget options and YAML examples
- Plugin Iframe Protocol — postMessage protocol details