declarative-forms
Ask the user for an object, the way prompt() asks for a string.
Every other form library gives you a component to mount. This one gives you a function to call:
const release = await ask([
{ name: 'title', displayName: 'Release title' },
{ name: 'notes', kind: 'textarea', displayName: 'What changed' },
{
name: 'reviewers',
kind: 'select',
multiple: true,
displayName: 'Sign-off from',
options: () => fetchReviewers(),
},
]);
// { title: 'Sunrise 2.0', notes: '…', reviewers: ['Ada', 'Grace'] }That is the whole integration. There is no component, no form state, no mount point, and no place in your tree where the form has to live. You ask a question from wherever you happen to be standing in your code, and the answer arrives where you asked. The dialog draws itself, loads its own options, keeps every field in sync, and resolves.
prompt() is the one form API the browser gives you for free, and the shape everybody finds obvious: you ask, the browser draws the dialog, you get the answer. Its only flaw is that it asks for exactly one string. declarative-forms keeps that shape and removes the limit — you describe the record you want, and you get a plain object back.
Asking for data → is the two-minute version of this idea, including the nine-line ask helper. Everything below follows from it.
Try it
Press the button: three tabs, an option list that reloads when the team changes, a credits list, and a Schedule… dialog that opens on top of the first, with a third on top of that. Values shows the object you would receive, updated as you type. Below the panel is the code that produces all of it — imports, descriptors and the call that opens the dialog, with nothing left out. The button runs exactly that code, and none of it describes rendering.
Values
—
import 'declarative-forms/styles.css';
import { DeclarativeForm, html } from 'declarative-forms';
// Pretend this is your API. Any options function may be async.
const reviewersOf = async (team) => {
await new Promise((resolve) => setTimeout(resolve, 500));
return (
{
design: ['Ada Lovelace', 'Grace Hopper', 'Lin Chen'],
infra: ['Radia Perlman', 'Alan Turing'],
}[team] ?? []
);
};
// A button in the dialog below opens this one, so it appears on top of it.
// Its own list then opens a third dialog on top of that.
const openSchedule = (release) =>
new DeclarativeForm({
fields: [
{
name: 'when',
kind: 'cards',
displayName: 'Publish',
defaultValue: 'now',
cards: [
{ value: 'now', content: html('<b>Immediately</b><br>on confirm') },
{ value: 'at', content: html('<b>At a set time</b><br>your timezone') },
],
},
{
name: 'at',
type: 'datetime-local',
displayName: 'Moment',
isActive: ({ data }) => data['when'] === 'at',
},
{
name: 'freezes',
kind: 'array',
displayName: 'Never publish during',
newButtonLabel: 'Add window',
of: [
{ name: 'reason', displayName: 'Reason', placeholder: 'Conference' },
{ name: 'until', type: 'date', displayName: 'Until' },
],
renderEntry: (entry) => `${entry['reason']} — until ${entry['until']}`,
isValidRecord: (entry) => Boolean(entry['reason'] && entry['until']),
suggested: [{ reason: 'Company all-hands', until: '2026-09-01' }],
},
{
name: 'recap',
kind: 'message',
// The values of every open dialog, outermost first.
message: ({ data, stackData }) =>
html(`Publishing <b>${stackData[0]['title'] || 'this release'}</b>
${data['when'] === 'now' ? 'as soon as you confirm' : 'later'}.`),
},
],
buttons: {
Apply: {
id: 'apply',
action: (values) => {
const at = values['when'] === 'now' ? 'Immediately' : values['at'];
release.field('publishAt').setValue(at);
void release.update();
},
},
},
onCancel: () => {},
}).openInModal();
const release = new DeclarativeForm({
fields: [
// `tab` groups fields. The tab bar builds itself, and hides a tab when
// none of its fields is active.
{
name: 'title',
displayName: 'Release title',
tab: 'Notes',
placeholder: 'Sunrise 2.0',
tooltip: 'Shown at the top of the changelog',
},
{ name: 'notes', kind: 'textarea', displayName: 'What changed', tab: 'Notes' },
{
// Derived, and never shown. Recalculated before any button action runs.
// Watch `slug` in the values panel above.
name: 'slug',
kind: 'computed',
compute: ({ data }) =>
'/releases/' +
String(data['title'] || 'untitled')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-'),
},
{
name: 'publishAt',
displayName: 'Publish at',
tab: 'Notes',
defaultValue: 'Immediately',
},
{
name: 'visibility',
kind: 'select',
displayName: 'Visible to',
tab: 'Audience',
defaultValue: 'team',
options: [
{ value: 'team', label: 'One team' },
{ value: 'company', label: 'Everyone here' },
{ value: 'public', label: 'The public' },
],
},
{
name: 'team',
kind: 'select',
displayName: 'Which team',
tab: 'Audience',
defaultValue: 'design',
options: [
{ value: 'design', label: 'Design' },
{ value: 'infra', label: 'Infrastructure' },
],
// A hidden field disappears from getValues() completely.
isActive: ({ data }) => data['visibility'] === 'team',
},
{
name: 'reviewers',
kind: 'select',
multiple: true,
displayName: 'Sign-off from',
tab: 'Audience',
isActive: ({ data }) => data['visibility'] === 'team',
reloadOnChangeOf: ['team'], // reloads, and ignores out-of-date answers
options: ({ data }) => reviewersOf(data['team']),
},
{
name: 'announce',
kind: 'checkbox',
tab: 'Audience',
label: 'Post to #releases when it goes live',
},
{
name: 'credits',
kind: 'array',
displayName: 'Credits',
tab: 'Credits',
newButtonLabel: 'Add person',
of: [
{ name: 'who', displayName: 'Name' },
{
name: 'role',
kind: 'select',
displayName: 'Role',
options: ['Author', 'Reviewer', 'Release manager'],
defaultValue: 'Author',
},
],
renderEntry: (entry) => `${entry['who']} — ${entry['role']}`,
isValidRecord: (entry) => String(entry['who'] ?? '').trim() !== '',
suggested: [{ who: 'Ada Lovelace', role: 'Author' }],
},
],
buttons: {
Publish: {
id: 'publish',
// The button stays disabled until this returns true. May be async.
isActive: ({ data }) => Boolean(data['title']) && data['credits']?.length > 0,
action: (values) => console.log(values),
},
'Schedule…': {
id: 'schedule',
class: 'secondary',
doNotCloseModal: true, // this dialog stays open underneath
action: () => openSchedule(release),
},
},
onCancel: () => {},
});
release.openInModal();Every behaviour above is declared in that object. You implement none of it:
- Tabs — one
tabkey per field. The tab bar builds itself, and hides a tab when none of its fields is active. - Conditional fields —
isActivehides Which team and Sign-off from when the release is public, and removes them from the values. - Async, dependent options —
reloadOnChangeOf: ['team']reloads the reviewer list. If an older request answers after a newer one, the library throws the older answer away. - Derived values —
slugfollows the title, and is recalculated before any button action runs. - Nested records — the credits list opens one dialog per entry, and offers a ready-made entry you can accept with a checkbox.
- Stacked dialogs — Schedule… opens a second dialog and leaves the first one open behind it; the Never publish during list opens a third. With
stackData, a dialog can read the values of the dialogs below it: the recap message reads the title from the very first one. - Button state — Publish stays disabled until there is a title and at least one credit.
Two kinds are not in the demo but work the same way: file uploads, and custom fields that render anything you write.
How it differs from other form libraries
The difference underneath all the others: a call, not a component. React Hook Form, Formik, VeeValidate, react-jsonschema-form, JSONForms and Formily all hand you something to mount. The form becomes a node in your tree, and it needs a parent, an open flag, a close handler, a submit handler, and a route from the answer back to the code that wanted it. Here the form has no location. It is a question, asked and answered — so a dialog opened from inside a loop, a retry, or another dialog is just another call. Read the full argument →
vs. React Hook Form, Formik, VeeValidate. Those are state libraries, and each one is tied to a single framework. You still write every input, label and layout yourself. declarative-forms renders the whole dialog and needs no framework: it is plain DOM plus one web component, so you can use it from React, Vue, Svelte, or a plain <script> tag.
vs. JSON-Schema renderers (react-jsonschema-form, JSONForms, formily). Those build a form from a static data schema. Here the descriptor is live: options, isActive, defaultValue, placeholder, message, tab and compute may each be a function of the current form data, and reloadOnChangeOf says which field depends on which. Fields that react to other fields, and options loaded from a server, are the main feature here — not something bolted on afterwards.
vs. <dialog> plus a UI kit. With those you would build the following yourself. Here they are included: stacked dialogs, where a child dialog can read its parent's data; tabs that disappear once they are empty; buttons whose enabled and visible state is a function of the form data, and may be async; and repeating sub-forms with suggestions the user can accept or reject.
No runtime dependencies. No build step required.
Where it fits best
It fits best in settings and metadata dialogs for document-based apps: many optional fields, grouped into tabs, where the available choices depend on what the user has already selected. For example:
- admin panels and settings dialogs
- configuration flows with conditions and dependencies
- modal wizards with several steps
- forms whose options are loaded from a server and depend on other fields
- editing lists whose entries are records of their own
- internal tools that need a data-driven UI without depending on a framework
Where it does not fit
Said plainly, so you can rule it out quickly:
- It is not a general-purpose form library. It renders one fixed layout. If you need full control over the markup, use a state library instead.
- It has no validation framework.
isActivehides fields, andisValidRecordand a button'sisActivecontrol when the form can be submitted — but there are no validation rules, no error messages, and no schema validation. - Accessibility is not finished. Labels, ids and focusable buttons are correct. Dialog semantics, ARIA for the combobox, and checkboxes you can reach with the keyboard are still missing. Read Accessibility for the full list before you use it where accessibility conformance is required.
Next
- Asking for data — start here: the model, and the
askhelper - Getting started — install and build your first form
- Field kinds — the ten built-in kinds
- Reactivity — the part that makes this library worth using
- Migrating from v1 — if you still use the pre-TypeScript version