Complex HTML forms without complex JavaScript.
SmarkForm is a markup-driven controller for structured HTML forms. Annotate
your markup with data-smark, and the DOM becomes a tree of fields that
imports and exports as JSON.
In SmarkForm, everything is a field, and every field returns a JSON value. A form is a field composed of named fields (it returns an object); a list is a field composed of a sequence of fields (it returns an array); an input returns a string, a number returns a number, a checkbox a boolean, and so on. Because forms and lists are themselves fields, you can nest them to any depth and describe any JSON data structure — including variable-length arrays, clamped to a length range or not.
No component framework. No imposed DOM structure or CSS.
📋 Quick reference: See the Developer Cheatsheet for the full API surface, actions, events, and component types at a glance.
Real forms are rarely flat: an order contains line items, a person contains phones and addresses, a school contains students. In plain HTML that structure is easy to draw but hard to manage, because every row you add needs JavaScript to wire it up and keep it in sync with your data.
SmarkForm describes the structure once, in markup. Here is a list of nested objects:
<div id="form">
<input name="name" data-smark placeholder="Name">
<input name="email" data-smark placeholder="Email">
<ul data-smark='{"type":"list","name":"phones"}'>
<li><input name="number" type="tel" data-smark placeholder="Phone"></li>
</ul>
<button data-smark='{"action":"addItem","context":"phones"}'>➕ Add phone</button>
<button data-smark='{"action":"removeItem","context":"phones"}'>➖ Remove phone</button>
<button data-smark='{"action":"export"}'>💾 Save</button>
</div>One line of JavaScript to bring it to life:
const form = new SmarkForm(document.querySelector("#form"));Read the data whenever you need it:
const data = await form.export();
console.log(data);...or react to the declared "export" trigger:
form.on("AfterAction_export", ({ data }) => console.log(data));...and this is what comes out of it:
{
"name": "Alice",
"email": "alice@example.com",
"phones": [{ "number": "555-1234" }]
}The HTML is your UI. The JSON is your data. SmarkForm connects them.
Every data-smark element becomes a field — forms return objects, lists
return arrays — and the data stays in sync as the user adds, removes, sorts or
moves things, with no manual wiring.
The one idea behind SmarkForm is that every component is a field, and every field produces a JSON value:
The differences between component types are only the shape of the value they return:
| Field type | Returns (JSON) | Notes |
|---|---|---|
form |
object { … } |
composed of named child fields |
list |
array [ … ] |
a sequence of fields; grows and shrinks at runtime |
input |
string | with format:"json", the text parses to any JSON value |
number |
number or null |
string values are coerced |
checkbox |
boolean | |
radio |
string or null |
radios sharing a name behave as one field |
date, time, datetime-local |
ISO string or null |
normalised on import and export |
color |
"#RRGGBB" or null |
null means not selected — impossible with native color inputs |
file |
data-URL string, or object with format:"json", or null |
|
image, video, audio |
same as file |
specialised file fields |
Because a form and a list are themselves fields, they can contain other
forms and lists. Nest them to any depth and you describe any JSON data
structure — including variable-length arrays, optionally clamped to a length
range with min_items / max_items.
The rest of the model follows from that:
- A trigger is a component that performs an action on a field (add an item, export data, navigate…) — it holds no data itself.
- A label is a component that only displays text; it holds no data
either, but it automatically connects to the corresponding field — no manual
for="…"needed. - Components are addressed by their place in the SmarkForm tree, not the DOM.
- Value fields whose JSON type has no empty form — number, date, color, radio,
file… — return
nullto mean unknown or not applicable, instead of a misleading value.
That single model is the whole conceptual key. Everything else in the documentation is a variation of it.
Because a form and a list are just fields, the real payoff is structure: forms nested inside forms, lists nested inside lists, and objects nested inside lists — all described once, in plain HTML.
Here is a three-level structure (groups → students → grades) with no application-specific JavaScript:
<div id="school">
<input name="name" data-smark placeholder="School name">
<ul data-smark='{"type":"list","name":"groups"}'>
<li>
<input name="tutor" data-smark placeholder="Tutor">
<ul data-smark='{"type":"list","name":"students"}'>
<li>
<input name="name" data-smark placeholder="Student">
<div data-smark='{"type":"form","name":"grades"}'>
<input name="math" type="number" data-smark>
<input name="science" type="number" data-smark>
</div>
</li>
</ul>
<button data-smark='{"action":"addItem","context":"students"}'>➕ Add student</button>
</li>
</ul>
<button data-smark='{"action":"addItem","context":"groups"}'>➕ Add group</button>
</div>...and the JSON it exports:
{
"name": "Springfield Elementary",
"groups": [
{
"tutor": "Ms. Krabappel",
"students": [
{ "name": "Bart", "grades": { "math": 4, "science": 3 } }
]
}
]
}The ➕ Add buttons carry no code — they are declared with data-smark and
act on the list they can reach. Lists can be reordered and items can be moved
between nested lists, and the data follows.
▶ Try it live in the interactive playground, with HTML, CSS, JS and the resulting JSON side by side.
The Showcase is a large set of runnable, editable examples. Some highlights:
- Multiple-level nesting
- Deeply nested forms
- Nested lists and forms
- Smooth navigation
- Context-driven hotkeys
- Field masking
- Team Event Planner
- Simple Calculator
Prefer to start from working code? Download any of the ready-to-run examples — Company, TO-DO List, Adventure Tracking, Beach Surveillance, Event Registration, Expense Report, Medical Intake, Property Inspection, Restaurant Reservation — and edit it locally.
SmarkForm's canonical configuration lives in one attribute, and it can be reduced to almost nothing:
<!-- Nothing at all: type and name are inferred from the tag and its attributes -->
<input name="email" data-smark>
<!-- Minimal: a field needs a name; the type is inferred (ul → list) -->
<ul data-smark='{"name":"students"}'> … </ul>
<!-- The same, as a data-smark-<option> attribute -->
<ul data-smark-name="students"> … </ul>
<!-- String shorthand: the value is just the component type. Useful when the
tag doesn't imply it (a div can be a list); a name is still required. -->
<div data-smark="list" data-smark-name="students"> … </div>
<!-- The type is often inferred from the tag, so data-smark can be bare -->
<label data-smark>Your e-mail</label>
<textarea data-smark></textarea>
<!-- The HTML input type stays native: this is a SmarkForm "input" field of type email -->
<input name="email" type="email" data-smark>
<!-- Inside a list, the item template needs no data-smark at all. With
of:"input", each <li> wraps one unnamed field (the Singleton Pattern) and
can still carry its own label and buttons. -->
<ul data-smark='{"name":"emails","of":"input"}'>
<li>
<label data-smark>Your e-mail</label>
<input type="email" data-smark>
<button data-smark-action="removeItem">➖</button>
</li>
</ul>
<!-- Full form: any component options, as JSON -->
<input data-smark='{"type":"input","name":"email","placeholder":"you@example.com"}'>
<!-- Per-option attributes: convenient with template engines -->
<input data-smark-type="input" data-smark-name="email" data-smark-placeholder="you@example.com">Why JSON inside data-smark? SmarkForm deliberately keeps a component's
configuration in one attribute so that the markup stays portable: no
framework-specific attribute conventions, and a single, predictable place to
look. The string shorthand (data-smark="list") and supported
data-smark-<option> attributes are conveniences layered on top of that JSON
object, not a different model. Prefixed attributes use their suffix literally;
HTML lowercases attribute names, so public option names use snake_case across
JSON, constructor options, and programmatic option objects. Action identifiers
such as addItem are values, not option names, and keep their documented
spelling. Use JSON for structured values without a documented attribute parser.
👉 Full syntax: The data-smark attribute
and Shorthand syntaxes.
A trigger is any element carrying an action. By default it acts on the
component it can reach from its own position in the tree:
<ul data-smark='{"type":"list","name":"contacts"}'>
<li>
<input name="name" data-smark>
<button data-smark='{"action":"removeItem"}'>➖</button>
</li>
</ul>
<button data-smark='{"action":"addItem"}'>➕ Add contact</button>When you need to reach somewhere else, address the component by name or path instead of by DOM position. This is what keeps your code stable when the layout changes:
Form
│
├── customer
│
└── contacts
├── contact #1 → name, phones[]
│
└── contact #2 → name, phones[]
<button data-smark='{"action":"addItem","context":"/contacts"}'>➕ Add contact</button>
<button data-smark='{"action":"export","context":"/invoice"}'>💾 Export invoice</button>Paths traverse the SmarkForm tree, not the DOM, and support parents (..),
siblings (.-1) and wildcards (*).
👉 Full reference: Form Traversing.
| Approach | UI defined by | Data model | Dynamic nested lists |
|---|---|---|---|
| Native HTML | HTML | manual | manual JS |
| React / Vue form libraries | components | framework state | framework-specific |
| Schema-driven form generators | schema | schema | renderer-dependent |
| SmarkForm | HTML | JSON | built in |
The point is not that SmarkForm "wins". It's that SmarkForm chooses HTML as the source of truth — your markup is the form definition, and the JSON data model is derived from it rather than replacing it.
Core concepts — how SmarkForm works:
- Markup-driven:
every
data-smarkelement is a field that returns a JSON value. - Nested forms and variable-length lists are themselves fields, so they nest to any depth — of scalars or subforms.
- Declarative triggers, actions and contexts, addressed by relative paths — not by DOM position.
- JSON import and export for the whole form or any sub-tree.
Benefits — why you'd want it:
- Little or no manual wiring between controls and data.
- Complete freedom over layout and CSS — SmarkForm imposes neither.
- Framework independence: works with whatever renders your HTML.
- Keyboard navigation and accessibility built in.
Secondary properties — nice to have:
- Lightweight and dependency-free.
- Context-driven hotkeys.
- Extendable with custom component types and HTML mixin templates.
npm install smarkformOr load it straight from a CDN — as a global UMD script:
<script src="https://cdn.jsdelivr.net/npm/smarkform/dist/SmarkForm.umd.js"></script>
<script>
const form = new SmarkForm(document.querySelector("#form"));
</script>...or as an ES module:
<script type="module">
import SmarkForm from "https://cdn.jsdelivr.net/npm/smarkform/dist/SmarkForm.esm.js";
</script>📌 The snippets above track the latest published version (handy for playgrounds). For production, pin a specific version; see Getting SmarkForm.
SmarkForm is not a React, Vue or Svelte library, and that is a deliberate
choice. It manages the form markup you already have, so it composes with
whatever rendered it — server-side templates, vanilla JavaScript, Web
Components, or a component framework. Point new SmarkForm(...) at a
container and SmarkForm owns that form's structure and interaction, not
your rendering.
SmarkForm manages the structure and interaction of your form. It does not replace native HTML validation or your server-side validation. Use the platform:
<input name="email" type="email" required data-smark>SmarkForm keeps forms keyboard-navigable, manages focus around dynamically added and removed items, and fills in missing ARIA attributes where it can. Semantic labelling remains the responsibility of your markup. Feedback and bug reports are welcome in the A11Y discussion.
👉 See Smooth navigation and Context-driven hotkeys for keyboard-first examples.
~110 KB minified · ~30 KB gzip · ~26 KB brotli · zero runtime dependencies, shipped as both a tree-shakeable ES module and a universal UMD bundle.
The dependency count is the important number: SmarkForm pulls in nothing else, so it never becomes the reason you need another framework in your page.
SmarkForm is at 0.x: the implemented features are stable and in active use, but the public API may still change before 1.0.
Not implemented yet — on the roadmap, not committed to a release:
- Built-in validation and interoperability with JSON Schema.
- TypeScript declarations for the public API.
- A machine-readable, generated API reference.
- Shareable, URL-addressable playground examples.
SmarkForm is also deliberately framework-agnostic: there is no React / Vue / Svelte wrapper, and none is planned as a first-class dependency. A small set of integration recipes may come later.
For detailed usage instructions and API reference check out the 📔 SmarkForm Reference Manual.
-
Showcase — interactive examples of everything SmarkForm can do.
-
Quick Start — from an empty page to a working form.
-
Live Examples — downloadable forms you can run locally and tinker with.
-
Getting SmarkForm — ESM and UMD, via npm, GitHub or CDN.
-
Developer Cheatsheet — the whole API at a glance.
For troubleshooting tips and frequently asked questions, see the Frequently Asked Questions (FAQ).
Open a discussion on the GitHub repository if you don't find an answer in the docs.
You can also reach out through the Contact Page or by email at smarkform@bitifet.net.
Contributions are welcome! If you find any issues or have suggestions for improvements, please open an issue or submit a pull request.
Before contributing, make sure to read our contribution guidelines.
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
front-end – form – form-controller – form-library – json-form – lightweight – vanilla – css-agnostic – DOM – html-form – no-dependencies – a11y
