Skip to content

Custom Widgets Overview

GolemUI ships with a rich set of built-in widgets, but real-world applications often need something tailor-made. Custom widgets let you plug your own widgets into the form engine while keeping all the benefits: data binding, validation, event handling, and state management.

Every widget in GolemUI has a kind that determines its role:

KindPurposeData bindingEventsImplementation guide
layoutContainers that arrange child widgetsNochangeLayout widget
inputControls that read and write form dataYeschange, filterInput widget
displayRead-only presentational contentNoNoneDisplay widget
actionButtons and triggers that fire eventsNoclickAction widget

To build custom widgets, GolemUI provides Hooks (React), Composables (Vue), and Adapters (Angular, Lit, and Vanilla JS). These framework-specific helpers connect your widget to the form engine. Under the hood they interact with the FormContext — the central object that manages the widget registry, the reactive store, and the event stream. You don’t touch the FormContext directly; the hooks and adapters handle it for you.

Vanilla JS widgets are authored as standards-based Web Components: extend LitElement, consume the relevant context decorators from @golemui/lit, and register the class with customElements.define(...). Each kind’s chapter ships full code per framework so you can copy-paste from the Vanilla JS tab directly.

Throughout this section, we build a Product Card Rating composed of four custom widgets — one for each kind:

  • LayoutproductCard: a card container with a title.
  • InputproductRating: a star-based rating control.
  • DisplayproductDescription: an image with a markdown description.
  • ActionproductShare: social sharing buttons that fire an event.

Here’s the full form definition we’re working toward:

import { gui } from '@golemui/gui-shared';
gui.layouts.custom(
'productCard',
[
gui.displays.custom('productDescription', {
img: 'assets/product-image.png',
description: `### My Cool Product
The declarative form engine.`,
}),
gui.inputs.custom('productRating', 'product.rating', {
maxRating: 10,
}),
gui.actions.custom('productShare', {
onClick: 'shareEvent',
}),
],
{ title: 'Product Card Name' },
);
{
"kind": "layout",
"type": "productCard",
"props": { "title": "Product Card Name" },
"children": [
{
"kind": "display",
"type": "productDescription",
"props": {
"img": "assets/product-image.png",
"description": "### GolemUI\nThe declarative form engine for JavaScript. Build complex, data-driven forms with zero boilerplate."
}
},
{
"kind": "input",
"type": "productRating",
"path": "product.rating",
"props": { "maxRating": 10 }
},
{
"kind": "action",
"type": "productShare",
"on": { "click": "shareEvent" }
}
]
}

And because custom widgets live side by side with built-in ones, you can easily extend the card — say, by adding a comment field and a submit button without writing any extra widgets:

import { gui } from '@golemui/gui-shared';
gui.layouts.custom(
'productCard',
[
gui.displays.custom('productDescription', {
img: 'assets/product-image.png',
description: `### My Cool Product
The declarative form engine.`,
}),
gui.inputs.custom('productRating', 'product.rating', {
maxRating: 10,
validator: { type: 'number', required: true },
}),
gui.inputs.textarea('product.comment', {
hint: 'Enter your comment (maximum 500 characters)',
placeholder: 'Let us know why you love our product',
counterMode: 'current',
validator: { maxLength: 500, required: true },
}),
gui.actions.custom('productShare', {
onClick: 'shareEvent',
}),
gui.actions.button({
label: 'Submit',
icon: 'save',
iconPosition: 'right',
actionType: 'submit',
}),
],
{ title: 'Product Card Name' },
);
{
"kind": "layout",
"type": "productCard",
"props": { "title": "Product Card Name" },
"children": [
{
"kind": "display",
"type": "productDescription",
"props": {
"img": "assets/product-image.png",
"description": "### GolemUI\nThe declarative form engine for JavaScript. Build complex, data-driven forms with zero boilerplate."
}
},
{
"kind": "input",
"type": "productRating",
"path": "product.rating",
"props": { "maxRating": 10 },
"validator": { "type": "number", "required": true }
},
{
"kind": "input",
"type": "textarea",
"path": "product.comment",
"props": {
"hint": "Enter your comment (maximum 500 characters)",
"placeholder": "Let us know why you love our product",
"counterMode": "current"
},
"validator": { "type": "string", "maxLength": 500, "required": true }
},
{
"kind": "action",
"type": "productShare",
"on": { "click": "shareEvent" }
},
{
"kind": "action",
"type": "button",
"label": "Submit",
"props": { "icon": "save", "iconPosition": "right" },
"on": { "click": "submit" }
}
]
}

