{"slug": "building-dynamic-forms-in-javascript-with-bryntum-widgets", "title": "Building dynamic forms in JavaScript with Bryntum widgets", "summary": "Bryntum, a provider of scheduling components, has published a guide demonstrating how to build dynamic forms in vanilla JavaScript using its Grid trial package, which includes core UI widgets. The tutorial shows how to define form fields as JSON data and generate the UI dynamically, including conditional fields based on user input, and provides step-by-step setup instructions with a Vite project.", "body_md": "# Building dynamic forms in JavaScript with Bryntum widgets\n\nWe strive to keep posts updated, but code samples may sometimes be outdated. Humans, see the\n\n[Bryntum documentation]; agents,[https://mcp.bryntum.com]for the latest info.\n\nA form that’s hardcoded in HTML needs a code change every time a question changes. For forms that change often, like an event registration form where the ticket types and workshops differ per event, it helps to define the fields as data and generate the UI from it. Update the JSON in the backend, and the form updates. The form is dynamic. A dynamic form can also mean form inputs change based on user input.\n\nIn this guide, we’ll show you how to build a dynamic event registration form in vanilla JavaScript using Bryntum widgets. The form is defined in a JSON file and validates user input. It also includes a dynamically displayed field that shows based on a user’s answer.\n\nBryntum is known for scheduling components such as the [Gantt](https://bryntum.com/products/gantt/), [Scheduler](https://bryntum.com/products/scheduler/), and [Scheduler Pro](https://bryntum.com/products/schedulerpro/), but every Bryntum product ships with a set of core UI [widgets](https://bryntum.com/products/grid/docs/api/widgets): text fields, combos, date and time fields, checkboxes, radio groups, buttons, popups, and more. You can see them all in the [kitchen sink demo](https://bryntum.com/products/grid/examples/kitchen-sink/).\n\n## Getting started: Setting up the project\n\nCreate a vanilla JavaScript Vite project and install the Bryntum Grid trial, which includes all the widgets we need:\n\n```\nnpm create vite@latest dynamic-forms -- --template vanilla\ncd dynamic-forms\nnpm install\nnpm install @bryntum/grid@npm:@bryntum/grid-trial\n```\n\nIf you have a Bryntum license, refer to our [npm Repository Guide](https://bryntum.com/products/grid/docs/guide/Grid/npm/repository/private-repository-access) and install the licensed package.\n\nAdd a `vite.config.js`\n\nfile in the project root to prevent Vite from loading the Bryntum bundle twice in dev mode:\n\n``` js\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n    optimizeDeps : {\n        include : ['@bryntum/grid']\n    }\n});\n```\n\nDelete the Vite starter files `src/counter.js`\n\nand the `src/assets`\n\nfolder:\n\n```\nrm src/counter.js && rm -r src/assets\n```\n\nReplace the `<body>`\n\nof `index.html`\n\nwith a target element for the form and a `<pre>`\n\nelement for displaying the submitted values:\n\n```\n<body>\n    <div id=\"app\"></div>\n    <pre id=\"output\" hidden></pre>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n</body>\n```\n\nReplace the contents of `src/style.css`\n\nwith the Bryntum CSS imports and some page styling:\n\n```\n@import \"@bryntum/grid/fontawesome/css/fontawesome.css\";\n@import \"@bryntum/grid/fontawesome/css/solid.css\";\n@import \"@bryntum/grid/grid.css\";\n@import \"@bryntum/grid/svalbard-light.css\";\n@import url(\"https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600&display=swap\");\n\nhtml,\nbody {\n    height : 100%;\n    margin : 0;\n}\n\nbody {\n    font-family : 'Poppins', 'Segoe UI', Arial, sans-serif;\n    background  : #f4f5f7;\n}\n\n#app {\n    display         : flex;\n    justify-content : center;\n    padding         : 2rem 1rem;\n}\n\n#output {\n    max-width     : 480px;\n    margin        : 1rem auto 2rem;\n    padding       : 1rem;\n    border        : 1px solid #d8dbe0;\n    border-radius : 0.5rem;\n    background    : #fff;\n    overflow-x    : auto;\n}\n```\n\nThis imports the Bryntum Grid structural CSS, Font Awesome icons, and the Svalbard light theme. The Bryntum Grid has several [themes](https://bryntum.com/products/grid/docs/guide/Grid/customization/styling#using-a-theme) with light and dark variants. You can customize these or create a theme from scratch.\n\nThe first four imports are required for Bryntum widgets: FontAwesome for icons, `grid.css`\n\nfor structural styles, and a theme (we use Svalbard light, the default).\n\n## Defining the form in JSON\n\nWe’ll define the form as a JSON array, which makes it easy to update. Create a `formSchema.json`\n\nfile in the `src`\n\nfolder with the following contents:\n\n```\n[\n    {\n        \"type\"        : \"textfield\",\n        \"name\"        : \"name\",\n        \"label\"       : \"Full name\",\n        \"placeholder\" : \"Jane Doe\",\n        \"required\"    : true\n    },\n    {\n        \"type\"        : \"textfield\",\n        \"name\"        : \"email\",\n        \"label\"       : \"Email\",\n        \"placeholder\" : \"jane@example.com\",\n        \"required\"    : true,\n        \"validate\"    : \"email\"\n    },\n    {\n        \"type\"    : \"radiogroup\",\n        \"name\"    : \"ticket\",\n        \"label\"   : \"Ticket type\",\n        \"value\"   : \"standard\",\n        \"options\" : {\n            \"standard\" : \"Standard\",\n            \"vip\"      : \"VIP\",\n            \"student\"  : \"Student\"\n        }\n    },\n    {\n        \"type\"     : \"combo\",\n        \"name\"     : \"workshop\",\n        \"label\"    : \"Workshop\",\n        \"items\"    : [\"Data grids in depth\", \"Scheduling patterns\", \"Gantt for large projects\"],\n        \"required\" : true\n    },\n    {\n        \"type\"     : \"numberfield\",\n        \"name\"     : \"quantity\",\n        \"label\"    : \"Number of tickets\",\n        \"value\"    : 1,\n        \"min\"      : 1,\n        \"max\"      : 10,\n        \"required\" : true\n    },\n    {\n        \"type\"     : \"datefield\",\n        \"name\"     : \"eventDate\",\n        \"label\"    : \"Attendance date\",\n        \"required\" : true,\n        \"validate\" : \"futureDate\"\n    },\n    {\n        \"type\"     : \"timefield\",\n        \"name\"     : \"arrivalTime\",\n        \"label\"    : \"Arrival time\",\n        \"value\"    : \"9:00 AM\",\n        \"validate\" : \"businessHours\"\n    },\n    {\n        \"type\" : \"checkbox\",\n        \"name\" : \"meatMeal\",\n        \"text\" : \"I'd like a meal with meat\"\n    },\n    {\n        \"type\"     : \"radiogroup\",\n        \"name\"     : \"meatChoice\",\n        \"label\"    : \"Meat option\",\n        \"value\"    : \"beef\",\n        \"options\"  : {\n            \"beef\"    : \"Beef\",\n            \"chicken\" : \"Chicken\"\n        },\n        \"showWhen\" : {\n            \"field\"  : \"meatMeal\",\n            \"equals\" : true\n        }\n    },\n    {\n        \"type\"        : \"textareafield\",\n        \"name\"        : \"dietary\",\n        \"label\"       : \"Dietary requirements\",\n        \"placeholder\" : \"Allergies, preferences...\"\n    },\n    {\n        \"type\" : \"checkbox\",\n        \"name\" : \"terms\",\n        \"text\" : \"I accept the terms and conditions\"\n    }\n]\n```\n\nEach array object is Bryntum widget config. The `type`\n\nstring names the widget class to create: [ textfield](https://bryntum.com/products/grid/docs/api/Core/widget/TextField),\n\n[,](https://bryntum.com/products/grid/docs/api/Core/widget/Combo)\n\n`combo`\n\n[,](https://bryntum.com/products/grid/docs/api/Core/widget/NumberField)\n\n`numberfield`\n\n[,](https://bryntum.com/products/grid/docs/api/Core/widget/DateField)\n\n`datefield`\n\n[,](https://bryntum.com/products/grid/docs/api/Core/widget/TimeField)\n\n`timefield`\n\n[,](https://bryntum.com/products/grid/docs/api/Core/widget/TextAreaField)\n\n`textareafield`\n\n[, and](https://bryntum.com/products/grid/docs/api/Core/widget/Checkbox)\n\n`checkbox`\n\n[. The](https://bryntum.com/products/grid/docs/api/Core/widget/RadioGroup)\n\n`radiogroup`\n\n`name`\n\nis the key each field’s value is stored under when we collect the results. Constraints like `min`\n\nand `max`\n\non the number field are validated by the widget itself.There are three custom keys, handled by the mapping code in the next sections: `required`\n\nmarks a field the user must fill in, `validate`\n\nnames a custom validation function, and `showWhen`\n\nmakes a field conditional on another field’s value.\n\n## Writing the validators\n\nReplace the code in the `src/main.js`\n\nfile with the following imports and a registry of the custom validators the schema refers to by name:\n\n``` python\nimport { Panel, Toast } from '@bryntum/grid';\nimport formSchema from './formSchema.json';\nimport './style.css';\n\nconst validators = {\n    email({ value }) {\n        const message = 'Enter a valid email address';\n\n        if (value && !/^\\S+@\\S+\\.\\S+$/.test(value) && !this.containsFocus) {\n            this.setError(message, true);\n        }\n        else {\n            this.clearError(message, true);\n        }\n    },\n\n    futureDate({ value }) {\n        const\n            message = 'Attendance date cannot be in the past',\n            today   = new Date();\n\n        today.setHours(0, 0, 0, 0);\n\n        if (value && value < today && !this.containsFocus) {\n            this.setError(message, true);\n        }\n        else {\n            this.clearError(message, true);\n        }\n    },\n\n    businessHours({ value }) {\n        const\n            message = 'Arrival time must be between 8:00 AM and 6:00 PM',\n            minutes = value && value.getHours() * 60 + value.getMinutes();\n\n        if (value && (minutes < 8 * 60 || minutes > 18 * 60) && !this.containsFocus) {\n            this.setError(message, true);\n        }\n        else {\n            this.clearError(message, true);\n        }\n    }\n};\n```\n\nEach function follows the structure of the Bryntum [ checkValidity](https://bryntum.com/products/grid/docs/api/Core/widget/Field#config-checkValidity) field config: Bryntum calls this function with the field as\n\n`this`\n\nwhenever the field validates, and the function marks the field invalid with [or removes the error with](https://bryntum.com/products/grid/docs/api/Core/widget/mixin/Validatable#function-setError)\n\n`setError`\n\n[.](https://bryntum.com/products/grid/docs/api/Core/widget/mixin/Validatable#function-clearError)\n\n`clearError`\n\nValidation also runs while the user is typing, so each validator only sets its error when the field no longer has focus (`this.containsFocus`\n\nis false). A half-typed email address isn’t flagged mid-keystroke, and errors clear as soon as the user starts fixing the field.\nPassing `true`\n\nas the second argument of `setError`\n\nand `clearError`\n\nskips re-syncing the field’s valid state, which would call `checkValidity`\n\nagain.\n\n## Mapping schema entries to Bryntum widgets\n\nNext, add the function that turns a schema entry, from the JSON file, into a widget config:\n\n```\nfunction toWidgetConfig({ validate, showWhen, required, ...config }) {\n    return {\n        ...config,\n        ref : config.name,\n        cls : {\n            'conditional-field' : Boolean(showWhen),\n            'required-field'    : Boolean(required)\n        },\n        hidden        : Boolean(showWhen),\n        checkValidity : validate && validators[validate]\n    };\n}\n```\n\nThis function removes the three custom keys and passes everything else through. The `validate`\n\nname is resolved to a function from the registry and becomes the field’s `checkValidity`\n\nconfig. Fields with a `showWhen`\n\ncondition start hidden. Setting `ref`\n\nto the field’s `name`\n\nmakes every field reachable through the form’s [ widgetMap](https://bryntum.com/products/grid/docs/api/Core/widget/Container#property-widgetMap), which we use for the conditional logic later.\n\nThe `required`\n\nkey is enforced in the submit handler rather than passed to the widget, because the built-in `required`\n\nconfig flags empty fields as invalid before the user has touched the form. The `required-field`\n\nCSS class will render the asterisk instead.\nAdd this style to `src/style.css`\n\n:\n\n```\n.required-field label::after {\n    content : \" *\";\n}\n```\n\n## Creating the form and handling submission\n\nBryntum’s [ Container](https://bryntum.com/products/grid/docs/api/Core/widget/Container) widgets build their children from an\n\n[array of typed config objects, so rendering the form is a single](https://bryntum.com/products/grid/docs/api/Core/widget/Container#config-items)\n\n`items`\n\n`map`\n\ncall. We use a [, a Container with a title bar and a bottom toolbar (](https://bryntum.com/products/grid/docs/api/Core/widget/Panel)\n\n`Panel`\n\n[) for the submit button.](https://bryntum.com/products/grid/docs/api/Core/widget/Panel#config-bbar)\n\n`bbar`\n\nAdd the following to `src/main.js`\n\n:\n\n```\n// The schema's initial values, reused to reset the form after submitting\nconst initialValues = Object.fromEntries(\n    formSchema.map(({ name, value }) => [name, value ?? null])\n);\n\nconst output = document.querySelector('#output');\n\nconst form = new Panel({\n    appendTo      : 'app',\n    title         : 'Event registration',\n    width         : 480,\n    cls           : 'registration-form',\n    labelPosition : 'above',\n    defaults      : {\n        // Don't flag fields as invalid while the user is still typing\n        validateOnInput : false\n    },\n    items : formSchema.map(toWidgetConfig),\n    bbar  : [\n        '->',\n        {\n            type      : 'button',\n            ref       : 'registerButton',\n            text      : 'Register',\n            rendition : 'filled',\n            // Stays disabled until the terms checkbox is ticked (wired below)\n            disabled  : true,\n            onClick() {\n                // Flag visible, empty required fields with a temporary\n                // error that clears on the next interaction\n                const missing = formSchema\n                    .filter(field => field.required)\n                    .map(field => form.widgetMap[field.name])\n                    .filter(field => !field.hidden && (field.value == null || field.value === ''));\n\n                missing.forEach(field => field.setError('This field is required', false, true));\n\n                if (!missing.length && form.isValid) {\n                    // Hidden conditional fields still contribute to values,\n                    // so drop them from the submitted data\n                    const values = form.values;\n\n                    form.queryAll(widget => widget.hidden && widget.name)\n                        .forEach(widget => delete values[widget.name]);\n\n                    Toast.show({\n                        html    : 'Registration submitted!',\n                        timeout : 4000\n                    });\n                    output.textContent = JSON.stringify(values, null, 4);\n                    output.hidden = false;\n\n                    // Reset the form to the schema's initial values\n                    form.values = initialValues;\n                }\n                else {\n                    const errors = form.queryAll(widget => widget.isField && !widget.isValid)\n                        .map(field => {\n                            const messages = field.getErrors().join(', ');\n\n                            return field.label ? `${field.label}: ${messages}` : messages;\n                        });\n\n                    Toast.show({\n                        html    : `Please fix the following:<br>${errors.join('<br>')}`,\n                        timeout : 4000\n                    });\n                }\n            }\n        }\n    ]\n});\n```\n\nThe Panel does most of the form plumbing for us. The [ values](https://bryntum.com/products/grid/docs/api/Core/widget/Container#property-values) property collects every field’s value into an object keyed by field\n\n`name`\n\n, and [is only](https://bryntum.com/products/grid/docs/api/Core/widget/Container#property-isValid)\n\n`isValid`\n\n`true`\n\nwhen all contained fields are valid. Setting `labelPosition`\n\nand `defaults`\n\non the Panel applies them to every generated field, and the `'->'`\n\nitem in the `bbar`\n\npushes the Register button to the end of the toolbar.The submit handler checks the `required`\n\nfields. The third argument of `setError`\n\nmarks the error as temporary, meaning Bryntum removes it as soon as the user interacts with that field again, so the red outline around the invalid input disappears while the user fixes each field. If everything is valid, a [ Toast](https://bryntum.com/products/grid/docs/api/Core/widget/Toast) widget confirms the submission, the collected values render as JSON, and assigning\n\n`form.values = initialValues`\n\nresets the form to the state defined in the schema.Run `npm run dev`\n\nand open the app in your browser to try it: submitting the empty form lists each problem in a toast and highlights the fields, and a successful submission prints the values below the form:\n\n## Showing fields conditionally\n\nA dynamic form can also react to input. In our schema, the meat option radio group only applies when the meal checkbox is ticked, which its `showWhen`\n\nkey describes:\n\n```\n\"showWhen\" : {\n    \"field\"  : \"meatMeal\",\n    \"equals\" : true\n}\n```\n\nAdd the logic for this conditional field at the bottom of the `src/main.js`\n\nfile:\n\n``` js\nformSchema.filter(field => field.showWhen).forEach(({ name, showWhen }) => {\n    const\n        target     = form.widgetMap[name],\n        controller = form.widgetMap[showWhen.field];\n\n    function sync() {\n        if (controller.value === showWhen.equals) {\n            target.show();\n        }\n        else {\n            target.hide();\n        }\n    }\n\n    controller.on('change', sync);\n    sync();\n});\n```\n\nThis finds each conditional field and the field that controls it in the `widgetMap`\n\n, then shows or hides the target whenever the controller’s value changes. Bryntum skips hidden fields during validation, so a hidden required field never blocks submission. The submit handler we wrote earlier removes hidden fields from the collected values for the same reason.\n\nThe `show()`\n\nand `hide()`\n\nmethods toggle Bryntum’s `b-hidden`\n\nCSS class, which we can animate with a few lines of CSS. Add these styles to `src/style.css`\n\n:\n\n```\n/* Animate conditional fields in and out. `display` transitions\n   discretely, keeping the field visible while it fades and slides;\n   @starting-style provides the transition's entry values. */\n.conditional-field {\n    transition : opacity 0.3s ease, translate 0.3s ease, display 0.3s allow-discrete;\n}\n\n.conditional-field.b-hidden {\n    opacity   : 0;\n    translate : 0 -0.5em;\n}\n\n@starting-style {\n    .conditional-field:not(.b-hidden) {\n        opacity   : 0;\n        translate : 0 -0.5em;\n    }\n}\n```\n\nThe [ allow-discrete](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior) transition behavior keeps the field rendered until the fade-out finishes, and\n\n[supplies the starting values for the fade-in, so the field slides into place when it appears.](https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style)\n\n`@starting-style`\n\n## Preventing form submission if terms and conditions acceptance checkbox not checked\n\nRather than let the user submit and then flag the unchecked terms box as an error, we keep the Register button disabled until the terms are accepted. The button starts disabled through its config (`disabled : true`\n\n), and a `ref`\n\nputs it in the `widgetMap`\n\nso we can toggle it later.\n\nAdd the following code to the bottom of `src/main.js`\n\n:\n\n```\n// Keep the Register button disabled until the user accepts the terms.\n// This also re-disables it after a submit, since resetting the form\n// unchecks the terms checkbox and fires a change event.\nconst\n    termsField     = form.widgetMap.terms,\n    registerButton = form.widgetMap.registerButton;\n\nfunction syncRegisterButton() {\n    registerButton.disabled = !termsField.value;\n}\n\ntermsField.on('change', syncRegisterButton);\nsyncRegisterButton();\n```\n\nLike the conditional-field logic, this listens to the checkbox’s `change`\n\nevent and reads both widgets from the `widgetMap`\n\n. Because assigning `form.values = initialValues`\n\nunchecks the terms box after a successful submission, the same `change`\n\nhandler re-disables the button automatically, with no extra reset code.\n\n## Building this demo with the Bryntum MCP server and skills\n\nWe built this demo with the help of Bryntum’s AI tooling. The [Bryntum MCP server](https://bryntum.com/products/grid/docs/guide/Grid/ai-features/mcp-server) gives coding agents like Claude Code version-specific Bryntum documentation, which is how we verified widget type strings, the `checkValidity`\n\ncontract, and the `values`\n\nbehavior without leaving the editor. Add it to any MCP-capable agent from `https://mcp.bryntum.com`\n\n.\n\nThe [Bryntum skills](https://github.com/bryntum/skills) complement the MCP server with product-specific instructions that steer an agent toward current APIs and away from deprecated patterns, like v6-era theme imports or button styling.\n\n## Extending the form\n\nThe whole form is now three files: a JSON schema, a mapping layer of about 60 lines, and some CSS. Adding a field to the event registration form means adding an object to `formSchema.json`\n\n. New validation rules are new entries in the `validators`\n\nregistry, and any field becomes conditional by giving it a `showWhen`\n\nkey.\n\nSee the Pen [Bryntum widgets: dynamic form](https://codepen.io/editor/bryntum-snippets/pen/019f60f7-cd0b-7987-9da3-b5095487e2ed) by Bryntum ([@bryntum-snippets](https://codepen.io/bryntum-snippets)) on [CodePen](https://codepen.io).\n\nThe same approach extends to whatever your forms need next: more widget types from the [kitchen sink demo](https://bryntum.com/products/grid/examples/kitchen-sink/), cross-field validation in a validator that reads other fields through `widgetMap`\n\n, or a schema fetched from your backend instead of a static file.\n\n## Build it with Bryntum Grid\n\nStart a free trial, explore live demos, or read the docs.", "url": "https://wpnews.pro/news/building-dynamic-forms-in-javascript-with-bryntum-widgets", "canonical_source": "https://bryntum.com/blog/building-dynamic-forms-in-javascript-with-bryntum-widgets/", "published_at": "2026-07-24 10:03:18+00:00", "updated_at": "2026-08-13 12:08:51.086453+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Bryntum", "Vite", "Bryntum Grid"], "alternates": {"html": "https://wpnews.pro/news/building-dynamic-forms-in-javascript-with-bryntum-widgets", "markdown": "https://wpnews.pro/news/building-dynamic-forms-in-javascript-with-bryntum-widgets.md", "text": "https://wpnews.pro/news/building-dynamic-forms-in-javascript-with-bryntum-widgets.txt", "jsonld": "https://wpnews.pro/news/building-dynamic-forms-in-javascript-with-bryntum-widgets.jsonld"}}