Showcase
📖 Table of Contents
Welcome to the SmarkForm Showcase — a visual catalogue of what SmarkForm can do. Each example is a fully working form; explore the tabs to see the HTML, CSS, and JavaScript behind it.
For an example applying SmarkForm beyond forms, see Beyond Forms.
Some of the examples in this page may feel unrealistic or contrived. They are designed to demonstrate SmarkForm features, not to be production-ready applications.
Each preview also includes a JSON playground editor with Export, Import, Reset, and Clear controls. The editor is a documentation tool, not a SmarkForm feature; see Preview and the JSON playground for how to use it.
Just a Form
Edit a personal profile, add a photo, and attach an optional PDF résumé. Fields auto-register from HTML attributes — no JavaScript beyond initialization. Try editing values in the preview or exporting to JSON.
<div id="myForm">
<div class="profile-form">
<div class="profile-fields">
<p>
<label data-smark>Name:</label>
<input name='name' data-smark>
</p>
<p>
<label data-smark>Surname:</label>
<input name='surname' data-smark>
</p>
<p>
<label data-smark>Age:</label>
<input name='age' type='number' data-smark>
</p>
<p>
<label data-smark>Profile color:</label>
<span data-smark='{"name":"colour","type":"color"}'>
<input data-smark>
<button data-smark='{"action":"clear"}' title="Clear profile color">❌</button>
</span>
</p>
</div>
<div class="media-field profile-photo-field">
<label data-smark="label">Profile photo:</label>
<img
data-smark='{"type":"image","name":"photo","placeholder":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5NiIgaGVpZ2h0PSI5NiIgdmlld0JveD0iMCAwIDk2IDk2Ij48cmVjdCB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIHJ4PSI0OCIgZmlsbD0iI2U4ZWRmNCIvPjxjaXJjbGUgY3g9IjQ4IiBjeT0iMzQiIHI9IjE3IiBmaWxsPSIjODc5NGE1Ii8+PHBhdGggZD0iTTE0IDkwYzItMjAgMTUtMzIgMzQtMzJzMzIgMTIgMzQgMzIiIGZpbGw9IiM4Nzk0YTUiLz48L3N2Zz4="}'
class="profile-photo"
width="96" height="96"
alt="Profile photo"
>
</div>
<div class="media-field resume-field">
<label data-smark="label">Résumé (PDF):</label>
<input
type="file"
data-smark='{"type":"file","name":"cv","accept":"application/pdf"}'
placeholder="Choose or drop a PDF résumé"
>
</div>
</div>
</div>
#myForm .profile-form { display: grid; grid-template-columns: minmax(0, 1fr) 7rem; grid-template-areas: "fields photo" "resume resume"; align-items: start; gap: .35rem 1rem; }
#myForm .profile-fields { grid-area: fields; min-width: 0; }
#myForm .profile-fields p { display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); align-items: center; gap: .45rem; margin: 0 0 .4rem; }
#myForm .profile-fields p > span { display: flex; align-items: center; gap: .25rem; min-width: 0; }
#myForm .profile-fields p > span input { flex: 1; min-width: 0; }
#myForm .media-field { display: grid; gap: .35rem; min-width: 0; }
#myForm .profile-photo-field { grid-area: photo; justify-items: center; }
#myForm .profile-photo { display: block; width: 96px; height: 96px; max-width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 50%; background: #e8edf4; object-fit: cover; }
#myForm .resume-field { grid-area: resume; }
#myForm .resume-field input { width: 100%; min-width: 0; box-sizing: border-box; }
@media (max-width: 28rem) { #myForm .profile-form { grid-template-columns: minmax(0, 1fr) 6.5rem; column-gap: .5rem; } #myForm .profile-fields p { grid-template-columns: 5rem minmax(0, 1fr); gap: .25rem; } }
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {"name":"Alice","surname":"Johnson","age":28,"colour":"#6366f1","photo":null,"cv":null}
});// Marked fields are discovered automatically; no custom JavaScript is needed.
👉 Fields auto-register from data-smark attributes — no per-field JavaScript.
👉 Singleton pattern. The profile color field uses a <span data-smark> wrapper with the name option, while the inner input carries the bare data-smark attribute. The ❌ button inside the singleton resolves its target implicitly.
👉 Label auto-linking. A <label data-smark> finds the next field in component-tree order — no for or id attributes needed here.
👉 Type-specific values. The profile color field exports null when it has no selection, while empty text fields export "" and Age exports a number. Try the ❌ button to see its nullable state.
👉 Media fields. No photo or résumé is preloaded. The profile picture shows the custom silhouette placeholder; choose a photo or PDF to fill either field. Both values are null until you add a file.
See Image and File for media field options, or check the ✏️ Edit tab to see the full source.
Features demonstrated
null; empty text fields remain strings. See Component Types Overview. data-smark and infers common types from their elements. Named data fields take their key from the HTML name attribute or a SmarkForm name option; labels and triggers are components too, but do not add data fields. See Core Concepts. <span data-smark='{"name":"colour","type":"color"}'> that holds the component options, while the inner <input data-smark> carries the bare attribute. This allows buttons and other elements to share the same field context without repeating the options. See Singleton Pattern. <label data-smark> is automatically associated by SmarkForm with its adjacent field — no for or id attributes needed. The label next to the profile color field is enhanced and linked without any explicit wiring. See Label Component Type. clear on its nearest field, so no explicit target is needed. See Triggers. file type imports and exports complete file contents: a self-describing, base64 data URL by default, or a structured object with format:"json". The résumé field here accepts PDFs and remains null until a file is selected. See File Type. Project Kanban Board
A task management board with drag-and-drop columns and keyboard shortcuts. Drag tasks between To Do, In Progress, and Done. Hold Ctrl to discover hotkeys.
<div id="myForm">
<div class="kanban">
<div class="column">
<div data-smark='{"type":"#kanbanCol","name":"todo"}'>
<span data-for="colLabel">To Do</span>
</div>
</div>
<div class="column">
<div data-smark='{"type":"#kanbanCol","name":"progress"}'>
<span data-for="colLabel">In Progress</span>
</div>
</div>
<div class="column">
<div data-smark='{"type":"#kanbanCol","name":"done"}'>
<span data-for="colLabel">Done</span>
</div>
</div>
</div>
<p class="hint">💡 Hold <kbd>Ctrl</kbd> to discover shortcuts — drag ☰ handles to reorder or move between columns</p>
<button
data-smark='{"action":"removeItem","context":"*","target":"*","preserve_non_empty":true,"autoscroll":"elegant"}'
title="Clear empty tasks from all columns"
class="clear-empties"
>🧹</button>
</div>
<template id="kanbanCol">
<style>
.kanban-mixin strong { display: block; margin-bottom: 0.4em; font-weight: 600; }
.kanban-mixin .card { display: flex; gap: 0.3em; margin: 0.3em 0; padding: 0.4em; background: #f8f9fa; border-radius: 4px; }
.kanban-mixin .card input { flex: 1; }
.kanban-mixin .empty { color: #999; font-style: italic; padding: 1em; text-align: center; }
</style>
<div data-smark='{"type":"list","sortable":true,"min_items":0,"moving_depth":1}' class="kanban-mixin">
<div data-smark='{"role":"header"}'>
<strong id="colLabel">Column</strong>
<button data-smark='{"action":"addItem","hotkey":"+"}' title="Add task">➕</button>
</div>
<div data-smark='{"role":"empty_list"}' class="empty">No tasks</div>
<div class="card">
<span data-smark='{"type":"label"}' class="handle" title="Drag to reorder">☰</span>
<input data-smark type="text" name="title" placeholder="Task title...">
<button data-smark='{"action":"removeItem","hotkey":"-"}' title="Remove">➖</button>
</div>
</div>
</template>
#myForm .kanban { display: flex; gap: 1em; align-items: stretch; }
#myForm .column { flex: 1; min-width: 200px; border: 1px solid #ddd; border-radius: 6px; padding: 0.5em; display: flex; flex-direction: column; }
#myForm .column > [data-smark] { flex: 1; min-height: 0; }
#myForm .hint { font-size: 0.82em; color: #888; margin-top: 1em; }
#myForm .hint kbd { background: rgba(0,0,0,.06); border: 1px solid #ccc; border-radius: 3px; padding: 1px 4px; }
#myForm .clear-empties { display: block; margin: 0.5em auto 0; }
button[data-hotkey]::after { content: "Ctrl+" attr(data-hotkey); position: absolute; top: 0.75em; left: 0; font-size: 0.7em; background: #333; color: #fff; padding: 1px 4px; border-radius: 3px; white-space: nowrap; }
button { position: relative; }
#myForm .kanban input{ width: 4em; }
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {"todo":[{"title":"Write documentation"},{"title":"Review PR"}],"progress":[{"title":"Fix layout bug"}],"done":[{"title":"Setup CI"}]}
});myForm.on("BeforeAction_removeItem", async (ev) => {
// Never confirm for bulk operations (target:*) or preserve_non_empty
if (
ev.target instanceof Array
|| ev.preserve_non_empty
) return;
if (await ev.target?.isEmpty()) return;
if (!window.confirm("Remove this task?")) ev.preventDefault();
});
👉 Drag between columns. All three lists have sortable:true — drag any task card to another column to move it.
👉 Hotkeys. Hold Ctrl to discover + (add task) and - (remove task) on each column. The same hotkeys adapt to the column you’re working in (context sensitivity).
👉 Empty-list placeholders show “No tasks” when a column is empty (min_items:0).
See Hotkeys, Keyboard Navigation.
Features demonstrated
<template id="kanbanCol"> and reused three times via type:"#kanbanCol". Each instance gets its own identity (name), data, and options — identical structure, separate state. data-for snippet slots inject per-instance content (the column headings) into the template. See Mixin Types. sortable:true and moving_depth:1. The lists share the same mixin template, so SmarkForm treats them as compatible; other cross-list moves still obey the configured sibling-distance limits on both lists. See List Type — sortable. context:"*" to find all lists and target:"*" to address their items. With preserve_non_empty:true, one action removes only empty tasks from every column. See Form Traversing — Wildcard Context. BeforeAction_removeItem listener checks whether a task has data and calls ev.preventDefault() if the user cancels its removal. Bulk cleanup skips the prompt. See Events — BeforeAction. item template; item is the default and required role. Optional roles include header, footer, empty_list, placeholder, separator, and last_separator. These columns use header for their title and Add button and empty_list to show “No tasks”. See List Type — template roles. label component, which carries no value of its own. With list sorting enabled, it marks where dragging starts. See Label Type. <style> block. SmarkForm injects and deduplicates it globally, not as scoped CSS; this template prefixes its selectors with .kanban-mixin to limit where they match. See Mixin Types. Race Registration System
Register a family for a race event. Fill the first member, then ✨ duplicate for siblings — only edit what differs. Each member gets a race bib, meal selection, and emergency contact.
The form auto-numbers bibs and exports structured data. Age uses native number input constraints; the example does not implement a race-distance business rule.
<script src="https://cdn.jsdelivr.net/npm/imask@6.6.3"></script>
<script type="smark-mask" data-name="raceFee">
(node) => {
node.inputMode = "decimal";
node.placeholder = "0.00";
if (typeof IMask !== "function") {
console.warn("IMask did not load; the fee field will remain unformatted.");
return null;
}
const imask = IMask(node, {
mask: Number,
scale: 2,
signed: false,
thousandsSeparator: " ",
radix: ".",
padFractionalZeros: true,
normalizeZeros: true,
});
return {
get unmaskedValue() {
return imask.masked.isComplete ? imask.masked.unmaskedValue : null;
},
set unmaskedValue(value) {
imask.unmaskedValue = value;
const length = node.value.length;
try { node.setSelectionRange(length, length); } catch (_error) {}
if ("_selection" in imask) {
imask._selection = { start: length, end: length };
}
},
};
}
</script>
<div id="myForm">
<header>
<h3>🏃 Race Registration</h3>
<button data-smark='{"action":"addItem","context":"members","hotkey":"+"}' title="Add member">➕ Add Member</button>
<button data-smark='{"action":"removeItem","context":"members","target":"*","preserve_non_empty":true,"hotkey":"Delete"}' title="Remove empty slots">🧹</button>
</header>
<ul data-smark='{"type":"list","name":"members","sortable":true,"max_items":5,"export_empties":false}'>
<li>
<div>
<p>
<span data-smark='{"action":"position"}' class="bib">#N</span>
<label data-smark>Name:</label>
<input data-smark name="name" type="text" placeholder="Full name">
</p>
<div class="row">
<label data-smark>Age:</label>
<input data-smark name="age" type="number" min="5" max="99" style="width:6em">
<label data-smark style="margin-left:1em">Sex:</label>
<span class="radio-group">
<label><input type="radio" data-smark name="sex" value="M"> M</label>
<label><input type="radio" data-smark name="sex" value="F"> F</label>
</span>
</div>
<p class="row fee-row">
<label data-smark>Registration fee:</label>
<input data-smark='{"type":"number","name":"fee","mask":"raceFee"}' class="fee" placeholder="0.00">
</p>
<details>
<summary>
<span data-smark="label">Emergency Contact:</span>
<input data-smark name="emergency_name" type="text">
</summary>
<p>
<label data-smark>Phone:</label>
<input data-smark name="emergency_phone" type="tel">
</p>
</details>
<div class="row">
<div style="flex:1">
<label data-smark>T-shirt:</label>
<select data-smark name="tshirt">
<option value="">— Select —</option>
<option>S</option><option>M</option><option>L</option><option>XL</option>
</select>
</div>
<div style="flex:1">
<label data-smark>Color:</label>
<input data-smark type="color" name="tshirt_color" value="#3498db">
</div>
</div>
<p>
<label data-smark>Meal:</label>
<span class="radio-group">
<label><input type="radio" data-smark name="meal" value="Regular" checked> Regular</label>
<label><input type="radio" data-smark name="meal" value="Vegetarian"> Vegetarian</label>
<label><input type="radio" data-smark name="meal" value="Vegan"> Vegan</label>
</span>
</p>
<button data-smark='{"action":"addItem","source":".-1","hotkey":"*"}' title="Duplicate this member">✨ Duplicate</button>
<button data-smark='{"action":"removeItem","failback":"clear","hotkey":"-"}' title="Remove this member">➖</button>
</div>
</li>
</ul>
<p class="hint">💡 Hold <kbd>Ctrl</kbd> to discover shortcuts — ✨ duplicates the previous member</p>
</div>
#myForm { max-width: 520px; font-size: 0.95em; }
#myForm h3 { margin: 0 0 0.3em; }
#myForm header { display: flex; gap: 0.5em; align-items: center; margin-bottom: 0.8em; }
#myForm ul { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5em; }
#myForm li { border: 1px solid #ddd; border-radius: 6px; padding: 0.6em; }
#myForm li p { display: flex; align-items: center; gap: 0.5em; margin: 0.3em 0; }
#myForm li label { font-weight: 500; min-width: 6em; }
#myForm li input[type="text"], #myForm li input[type="tel"], #myForm li input[type="number"] { flex: 1; padding: 0.3em 0.5em; border: 1px solid #ccc; border-radius: 4px; }
#myForm li input.fee { flex: 1; min-width: 0; }
#myForm li select { padding: 0.3em 0.5em; border: 1px solid #ccc; border-radius: 4px; }
#myForm li details { margin-top: 0.3em; }
#myForm li summary { cursor: default; user-select: none; font-weight: 500; }
#myForm li details > summary { display: flex; align-items: center; gap: 0.4em; list-style: none; }
#myForm li details > summary::-webkit-details-marker { display: none; }
#myForm li details > summary::before { content: "▸"; flex: 0 0 auto; transition: transform .15s; }
#myForm li details[open] > summary::before { transform: rotate(90deg); }
#myForm li details > summary input[name="emergency_name"] { flex: 1; min-width: 0; }
#myForm li details[open] { border: 1px solid #eee; border-radius: 4px; padding: 0.3em 0.5em; }
#myForm .bib { font-weight: bold; min-width: 1.5em; text-align: center; background: #f0f0f0; border-radius: 4px; padding: 0.1em 0.4em; }
#myForm .row { display: flex; gap: 0.5em; align-items: center; margin: 0.3em 0; flex-wrap: wrap; }
#myForm .radio-group { display: flex; gap: 0.8em; }
#myForm .radio-group label { min-width: auto; font-weight: 400; cursor: pointer; }
#myForm .hint { font-size: 0.82em; color: #888; margin-top: 1em; }
#myForm .hint kbd { background: rgba(0,0,0,.06); border: 1px solid #ccc; border-radius: 3px; padding: 1px 4px; }
button[data-hotkey]::after { content: "Ctrl+" attr(data-hotkey); position: absolute; top: 0.75em; left: 0; font-size: 0.7em; background: #333; color: #fff; padding: 1px 4px; border-radius: 3px; white-space: nowrap; }
button { position: relative; }
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {"members":[{"name":"Alice Johnson","age":34,"fee":45.5,"sex":"F","emergency_name":"Bob Johnson","emergency_phone":"555-1234","tshirt":"M","tshirt_color":"#3498db","meal":"Vegetarian"}]}
});// SmarkForm handles the field values, list actions, and position labels.
👉 List + form nesting. Each family member is a nested form inside a list — JSON exports as {"members":[{...},...]}.
👉 ✨ Duplicate. The Duplicate button uses source:".-1" to copy the previous member’s data, then you edit only what differs.
👉 Collapsible sections. Emergency contact is in a <details> element, collapsed by default. Alt+Enter opens it and navigates into hidden fields.
👉 Auto-numbered bibs. {"action":"position"} numbers each member.
👉 Formatted registration fee. The fee shows grouped digits and two decimal places, while export keeps it as a number. On mobile, the field opens a decimal keypad.
👉 Type-specific values. Age exports as a number. Empty Number and Radio fields may export null; text and native select values can be empty strings. The Color field has an authored default value.
👉 Hotkeys. Hold Ctrl — + adds a member, - removes, * duplicates, Delete clears empty slots.
See Form Nesting, List Types, Value Coercion.
Features demonstrated
source:".-1" on addItem tells SmarkForm to export the previous item and import it into the new one — edit only what differs. See List Type — source, Form Traversing — Path syntax. <details> and <summary> elements provide the disclosure. SmarkForm navigation can open a collapsed section and move into its hidden fields with Alt+Enter. The summary field stays in the normal Tab order; while the section is closed, its hidden body fields are skipped by Tab / Shift+Tab and SmarkForm's Enter / Shift+Enter navigation. Use Alt+Enter to open the section and navigate forward into its fields, or Alt+Shift+Enter to navigate backward into it. Shift+Space toggles the section from a summary field; from a field in the open body, it closes the section and returns focus to the summary. See Keyboard Navigation. type:"number") exports as a JavaScript number, not a string. Empty Number/Radio values may be null, while empty text/select values are strings. The list omits wholly empty rows through export_empties:false. See Value Coercion. action:"position" — automatically numbers each member in the list with no extra code. See List Type — position. target:"*". preserve_non_empty:true skips items that contain data, so one action clears only the empty rows. See List Type — removeItem. max_items:5 caps the list, and export_empties:false omits wholly empty members. When only one member remains, failback:"clear" clears that row rather than removing it. See List Type. null. A native <select> uses the generic Input type and exports a string; it is not a separate SmarkForm type. See Component Types Overview. data-smark. It formats the visible value while exporting a number; inputMode requests a decimal keypad on mobile. See Field Masking, Number Type, and Input Type. Hotel Schedule by Period
Set hotel opening hours for date-based periods. Name each period, choose its date range, then set hours for each service. Open a period to edit its schedule; remove all a service’s intervals to mark it closed for that period.
<div id="myForm">
<div class="hotel-planner">
<ul data-smark='{"type":"list","name":"periods","of":"form","min_items":0,"max_items":6,"sortable":true}'>
<li data-smark-role="empty_list" class="no-periods">No periods yet. Add an operating period to get started.</li>
<li>
<details class="period-card" open>
<summary>
<span class="period-count"><span data-smark='{"action":"position"}'>1</span><span aria-hidden="true"> of </span><span data-smark='{"action":"count"}'>2</span></span>
<span data-smark='{"type":"label"}' class="period-handle" title="Drag to reorder periods">⠿</span>
<input data-smark type="text" name="name" class="period-name" placeholder="Period name">
<label class="period-summary-date">From <input data-smark type="date" name="start_date"></label>
<label class="period-summary-date">To <input data-smark type="date" name="end_date"></label>
<button data-smark='{"action":"removeItem"}' type="button" title="Remove period">🗑️</button>
</summary>
<div class="period-content">
<div class="service-grid">
<div data-smark='{"type":"#hotelService","name":"reception"}'>
<span data-for="serviceLabel">🛎️ Reception</span>
</div>
<div data-smark='{"type":"#hotelService","name":"restaurant"}'>
<span data-for="serviceLabel">🍽️ Restaurant</span>
</div>
<div data-smark='{"type":"#hotelService","name":"bar"}'>
<span data-for="serviceLabel">🍸 Bar</span>
</div>
<div data-smark='{"type":"#hotelService","name":"pool"}'>
<span data-for="serviceLabel">🏊 Pool</span>
</div>
</div>
<div class="period-actions">
<button data-smark='{"action":"addItem","source":".-1"}' type="button" title="Duplicate this period">✨ Duplicate period</button>
</div>
</div>
</details>
</li>
</ul>
<button data-smark='{"action":"addItem","context":"periods"}' type="button">➕ Add period</button>
</div>
</div>
<template id="hotelService">
<section class="hotel-service" data-smark='{"type":"list","min_items":0,"max_items":3}'>
<div data-smark='{"role":"header"}' class="service-heading">
<strong id="serviceLabel">Service</strong>
<span>OPENING HOURS</span>
</div>
<div data-smark='{"role":"empty_list"}' class="service-closed">Closed this period</div>
<div class="hours-row">
<label>Opens <input data-smark type="time" name="start"></label>
<label>Closes <input data-smark type="time" name="end"></label>
<button data-smark='{"action":"removeItem"}' type="button" title="Remove interval">➖</button>
</div>
<div data-smark='{"role":"footer"}' class="service-footer">
<button data-smark='{"action":"addItem"}' type="button">➕ Add hours</button>
</div>
</section>
</template>
#myForm .hotel-planner { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
#myForm .hotel-planner ul { list-style: none; margin: 0; padding: 0; }
#myForm .hotel-planner > ul { display: grid; width: 100%; min-width: 0; box-sizing: border-box; gap: .75rem; }
#myForm .hotel-planner > ul > li { min-width: 0; }
#myForm .no-periods { padding: 1.25rem; text-align: center; color: #64748b; border: 1px dashed #94a3b8; border-radius: .75rem; }
#myForm .period-card { width: 100%; min-width: 0; box-sizing: border-box; overflow: hidden; border: 1px solid #cbd5e1; border-radius: .9rem; background: linear-gradient(135deg, #fff, #f4f7fb); box-shadow: 0 3px 12px rgba(15, 23, 42, .08); }
#myForm .period-card > summary { display: flex; width: 100%; min-width: 0; box-sizing: border-box; flex-wrap: nowrap; align-items: center; gap: .5rem; padding: .75rem 1rem; background: linear-gradient(100deg, #e0e7ff, #ecfeff); cursor: pointer; white-space: nowrap; overflow: hidden; }
#myForm .period-handle { cursor: grab; color: #475569; user-select: none; }
#myForm .period-count { flex: 0 0 auto; color: #4338ca; font-size: .8rem; font-weight: 700; }
#myForm .period-name { flex: 1 1 10rem; min-width: 7rem; font-size: 1.05rem; font-weight: 700; text-align: left; }
#myForm .period-summary-date { flex: 0 1 10rem; min-width: 0; display: flex; align-items: center; gap: .25rem; font-size: .75rem; font-weight: 600; overflow: hidden; }
#myForm .period-summary-date input { box-sizing: border-box; width: 100%; min-width: 0; }
#myForm .period-card > summary > button { flex: 0 0 auto; margin-left: auto; }
#myForm .period-content { padding: .85rem; }
#myForm .service-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
#myForm .hotel-service { min-width: 0; padding: .7rem; border: 1px solid #dbe3ed; border-radius: .7rem; background: rgba(255, 255, 255, .82); }
#myForm .service-heading { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; padding-bottom: .45rem; border-bottom: 1px solid #e2e8f0; }
#myForm .service-heading strong { color: #334155; }
#myForm .service-heading span { color: #64748b; font-size: .65rem; font-weight: 700; letter-spacing: .08em; }
#myForm .service-closed { padding: .7rem .25rem; color: #64748b; font-style: italic; text-align: center; }
#myForm .hours-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: .4rem; padding: .45rem 0; border-bottom: 1px solid #eef2f7; }
#myForm .hours-row label { display: grid; gap: .2rem; font-size: .75rem; }
#myForm .hours-row input { box-sizing: border-box; width: 100%; min-width: 0; }
#myForm .hours-row button { padding: .25rem .45rem; }
#myForm .service-footer { padding-top: .5rem; }
#myForm .service-footer button { width: 100%; padding: .35rem; }
#myForm .period-actions { display: flex; justify-content: flex-end; padding-top: .75rem; }
@media (max-width: 36rem) {
#myForm .service-grid { grid-template-columns: 1fr; }
#myForm .period-card > summary { gap: .25rem; padding: .5rem .4rem; }
#myForm .period-count { font-size: .65rem; }
#myForm .period-name { flex: 1 1 3rem; min-width: 2.8rem; font-size: .75rem; }
#myForm .period-summary-date { flex: 0 1 4.2rem; min-width: 0; gap: .1rem; font-size: .55rem; overflow: hidden; }
#myForm .period-summary-date input { font-size: .55rem; padding: .1rem; }
#myForm .period-handle { font-size: .8rem; }
#myForm .period-card > summary > button { padding: .2rem .3rem; }
}
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"periods": [
{
"name": "Peak",
"start_date": "2026-04-01",
"end_date": "2026-09-30",
"reception": [{"start": "07:00:00", "end": "23:00:00"}],
"restaurant": [
{"start": "07:00:00", "end": "10:30:00"},
{"start": "19:00:00", "end": "22:30:00"}
],
"bar": [{"start": "10:00:00", "end": "23:00:00"}],
"pool": [{"start": "08:00:00", "end": "21:00:00"}]
},
{
"name": "Off-peak",
"start_date": "2026-10-01",
"end_date": "2027-03-31",
"reception": [{"start": "08:00:00", "end": "22:00:00"}],
"restaurant": [
{"start": "07:30:00", "end": "10:00:00"},
{"start": "18:00:00", "end": "21:00:00"}
],
"bar": [],
"pool": []
}
]
}
});
Open a period to edit its dates and service hours. Add split shifts where a service closes during the day; remove all intervals to mark it closed. Drag the ⠿ handle to reorder periods, or choose ✨ Duplicate period to copy the preceding period and adjust only what differs.
Features demonstrated
empty_list role provides content to show when it has no items. Here, each service displays “Closed this period” until hours are added. See List Type. addItem's source: ".-1" option to copy the previous list item, then you can change only its dates or service hours. See List Type — addItem. sortable:true to let people drag list items into a new order. Each period keeps its service schedules when it moves. See List Type — sortable. #hotelService for Reception, Restaurant, Bar, and Pool; data-for fills each service label. See Mixin Types. position and count to show where the current period sits in the list, such as “1 of 2”. The position changes when periods are reordered; the count changes when periods are added or removed. See List Type — actions. Want the full feature set? Each example above demonstrates a curated subset of SmarkForm capabilities. See the individual reference pages for the complete API and advanced patterns.
More examples from the documentation
The Showcase above is a curated selection. Many other examples live beside the guides and references that explain them. The following collection brings selected examples together for browsing; use each Original link to open it in its full documentation context.
The examples
Edit a profile and its skill list
<div id="myForm">
<fieldset data-smark='{"type":"form","name":"profile"}'>
<p>
<label data-smark>Name:</label>
<input name="name" type="text" data-smark>
</p>
<fieldset data-smark='{"type":"form","name":"contact"}'>
<legend>Contact</legend>
<label data-smark>Email:</label>
<input name="email" type="email" data-smark>
</fieldset>
</fieldset>
<div>
<strong>Skills</strong>
<ul data-smark='{"type":"list","name":"skills","of":"input","min_items":0}'>
<li><input type="text" data-smark></li>
</ul>
</div>
</div>
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"profile": {
"name": "Ada Lovelace",
"contact": { "email": "ada@example.com" }
},
"skills": ["mathematics", "computing"]
}
});// The playground's Export control shows the component tree's current value.
Edit a profile and its skill list
Try it: Change the profile fields or add/remove skills, then choose Export to see how named forms and list items map into the value below.
Original: Core Concepts.
Set a daily schedule using time intervals
<div id="myForm">
<p class="schedule-controls">
<button data-smark='{"action":"removeItem","context":"schedule","hotkey":"-"}' title="Remove interval" type="button">➖</button>
<button data-smark='{"action":"addItem","context":"schedule","hotkey":"+"}' title="Add interval" type="button">➕</button>
<strong data-smark="label">Schedule:</strong>
<span data-smark='{"type":"list","name":"schedule","of":"form","min_items":0,"max_items":3,"export_empties":true}'>
<span>
<input class="small" data-smark type="time" name="start"> to
<input class="small" data-smark type="time" name="end">
</span>
<span data-smark-role="empty_list">(Closed)</span>
<span data-smark-role="separator">, </span>
<span data-smark-role="last_separator"> and </span>
</span>
</p>
</div>
#myForm [data-hotkey] { position: relative; }
#myForm [data-hotkey]::after {
content: "Ctrl+" attr(data-hotkey);
position: absolute;
top: 0.75em;
left: 0;
padding: 1px 4px;
border-radius: 3px;
background: #333;
color: white;
font-size: .7em;
white-space: nowrap;
}
#myForm .schedule-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .35em; }
#myForm input.small { width: 7.5em; }
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"schedule": [
{"start":"09:00:00","end":"12:00:00"},
{"start":"13:00:00","end":"15:00:00"},
{"start":"15:30:00","end":"18:00:00"}
]
}
});// The list, role templates, limits, and trigger behavior are declarative.
Set a daily schedule using time intervals
👉 Three template roles. empty_list shows (Closed) when there are no intervals. The first gap uses separator (a comma); the last uses last_separator (“and”). Try removing all rows, then add them back.
👉 Bounds. The list permits zero through three intervals. SmarkForm disables the Add and Remove triggers automatically at the corresponding limits.
👉 Try it! Remove an interval and watch the wording update. Hold Ctrl to see the optional + / - hotkeys.
Original: «list» Component Type.
Manage billing, shipping, and pets
<div id="myForm">
<fieldset data-smark='{"type":"form","name":"billing"}'>
<legend>Billing</legend>
<label data-smark>Address</label>
<input name="address" data-smark>
</fieldset>
<fieldset data-smark='{"type":"form","name":"shipping"}'>
<legend>Shipping</legend>
<label data-smark>Address</label>
<input name="address" data-smark>
</fieldset>
<button data-smark='{"action":"export","context":"billing","target":"../shipping"}'>
Copy billing to shipping
</button>
<fieldset>
<legend>Pets</legend>
<ul data-smark='{"type":"list","name":"pets","min_items":0,"max_items":4}'>
<li>
<label data-smark>Name</label>
<input name="name" data-smark>
<button data-smark-action="removeItem">Remove this pet</button>
</li>
</ul>
<button data-smark='{"action":"addItem","context":"pets"}'>Add pet</button>
</fieldset>
</div>
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"billing": { "address": "42 Analytical Engine Way" },
"shipping": { "address": "18 St James's Square" },
"pets": [{ "name": "Ada" }, { "name": "Babbage" }]
}
});// Every control in this example uses a declarative SmarkForm action.
Manage billing, shipping, and pets
Try it: Click Copy billing to shipping. export copies from the billing context into its sibling target. Add or remove pets: the outside add trigger uses explicit context, while each item-level remove trigger uses natural context and placement to identify its target.
Original: «trigger» Component Type.
Manage school groups, students, and grades
<div id="myForm">
<div class="school-groups">
<div class="school-header">
<input name="name" placeholder="School name" type="text" data-smark>
<input name="level" placeholder="Level (e.g. Primary)" type="text" data-smark>
<input name="year" placeholder="Academic year" type="text" data-smark>
</div>
<ul data-smark='{"type":"list","name":"groups","sortable":true,"min_items":0}'>
<li data-smark='{"role":"empty_list"}' class="empty">No groups yet</li>
<li>
<div class="group-card">
<details>
<summary>
<span data-smark='{"type":"label"}' class="handle" title="Drag to reorder groups">⠿</span>
<span data-smark='{"action":"position"}'>#</span>
<input name="name" placeholder="Group name" type="text" data-smark>
<button data-smark='{"action":"removeItem"}' title="Remove group">➖</button>
</summary>
<div class="group-body">
<label class="tutor">Tutor: <input name="tutor" placeholder="Tutor name" type="text" data-smark></label>
<div class="students">
<strong>Students</strong>
<ul data-smark='{"type":"list","name":"students","sortable":true,"moving_depth":2,"min_items":0}'>
<li data-smark='{"role":"empty_list"}' class="empty">No students yet</li>
<li>
<details>
<summary>
<span data-smark='{"type":"label"}' class="handle" title="Drag to move between groups">⠿</span>
<input name="name" placeholder="Student name" type="text" data-smark>
<button data-smark='{"action":"removeItem"}' title="Remove student">➖</button>
</summary>
<div data-smark='{"type":"form","name":"grades"}' class="grades">
<div class="grade-column">
<strong>Math</strong>
<ul data-smark='{"type":"list","name":"math","min_items":0}'><li><input name="grade" type="number" step="0.1" min="0" max="10" data-smark></li></ul>
<button data-smark='{"action":"removeItem","context":"math"}' title="Remove grade">➖</button>
<button data-smark='{"action":"addItem","context":"math"}' title="Add grade">➕</button>
</div>
<div class="grade-column">
<strong>Literature</strong>
<ul data-smark='{"type":"list","name":"literature","min_items":0}'><li><input name="grade" type="number" step="0.1" min="0" max="10" data-smark></li></ul>
<button data-smark='{"action":"removeItem","context":"literature"}' title="Remove grade">➖</button>
<button data-smark='{"action":"addItem","context":"literature"}' title="Add grade">➕</button>
</div>
<div class="grade-column">
<strong>Science</strong>
<ul data-smark='{"type":"list","name":"science","min_items":0}'><li><input name="grade" type="number" step="0.1" min="0" max="10" data-smark></li></ul>
<button data-smark='{"action":"removeItem","context":"science"}' title="Remove grade">➖</button>
<button data-smark='{"action":"addItem","context":"science"}' title="Add grade">➕</button>
</div>
</div>
</details>
</li>
</ul>
<button data-smark='{"action":"addItem","context":"students"}' title="Add student">➕ Add Student</button>
</div>
</div>
</details>
</div>
</li>
</ul>
<button data-smark='{"action":"addItem","context":"groups"}' title="Add group">➕ Add Group</button>
</div>
</div>
#myForm .school-groups { max-width: 520px; font-size: .95em; }
#myForm .school-groups ul { list-style: none; padding: 0; margin: 0; }
#myForm .school-header { display: flex; gap: .5em; margin-bottom: .6em; flex-wrap: wrap; }
#myForm .school-header input { flex: 1; min-width: 120px; padding: .35em .5em; }
#myForm .group-card { border: 1px solid #ddd; border-radius: 8px; margin: .4em 0; background: #fafafa; overflow: hidden; }
#myForm .group-card summary { display: flex; align-items: center; gap: .4em; padding: .35em .5em; background: #fff; }
#myForm .group-card details[open] summary { border-bottom: 1px solid #eee; }
#myForm .group-card summary input { flex: 1; min-width: 0; }
#myForm .handle { cursor: grab; color: #999; user-select: none; }
#myForm .group-body { padding: .4em .5em .2em 1.8em; }
#myForm .tutor { display: flex; gap: .4em; margin-bottom: .3em; }
#myForm .tutor input { flex: 1; min-width: 0; }
#myForm .students { margin-top: .3em; }
#myForm .students > strong { display: block; font-size: .85em; margin-bottom: .2em; }
#myForm .students ul { max-height: 240px; overflow-y: auto; border: 1px solid #eee; border-radius: 4px; padding: .2em .3em; background: #fff; }
#myForm .students li { margin: .15em 0; }
#myForm .students summary { display: flex; align-items: center; gap: .3em; padding: .2em .3em; background: #fafafa; border: 1px solid #eee; }
#myForm .students summary input { flex: 1; min-width: 0; }
#myForm .grades { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: .3em; }
#myForm .grade-column { display: flex; flex-direction: column; gap: 2px; }
#myForm .grade-column strong { text-align: center; font-size: .85em; border-bottom: 1px solid #ddd; }
#myForm .grade-column input { width: 100%; box-sizing: border-box; }
#myForm .empty { color: #999; font-style: italic; padding: .5em; }
#myForm .school-groups button { padding: .2em .6em; }
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"name": "Springfield Elementary",
"level": "Primary",
"year": "2025/2026",
"groups": [
{"name":"Class A","tutor":"Mr. Smith","students":[
{"name":"Lisa Simpson","grades":{"math":[{"grade":9.5},{"grade":8.0}],"literature":[{"grade":7.3}],"science":[{"grade":8.8}]}},
{"name":"Bart Simpson","grades":{"math":[{"grade":4.0}],"literature":[{"grade":5.5}],"science":[{"grade":3.0}]}}
]},
{"name":"Class B","tutor":"Ms. Johnson","students":[
{"name":"Milhouse Van Houten","grades":{"math":[{"grade":6.0}],"literature":[{"grade":7.0}],"science":[{"grade":6.5}]}}
]}
]
}
});// Lists, nested forms, and cross-list behavior are declarative.
Manage school groups, students, and grades
👉 Nested data. The export contains a school, groups, students, and separate lists of Math, Literature, and Science grades for each student.
👉 Cross-list movement. Drag a student handle between groups. Their grades move with them; the sibling distance between student lists is 2, so they use moving_depth:2.
👉 Try it! Open a class and student, move Bart to Class B, then add a grade. The JSON editor shows the updated nested value.
Original: «list» Component Type.
Keep contacts with multiple phone numbers
<div id="myForm">
<div class="phonebook">
<ul data-smark='{"type":"list","name":"users"}' class="contact-list">
<li>
<article class="contact-card">
<header class="contact-heading">
<strong>Contact <span data-smark-action="position">1</span></strong>
<button data-smark='{"action":"removeItem","hotkey":"-"}' type="button" title="Remove contact" aria-label="Remove contact">🗑</button>
</header>
<div class="contact-fields">
<label>Name<input data-smark type="text" name="name" placeholder="First name"></label>
<label>Surname<input data-smark type="text" name="surname" placeholder="Last name"></label>
</div>
<section class="phone-section">
<strong>Phone numbers</strong>
<ul data-smark='{"type":"list","name":"phones"}' class="phone-list">
<li class="phone-row">
<label data-smark class="phone-label">📞 Telephone <span data-smark-action="position">1</span></label>
<input type="tel" data-smark name="phone" placeholder="Phone number">
<button data-smark='{"action":"removeItem","hotkey":"-"}' type="button" title="Remove phone" aria-label="Remove phone">✕</button>
</li>
</ul>
<button data-smark='{"action":"addItem","context":"phones","hotkey":"+"}' type="button" title="Add phone" aria-label="Add phone">➕ Phone</button>
</section>
</article>
</li>
</ul>
<footer class="phonebook-footer">
<span>Total contacts: <strong data-smark='{"action":"count","context":"users"}'>0</strong></span>
<button data-smark='{"action":"addItem","context":"users","hotkey":"+"}' type="button" title="Add contact" aria-label="Add contact">➕ Contact</button>
</footer>
</div>
</div>
#myForm .phonebook { max-width: 34rem; font-family: system-ui, sans-serif; }
#myForm .contact-list, #myForm .phone-list { list-style: none; margin: 0; padding: 0; }
#myForm .contact-list { display: grid; gap: .65rem; }
#myForm .contact-card { padding: .55rem .75rem .75rem; border: 1px solid #d7dfeb; border-radius: .65rem; background: linear-gradient(135deg, #fff, #f8fafc); }
#myForm .contact-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .5rem; color: #334155; }
#myForm .contact-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; }
#myForm .contact-fields label { display: grid; gap: .2rem; color: #475569; font-size: .8rem; }
#myForm .contact-fields input, #myForm .phone-row input { width: 100%; min-width: 0; box-sizing: border-box; }
#myForm .phone-section { margin-top: .65rem; padding-top: .55rem; border-top: 1px solid #e2e8f0; }
#myForm .phone-section > strong { display: block; margin-bottom: .25rem; color: #475569; font-size: .85rem; }
#myForm .phone-row { display: grid; grid-template-columns: minmax(7.5rem, auto) minmax(0, 1fr) auto; align-items: center; gap: .4rem; padding: .25rem 0; }
#myForm .phone-label { color: #475569; white-space: nowrap; }
#myForm .phonebook button { padding: .25rem .5rem; }
#myForm .phonebook-footer { display: flex; justify-content: space-between; align-items: center; gap: .5rem; margin-top: .55rem; color: #64748b; font-size: .85rem; }
@media (max-width: 26rem) { #myForm .contact-fields { grid-template-columns: 1fr; } #myForm .phone-row { grid-template-columns: minmax(6.5rem, auto) minmax(0, 1fr) auto; gap: .25rem; } }
/* Hold Ctrl to reveal hotkey hints */
#myForm button { position: relative; }
#myForm [data-hotkey]::after {
content:"Ctrl+" attr(data-hotkey);
position:absolute; top:0.75em; left:0;
font-size:0.7em;
background:#333; color:#fff;
padding:1px 4px; border-radius:3px;
white-space:nowrap;
}
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {"users":[{"name":"Alice","surname":"Johnson","phones":[{"phone":"555-0142"},{"phone":"555-0188"}]},{"name":"Sam","surname":"Rivera","phones":[{"phone":"555-0126"}]}]}
});
Keep contacts with multiple phone numbers
👉 Try it! Focus a phone number and hold Ctrl: - removes that phone. Focus a contact name instead and the same shortcut removes the contact. The + shortcut adds a phone within its list or a contact at the outer level.
See List Type for nested lists and Conflict Resolution for hotkey priority.
Original: Hotkeys.
Maintain a contact list with expandable details
<div id="myForm">
<div data-smark='{"name":"contacts","type":"list","min_items":1,"export_empties":false}'>
<details>
<summary>
<input data-smark type="text" name="fullname" placeholder="Full name">
</summary>
<div class="contact-details">
<input data-smark type="email" name="email" placeholder="Email">
<input data-smark type="tel" name="phone" placeholder="Phone">
<button data-smark='{"action":"removeItem","failback":"clear","hotkey":"Delete"}' title="Remove">🗑</button>
</div>
</details>
</div>
<button data-smark='{"action":"addItem","context":"contacts","hotkey":"+"}'>➕ Add contact</button>
<textarea data-smark name="notes" placeholder="Notes about this contact list"></textarea>
</div>
#myForm {
font-family: sans-serif;
}
#myForm details {
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 6px;
padding: 4px 8px;
}
#myForm summary {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
list-style: none;
user-select: none;
}
#myForm summary::before {
content: "▶";
font-size: .75em;
transition: transform .15s;
flex-shrink: 0;
}
#myForm details[open] > summary::before {
transform: rotate(90deg);
}
#myForm summary input {
flex: 1;
min-width: 0;
font-weight: bold;
border: none;
background: transparent;
outline: none;
}
#myForm .contact-details {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px 0 2px 1.2em;
}
#myForm .contact-details input {
flex: 1;
min-width: 120px;
}
#myForm .contact-details button {
margin-left: auto;
}
#myForm textarea[name="notes"] {
display: block;
width: 100%;
min-height: 60px;
margin-top: 8px;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: inherit;
box-sizing: border-box;
resize: vertical;
}
/* Hold Ctrl to reveal hotkey hints. */
#myForm [data-hotkey] { position: relative; }
#myForm [data-hotkey]::after {
content: "Ctrl+" attr(data-hotkey);
position: absolute; top: 0.75em; left: 0;
font-size: .7em; background: #333; color: #fff;
padding: 1px 4px; border-radius: 3px; white-space: nowrap;
}
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"contacts": [
{"fullname":"Alice Smith","email":"alice@example.com","phone":"+1 555 100 0001"},
{"fullname":"Bob Jones","email":"bob@example.com","phone":"+1 555 100 0002"},
{"fullname":"Carol White","email":"carol@example.com","phone":"+1 555 100 0003"}
],
"notes": ""
}
});// Native details plus SmarkForm keyboard navigation; no custom JS is needed.
Maintain a contact list with expandable details
Try it: Edit a contact name in the summary, expand its details, and use Enter, Shift+Enter, Alt+Enter, and Alt+Shift+Enter to test forward/backward navigation across collapsed list items.
The shared JSON editor is disabled because its controls would add extra fields and tab stops to this keyboard-navigation example.
Original: Keyboard Navigation.
Set separate work hours for morning and afternoon
<div id="myForm">
<div data-smark='{"type":"#periodCard","name":"morning"}'>
<span data-for="periodLabel">Morning</span>
</div>
<div data-smark='{"type":"#periodCard","name":"afternoon"}'>
<span data-for="periodLabel">Afternoon</span>
</div>
</div>
<template id="periodCard">
<section data-smark='{"type":"form"}' class="period-card">
<header class="period-heading">
<span aria-hidden="true">🕒</span>
<h4><span id="periodLabel">Period</span></h4>
</header>
<div data-smark='{"type":"#timeRange","name":"hours"}'></div>
</section>
</template>
<template id="timeRange">
<div data-smark='{"type":"form"}' class="time-range">
<label>Start time<input data-smark type="time" name="start"></label>
<label>End time<input data-smark type="time" name="end"></label>
</div>
</template>
#myForm { display: flex; flex-direction: column; align-items: stretch; gap: .75rem; width: 100%; box-sizing: border-box; }
#myForm > * { width: 100%; box-sizing: border-box; }
#myForm .period-card { min-width: 0; padding: .65rem .8rem; border-left: 3px solid #6366f1; border-radius: .45rem; background: #f8fafc; }
#myForm .period-heading { display: flex; align-items: center; gap: .45rem; margin-bottom: .45rem; color: #4338ca; }
#myForm .period-heading h4 { margin: 0; font-size: 1rem; font-weight: 650; }
#myForm .time-range { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; }
#myForm .time-range label { display: grid; gap: .2rem; color: #475569; font-size: .8rem; }
#myForm .time-range input { width: 100%; min-width: 0; box-sizing: border-box; }
@media (max-width: 26rem) { #myForm .time-range { grid-template-columns: 1fr; } }
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"morning": {"hours": {"start": "09:00:00", "end": "12:00:00"}},
"afternoon": {"hours": {"start": "13:00:00", "end": "17:00:00"}}
}
});// Mixin expansion and value binding are declarative; no custom JavaScript is needed.
Set separate work hours for morning and afternoon
👉 Nested mixins. #periodCard contains a #timeRange reference, so the outer markup can reuse the composed component twice without repeating the period card or time-input layout.
👉 Snippet slots. Each placeholder replaces periodLabel with its own heading. The two instances keep independent morning and afternoon values.
👉 Try it! Change the start time for one period and inspect the nested JSON. Mixins only reuse the markup; each period still exports as a normal nested object. See the List reference for nested Form/List values.
Original: Mixin Types.
Copy a billing address into a shipping address
<div id="myForm">
<fieldset data-smark='{"type":"form","name":"billing"}'>
<legend>Billing address</legend>
<p>
<label data-smark>Street:</label>
<input data-smark type="text" name="street">
</p>
<p>
<label data-smark>City:</label>
<input data-smark type="text" name="city">
</p>
<p>
<label data-smark>Postcode:</label>
<input data-smark type="text" name="postcode">
</p>
</fieldset>
<p>
<button data-smark='{
"action":"export",
"context":"billing",
"target":"../shipping"
}'>📋 Copy to shipping</button>
</p>
<fieldset data-smark='{"type":"form","name":"shipping"}'>
<legend>Shipping address</legend>
<p>
<label data-smark>Street:</label>
<input data-smark type="text" name="street">
</p>
<p>
<label data-smark>City:</label>
<input data-smark type="text" name="city">
</p>
<p>
<label data-smark>Postcode:</label>
<input data-smark type="text" name="postcode">
</p>
</fieldset>
</div>
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"billing": {
"street": "10 Downing Street",
"city": "London",
"postcode": "SW1A 2AA"
},
"shipping": {
"street": "",
"city": "",
"postcode": ""
}
}
});
Copy a billing address into a shipping address
Fill in the billing address, then click 📋 Copy to shipping to copy the data to the shipping address with no JavaScript required.
Original: Importing and Exporting Data.
Track tasks and set each task’s priority
<div id="myForm">
<button data-smark='{"action":"addItem","context":"tasks"}' type="button">➕ Add task</button>
<ul class="task-list" data-smark='{"type":"list","name":"tasks","min_items":1,"max_items":8}'>
<li class="task-row">
<label>
<span data-smark='{"action":"position"}'>N</span>
<input data-smark type="text" name="title" placeholder="Task description">
</label>
<select data-smark name="priority">
<option value="Normal">Normal</option>
<option value="High">High</option>
<option value="Low">Low</option>
</select>
<button data-smark='{"action":"removeItem"}' type="button" title="Remove task">➖</button>
</li>
</ul>
</div>
#myForm .task-list { list-style: none; padding: 0; }
#myForm .task-row { display: flex; align-items: center; gap: .5rem; margin-block: .4rem; padding: .5rem; border: 1px solid #ccc; border-radius: .4rem; }
#myForm .task-row label { display: flex; align-items: center; gap: .5rem; flex: 1; }
#myForm .task-row input { flex: 1; min-width: 0; }
#myForm .task-row.task-entering { transform: translateX(-2rem); opacity: 0; transition: transform 220ms ease-out, opacity 220ms ease-out; }
#myForm .task-row.task-visible { transform: translateX(0); opacity: 1; }
#myForm .task-row.task-leaving { transform: translateX(1rem); opacity: 0; transition: transform 220ms ease-in, opacity 220ms ease-in; }
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {"tasks":[{"title":"Confirm the picnic site","priority":"High"},{"title":"Send invitations","priority":"Normal"}]}
});const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
myForm.onAll("afterRender", async (ev) => {
if (ev.context.parent?.options.type !== "list") return;
const row = ev.context.targetNode;
row.classList.add("task-entering");
await delay(1); // Let the browser paint the starting position first.
row.classList.add("task-visible");
});
myForm.onAll("beforeUnrender", async (ev) => {
if (ev.context.parent?.options.type !== "list") return;
ev.context.targetNode.classList.remove("task-visible");
ev.context.targetNode.classList.add("task-leaving");
await delay(220); // Keep the row attached until its CSS transition ends.
});
Track tasks and set each task's priority
Add and remove rows to see the transitions. afterRender runs after insertion, so the handler adds the initial state, yields briefly, then applies the visible state. beforeUnrender applies the exit state and returns a promise; SmarkForm waits for that promise before removing the row.
The guard checks that the event target is a direct child of a list, so the handlers apply to task rows rather than every component in the form.
Original: Events.
Enter a payment card number
<script src="https://cdn.jsdelivr.net/npm/imask@6.6.3"></script>
<div id="myForm">
<div data-smark='{"type":"form","name":"payment"}'>
<p>
<label data-smark>Card Number:</label>
<input data-smark='{"type":"number","name":"cardNumber","mask":"card"}'>
</p>
</div>
</div>
input:invalid {
outline: 1px solid #d4c070;
outline-offset: -1px;
}
SmarkForm.registerMask("card", (node) => {
// DRY: set placeholder and keyboard hint inside the factory
node.placeholder = "0000 0000 0000 0000";
node.inputMode = "numeric";
// Start lazy so the native placeholder shows while empty
let showLazy = true;
const imask = new IMask(node, {
mask: "0000 0000 0000 0000",
lazy: true,
});
let prevValue = node.value;
let lastKey = "";
node.addEventListener("keydown", (e) => { lastKey = e.key; });
node.addEventListener("input", () => {
const hasContent = imask.masked.unmaskedValue.length > 0;
if (hasContent && showLazy) {
// First digit: switch to non-lazy + underscore placeholders
showLazy = false;
imask.updateOptions({
mask: "0000 0000 0000 0000",
lazy: false,
placeholderChar: "_",
});
} else if (!hasContent && !showLazy) {
// Last digit deleted: switch back to lazy (. .restore placeholder)
showLazy = true;
imask.updateOptions({
mask: "0000 0000 0000 0000",
lazy: true,
});
}
// Blink on rejected input, but not for Backspace/Delete hitting a separator
if (node.value === prevValue && node === document.activeElement) {
if (lastKey !== "Backspace" && lastKey !== "Delete") {
node.style.boxShadow = "0 0 0 2px #f80";
setTimeout(() => node.style.boxShadow = "", 250);
}
}
prevValue = node.value;
// Mark invalid when partially filled (triggers native :invalid CSS)
const raw = imask.masked.unmaskedValue;
const showError = raw.length > 0 && !imask.masked.isComplete;
node.setCustomValidity(showError ? "Please enter the full 16-digit card number" : "");
});
// Wrap IMask so unmaskedValue returns null for incomplete cards
return {
get unmaskedValue() {
return imask.masked.isComplete ? imask.masked.unmaskedValue : null;
},
set unmaskedValue(v) {
// Use IMask's top-level setter (syncs the DOM via updateControl).
imask.unmaskedValue = v;
// IMask diffs an `input` event against its last saved selection, which
// is only refreshed on focus/keydown. Since SmarkForm dispatches an
// `input` event after every import, a stale selection would make IMask
// read the imported value as a duplicate insertion (e.g. "4111" →
// "4111 4111"). Sync caret and selection to the end instead.
const len = node.value.length;
node.setSelectionRange(len, len);
imask._selection = { start: len, end: len };
},
};
});
const myForm = new SmarkForm(document.getElementById("myForm"));
Enter a payment card number
The wrapper object overriding unmaskedValue to return null for incomplete card numbers is the key refinement — export() never returns partially typed data. This example deliberately declares a number field, so complete digits export as a JavaScript number, matching the field-type coercion rules. For identifiers where leading zeroes or full numeric precision must be preserved, use type:"input" and keep the unmasked digits as a string. The field is marked :invalid when partially filled (not empty but not complete), and rejected keystrokes trigger a brief orange blink — visually distinct from the persistent invalid state.
Original: Field Masking.
Attach a PDF résumé
<div id="myForm">
<label data-smark="label">Resume (PDF):</label>
<input
type="file"
data-smark='{"type":"file","name":"cv","accept":"application/pdf"}'
placeholder="Click to pick a PDF, or drop it here…"
>
</div>
#myForm input {
display: block;
width: 100%;
box-sizing: border-box;
padding: .5em .75em;
border: 1px dashed #aaa;
border-radius: .5rem;
}
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"cv": "data:application/pdf;name=resume.pdf;size=5;lastModified=0;base64,aGVsbG8="
}
});
Attach a PDF résumé
👉 Raw export: pick a file (or drop it on the field) and press ⬇️ Export — you get a single data-URL string.
👉 Import anything: paste one of the following into the JSON editor and press ⬆️ Import:
- a data-URL string (
name/size/lastModifiedcome from the header), - a JSON object (or a JSON string of one) — partial objects are fine,
- a bare base64 payload string,
- an URL string (fetched and embedded — see URLs as Input Values),
null(clears the field).
👉 accept filters the picker AND drop/paste: try dropping a .png on this field — it is silently ignored (only application/pdf is accepted).
Try it! Load the demo cv value, then replace the imported name by editing the visible text field and export again.
Original: «file» Component Type.
Upload a square profile avatar
<div id="myForm">
<p>
<label data-smark="label">Square avatar (auto-resized & converted):</label>
</p>
<img
data-smark='{"name":"avatar","image_resize":[200,200],"image_format":"webp"}'
width="200" height="200"
class="avatar"
alt="Square avatar"
>
</div>
#myForm .avatar {
display: block;
max-width: 100%;
border: 1px dashed #aaa;
border-radius: .5rem;
object-fit: cover;
}
const myForm = new SmarkForm(document.getElementById("myForm"), {
"value": {
"avatar": "/assets/logo/smarkform_compact.svg"
}
});
Upload a square profile avatar
👉 Pick a photo and it is stretched into a 200×200 square and, where the browser can encode it, converted to WebP.
👉 Not every engine can encode every format. When the requested conversion is unavailable (e.g. WebP on older engines), the original bytes are kept and — because image_enforce defaults to "warn" — an in-page toast and a bubbling smark:imageNotice event explain why.
👉 Imports are untouched: demo values and import()ed data are displayed as-is; the resize/format pipeline only applies to newly picked/dropped/ pasted files.
Try it! The demo value (a URL-seeded image) is already loaded; pick a photo from your disk and watch it become a 200×200 square.
Original: «image» Component Type.
Calculate expressions with a keypad or keyboard
<div id="myForm">
<div class="calculator" data-smark='{"type":"input","name":"display"}'>
<input data-smark type="text" class="display" value="0" pattern="[0-9+\*\/\(\)\.\-]+">
<div class="buttons">
<button data-smark='{"action":"import","data":"C"}' class="clear">C</button>
<button data-smark='{"action":"import","data":"("}'>(</button>
<button data-smark='{"action":"import","data":")"}'>)</button>
<button data-smark='{"action":"import","data":"/"}' class="operator">÷</button>
<button data-smark='{"action":"import","data":"7"}'>7</button>
<button data-smark='{"action":"import","data":"8"}'>8</button>
<button data-smark='{"action":"import","data":"9"}'>9</button>
<button data-smark='{"action":"import","data":"*"}' class="operator">×</button>
<button data-smark='{"action":"import","data":"4"}'>4</button>
<button data-smark='{"action":"import","data":"5"}'>5</button>
<button data-smark='{"action":"import","data":"6"}'>6</button>
<button data-smark='{"action":"import","data":"-"}' class="operator">−</button>
<button data-smark='{"action":"import","data":"1"}'>1</button>
<button data-smark='{"action":"import","data":"2"}'>2</button>
<button data-smark='{"action":"import","data":"3"}'>3</button>
<button data-smark='{"action":"import","data":"+"}' class="operator">+</button>
<button data-smark='{"action":"import","data":"0"}'>0</button>
<button data-smark='{"action":"import","data":"."}'>.</button>
<button data-smark='{"action":"import","data":"Backspace"}'>←</button>
<button data-smark='{"action":"import","data":"="}' class="equals">=</button>
</div>
</div>
</div>
#myForm .calculator { background-color: #333; border-radius: 10px; padding: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); width: 300px; }
#myForm .display { width: 100%; box-sizing: border-box; background: #fff; border: 1px solid #ccc; border-radius: 5px; padding: 10px; margin-bottom: 10px; font-size: 24px; text-align: right; }
#myForm .display:invalid { background-color: #fcc; }
#myForm .buttons { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; }
#myForm button { padding: 15px; font-size: 18px; border: none; border-radius: 5px; cursor: pointer; background-color: #555; color: white; }
#myForm button:hover { background-color: #777; }
#myForm .operator { background-color: #f9a825; }
#myForm .operator:hover { background-color: #ffb300; }
#myForm .equals { background-color: #4caf50; }
#myForm .equals:hover { background-color: #66bb6a; }
#myForm .clear { background-color: #d32f2f; }
#myForm .clear:hover { background-color: #ef5350; }
#myForm button { position: relative; }
#myForm [data-hotkey]::after {
content: "Ctrl+" attr(data-hotkey);
position: absolute; top: 0.75em; left: 0;
font-size: 0.7em; background: #333; color: #fff;
padding: 1px 4px; border-radius: 3px; white-space: nowrap;
}
#myForm .calculator input.display { caret-color: transparent; }
const myForm = new SmarkForm(document.getElementById("myForm"));var invalidChars = /[^0-9+\-*\/().]+/g;
function updateDisplay(prevValue, key) {
switch (key.toLowerCase()) {
case "c":
case "delete":
return "0"; /* Clear display */
break;
case "backspace":
return prevValue.slice(0, -1) || "0"; /* Remove last character */
break;
case "=":
case "enter": /* Keyboard enter key */
try {
/* Evaluate expression */
const sanitized = prevValue.replaceAll(invalidChars, '');
return eval(sanitized);
} catch (e) {
return "Error!";
}
break;
default:
if (!! key.match(invalidChars)) {
return null; /* Let the browser handle unrelated keys. */
};
if (prevValue.replace(/[0\s]+/, "") === "") {
return key; /* Replace 0 with new input */
};
return prevValue + key; /* Append to existing value */
};
};
myForm.on("BeforeAction_import", async (ev)=>{
const prevValue = await ev.context.export();
const key = ev.data;
ev.data = updateDisplay(prevValue, key);
});
myForm.on("keydown", async (ev)=>{
const prevValue = await ev.context.export();
const key = ev.originalEvent.key;
const data = updateDisplay(prevValue, key);
if (data === null) return;
ev.preventDefault();
await ev.context.import(data);
});
Calculate expressions with a keypad or keyboard
Type digits or operators directly, or use the buttons. The keydown handler prevents the browser from moving the caret or editing the text; every key is processed by updateDisplay(). Calculator keys are imported as a new display value; other keys are left to the browser, so Tab continues to move focus. Enter is an alias for =, Delete clears, and Backspace removes the last character.
Original: Beyond Forms.