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.
Widget kinds
Section titled “Widget kinds”Every widget in GolemUI has a kind that determines its role:
| Kind | Purpose | Data binding | Events | Implementation guide |
|---|---|---|---|---|
layout | Containers that arrange child widgets | No | change | Layout widget |
input | Controls that read and write form data | Yes | change, filter | Input widget |
display | Read-only presentational content | No | None | Display widget |
action | Buttons and triggers that fire events | No | click | Action 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.
What we’re building
Section titled “What we’re building”Throughout this section, we build a Product Card Rating composed of four custom widgets — one for each kind:
- Layout —
productCard: a card container with a title. - Input —
productRating: a star-based rating control. - Display —
productDescription: an image with a markdown description. - Action —
productShare: 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 ProductThe 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 ProductThe 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:
Per-kind implementation guides
Section titled “Per-kind implementation guides”Each kind has its own dedicated chapter with the full per-framework implementation, key concepts, and a live demo:
- Creating a layout widget — the
productCardcontainer. - Creating an input widget — the
productRatingstar control. - Creating a display widget — the
productDescriptionimage+markdown card. - Creating an action widget — the
productSharesocial-buttons widget. - Sending events with your widgets — emitting and receiving events, with detail payloads.
Registering custom widgets
Section titled “Registering custom widgets”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,};import type { WidgetLoaders, WithWidget } from '@golemui/core';import type { Type } from '@angular/core';
export const customWidgetLoaders: WidgetLoaders<Type<WithWidget>> = { productCard: async () => (await import('./widgets/product-card/product-card.component')) .ProductCardComponent, productRating: async () => (await import('./widgets/product-rating/product-rating.component')) .ProductRatingComponent, productDescription: async () => ( await import( './widgets/product-description/product-description.component' ) ).ProductDescriptionComponent, productShare: async () => (await import('./widgets/product-share/product-share.component')) .ProductShareComponent,};import type { WidgetLoaders } from '@golemui/core';
export const customWidgetLoaders: WidgetLoaders<any> = { productCard: async () => { await import('./widgets/product-card.element'); return 'app-product-card'; }, productRating: async () => { await import('./widgets/product-rating.element'); return 'app-product-rating'; }, productDescription: async () => { await import('./widgets/product-description.element'); return 'app-product-description'; }, productShare: async () => { await import('./widgets/product-share.element'); return 'app-product-share'; },};import type { WidgetLoaders, WithWidget } from '@golemui/core';import type { Component } from 'vue';
export const customWidgetLoaders: WidgetLoaders<Component<WithWidget>> = { productCard: async () => (await import('./widgets/ProductCard.vue')).default, productRating: async () => (await import('./widgets/ProductRating.vue')).default, productDescription: async () => (await import('./widgets/ProductDescription.vue')).default, productShare: async () => (await import('./widgets/ProductShare.vue')).default,};// Each module's side-effect `customElements.define(...)` registers the tag;// the loader returns the tag name as a string.
export const customWidgetLoaders = { productCard: async () => { await import('./widgets/product-card.element'); return 'app-product-card'; }, productRating: async () => { await import('./widgets/product-rating.element'); return 'app-product-rating'; }, productDescription: async () => { await import('./widgets/product-description.element'); return 'app-product-description'; }, productShare: async () => { await import('./widgets/product-share.element'); return 'app-product-share'; },};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.
Putting it all together
Section titled “Putting it all together”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} />;}import { Component } from '@angular/core';import { FormComponent } from '@golemui/angular';import type { FormEvent } from '@golemui/core';import { gui } from '@golemui/gui-shared';import { customWidgetLoaders } from './widget-loaders';
@Component({ standalone: true, imports: [FormComponent], template: ` <gui-form [config]="config" (formEvent)="handleFormEvent($event)" ></gui-form> `,})export class ProductPage { config = { formConfig: { widgetLoaders: customWidgetLoaders }, 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' }), ], };
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)}`, };
handleFormEvent(event: FormEvent) { if (event.name === 'shareEvent') { const network = event.detail as string; const buildUrl = this.SHARE_URLS[network]; if (buildUrl) { window.open(buildUrl(window.location.href), '_blank', 'noopener,noreferrer'); } } }}import { html, LitElement } from 'lit';import { customElement } from 'lit/decorators.js';import type { FormEvent } from '@golemui/core';import '@golemui/lit';import { gui } from '@golemui/gui-shared';import { customWidgetLoaders } from './widget-loaders';
@customElement('app-product-page')export class ProductPage extends LitElement { config = { formConfig: { widgetLoaders: customWidgetLoaders }, 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' }), ], };
override createRenderRoot() { return this; }
override render() { return html` <gui-form .config=${this.config} @form-event=${(e: CustomEvent<FormEvent>) => this.handleFormEvent(e.detail)} ></gui-form> `; }
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)}`, };
handleFormEvent(event: FormEvent) { if (event.name === 'shareEvent') { const network = event.detail as string; const buildUrl = this.SHARE_URLS[network]; if (buildUrl) { window.open(buildUrl(window.location.href), '_blank', 'noopener,noreferrer'); } } }}<script setup lang="ts">import type { FormEvent } from '@golemui/core';import { GuiForm } from '@golemui/gui-vue';import { gui } from '@golemui/gui-shared';import { customWidgetLoaders } from './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 };
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)}`,};
function 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'); } }}</script>
<template> <GuiForm :config="config" @form-event="handleFormEvent" /></template>import '@golemui/gui-components/index.css';import '@golemui/gui-lit';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 SHARE_URLS = { 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)}`,};
function handleFormEvent(event) { if (event.name === 'shareEvent') { const buildUrl = SHARE_URLS[event.detail]; if (buildUrl) { window.open(buildUrl(window.location.href), '_blank', 'noopener,noreferrer'); } }}
const form = document.getElementById('app-form');form.config = { formDef, formConfig: { widgetLoaders: customWidgetLoaders },};form.addEventListener('formEvent', (e) => handleFormEvent(e.detail));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.
See also
Section titled “See also”- Form Definition API / Custom Widgets —
gui.<group>.custom(...)reference for declaring instances. - Features / Widget Loaders — registering loaders in
formConfig. - Features / Form Events — handling events application-side.