The FormeoRenderer class is responsible for rendering Formeo form data into interactive HTML forms. It converts the structured form data into DOM elements, handles user interactions, applies conditional logic, and provides methods to retrieve user-submitted data.
- Installation
- Basic Usage
- Constructor
- Properties
- Methods
- Events
- Working with User Data
- Multi-page forms
- Conditional Logic
- Examples
Formeo ships ESM, CommonJS and UMD builds. Pick the one that matches how you load it.
import { FormeoRenderer } from 'formeo'
import 'formeo/dist/formeo.min.css'const { FormeoRenderer } = require('formeo')This works in Node from the release that ships the .cjs build (5.9.1+). Formeo renders in a browser, so in Node this is for bundlers and SSR imports, not for calling render() server-side.
<script
src="https://unpkg.com/formeo@5.13.1/dist/formeo.umd.js"
integrity="sha384-REPLACE_WITH_THE_HASH_FOR_THE_PINNED_VERSION"
crossorigin="anonymous"
></script>Pin a version instead of @latest so the hash stays valid, and add the matching integrity hash (shown on unpkg's file listing for that version) with crossorigin="anonymous". This exposes window.FormeoRenderer (and window.FormeoEditor) globally.
// Initialize the renderer with form data
const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
formData: myFormData
})
// Render the form
renderer.render()
// Get user-submitted data
const userData = renderer.userData
console.log(userData)Creates a new FormeoRenderer instance.
Parameters:
opts(Object): Configuration optionsrenderContainer(HTMLElement): The DOM element where the form will be renderedelements(Object): Custom form elements/controls configurationformData(Object): The form structure data to renderconfig(Object): Additional rendering configurationattrs(Object): Attributes for the rendered<form>, e.g.method,action,enctype,novalidate
pagination(String|Object, optional): Show the form's stages one at a time as tabs or a wizard. See Multi-page formsevents(Object):onRender,onChange,onSubmit,onPageChangecallbacks (see Events)
formDataArg(Object, optional): Alternative way to pass form data
Example:
const renderer = new FormeoRenderer({
renderContainer: document.getElementById('my-form'),
formData: {
id: 'contact-form',
stages: { /* stage data */ },
rows: { /* row data */ },
columns: { /* column data */ },
fields: { /* field data */ }
}
})Returns the current form structure data.
const formStructure = renderer.formDataGets the user-submitted data from the rendered form as a plain object. Automatically handles multiple values for the same field (like checkbox groups) by converting them into arrays.
Returns: Object.<string, string|string[]>
Example:
// Single values
{ username: 'john', email: 'john@example.com' }
// Multiple values for checkbox group
{ username: 'john', hobbies: ['reading', 'gaming', 'coding'] }Gets the user form data as an array of field objects, combining user input values with component metadata.
Returns: Array<{key: string, value: any, label: string}>
Each object contains:
key: The field identifiervalue: The user's input value for the fieldlabel: The field's label from component configuration
Example:
const formData = renderer.userFormData
// [
// { key: 'username', value: 'john', label: 'Username' },
// { key: 'email', value: 'john@example.com', label: 'Email Address' },
// { key: 'hobbies', value: ['reading', 'gaming'], label: 'Hobbies' }
// ]Returns the rendered form as an HTML string. With pagination, the string holds every page as it
is in the DOM at that moment: the navigation, and the pages not on show marked hidden. A static string has no page
switching of its own.
const htmlString = renderer.htmlThe index (0-based) of the currently shown page, when the pagination option is set. 0 when there is no pagination (see Multi-page forms).
renderer.page // 0The number of pages. 1 when there is no pagination or the form has a single stage.
renderer.pageCount // 1Sets the form structure data and cleans it.
renderer.formData = newFormDataShows a page without validating the one being left. Out-of-range indexes are clamped to the available pages. A skipped page gives way to the next page in play. A no-op without pagination.
renderer.page = 1Sets user data programmatically into the form fields. Useful for pre-filling forms or restoring saved data.
Parameters: Object - Key-value pairs where keys are field names and values are the data to set
Example:
renderer.userData = {
username: 'john_doe',
email: 'john@example.com',
hobbies: ['reading', 'gaming'] // For checkbox groups
}Keys with no matching field are skipped, and the renderer logs one console warning that lists them. The setter never throws, so answers saved from an older version of a form still restore. A
<select multiple>takes an array of values.
Renders the form data to the target container element. If a form is already rendered, it replaces the existing form.
With pagination, the page on show stays on show when its stage is in the new form data (matched
by stage id, wherever the stage now sits); otherwise the form starts on its first page. onPageChange doesn't fire,
and focus doesn't move. After destroy(), the next render starts on the first page.
Parameters:
formData(Object, optional): Form structure data. Defaults to the instance's current formData.
Example:
renderer.render()
// Or render different form data
renderer.render(differentFormData)Generates and returns the rendered form as a DOM element without appending it to the container.
Parameters:
formData(Object, optional): Form structure data. Defaults to the instance's current formData.
Returns: HTMLFormElement
Example:
const formElement = renderer.getRenderedForm()
document.body.appendChild(formElement)Removes the rendered form from the page and stops its pagination: the page navigation's document listeners are removed, and page returns 0 and pageCount 1 until the next render(). The renderer keeps its options and formData, so render() can be called again afterwards. Safe to call more than once, and before render().
renderer.destroy()Pass an events object to the constructor to run code when the form renders, when a field changes, and when the form is submitted.
const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
formData: myFormData,
events: {
onRender: ({ form, renderer, formData }) => {
// form is already attached to renderContainer
console.log('rendered', form)
},
onChange: ({ event, target, form, userData }) => {
console.log('field changed', target.name, userData)
},
onSubmit: ({ event, form, userData }) => {
event.preventDefault() // the app decides whether/how to prevent the default submit
console.log('submitted', userData)
},
onPageChange: ({ page, previousPage, stageId, form, renderer }) => {
console.log('page changed', previousPage, '->', page)
},
},
})
renderer.render()Fires synchronously after render() attaches the rendered <form> to renderContainer. Because render() is synchronous, code that runs right after calling it already sees the attached form; onRender is useful when that code lives elsewhere, such as inside the events object itself. It does not fire when reading html or calling getRenderedForm() directly, since neither attaches the form to the container.
Fires on every input event within the rendered form, including one fired by a condition's value action after the form has rendered. The conditions applied while rendering don't fire it. userData has the same shape as renderer.userData and is read from the form the event came from, at the time of the event.
For a multi-option checkbox group, target.name ends in [] but the matching userData key does not. Look it up with userData[target.name.replace(/\[\]$/, '')].
onChange also receives a formeo:rowschange event (event.detail: { action: 'add' | 'remove', index }) when a repeating table row or an input group copy is added or removed. Its target is the table's or group's wrapper, which has no name, so check event.type === 'formeo:rowschange' before reading target.name.
Fires on the form's native submit event. Formeo does not call event.preventDefault() for you — the app decides whether to stop the browser's default submission and how to handle userData.
Fires with the pagination option (see Multi-page forms) on every real page change: clicking a tab or a step, Previous/Next, Enter acting as Next, setting renderer.page, or a validation pass jumping to the page of the first invalid control. It does not fire on a render (the first one, or a later one that keeps the page), or when the target page is the same as the current one (for example clicking the current tab, or setting renderer.page to its current value). stageId and previousStageId are the ids of the stages shown and left. A page change caused by a skipped page fires it too.
config.action.onRender is still supported for backwards compatibility. Unlike events.onRender, it runs on the next animation frame after the form has been attached to the page (see dom.onRender), rather than synchronously:
const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
formData: myFormData,
config: {
action: {
onRender: form => console.log('form is in the page', form),
},
},
})There are two primary ways to get user-submitted data:
Best for simple processing and API submissions:
const data = renderer.userData
// { name: 'John', email: 'john@example.com', subscribe: 'true' }
// Send to API
fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data)
})Best when you need field labels and metadata:
const formData = renderer.userFormData
// [
// { key: 'name', value: 'John', label: 'Full Name' },
// { key: 'email', value: 'john@example.com', label: 'Email Address' }
// ]
// Display in a summary
formData.forEach(field => {
console.log(`${field.label}: ${field.value}`)
})You can programmatically populate form fields:
// Pre-fill a form
renderer.userData = {
username: 'john_doe',
email: 'john@example.com',
country: 'US'
}
// Works with checkbox groups
renderer.userData = {
interests: ['technology', 'sports', 'music']
}
// Works with radio buttons
renderer.userData = {
gender: 'male'
}Keys with no matching field are skipped, and the renderer logs one console warning that lists them. The setter never throws, so answers saved from an older version of a form still restore. A
<select multiple>takes an array of values.
// Text, email, number, etc.
renderer.userData = { firstName: 'John' }A Table field without inputs adds nothing to userData. It renders as a <table> inside a
focusable .f-table-wrap region, which a condition on the field hides as a whole.
A matrix (a table with input columns) adds one key per radio row, base[row], and one per checkbox or text cell,
base[row][column], where base is the field's name or f-<id>. Setting userData with the same keys fills it
back in. See Values and names.
A repeating table's rows are keyed by position: order[0][qty], order[1][qty]; see
Repeating rows.
A checkbox or radio group is one field with options. It renders as a wrapper element (id="f-<fieldId>", the element conditions target) that holds one <input> per option.
- Name. Every option input is named
attrs.namewhen it is set. Otherwise it falls back toattrs.id, then to the rendered field id (f-<fieldId>).userDatais keyed by that name. Give two groups different names: radio groups that share a name act as one group. Checkbox groups with more than one option render their inputs asname[], so a regular form POST sends every checked value (PHP, Rails and Express'sextendedparser read these as arrays).userDatadrops a trailing[]from every field name, including one the author already put inattrs.name, so{ hobbies: ['reading', 'coding'] }either way. One checked value is still a string. TheuserDatasetter accepts either form of the name (hobbiesorhobbies[]). - Required. A required radio group needs one option picked. A required checkbox group needs at least one box checked, not all of them. The browser's own validation message appears either way, and the group label shows
*. - Other attributes.
disabledandformare copied to every option input. Everything else set on the group (data-*,aria-*,title, custom attributes) goes on the wrapper element.
// Setting checked values; a single value is fine too
renderer.userData = { toppings: ['cheese', 'olives'] }
// Getting checked values
renderer.userData
// { toppings: ['cheese', 'olives'] } if several are checked
// { toppings: 'cheese' } if only one is checked
// With more than one option, the group's inputs are actually named "toppings[]" so a native
// <form> POST keeps every checked value; userData strips the suffix either way.Set config.other: true on a checkbox or radio group to add a last Other choice with a text box, so people can
answer with something you didn't list. In the editor, switch on Other option in the field's Configuration panel.
{
tag: 'input',
attrs: { type: 'checkbox', name: 'hobbies' },
config: { label: 'Hobbies', other: true, otherLabel: 'Something else' },
options: [
{ label: 'Reading', value: 'reading' },
{ label: 'Coding', value: 'coding' },
],
}- Label. The choice shows
config.otherLabel, orOtherwhen it's empty. When you switch Other on, the editor fills the label in the editor's language, and it's saved with the form. - Values. Choosing Other submits
otheras one of the group's values, and the typed text posts under the group's name plus-other:{ hobbies: ['reading', 'other'], 'hobbies-other': 'Knitting' }. The text box is disabled until Other is chosen, so the-otherkey only appears while it is. An unnamed group uses its field id (f-<fieldId>-other). - Setting userData.
renderer.userData = { hobbies: ['other'], 'hobbies-other': 'Knitting' }checks Other and fills its text box. - userFormData. The typed text's entry is labelled with the group label and the Other label, for example
Hobbies (Something else). - Required. In a required group the text box is required too, but only while Other is chosen.
- Conditions. A condition can test the group for the value
other. The typed text can't be a condition source, and Other isn't one of the group'soptions, so a condition can't check or uncheck it. - Names to avoid. Don't give another field the name
<group name>-other, or both answers will share that key. If the group already has an option whose value isother, as in the two-field example, delete that option and its companion field when you switch to the built-in Other. - Static HTML.
getRenderedForm()andhtmlproduce the text box disabled. Enabling it needs the listeners thatrender()attaches.
// Setting selected radio button
renderer.userData = { size: 'medium' }
// Getting selected value
const data = renderer.userData
// { size: 'medium' }The File Upload control renders a standard <input type="file">. Formeo doesn't upload anything itself; your app sends the file.
C:\fakepath\cv.pdfis how browsers show a file input'svalue. The file itself is in the form'sFormData.renderer.userDataholdsFileobjects for file inputs, andJSON.stringifyturns aFileinto{}. SendFormDatainstead:
const renderer = new FormeoRenderer({
renderContainer: '#formeo-renderer',
events: {
onSubmit: ({ event, form }) => {
event.preventDefault()
// don't set Content-Type: the browser adds the multipart boundary
fetch('/upload', { method: 'POST', body: new FormData(form) })
},
},
})
renderer.render(formData)For a regular (non-JavaScript) submit, set the form attributes:
new FormeoRenderer({
renderContainer: '#formeo-renderer',
config: { attrs: { method: 'post', enctype: 'multipart/form-data', action: '/upload' } },
})Give the upload field a name attribute in the editor to control the key your server receives.
Capturing marketing attribution (utm_source, gclid, and similar) from the URL and adding it to userData, only
after the host app's own consent check passes, is a host-side recipe — see
Consent-Aware Attribution.
Each stage in formData is one page. Pass the pagination option to show them one at a time as tabs or a wizard, instead of all at once in a single <form>. Navigation (tabs, the wizard's Previous/Next bar and step list, and page headings) only applies with 2 or more stages — a single-stage form always renders as before, with no navigation added, except that a Submit button is still appended below it when submit: true (see Options below).
const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
pagination: 'tabs', // or 'wizard', or an options object
})pagination accepts:
'tabs'— arole="tablist"of buttons, one per page, with the ARIA tabs pattern (see Tabs below).'wizard'— Previous/Next buttons with an optional step list (see Wizard below).- An options object:
{ type, progress, submit, heading, labels: { previous, next, page, submit, tablist, steps, navigation, status } }
| Key | Default | Meaning |
|---|---|---|
type |
— (required) | 'tabs' or 'wizard' |
progress |
true |
Wizard only: show the clickable step list above the pages. false leaves it out. |
submit |
false |
Add a Submit button: in a wizard it replaces Next on the last page, in tabs it sits below the pages. A single-page form (no navigation) still gets the button, appended below the page. See Wizard and Tabs. |
heading |
false |
Show each page's title as a heading inside the page, once the form has 2 or more pages: true for <h2>, or a level from 2 to 6. See Page headings. |
labels.previous |
'Previous' |
Wizard's Previous button text |
labels.next |
'Next' |
Wizard's Next button text |
labels.submit |
'Submit' |
The Submit button's text (with submit: true) |
labels.page |
'Page {n}' |
Fallback page title, used when a stage has no config.title. {n} is replaced with the 1-based page number. |
labels.tablist |
'Pages' |
Accessible name of the tablist |
labels.steps |
'Progress' |
Accessible name of the wizard's step list |
labels.navigation |
'Page navigation' |
Accessible name of the wizard's Previous/Next bar |
labels.status |
'{title} ({n} of {count})' |
What screen readers hear on each wizard page change. {title} is the page's title, {n} its 1-based position and {count} the number of pages. |
To translate the navigation, pass your own strings in labels. The renderer doesn't load language files.
Any other value for pagination (including undefined, null, or an unrecognized type) is treated as no pagination, and every stage renders visibly as it did before this feature.
A page's title comes from stages[<id>].config.title. If a stage has no title, the page falls back to labels.page ('Page 2', or 'Seite 2' with labels: { page: 'Seite {n}' }). Titles show up as tab labels and, in a wizard, as step list labels.
formData.stages['stage-1'].config = { title: 'About you' }With heading, each page starts with a heading holding its title (the same text as its tab or step, including the
labels.page fallback): <h2 class="formeo-pages-heading"> for heading: true, or pick the level that fits your
page's outline with heading: 2 to heading: 6. In a wizard, each page is a role="group" named by its heading; tab
panels stay named by their tab. Headings only make sense as navigation aids, so a single-stage form never gets one,
even with heading: true.
new FormeoRenderer({ renderContainer, pagination: { type: 'wizard', heading: 3 } })Clicking a tab shows its page immediately; there is no validation on switching. The tablist follows the WAI-ARIA tabs pattern with automatic activation and roving tabindex:
ArrowLeft/ArrowRightmove to the previous/next tab and wrap around at the ends. In a right-to-left form (dir="rtl"), they swap:ArrowLeftmoves to the next tab.Home/Endjump to the first/last tab.- Moving focus to a tab shows its page right away.
Each page gets an id unique to its form (the tab's aria-controls points at it), because the stage's own id is also used by the editor and by any other form rendered from the same formData. The stage id stays on the page as data-stage-id.
With submit: true, a Submit button is added below the pages, in <div class="formeo-pages-actions">, and is shown
on every tab. Submitting still shows the page of the first invalid control (see Validation). A
single-page form with submit: true gets this same button and wrapper, with no tablist above it.
A wizard adds:
- An optional step list (
<ol class="formeo-pages-steps">, on by default — setprogress: falseto remove it). Each step shows the page's title and adata-stateof"done","current"or"upcoming". Clicking a step ahead of the current page validates every page in between (see Validation below); clicking a step behind the current page is always allowed. - A bottom bar (
<div role="group">) with a Previous button, a Next button, and a visually-hidden status such as "Account (2 of 3)" (aria-live="polite", announced to screen readers on every page change; set its wording withlabels.status). Next validates the current page before moving on. Next is hidden on the last page in play, and Previous is disabled on the first. - Pressing Enter in a text
<input>(not a submit/button/reset/image/file input) acts as Next on every page but the last page in play, instead of submitting a half-filled form. Textareas keep their newline behavior. On the last page in play, Enter submits the form natively. - With
submit: true, a Submit button (<button type="submit" class="formeo-pages-submit">, text fromlabels.submit) takes Next's place on the last page in play. Without it, end the wizard with a submit field of your own on its last page in play: Next disappears there, and without a submit button some browsers won't submit on Enter. A single-page wizard withsubmit: truegets this button below the page too, in its own<div class="formeo-pages-actions">, since there's no Previous/Next bar to hold it.
A condition can skip a page: give it a then action whose target is the page's stage,
stages.<stageId>, with targetProperty: 'isNotVisible'. isVisible brings the page back. Nothing is undone
automatically, so pair the two, as for fields:
stages: {
'about-you': {
id: 'about-you',
config: { title: 'About you' },
children: ['row-1'],
conditions: [
{
if: [{ source: 'fields.account-type', sourceProperty: 'value', comparison: '!=', target: 'business' }],
then: [{ target: 'stages.company', targetProperty: 'isNotVisible' }],
},
{
if: [{ source: 'fields.account-type', sourceProperty: 'value', comparison: '==', target: 'business' }],
then: [{ target: 'stages.company', targetProperty: 'isVisible' }],
},
],
},
// …
}A skipped page:
- Leaves the navigation. Its tab or step is hidden. Next, Previous, Enter, the step list and the tab keys pass over it, and the status counts only the pages still in play ("Contact (2 of 2)"). Steps are numbered without a gap.
- Doesn't validate or submit. Its controls are disabled while it's skipped, so they never block Next or submit, and
its answers are left out of
userDataand of a native form POST. Their values stay in the page, so they're back if the page is. - Can be the page on show. The next page in play takes its place (or the previous one, at the end), and
onPageChangefires. Focus moves to the new page only when it was on the skipped one. A form, or a page kept byrender(), whose first page starts out skipped opens on the next page in play withoutonPageChange. - Never leaves the form empty. A condition can't skip the last page still in play; it logs a warning instead.
- Keeps its index.
renderer.pageandpageCountstill count every stage. Settingrenderer.pageto a skipped page shows the next page in play.
While a page is skipped, conditions read its fields as unanswered: an empty value, unchecked and not visible. Its
answers stay on the page and count again if it comes back. So if "Account type" skips the Company page, a VAT page
shown only when "VAT registered?" (a field on the Company page) is "yes" drops out along with Company, and returns
with it. A condition that skips or brings back a page still reads that page's own fields as they are, so a page can
skip itself. Conditions reading a page's fields run again whenever it's skipped or comes back, so a value action
driven by its answers can fire then too: "VAT registered?" != "yes" setting another field to "none" sets it as
Company is skipped.
A skipped page can hold the author's own submit field. With submit: false (the default), skipping the page holding
it disables that button along with every other control on the page, leaving the form with no enabled submit — Enter
does nothing either, since the disabled button was the form's default button. Keep your own submit field on a page no
condition can skip, or use submit: true instead. This applies to tabs, the wizard and an unpaginated form alike.
Without pagination, a skipped stage just disappears, with its answers.
The editor writes these conditions from a page's Conditions panel (see Page Tabs).
Whether the user submits the form or clicks Next/a step, an invalid control never gets silently skipped:
- The wizard's Next button and step list validate the current page (and, for a step further ahead, every page up to but not including the target) the way native submission would, stopping at and reporting the first invalid control.
- Submitting the form (a submit button, Enter's implicit submission, or
form.requestSubmit()) or callingform.reportValidity()shows the page holding the first invalid control, so the browser's native validation bubble lands on a visible field. - A plain
checkValidity(), on the form or on a single control, is silent and never switches pages. It's safe to call fromonChange, for example to disable a submit button until the form is valid. - A condition that hides a field suspends only its
required: the field stops being required until it's shown again (seethenactions), regardless of which page it lives on. A hidden field that fails another constraint, such aspatternor a value that doesn't match itstype, still blocks submission and Next, as it would natively. - A form with
novalidateis never checked, just as native submission skips it: Next, Enter and the step list move on freely.
The tablist, the wizard's step list and its Previous/Next bar are named for assistive technology by labels.tablist
('Pages'), labels.steps ('Progress') and labels.navigation ('Page navigation'). Translate them along with the
button labels:
pagination: {
type: 'wizard',
labels: {
previous: 'Zurück',
next: 'Weiter',
page: 'Seite {n}',
submit: 'Absenden',
tablist: 'Seiten',
steps: 'Fortschritt',
navigation: 'Seitennavigation',
status: '{title} ({n} von {count})',
},
}renderer.page // 0-based index of the page currently shown
renderer.page = 1 // shows a page directly; out-of-range indexes are clamped; does not validate
renderer.pageCount // number of pages (1 without pagination or with a single stage)Pass events.onPageChange to run code on every real page change — see onPageChange.
Pagination renders these class names for styling:
| Class | Element |
|---|---|
.formeo-pages-nav |
The tablist (<nav role="tablist">) or the wizard's Previous/Next bar (<div role="group">) |
.formeo-pages-tabs |
Modifier on .formeo-pages-nav when it's the tablist |
.formeo-pages-tab |
A tab button |
.formeo-pages-wizard |
Modifier on .formeo-pages-nav when it's the wizard's Previous/Next bar |
.formeo-pages-steps |
The wizard's step list (<ol>) |
.formeo-pages-step[data-state] |
A step (<li>); data-state is "done", "current" or "upcoming" |
.formeo-pages-previous |
The wizard's Previous button |
.formeo-pages-next |
The wizard's Next button |
.formeo-pages-submit |
The Submit button added by submit: true |
.formeo-pages-actions |
In tabs, the bar below the pages holding the Submit button |
.formeo-pages-heading |
A page's heading, added by heading |
.formeo-pages-status |
The wizard's page status, e.g. "About you (1 of 2)" (visually hidden, screen-reader only) |
.formeo-stage[data-skipped] |
A page skipped by a condition (also hidden) |
const formData = {
id: 'signup',
stages: {
'stage-1': { id: 'stage-1', config: { title: 'About you' }, children: ['row-1'] },
'stage-2': { id: 'stage-2', config: { title: 'Account' }, children: ['row-2'] },
},
rows: {
'row-1': { id: 'row-1', config: {}, children: ['col-1'] },
'row-2': { id: 'row-2', config: {}, children: ['col-2'] },
},
columns: {
'col-1': { id: 'col-1', config: { width: '100%' }, children: ['name'] },
'col-2': { id: 'col-2', config: { width: '100%' }, children: ['email'] },
},
fields: {
name: { id: 'name', tag: 'input', attrs: { type: 'text', name: 'name' }, config: { label: 'Name' } },
email: {
id: 'email',
tag: 'input',
attrs: { type: 'email', name: 'email', required: true },
config: { label: 'Email' },
},
},
}
const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
pagination: 'wizard',
events: {
onPageChange: ({ page }) => console.log(`page ${page + 1}`),
},
})
renderer.render(formData)- The editor builds pages with its
pagesoption (see Page Tabs); you can also define them directly informData.
Conditions let one component react to another: show or hide a field or a whole row, check an option, or set a value. The editor writes them from a field's Conditions panel. You can also write them by hand in formData.
Any component (field, row, column or stage) can carry a conditions array. Each entry is one condition:
{
if: [ /* one or more clauses */ ],
then: [ /* one or more actions */ ],
}When every clause of an if group matches, every action in then runs. Components are referenced by address: fields.<fieldId>, rows.<rowId>, columns.<columnId>, or one option of a group, fields.<fieldId>.options[<index>]. Addresses use the ids from formData, without the f- prefix the renderer adds to element ids.
A column works as a clause source, but a show/hide action that targets a column hides the whole row it sits in. To hide part of a row, target its fields.
| Key | Values | Meaning |
|---|---|---|
source |
address | The component to read. |
sourceProperty |
value, isChecked, isNotChecked, isVisible, isNotVisible |
What to read. value is the text of an input, the selected value of a select (an array for multiple), the checked value of a radio group, or the list of checked values of a checkbox group. The other four are true/false, and a clause using them matches when they are true, so comparison and target are ignored. |
comparison |
== (equals), != (notEquals), ⊃ (contains), !⊃ (notContains) |
How to compare value with target. For checkbox groups and multi-selects, == matches when any checked value equals target. |
target |
text, or an address | The value to compare against, or another component whose targetProperty is compared. |
targetProperty |
value |
Used only when target is an address. |
logical |
&& (and), || (or) |
Joins this clause to the one before it. Ignored on the first clause, and a missing value means OR. && binds tighter than ||: A || B && C means A || (B && C). |
The editor saves the symbol forms (==, &&); the word forms are accepted too.
targetProperty |
Effect on target (an address) |
|---|---|
isVisible / isNotVisible |
Shows or hides the target (a field or a whole row). Required inputs inside a hidden target stop being required until it is shown again. |
isChecked / isNotChecked |
Checks or unchecks the target. Point at one option, fields.<id>.options[<index>], or a matrix cell, fields.<id>.table.rows[<r>].cells[<c>]. |
value |
Sets the target's value to value when assignment is =. An input event fires, so conditions that read the target run too. |
A matrix's rows and cells have their own addresses, fields.<id>.table.rows[<r>] and
fields.<id>.table.rows[<r>].cells[<c>]. See Conditions on rows and cells.
Each condition runs once when the form renders, and again whenever a component its clauses read from changes (input for text fields, change for selects, checkboxes and radios). Nothing is undone automatically when a condition stops matching. To hide a field except when something is true, pair two conditions, as in the first example.
For a checkbox or radio group, use the built-in Other choice instead. This pattern is for fields without one, such as a select.
fields: {
country: {
id: 'country',
tag: 'select',
config: { label: 'Country' },
options: [
{ label: 'Canada', value: 'ca' },
{ label: 'Other', value: 'other' },
],
},
'country-other': {
id: 'country-other',
tag: 'input',
attrs: { type: 'text', required: true },
config: { label: 'Which country?' },
conditions: [
{
if: [{ source: 'fields.country', sourceProperty: 'value', comparison: '!=', target: 'other' }],
then: [{ target: 'fields.country-other', targetProperty: 'isNotVisible' }],
},
{
if: [{ source: 'fields.country', sourceProperty: 'value', comparison: '==', target: 'other' }],
then: [{ target: 'fields.country-other', targetProperty: 'isVisible' }],
},
],
},
}The discount code field appears only for a Team plan with 10 seats. The first condition hides it when either answer differs (||). The second shows it when both match (&&).
fields: {
plan: {
id: 'plan',
tag: 'input',
attrs: { type: 'radio' },
config: { label: 'Plan' },
options: [
{ label: 'Free', value: 'free' },
{ label: 'Team', value: 'team' },
],
},
seats: {
id: 'seats',
tag: 'input',
attrs: { type: 'number' },
config: { label: 'Seats' },
},
'discount-note': {
id: 'discount-note',
tag: 'input',
attrs: { type: 'text' },
config: { label: 'Discount code' },
conditions: [
{
if: [
{ source: 'fields.plan', sourceProperty: 'value', comparison: '!=', target: 'team' },
{ logical: '||', source: 'fields.seats', sourceProperty: 'value', comparison: '!=', target: '10' },
],
then: [{ target: 'fields.discount-note', targetProperty: 'isNotVisible' }],
},
{
if: [
{ source: 'fields.plan', sourceProperty: 'value', comparison: '==', target: 'team' },
{ logical: '&&', source: 'fields.seats', sourceProperty: 'value', comparison: '==', target: '10' },
],
then: [{ target: 'fields.discount-note', targetProperty: 'isVisible' }],
},
],
},
}Both examples are covered by tests in src/lib/js/renderer/conditions.test.js.
import { FormeoRenderer } from 'formeo'
const formData = {
id: 'my-form',
stages: { /* ... */ },
rows: { /* ... */ },
columns: { /* ... */ },
fields: { /* ... */ }
}
const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
formData: formData
})
renderer.render()const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
formData: myFormData
})
renderer.render()
// Add submit handler
const form = document.querySelector('.formeo-render')
form.addEventListener('submit', (e) => {
e.preventDefault()
const userData = renderer.userData
console.log('User submitted:', userData)
// Or get with labels
const userFormData = renderer.userFormData
console.log('Structured data:', userFormData)
// Submit to API
// Forms with file inputs: send new FormData(form) instead (see File uploads)
fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
})
})const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
formData: myFormData
})
renderer.render()
// Load saved data (e.g., from localStorage or API)
const savedData = {
name: 'John Doe',
email: 'john@example.com',
preferences: ['newsletter', 'updates']
}
// Pre-fill the form
renderer.userData = savedDataconst renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
formData: initialFormData
})
renderer.render()
// Later, switch to a different form
document.getElementById('load-different-form').addEventListener('click', () => {
renderer.render(differentFormData)
})const renderer = new FormeoRenderer({
renderContainer: document.getElementById('form-container'),
formData: myFormData
})
renderer.render()
const form = document.querySelector('.formeo-render')
form.addEventListener('submit', async (e) => {
e.preventDefault()
// Get structured data with labels
const formData = renderer.userFormData
// Validate required fields
const errors = []
formData.forEach(field => {
if (field.label.includes('*') && !field.value) {
errors.push(`${field.label} is required`)
}
})
if (errors.length > 0) {
alert('Please fix the following errors:\n' + errors.join('\n'))
return
}
// Process submission
try {
// Forms with file inputs: send new FormData(form) instead (see File uploads)
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(renderer.userData)
})
if (response.ok) {
alert('Form submitted successfully!')
form.reset()
}
} catch (error) {
console.error('Submission error:', error)
alert('Failed to submit form')
}
})You can provide custom form elements through the elements option:
const renderer = new FormeoRenderer({
renderContainer: container,
formData: myFormData,
elements: {
'custom-field': {
// Custom element configuration
action: {
// Custom event handlers
},
dependencies: {
// Required scripts/styles
}
}
}
})A row with config.inputGroup: true gets an Add + button. The person filling in the form adds copies of the row,
and each copy gets a remove button ("Remove group 2").
{
config: {
inputGroup: true,
legend: 'Phone Numbers'
}
}Names. A copy's text inputs, textareas and single selects keep the original's name, so userData holds an array:
{ phone: ['555-1234', '555-9876'] }. Radio and checkbox groups, multiple selects and matrices can't share a name, so
copy n uses <name>-<n>, where <name> is the configured name without a trailing [] (contact-1, contact-2, or
the field's f-<id> when it has no name). Removing a copy renumbers the later ones. userData shows checkbox groups
without the [] suffix, so a copy's key reads likes-2.
Restoring answers. Setting renderer.userData creates the copies a saved answer needs (an array's extra values, or
the highest -<n>), then fills them. Input groups have no maximum, so the setter creates at most 500 copies. Keys past
that, and array values past the 501st, are reported in the setter's warning for keys with no matching field (for
example phone (values 502-600)). It never removes copies. A copy created this way takes on the current
hidden-by-condition or skipped-page state, like one added by click.
Accessibility and events. Focus moves to a new copy's first control, or after a remove to the next group's remove
button, else the previous one's, else the Add button, and a status region announces the change. Adding or removing a
copy fires formeo:rowschange on the group's wrapper, and onChange receives it.
Limits.
- Conditions apply to the original row's fields, not to copies.
- A group whose grouped control is named
xshouldn't also contain a control namedx-<n>: copies are named<name>-<n>, so the names would collide. - A repeating table inside a copy renders without its Add and remove buttons
and keeps the rows it started with; the
userDatasetter doesn't grow it.
A row's attrs and a column's attrs render on the .formeo-row / .formeo-column element, the one with id
f-<id>. The fieldset or wrapper around a row gets none of them. Conditions find their target by that id, but
show and hide its parent element (for a row, the formeo-row-wrap div or fieldset), not the element itself.
rows: {
'row-1': {
id: 'row-1',
className: ['formeo-row'], // saved by the editor
config: {},
children: ['col-1'],
attrs: { 'data-section': 'contact', className: 'contact' },
},
},
// renders <div class="formeo-row contact" data-section="contact" id="f-row-1">classNameandclassare both merged with the component's own class list,formeo-row/formeo-columnfor editor-built forms.- A column's
styleis kept without anywidthdeclaration, even an!importantone, and the width fromconfig.widthis added after it, soconfig.widthalways wins. idandtagare ignored: Formeo needs the element's id and always renders adiv.
config.labelPosition sets where a field's label sits:
| Value | Layout | Order |
|---|---|---|
top |
label above | label, control |
bottom |
label below | control, label |
before |
label beside, leading | label, control |
after |
label beside, trailing | control, label |
before and after follow the text direction, so in a right-to-left form before is on the right. The label and
control are always in the DOM in the order you see them, so screen readers read them in that order. In the editor, add
Label position to a field from its Configuration panel's "Add config" dialog. It starts at the position the label
already has, and is then a dropdown in that panel. A field whose data already has labelPosition shows the dropdown
straight away.
Without labelPosition, a lone checkbox or radio is after and everything else is top. A checkbox or radio group's
labelPosition moves the group's label. Each option's label always follows its own input.
Every rendered field with a visible label is wrapped like this:
<div class="f-field f-label-before">
<label for="f-name">Name</label>
<input id="f-name" name="name" type="text">
</div>before and after sit side by side while the control has at least half the row. In a narrower column or viewport
they stack, with the label above (before) or below (after). (A lone checkbox or radio keeps its own size, so it
never stacks.) The label's width beside the control is --formeo-label-width, 10rem by default:
.my-form {
--formeo-label-width: 14rem;
}A checkbox or radio group is named by its label: the group is role="group" with aria-labelledby pointing at the
label, whose id is f-<id>-label.
Fields without a visible label (hideLabel, hidden inputs, headers, paragraphs, dividers and buttons) have no wrapper,
and labelPosition doesn't apply to them.
labelAfter: older forms and control definitions may use config.labelAfter. The renderer still reads it: true
is bottom (after for a lone checkbox or radio) and false is top (before). labelPosition wins when both are
set. The editor converts labelAfter to labelPosition when it loads a field, so the form saves with
labelPosition.
Replacing a :has() workaround: if you used a rule like
.formeo-render div:has(> label + input) { display: flex; } to put labels beside inputs, set labelPosition: 'before'
on those fields (or target .f-field.f-label-before) and delete the rule. The built-in layout also stacks on narrow
screens.
The renderer caches all rendered components internally:
// Components are stored in this.components
// Keyed by their base ID (without the render prefix)
// You can access component data if needed
const component = renderer.components[componentId]-
Always render before accessing userData: Ensure
render()is called before trying to get user data. -
Use userFormData for display: When showing submission summaries or confirmations, use
userFormDatato include labels. -
Use userData for API submissions: For backend processing,
userDataprovides a clean object structure. -
Handle arrays properly: Remember that checkbox groups return arrays when multiple values are selected.
-
Clean form data: The renderer automatically cleans form data, so don't worry about data format inconsistencies.
-
Re-render for new forms: Call
render()with new form data to switch between different forms.
| Property/Method | Type | Description |
|---|---|---|
formData (get) |
Object | Get current form structure |
formData (set) |
- | Set form structure |
userData (get) |
Object | Get user-submitted data as object |
userData (set) |
- | Set/pre-fill form field values |
userFormData (get) |
Array | Get user data with labels and metadata |
html (get) |
String | Get rendered form as HTML string |
page (get) |
Number | Get the 0-based index of the currently shown page |
page (set) |
- | Show a page without validating; index is clamped |
pageCount (get) |
Number | Get the number of pages (1 without pagination) |
render(formData) |
- | Render form to container |
getRenderedForm(formData) |
HTMLElement | Get rendered form element |
Form not rendering:
- Ensure
renderContaineris a valid DOM element - Check that
formDatahas the required structure (stages, rows, columns, fields)
userData returns empty object:
- Make sure the form has been rendered
- Check that form fields have proper
nameattributes - Checkbox and radio groups submit under attrs.name, falling back to attrs.id, then f-
- Checkbox groups with more than one option add
[]to that name
Checkbox values not appearing as arrays:
- If only one checkbox is selected, it returns a single value
- Multiple selections automatically convert to arrays
Conditions not working:
ifmust be an array of clauses, and addresses look likefields.<fieldId>(the id from formData, withoutf-)thenusesisVisible/isNotVisible/isChecked/isNotChecked/value, notvisibleorset- Conditions don't undo themselves; add the opposite condition to show a field again