@restrella/form-generator
v2.0.8
Published
Form generator: renders themeable, validated forms from a JSON schema (v2)
Readme
Form generator v2
📖 Read the documentation → balance3840.github.io/form-generator/
A web component that renders themeable, validated forms from a JSON schema.
<script type="module" src="https://unpkg.com/@restrella/form-generator@2/dist/form-generator/form-generator.esm.js"></script>
<re-form-generator id="form"></re-form-generator>
<script>
const form = document.getElementById('form');
form.schema = {
version: 2,
title: 'Contact us',
fields: [
{ type: 'input', id: 'name', model: 'name', inputType: 'text', label: 'Name', validationType: 'string', validations: [{ name: 'required' }], layout: { cols: 6 } },
{ type: 'input', id: 'email', model: 'email', inputType: 'email', label: 'Email', validationType: 'string', validations: [{ name: 'required' }, { name: 'email' }], layout: { cols: 6 } },
{ type: 'textarea', id: 'msg', model: 'message', label: 'Message', validationType: 'string' },
],
};
form.addEventListener('submitted', e => console.log(e.detail.values));
</script>Install it with npm install @restrella/form-generator, or load it from a CDN as above.
You rarely need to write this JSON by hand: the form-builder app is a visual editor that produces it.
What changed in v2
v2 is a breaking release. In short:
| v1 | v2 |
| --- | --- |
| Bare array of fields, plus separate action / mapping props | One document: { version: 2, title, description, fields, theme, settings, action } (the props still work and override the document) |
| layout.row / layout.group / groupTitle | Rows of columns (type: "columns"), each column a stack of fields; section titles are heading fields |
| Choices as values (select), items (radio/checkbox) | One shape for every choice field: options: [{ label, value, group? }] |
| Pre-checked via items[].attributes.checked, multi-select defaultOptions (indexes) | defaultValue |
| Fields without visible: true were hidden | Fields are visible unless visible: false |
| Hard-coded colours | Themes (CSS variables), presets, fonts, input styles |
| Success = dismissible alert | Success screen (settings.successMessage) |
| – | New: conditional logic, multi-step forms, rating, opinion scale, slider, colour, heading / paragraph / divider blocks |
Migrating a v1 schema
The generator accepts v1 schemas and migrates them on the fly, so old pages keep rendering. To convert for good:
npm run migrate -- old-schema.json -o new-form.json
# also fold in the old props, and show the fields v1 would have hidden:
npm run migrate -- old-schema.json --action action.json --mapping mapping.json --show-hidden -o new-form.jsonEvery change is reported on stderr. In code: import { migrateV1 } from '@restrella/form-generator' returns { doc, notes }. The form builder also has an Import / migrate tab that does the same with a report.
Document reference
{
"version": 2,
"title": "Event feedback",
"description": "Optional text under the title",
"theme": { "preset": "indigo", "primaryColor": "#4f46e5" },
"settings": { "submitButtonText": "Send", "successMessage": "Thanks!" },
"action": { "endpoint": "https://api.example.com/forms" },
"i18n": { "defaultLanguage": "en", "languages": ["en", "es"], "translations": { "es": { "title": "Opiniones del evento" } } },
"fields": [ /* see below */ ]
}Fields
Inputs accept config.prefix / config.suffix (fixed text next to the box, e.g. "m²"). Button and message texts can use {count:model} (number of items picked in a field) and {value:model} (its answer). Text inputs also accept attributes.autocomplete (e.g. given-name, email) for browser autofill, attributes.rows on textarea and attributes.step on number. Common properties: type, id, model (the key in the submitted data), label, helpText, tooltip (more help behind a small "?" after the label: shown on hover, keyboard focus and tap, read by screen readers), placeholder, defaultValue, visible, disabled, readonly, keyLocked (something outside the form, like the contract PDF, reads this answer by its model: the builder does not let it be renamed, nor a required field deleted), validationType, validations, logic.
| type | Notes |
| --- | --- |
| input | inputType: text email number tel url password date time datetime-local month week color checkbox (checkbox uses checkboxLabel) |
| textarea | |
| select, multiSelect, radioGroup, checkboxGroup | options: [{ label, value, group? }] (group lists options together in a dropdown). For radio / checkbox groups: config.optionColumns (1-4) and config.allowOther + config.otherLabel add an "Other…" option with a text box (the typed text is the answer). config.optionStyle: "cards" shows each choice as a card with a title and an optional option description, "chips" as small pills you switch on and off (a checkbox group as chips is a multi-pick); config.optionsFrom: { field, labelKey } takes the choices from the items of a repeating group (e.g. the units of this room); on a radio group config.autoAdvance goes to the next step as soon as an answer is picked. config.visibleOptions: N shows the first N options and a "Show more options" button for the rest (a picked option is always shown). See Rich options for badges, logos, links and help on each option |
| toggle | on/off switch (validationType: "boolean") |
| address | address search with short labels ("Street 5, 2200 City"). config.provider: osm (OpenStreetMap, worldwide, free, no key, light use), google (Places API New), mapbox, geoapify, dk (Danish official register, Dataforsyningen) or custom (your own source, as in search). Keyed providers take config.apiKey (it is visible in the page: restrict it to your domain). config.countries (ISO codes, e.g. ["dk"]) and config.city narrow the search, config.multiple allows several picks (use validationType: "array"), config.answer is what is saved: text (default), id or both ({ id, label }). Floors and doors are only available from providers that have them (e.g. dk) |
| search | live search against your own endpoint, with removable chips when config.multiple. config.source: { url: "https://…?q={q}", resultsPath, labelKey, labelTemplate, valueKey, minChars, debounce, headers }. The answer is the valueKey of the pick (a list when multiple, so use validationType: "array"). The endpoint must return JSON and allow requests from your site (CORS). Step by step search (street → number → floor/door): add source.drill: { typeKey: "type", expandTypes: ["vejnavn", "adgangsadresse"] }. Results of those types open the next level when picked (the box is filled with their text and searched again; a result that equals the box text is the answer), other types are final. source.extraKey shows a small note next to a result, source.labelTemplate builds a short label from several fields |
| countrySelect | showDialCode, modelValueKey (code by default, or name / dialCode), defaultValue |
| file | attributes: { accept, multiple }. config.preview shows thumbnails of chosen images, config.compact replaces the big drop area with a small "add" button (good for photos), config.fileStyle: "block" makes that button a full-width dashed bar |
| rating | config: { max } |
| scale | config: { min, max, minLabel, maxLabel }. config.nps: true makes it a Net Promoter Score question: 0-10 kept on one line on phones, with a thin red / amber / green line for detractors, passives and promoters ("plain" drops the colours) |
| phone | a country picker (flag and dial code, the re-country-select list) and the number. The answer is "+45 20123456" (country code, a space, the digits; a national leading 0 is dropped except for Italy). config.defaultCountry ("dk") is where it starts, otherwise the visitor's region / the form language's country. Pasting +46 70… picks the country. The phoneCountries rule also limits the picker. config.phoneStyle: inline (default: one box, the flag and dial code inside it) or separate (the picker next to the box) |
| likert | agree / disagree style answers. The points are options ([{ label: "Strongly disagree", value: 1 }, …]). Without config.rows it is one question and the answer is a point; with config.rows: [{ label, value }] it is a table of statements and the answer is { [row value]: point } (on phones the rows become cards). config.showNumbers shows 1, 2, 3… above the points. Looks (the answer is always the point's number): config.style is dots (default), buttons (numbered boxes), emoji (options[].emoji, faces by default) or images (options[].image); config.colorMode: "gradient" tints the points red → green, options[].color sets one point's color, config.accent the color of the picked point; config.labels (one question) is all (default), ends or none. required means every statement is answered |
| ranking | people put the options in order (click to place, drag or arrows to move). The answer is the list of option values, best first. config.rankLimit asks only for a top N. required means all options (or the top N) are placed |
| slider | config: { min, max, step, unit } |
| heading, paragraph, divider | content blocks (label / helpText, content, config.level) |
| columns | a row of columns, see Layout |
| pageBreak | splits the form into steps (with config.ending it starts the thank-you page instead). Its label is the title of the step that starts after it, helpText its description (the first step's are settings.firstStepTitle / firstStepDescription). It can have logic to skip the whole step |
| section | a titled card around other fields (fields). config: style (card or plain), collapsible, startCollapsed. See App-like flows |
| repeater | a group people can add several of (guests, rooms, meters…). The answer is a list of objects. See App-like flows |
| tabs | switch between groups of fields: tabs: [{ id, label, fields }], config.style (line or pills). The tab with an error opens by itself |
| modal | a dialog (pop-up form) that a button opens with the openModal action: label (title), fields, config.confirmLabel / cancelLabel / confirmActions. See Dialogs |
| button | a button that runs config.actions, see Buttons and actions |
| callout | a message box: label (title), content, config.tone (info success warning danger). Combine with logic to show it only when it applies paragraph and callout accept config.truncate to cut a long text short: { "by": "lines" \| "characters", "limit": 5, "mode": "viewMore" \| "tooltip" } (viewMore adds a "View more / View less" button, tooltip a "?" that shows the whole text). |
| image, video, link, icon, embed | content blocks. image: config.src (https or an uploaded data URL), alt, caption, width (small / medium / full), align, link, rounded. video: config.url (YouTube (privacy-enhanced player), Vimeo, Loom, or an .mp4/.webm file), caption. link: label, config.url (http(s), mailto:, tel:), style (button / text), newTab. icon: config.emoji or config.icon (see BLOCK_ICONS), size, align, label / helpText under it. embed: config.url (https: a map, a booking calendar, a PDF), height |
| thank-you page | a pageBreak with config.ending: true: the blocks after it are shown after a successful send instead of the form (never as a step, no questions) |
| signature | a box to sign in (finger, pen, mouse). The answer is a PNG data URL. config.height, config.hint. config.mode: "dialog" shows a grey box with a button (signLabel) that opens the signing pad in a dialog and then shows the signature |
Layout: rows and columns
Fields are stacked top to bottom. To put fields side by side, wrap them in a columns row. Every column has a span (out of 12) and holds its own stack of fields, so you can have three short fields on the left next to one tall field on the right:
{
"type": "columns",
"id": "row1",
"config": { "align": "start", "justify": "start" },
"columns": [
{ "id": "left", "span": 8, "fields": [ { "type": "input", "id": "name", "model": "name", "label": "Name" }, { "type": "input", "id": "email", "model": "email", "label": "Email" } ] },
{ "id": "right", "span": 4, "fields": [ { "type": "textarea", "id": "bio", "model": "bio", "label": "About you" } ] }
]
}config.align: vertical alignment of the columns,startcenterendorstretch.config.justify: where a row whose spans add up to less than 12 sits,startcenterorend(e.g. a single centeredspan: 6column).- Rows can't be nested. A row can have
logicto show or hide all of it, and columns stack on narrow screens. - The old per-field width (
layout.cols) is still understood: adjacent partial-width fields are grouped into rows automatically when the schema loads.
Validation
validationType is a Yup type (string number boolean date array object); validations is a list of { name, params } where name is a Yup method. The message is the last parameter, and a friendly default is used when omitted:
{ "name": "required", "params": ["Please tell us your name"] }
{ "name": "min", "params": [3, "At least 3 characters"] }
{ "name": "matches", "params": ["^[0-9]{5}$", "Five digits please"] }On a boolean field, required means "must be ticked". A rule whose value is empty is ignored.
Available rules by validationType:
| Type | Rules |
| --- | --- |
| string | required min / max / length (characters) email url uuid matches (regex string) lowercase uppercase oneOf / notOneOf (list of values) sameAs / notSameAs (another field) |
| number | required min max moreThan lessThan integer positive negative oneOf / notOneOf sameAs / notSameAs minField / maxField (another field) |
| date | required min max (earliest / latest date) minField / maxField (not before / not after another field) |
| array | required min max length (number of choices) |
| boolean | required (must be ticked) |
| file (used automatically for type: "file") | required maxFileSize (MB per file) fileTypes ([".pdf", "image/*"]) minFiles maxFiles |
Phone and e-mail checks: phone (a number that exists in its country's numbering plan, checked with libphonenumber-js: length and leading digits), phoneCountries ([["dk", "se"], "message"]: only numbers from these countries) and emailDomains ([["company.com"], "message"]: only addresses on these domains, subdomains included).
Not every rule makes sense for every input: the builder offers them per kind of input, through rulesFor(field) / RULES_BY_KIND (a phone number gets phone, phoneCountries, matches, sameAs…; a URL url, maxLength, matches; a dropdown, a Likert or a ranking only required).
Rules that compare with another field take that field's model as their value, e.g. { "name": "sameAs", "params": ["email", "The emails do not match"] } or { "name": "minField", "params": ["startDate", "End must be after the start"] }. If the referenced field no longer exists the rule is ignored. A file field's attributes.accept is enforced on submit too, not just used as a hint for the file picker.
Conditional logic
"logic": { "action": "show", "match": "all", "conditions": [{ "field": "attended", "operator": "equals", "value": "yes" }] }action is one of show, hide, disable (can't be edited while the rule matches), enable (can only be edited while it matches) and require (becomes required while it matches). Give logic a list of rules to combine several (every show/hide rule has to allow it). match is all (AND), any (OR) or none (NOT any). A condition can be a group with its own match and conditions, nested as deep as needed: (A or B) and (C and D) is { "match": "all", "conditions": [ { "match": "any", "conditions": [A, B] }, { "match": "all", "conditions": [C, D] } ] }. Empty groups are ignored. Operators: equals not_equals contains not_contains gt lt empty not_empty. field is another field's model. For lists (checkbox groups, repeaters) gt / lt compare the number of entries, so { "field": "guests", "operator": "lt", "value": 1 } means "no guest added". Hidden fields are not validated and not submitted; fields switched off by a rule are not validated.
Months, weeks and years. { "type": "month" } answers "2026-10", { "type": "week" } an ISO week "2026-W42" (picked as a calendar row, with week numbers), { "type": "year" } "2026". config.multiple: true answers a list; config.minDate / maxDate ("today", "+60" or a date) grey out what is outside.
Rich options
Radio and checkbox groups can say more about each option (like a list of providers to pick from). Besides label, value and group, an option can have:
| Property | |
| --- | --- |
| badge | a short tag after the label, e.g. "Most used" |
| description | a second line of smaller text under the label |
| image | a logo (at most 28 px high, keeps its proportions). Where it goes is set per question with config.imagePosition: left (default: before the label, on its line; it wraps only when the line is full), right (right after the label, before its badge and help), top or bottom (on a line of its own, above the label or under the description and link). Only http(s), relative and data:image/…;base64 addresses are used. imageAlt describes it for screen readers (empty by default: the label already names it) |
| link | { label, url }: a link after the description that opens in a new tab (rel="noopener noreferrer"). Only http(s) URLs; clicking it does not pick the option |
| help | a text behind a small "?" after the label (hover, focus, click or tap; Escape closes it). Clicking the "?" does not pick the option |
{
"type": "radioGroup", "id": "deposit", "model": "deposit", "label": "Who holds the deposit?",
"config": { "optionStyle": "cards", "visibleOptions": 3 },
"options": [
{ "label": "Bank guarantee", "value": "bank", "badge": "Most used", "image": "https://example.com/bank.svg",
"description": "The bank pays if something goes wrong.", "link": { "label": "Read more", "url": "https://example.com/bank" },
"help": "You pay a small yearly fee instead of the full deposit." }
]
}With config.optionStyle: "cards", options that use any of these get the provider-list look: bordered cards on a light background, the radio / tick on the left and the content on the right, the picked card outlined in the primary colour. Cards without them keep the big tile look. All texts are translatable (field.<id>.optionBadge.<value>, optionDesc, optionHelp, optionLink, optionAlt), and so is a field's tooltip (field.<id>.tooltip).
App-like flows
Everything below is plain JSON, so a form can behave like a small application.
Steps. A pageBreak starts a new step. settings.stepsLayout: "sidebar" shows a list of the steps next to the form (a strip on small screens), with a tick on the steps that are filled in; the default is a progress bar. Above the form, settings.progressStyle picks bar (default), percent, steps (numbered dots), titles (numbered dots with the step titles) or none; showStepCount: false hides “Step 2 of 4”, progressPosition: "bottom" puts it above the buttons, stepHeader: false hides the step's title and description on its page, stepTransition is fade or slide (off when the visitor asks for less motion), backButtonText renames Back, and theme.buttonAlign: "split" puts Back on the left and Next on the right. A page break's config sets its own step: image (+ alt) at the top, nextLabel for its Next button, hideBack: true. The first step uses settings.firstStepImage / firstStepImageAlt. Every one of these texts is translatable. settings.stepNavigation: visited (default, in order: Next and the step list check each step before moving on, and a step gets its tick only when its answers are valid), free (any order: every step can be opened, everything is checked on send) or locked (the step list only goes back). settings.nextButtonText can use {next} (title of the next step): "{next} →". settings.saveDraft: true keeps the answers in this browser (draftKey names the slot; files are not kept) and shows a "Save and exit" button (exitText, exitUrl); the draft is removed after a successful submit.
The step list (sidebar). With stepsLayout: "sidebar" the list is on the left; stepsSide: "right" moves it to the right (on small screens it is always above the form). These settings add more to the list:
stepProgress: true: each step shows a ring that fills up as its required questions are answered (only questions that are shown count; a repeater counts as answered when it has itsminItemsand the required fields of every item are filled in). A step without required questions is full once it was visited. A full ring on a step without errors becomes a green circle with a tick.sidebarHeader: { title, subtitle?, backUrl?, action? }: a back arrow and a title at the top of the list ("Back and save as draft"). It replaces the "Save and exit" button. A click saves the draft (withsaveDraft), then firesformAction{ name: action, field: null, values }whenactionis set, otherwise firesexitand opensbackUrl(orexitUrl). In a frame,redirectTargetdecides whether the whole page or only the frame moves.{saved}in the subtitle becomes "Saved just now" / "Saved 5 minutes ago" (in the form language, kept up to date); it is left out until the first save.sidebarActions: [{ label, variant?, action, name? }]: full-width buttons under the steps.variant:primary(default),secondary,outline,ghostorlink(a centred text link).action:submit(sends the form like the submit button, with every check),next(the next step; hidden on the last one) oremit(firesformActionwithname). On small screens, where the list becomes a strip, the buttons move under the form.
"settings": {
"stepsLayout": "sidebar", "stepProgress": true, "saveDraft": true,
"sidebarHeader": { "title": "Back and save as draft", "subtitle": "{saved}", "backUrl": "/contracts" },
"sidebarActions": [
{ "label": "Create contract", "action": "submit" },
{ "label": "Preview", "variant": "link", "action": "emit", "name": "preview" }
]
}Sections. { "type": "section", "label": "Dates", "fields": [ … ] } groups fields in a card. Rows can sit inside sections. config.badge puts a round number ("1") next to the title, config.tooltip adds a "?" icon with a help text, and a section with a disable rule is greyed out and unusable (a numbered checklist that unlocks step by step). A section without fields is just a card with a title.
Tabs. { "type": "tabs", "tabs": [ { "id": "a", "label": "Units", "fields": [ … ] }, … ] }. Answers of all tabs are part of the same form; rules can use fields of other tabs.
Repeating groups. The answer is a list of objects, one per item:
{
"type": "repeater", "id": "guests", "model": "guests",
"config": { "itemLabel": "Guest #{n}", "addLabel": "Add guest", "minItems": 1, "maxItems": 4, "collapsible": true },
"fields": [
{ "type": "input", "id": "t_name", "model": "name", "label": "Name", "validations": [{ "name": "required", "params": [] }] },
{ "type": "input", "id": "t_email", "model": "email", "inputType": "email", "label": "Email" }
]
}→ { "guests": [ { "name": "Anna", "email": "…" }, … ] }. Config: layout (cards or table), itemLabel ({n} and {key} of an answer in the item), addLabel, addStyle (button or dashed), emptyText, minItems / maxItems, allowAdd / allowRemove, collapsible / startCollapsed, presets. A repeater can contain another repeater (the checklist of a room).
- Logic inside an item uses the other fields of the same item by their key (
"condition"), and still reaches normal fields. Errors are keyed by path (guests[0].email). layout: "table": the first child must be acolumnsrow; its columns become the table columns (the headers are their labels). Anything after that row appears under the row, only while it has something to show (e.g. defect details of a unit that is "Damaged").presets:[{ "label": "Kitchen", "description": "…", "values": { "name": "Kitchen", "units": [ { "unit": "Stove" } ] } }]. The add button then opens these choices and the new item starts with thevalues(nested lists included).
Small cards, dialogs and pages for items. Besides cards (the fields inline) and table, a repeater can show its items as small cards (layout: "grid", columns, or any layout with itemOpen other than inline). A card has a title (itemLabel), a summary line (itemMeta; {count:defects|No defects|1 defect|# defects} picks a text by the number of items in a nested group, {key} of an array lists its entries), an optional Done button (doneField: a yes/no answer of the item) and a ⋮ menu. Config:
| | |
| --- | --- |
| itemOpen | inline (default for cards), modal (clicking a card edits it in a dialog) or page (the item replaces the list, with a Back link, backLabel; its fields can use tabs and buttons) |
| addMode | inline (a blank item is added) or modal (a dialog with the item's fields asks first: addTitle, editTitle, confirmLabel, cancelLabel) |
| itemMenu | the ⋮ menu: any of edit duplicate up down remove (default for small cards: all) |
| sortable | drag small cards to reorder them (default on) |
Fields of an item can be config.dialogOnly (only in the item's dialog, e.g. the type of a meter) or config.hideInDialog (left out of it, e.g. a "+" button in a table row).
Dialogs
A modal block is a form in a pop-up. It is placed anywhere (typically inside the item or step whose answers it needs) and opened by a button with { "type": "openModal", "modal": "<block id>", "values": { … } }. What people type stays in the dialog (it is not part of the answers) until they confirm; then the block's config.confirmActions run, and they can use it as {draft:key}:
{ "type": "modal", "id": "stateDialog", "label": "Set the condition of everything",
"fields": [ { "type": "radioGroup", "id": "s", "model": "state", "label": "Condition", "options": [ … ], "validations": [{ "name": "required" }] } ],
"config": { "confirmLabel": "Set condition", "confirmActions": [ { "type": "setAll", "field": "units", "key": "condition", "value": "{draft:state}" } ] } }Fields inside a dialog are validated when it is confirmed. Names used by buttons, actions and optionsFrom are looked up from the item the block sits in outward, so "units" inside a room means that room's units.
Buttons and actions. { "type": "button", "label": "Review report", "config": { "variant": "outline", "actions": [ { "type": "emit", "name": "review" } ] } }. variant: primary secondary outline ghost danger; size: "large"; align: start center end stretch; icon: arrow-right arrow-left plus check download. Give a button logic to show it or to disable it while something is missing. Actions run in order and stop at the first one that cannot continue:
| Action | |
| --- | --- |
| next / back | move between steps (next checks the current step first) |
| goto | { "type": "goto", "step": "<page break id>" or "start", "validate": true } |
| set | { "type": "set", "field": "model", "value": "x" } (inside an item: a field of the same item first) |
| add | { "type": "add", "field": "guests", "values": { … } } adds an item (through its dialog when the group has addMode: "modal"). In values, {key} is an answer of the item the button is in: a "+" in a unit row can open the defect dialog with { "units": ["{unit}"] } |
| setAll | { "type": "setAll", "field": "units", "key": "condition", "value": "Good" } sets one answer on every item of a group |
| openModal | { "type": "openModal", "modal": "<block id>", "values": { … } } opens a dialog |
| submit / reset | send / start over. A page with its own submit button hides the built-in submit button |
| link | { "type": "link", "url": "https://…", "newTab": true } (http, https, mailto, tel or relative) |
| emit | fires the formAction event { name, field, values } so your own code can react |
| saveDraft / exit | save progress / save and fire exit (then open settings.exitUrl) |
Room-list style flows (type picker, global vs. per-item tools)
- Add dialog with a type picker:
presetson a repeater withaddMode: 'modal'shows the presets first;uniqueKeykeeps names unique ("Kitchen 2"),dialogContent: 'marked'limits the dialog to fields markeddialogOnly. - Global vs. per-item actions: the same button works at two levels.
setAlltakes a nested path (field: 'rooms.units') to change every item of every row; inside an item the plain path only touches that item. A step-level list (e.g.allDefects) can useoptionsFrom: { field: 'rooms.units', labelKey, groupKey }withsearchableto pick items from all rows, grouped. - Translating preset data:
config.presetTextKeys: ['name', 'unit']lists the properties insidepresets[].values(at any depth) whose text people read; those strings appear in the translations next to the preset label (e.g. the room name and the unit names). Stored values such as a condition ('Good') are never translated.addTitleandbackLabelare translatable too. - Item pages: an open item shows everything inside it, tabs and sections included; the rest of the step steps aside.
closeItemreturns to the list (e.g. a "Mark as done" button:set donethencloseItem).
Branding: logo, cover, page background, footer
Everything lives in theme (look) and footer (content):
theme.logo,logoHeight,logoAlign,logoLink(makes it a link),logoAlt.theme.cover,coverHeight,coverPosition(top|center|bottom),coverOverlay(0-80, darkens it),coverStyle(bannerorhero, which puts the logo, title and description on the image).theme.pageImage,pageImageFit(cover|contain|tile),pageOverlay: a background image behind the form. The component is only the card, so the page applies it withpageStyle(theme)(an object) orpageStyleText(theme)(astylestring), both exported.theme.headingFont(a separate font for titles) andtheme.headerAlign(left|center).footer:{ enabled, style: 'plain'|'card'|'bar', align, text, links: [{label,url,newTab}], social: [{type,url}], copyright ("© {year} Acme"), logo, logoHeight, backgroundColor, textColor }. It renders under the card; its text, link labels and copyright are translatable. Social types: facebook, instagram, linkedin, x, youtube, tiktok, email, phone, website.
Theme
theme takes a preset (indigo minimal soft ocean forest sunset midnight mono) and/or overrides: logo logoHeight logoAlign cover coverHeight (images: https://…, a relative path or a data:image/… URL; anything else is ignored) primaryColor backgroundColor textColor pageBackground fontFamily borderRadius inputStyle (outlined|filled|underlined) spacing (compact|comfortable|spacious) cardStyle (shadow|bordered|flat) maxWidth buttonStyle (solid|outline) buttonWidth (full|auto|equal: Back and Next in halves) buttonAlign (left|center|right) labelStyle (normal|uppercase) customCss. Everything is exposed as --rfg-* CSS variables you can also override from your own CSS.
Languages (i18n)
Write the form once in its default language, then add translations. Anything not translated falls back to the default language.
"i18n": {
"defaultLanguage": "en",
"languages": ["en", "es", "fr"],
"detect": true,
"switcher": "flags",
"translations": {
"es": {
"title": "Contáctanos",
"settings.submitButtonText": "Enviar",
"field.name.label": "Tu nombre",
"field.plan.option.free": "Gratis",
"ui.submit": "Mandar"
}
}
}- Which language is shown: the
languageprop if set (it also hides the switcher); else the visitor's choice in the switcher; else the browser language (navigator.languages,detectis on by default) when it matches one oflanguages(pt-BRmatchespt); elsedefaultLanguage. - Switcher:
"none"(default),"flags"or"dropdown": a language selector at the top of the form. Flags are emoji, so on Windows they show as letters. - Keys:
title,description,settings.<name>,settings.sidebarHeader.<title|subtitle>,settings.sidebarActions.<index>.label, andfield.<field id>.<label|helpText|tooltip|placeholder|content|checkboxLabel|config.minLabel|config.maxLabel|config.unit>,field.<id>.option.<option value>,field.<id>.<optionDesc|optionBadge|optionHelp|optionLink|optionAlt>.<option value>,field.<id>.validation.<rule name>(a custom error message). The builder's Translate tab lists them all. - Built-in texts (buttons, step counter, upload text, default error messages, country names) are translated for
en es fr de it pt nl pl ca daand can be overridden per language withui.*/validation.*keys (seeBUILT_IN_UIinsrc/utils/i18n.ts). Other languages use English for these unless you provide them. This works for the default language too, so a single-language form can reword them:"translations": { "en": { "ui.step": "Stage {current} of {total}" } }. Country names use the browser'sIntl.DisplayNames. ar,heandfasetdir="rtl"on the form.
<re-form-generator language="es"></re-form-generator> <!-- force a language -->
<script>
form.addEventListener('languageChanged', e => console.log(e.detail.language));
form.setLanguage('fr'); // switch from your own UI
</script>Spam protection
settings.captcha: { "provider": "turnstile", "siteKey": "…", "theme": "auto" }.
| provider | |
| --- | --- |
| honeypot | a hidden trap field, no account or key. A submission that fills it is silently dropped. Stops simple bots only |
| turnstile | Cloudflare Turnstile (answers get cf-turnstile-response) |
| hcaptcha | hCaptcha (h-captcha-response) |
| recaptcha | Google reCAPTCHA v2 checkbox (g-recaptcha-response) |
| recaptcha3 | Google reCAPTCHA v3, invisible (g-recaptcha-response) |
The token travels with the answers, and your server must verify it with the provider's secret key (see the builder's "Show how to check it"). The old action.recaptchaSiteKey still works as reCAPTCHA v2. Each provider publishes test site keys that always pass, handy while building.
Keys and secrets in a front-end form
Everything in a web page is readable by visitors, so treat anything you put in a form as public:
- Fine to include: captcha site keys, Mapbox public (
pk.) tokens, and Google / Geoapify browser keys that you restricted to your domain and to one API, with a spending limit or quota alert set. - Never include: captcha secret keys, Mapbox
sk.tokens, unrestricted keys, andaction.bearerTokenvalues that are not meant to be public (the token is sent from the visitor's browser). - For a key you cannot restrict, put a small proxy in between: the form calls your address, the proxy adds the key and forwards the request. The builder generates a ready-to-paste Cloudflare Worker for the address providers ("Keep the key off the page") and switches the field to it.
- Submit the form to your own server (
action.endpoint) and verify captcha tokens there.
The form's own code
script (a string at the top of the document) is JavaScript that runs with the form, like the “Script” box of the old forms, but in a sandbox: a hidden frame with no access to the page, its cookies, its storage or anybody's login. It talks to the form through a form object:
form.on('change', ({ key, value }) => { // also 'ready', 'step', 'beforeSubmit', 'submitted'
if (key === 'city') form.filterOptions('projects', o => o.label.includes(value));
});
form.set('year', '2000'); // or form.set({ a: 1, b: 2 })
form.get('city'); // form.values: every answer
form.setOptions('projects', [{ label: 'A', value: 'a' }]); // form.resetOptions('projects') undoes it
form.hide('vat'); form.show('vat'); // hidden fields are not checked and not sent
form.goToStep('stepId'); form.redirect('https://…');
form.alert('Thank you!'); // a message for the person (plain alert() works too)
form.style('email', { background: '#fff3f3', borderColor: '#e11d48' }); // colours, see below; form.resetStyle('email') undoes it
form.on('beforeSubmit', ({ values }) => values.age < 18 ? 'You must be 18 or older.' : true); // false or a message stops the sendform.style(field, colours) takes only colours (#hex, rgb()/hsl() or a colour name) for background (the input box), color (its text), borderColor, accentColor (selected options, focus ring), labelColor and highlight (a coloured box behind the whole question). A second call adds to the first; null removes one colour. Nothing else can be styled, so the code cannot add CSS to the page.
The code can call APIs with fetch, and beforeSubmit waits for an async handler (at most 4 seconds). The frame has no origin of its own, so only APIs that allow every site (Access-Control-Allow-Origin: *) answer, never with the visitor's cookies or login, and the page's Content-Security-Policy applies.
form.updateValue / form.getValue work too (the names older scripts used). The prop allow-script="false" never runs it; the builder's canvas never does.
Settings & action
settings: autofill (false stops the browser from suggesting saved addresses and the like; a single field can do the same with attributes.autocomplete: "off") submitButtonText successMessage errorMessage formErrorMessage showTitle showProgress hideSubmitButton allowResubmit redirectUrl, afterSubmit (message, page: the thank-you page, or redirect) and redirectTarget (top, default: an embedded form moves the whole page, with a Continue button when the browser refuses; self: only the frame), and for steps stepsLayout stepsSide progressStyle showStepCount progressPosition stepHeader stepTransition stepNavigation firstStepTitle firstStepDescription firstStepImage firstStepImageAlt nextButtonText backButtonText saveDraft draftKey exitText exitUrl, and for the step list stepProgress sidebarHeader sidebarActions (see App-like flows).
action (optional): endpoint httpMethod bearerToken headers formData webhookEndpoint recaptchaSiteKey mapping. Without an endpoint the form just emits submitted.
Component API
| Prop | |
| --- | --- |
| schema | document (object or JSON string) or a v1 array |
| model | initial values keyed by model |
| action, mapping | override schema.action / action.mapping |
| theme | object, preset id, or JSON string; overrides schema.theme |
| language | forces a language (e.g. es, pt-BR); see Languages |
| designMode | used by the builder: shows every field, no logic, inert |
Events: submitted ({ values, response, error }), valueChanged, validationError, handleSubmit, languageChanged ({ language }), formAction ({ name, field, values }, from a button's emit action; from the step list's header or buttons field is null), stepChanged ({ index, id, title }), draftSaved ({ values }, every time the draft is saved), exit ({ values }, "Save and exit" or the step list's back link).
Methods: submit(), validate(), reset(), getValues(), updateValue(key, value) (keys inside a repeater are paths like guests[0].email), goToStep(id, validate?), setLanguage(code), getLanguage().
Slots: field-label@<field id> renders custom content under a field's label.
Contributing
See CONTRIBUTING.md to run the project locally and for the release process.