Notice how textarea and button are built-in GolemUI widgets mixed right in with the custom productRating and productShare. That’s the power of the widget system — all widgets, custom or built-in, speak the same language.

Here’s what the extended card looks like with custom and built-in widgets working together:

Each kind has its own dedicated chapter with the full per-framework implementation, key concepts, and a live demo:

  1. Creating a layout widget — the productCard container.
  2. Creating an input widget — the productRating star control.
  3. Creating a display widget — the productDescription image+markdown card.
  4. Creating an action widget — the productShare social-buttons widget.
  5. Sending events with your widgets — emitting and receiving events, with detail payloads.

Before GolemUI can render your custom widgets, you need to register them in the widget loaders object. Widget loaders map a type string to a lazy-loaded widget. This is the same registry that built-in widgets use.

import type { WidgetLoaders, WithWidget } from '@golemui/core';
import type { ComponentType } from 'react';
export const customWidgetLoaders: WidgetLoaders<
ComponentType<WithWidget>
> = {
productCard: async () =>
(await import('./widgets/ProductCard')).ProductCard,
productRating: async () =>
(await import('./widgets/ProductRating')).ProductRating,
productDescription: async () =>
(await import('./widgets/ProductDescription')).ProductDescription,
productShare: async () =>
(await import('./widgets/ProductShare')).ProductShare,
};

The type key in the loader (productCard, productRating, etc.) must match the type field in your widget definitions. Widget loaders use dynamic import() so custom widgets are lazy-loaded — they’re only fetched when the form engine encounters them.

With all four widgets implemented and registered, here’s the complete form setup:

import type { FormEvent } from '@golemui/core';
import { GuiForm } from '@golemui/react';
import { gui } from '@golemui/gui-shared';
import { customWidgetLoaders } from './custom-widget-loaders';
const formDef = [
gui.layouts.custom('productCard', [
gui.displays.custom('productDescription', {
img: 'assets/product-image.png',
description: `### My Cool Product\nMy product is the best`,
}),
gui.inputs.custom('productRating', 'product.rating', {
maxRating: 10,
validator: { type: 'number', required: true },
}),
gui.actions.custom('productShare', {
onClick: 'shareEvent',
}),
], { title: 'Rate Our Product' }),
];
const formConfig = { widgetLoaders: customWidgetLoaders };
const config = { formDef, formConfig };
export function ProductPage() {
const SHARE_URLS: Record<string, (url: string) => string> = {
twitter: (url) => `https://x.com/intent/tweet?text=${encodeURIComponent('Check out this product!')}&url=${encodeURIComponent(url)}`,
facebook: (url) => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
linkedin: (url) => `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
};
const handleFormEvent = (event: FormEvent) => {
if (event.name === 'shareEvent') {
const network = event.detail as string;
const buildUrl = SHARE_URLS[network];
if (buildUrl) {
window.open(buildUrl(window.location.href), '_blank', 'noopener,noreferrer');
}
}
};
return <GuiForm config={config} formEvent={handleFormEvent} />;
}

Here’s the final result — the complete Product Card with all four custom widgets:

That’s it. Four custom widgets, three frameworks, one consistent API. The form engine handles data flow, validation, and event routing — your widgets just focus on rendering.