Introduction

πŸ“– Table of Contents

What SmarkForm Is (and Isn’t Yet)

SmarkForm is:

  • βœ… A markup-driven form controller: configuration lives in data-smark attributes, keeping HTML and JavaScript cleanly separated.
  • βœ… Markup-agnostic: it imposes no HTML structure or CSS on your design β€” designers keep full freedom, developers don’t worry about layout changes.
  • βœ… A tool for JSON-based import/export of complex, nested form data including subforms and variable-length lists.
  • βœ… Ready for context-driven hotkeys and smooth keyboard navigation.
  • βœ… Stable and in active use, but still pre-1.0 (API may evolve).

Not yet implemented (planned for a future release):

  • ❌ Built-in validation (field-level error messages).
  • ❌ The β€œAPI interface” for dynamic dropdown/select options from a server.

SmarkForm is currently at version 0.x. The implemented features are stable, but breaking changes may still occur before 1.0. See the Roadmap for what’s coming next.

About SmarkForm

SmarkForm is a lightweight and extendable form controller that enhances HTML forms to support subforms and variable-length lists without tying the layout to any specific structure. This enables it to import and export data in JSON format, while providing a smooth navigation with configurable hotkeys and a low-code experience among other features.

In SmarkForm, (sub)forms and lists are just form fields that import/export their data as JSON, number fields return numbers, checkboxes return booleans, radio buttons sharing the same name are threated as single field, color pickers can return null to distinguish when the color is unknown, and so on…

Special components called triggers can be placed along the form to call specified actions like adding or removing items from lists, importing or exporting data, etc… They automatically connect to the proper fields just by their context (i.e., their position in the form) which can be altered through specific properties..

Forms and lists can be nested to any depth, lists can dynamically grow or shrink. This allow to generate any possible JSON structure, from simple form.

SmarkForm provides a smooth and intuitive user experience while addressing some native HTML limitations; such as forcing type="color" fields to always hold a valid color value, which makes it impossible to tell whether the user selected the black color on purpose or if he just meant that the actual color is unknown.

Other features include context-driven keyboard shortcuts, smart auto-enablement/disablement of controls depending on whether they are applicable or not, among others…

All that in a 100% declarative approach providing a consistent UX across very different forms.

πŸ‘‰ See the Showcase for a quick overview of what SmarkForm can do.

Motivation

Three decades after the advent of web forms, their limitations persist. Traditional HTML forms are limited in structure and lack flexibility. They only support a single level of discrete key-value pairs, limited to text-only values while modern applications often require complex JSON structures with nested objects and arrays, which cannot be directly accommodated by legacy HTML forms.

Web component libraries and frameworks, in turn, address this issue by shifting templating and design logic from the view to the controller layer. However, this approach forces developers to manually implement custom behaviors by connecting multiple form components together. Every piece of form state β€” from field values to list structure and UI details such as which rows are expanded β€” must be explicitly declared, initialised, and kept in sync by the developer. Additionally, it places the burden of dealing with templating and styling details on developers, while designers lose control over the appearance of inner components. As a result, this approach leads to non-reusable and bespoke (unless you are Ok with sticking to the same appearance) implementations for each form.

Repeatedly reinventing components or adapting complex frameworks with every design change, along with struggles in importing/exporting JSON, managing dynamic subforms and lists, or enabling seamless usability and accessibility, are common challenges.

SmarkForm was created to address these challenges while preserving flexibility for designers and reducing maintenance overhead for developers. It provides a powerful and flexible solution for building forms directly in the markup (view layer) that seamlessly handles deep JSON structures while providing advanced features like subforms and variable-length lists. Designers can create custom templates using their existing HTML and CSS knowledge, while developers can import and manipulate complex data in JSON format.


A Picture is Worth a Thousand Words

Modern web development offers many solutions for building dynamic forms β€” from dedicated libraries to general-purpose UI frameworks. For forms specifically, the contrast can be striking. Below is the exact same form β€” a real-world Event Planner with nested subforms and a dynamic attendees list β€” implemented three ways.

  • The first one is implemented with SmarkForm.
  • The others are implemented with modern web frameworks trying to mimic the same behaviour and user experience as closely as possible while keeping the code as simple as possible.

All implementations were written by the same AI assistant (GitHub Copilot) with the explicit goal of making each as idiomatic and feature-equivalent as possible. The intent is a fair comparison β€” not advocacy for any particular tool.

The metrics speak for themselves but the example used was designed to showcase SmarkForm’s capabilities which may end up being favourable to it. That being said, Copilot was explicitly asked to use any existing component or plugin on top of those libraries that could help to make the comparison more fair.

Judge for yourself.


The Example: Event Planner

A moderately complex, real-world form:

  • Basic fields: title, date, time
  • A nested Organizer subform (name + email)
  • A dynamic Attendees list where each row expands to reveal extra details
  • Buttons to add, insert, remove, and prune (clean up empty rows) attendees
  • A position indicator that reflects each item’s current position in the list
  • Drag-and-drop to reorder attendees (with a dedicated drag handle so text selection still works inside inputs)

SmarkForm

All form behaviour is expressed through data-smark attributes directly in the HTML. The JavaScript is a single line. Import/export, list management, label associations and position counters are all built in.

πŸ“„ Source
πŸ‘οΈ Preview
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Event Planner – SmarkForm</title>
  <script src="https://cdn.jsdelivr.net/npm/smarkform@0.16.0/dist/SmarkForm.umd.js"></script>
</head>
<body>
  <div id="myForm">
    <div class="ep">
      <p>
        <label data-smark>πŸ“‹ Event:</label>
        <input data-smark name="title" type="text" placeholder="e.g. Sprint Review">
      </p>
      <p>
        <label data-smark>πŸ“… Date:</label>
        <input data-smark name="date" type="date">
      </p>
      <p>
        <label data-smark>⏰ Time:</label>
        <input data-smark name="time" type="time">
      </p>
      <fieldset data-smark='{"type":"form","name":"organizer"}'>
        <legend data-smark='label'>πŸ‘€ Organizer</legend>
        <p>
          <label data-smark>Name:</label>
          <input data-smark name="name" type="text">
        </p>
        <p>
          <label data-smark>Email:</label>
          <input data-smark name="email" type="email">
        </p>
      </fieldset>
      <div class="ep-list">
        <div>
          <button data-smark='{"action":"removeItem","context":"attendees","preserve_non_empty":true}' title="Remove empty rows">🧹</button>
          <button data-smark='{"action":"addItem","context":"attendees","hotkey":"+"}' title="Add attendee">βž•</button>
          <strong data-smark='label'>πŸ‘₯ Attendees:</strong>
        </div>
        <ul data-smark='{"type":"list","name":"attendees","sortable":true,"exportEmpties":false}'>
          <li>
            <details>
              <summary>
                <span data-smark='{"type":"label"}' class="bullet">
                  <span data-smark='{"action":"position"}'>N</span> ☰
                </span>
                <input data-smark type="text" name="name" placeholder="Name">
                <button data-smark='{"action":"removeItem","hotkey":"-"}' title="Remove">βž–</button>
                <button data-smark='{"action":"addItem","hotkey":"+"}' title="Insert here">βž•</button>
              </summary>
              <div class="ep-attendee">
                <input data-smark type="email" name="email" placeholder="Email">
                <input data-smark type="tel" name="phone" placeholder="Phone">
              </div>
            </details>
          </li>
        </ul>
      </div>
      <p class="ep-hint">πŸ’‘ Hold <kbd>Ctrl</kbd> to reveal shortcuts</p>
    </div>
  </div>
  <script>
    const myForm = new SmarkForm(document.getElementById('myForm'));
    // CDN drag-ghost workaround: use the compact <summary> row as the ghost
    // so folded and unfolded items produce a consistently-sized ghost image.
    document.getElementById('myForm').addEventListener('dragstart', function(e) {
      var li = e.target.closest && e.target.closest('li[draggable="true"]');
      if (!li) return;
      var ghost = li.querySelector('summary') || li;
      e.dataTransfer.setDragImage(ghost, 16, Math.round(ghost.offsetHeight / 2));
    }, true);
  </script>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 1em; margin: 0; }
    button[data-smark] { padding: .3em .6em; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; }
    button[data-smark]:hover { background: #e9ecef; }
    .ep { display: flex; flex-direction: column; gap: .4em; max-width: 460px; font-size: .95em; }
    .ep p { display: flex; align-items: center; gap: .5em; margin: 0; }
    .ep label { font-weight: 500; white-space: nowrap; min-width: 5em; }
    .ep input { padding: .3em .5em; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
    .ep fieldset { border: 1px solid #ddd; border-radius: 6px; padding: .4em .8em .6em; margin: 0; display: flex; flex-direction: column; gap: .3em; }
    .ep fieldset legend { font-weight: bold; padding: 0 .3em; }
    .ep-list > div { display: flex; align-items: center; gap: .4em; margin-bottom: .2em; }
    .ep-list ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25em; }
    .ep-list li details { border: 1px solid transparent; border-radius: 4px; }
    .ep-list li details[open] { border-color: #ccc; padding-bottom: 4px; }
    .ep-list li summary { display: flex; align-items: center; gap: .4em; cursor: default; padding: .1em .2em; list-style: none; user-select: none; }
    .ep-list li summary::before { content: "β–Ά"; font-size: .75em; transition: transform .15s; flex-shrink: 0; cursor: pointer; }
    .ep-list li details[open] > summary::before { transform: rotate(90deg); }
    .ep-attendee { display: flex; flex-wrap: wrap; gap: .4em; padding: .3em .4em .1em 1.4em; }
    .ep-attendee input { flex: 1; min-width: 110px; }
    .ep-hint { font-size: .8em; color: #888; margin: .1em 0 0; }
    /* Hotkey hints β€” SmarkForm sets data-hotkey on buttons when Ctrl is held */
    [data-hotkey] { position: relative; }
    [data-hotkey]::after {
      content: "Ctrl+" attr(data-hotkey);
      position: absolute; top: -1.6em; left: 0;
      font-size: .7em; background: #333; color: #fff;
      padding: 1px 4px; border-radius: 3px;
      white-space: nowrap; pointer-events: none;
    }
  </style>
</body>
</html>

React

The same form using React (v18 from CDN) and Babel Standalone for in-browser JSX compilation. All form state and behaviour is wired up explicitly in JavaScript.

πŸ“„ Source
πŸ‘οΈ Preview
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Event Planner – React</title>
  <script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@babel/standalone/babel.min.js"></script>
</head>
<body>
  <div id="root"></div>
  <script type="text/babel">
    const { useState, useEffect, useRef } = React;

    // Returns only inputs not inside a closed <details> element.
    // Enter/Shift+Enter navigation skips inputs hidden in collapsed rows.
    function getVisibleInputs() {
      return Array.from(document.querySelectorAll('input')).filter(inp => {
        let n = inp.parentElement;
        while (n) {
          if (n.tagName === 'SUMMARY') return true; // inside <summary>: always visible
          if (n.tagName === 'DETAILS' && !n.open) return false;
          n = n.parentElement;
        }
        return true;
      });
    }

    function EventPlanner() {
      const [title, setTitle] = useState('');
      const [date,  setDate]  = useState('');
      const [time,  setTime]  = useState('');
      const [organizer, setOrganizer] = useState({ name: '', email: '' });
      const [attendees, setAttendees] = useState([
        { id: 1, name: '', email: '', phone: '' }
      ]);

      // Queue a focus-by-index to be processed after the next render.
      const pendingFocusIdx = useRef(null);

      // Stable id counter β€” prevents fold/unfold state from shifting on add/remove.
      const nextId = useRef(2);
      const mkItem = () => ({ id: nextId.current++, name: '', email: '', phone: '' });

      const setOrg = (field) => (e) =>
        setOrganizer(o => ({ ...o, [field]: e.target.value }));

      const setAtt = (i, field) => (e) =>
        setAttendees(a =>
          a.map((item, j) => j === i ? { ...item, [field]: e.target.value } : item)
        );

      const addAfter = (i) => {
        pendingFocusIdx.current = i + 1;
        setAttendees(a => [
          ...a.slice(0, i + 1),
          mkItem(),
          ...a.slice(i + 1)
        ]);
      };

      const removeAt = (i) =>
        setAttendees(a => {
          const next = a.filter((_, j) => j !== i);
          pendingFocusIdx.current = Math.min(i, next.length - 1);
          return next;
        });

      const pruneEmpty = () =>
        setAttendees(a => {
          const filled = a.filter(x => x.name || x.email || x.phone);
          return filled.length ? filled : [mkItem()];
        });

      // After each render, focus the name input of any queued attendee row.
      useEffect(() => {
        if (pendingFocusIdx.current === null) return;
        const idx = pendingFocusIdx.current;
        pendingFocusIdx.current = null;
        const li = document.querySelector(`[data-ai="${idx}"]`);
        if (li) (li.querySelector('summary input') || li.querySelector('input'))?.focus();
      });

      // Mutable ref so the stable useEffect closure always reads the latest
      // state and functions without re-mounting the listeners on every render.
      const $ = useRef({});
      $.current = { attendees, addAfter, removeAt };

      // Enter/Shift+Enter: navigate between VISIBLE inputs only.
      // Space: type normally (fold/unfold blocked via onSummaryClick).
      // Shift+Space: toggle fold/unfold of the closest <details> row.
      function onInputKeyDown(e) {
        if (e.key === ' ') {
          if (e.shiftKey) {
            const details = e.target.closest('details');
            if (details) { e.preventDefault(); details.open = !details.open; }
          } else { e.stopPropagation(); }
          return;
        }
        if (e.key === 'Enter') {
          e.preventDefault();
          e.stopPropagation();
          const inputs = getVisibleInputs();
          const idx = inputs.indexOf(e.target);
          if (idx < 0) return;
          const next = e.shiftKey ? inputs[idx - 1] : inputs[idx + 1];
          if (next) next.focus();
        }
      }

      // Global keyboard: Ctrl-hold reveals hotkey badges;
      // Ctrl+= adds after focused row (or at end); Ctrl+- removes focused row.
      useEffect(() => {
        function onKeyDown(e) {
          if (e.key === 'Control') document.body.classList.add('show-hotkeys');
          if (!e.ctrlKey) return;
          const { attendees: atts, addAfter: add, removeAt: rem } = $.current;
          const li = document.activeElement && document.activeElement.closest('[data-ai]');
          const idx = li ? +li.dataset.ai : -1;
          if (e.key === '+' || e.key === '=') {
            e.preventDefault();
            add(idx >= 0 ? idx : atts.length - 1);
          } else if (e.key === '-' && idx >= 0) {
            e.preventDefault();
            rem(idx);
          }
        }
        function onKeyUp(e) {
          if (e.key === 'Control') document.body.classList.remove('show-hotkeys');
        }
        // Block keyboard-synthesised click (e.detail===0) on <summary> when an
        // input inside it has focus, so Space types in the input without toggling.
        function onSummaryClick(e) {
          if (e.detail) return;
          const s = e.target.closest && e.target.closest('summary');
          if (s && s.contains(document.activeElement) && document.activeElement.tagName === 'INPUT')
            { e.preventDefault(); e.stopImmediatePropagation(); }
        }
        document.addEventListener('keydown', onKeyDown);
        document.addEventListener('keyup', onKeyUp);
        document.addEventListener('click', onSummaryClick, { capture: true });
        return () => {
          document.removeEventListener('keydown', onKeyDown);
          document.removeEventListener('keyup', onKeyUp);
          document.removeEventListener('click', onSummaryClick, { capture: true });
        };
      }, []);

      return (
        <div className="ep">
          <p>
            <label htmlFor="ep-title">πŸ“‹ Event:</label>
            <input id="ep-title" type="text" value={title}
              onChange={e => setTitle(e.target.value)}
              onKeyDown={onInputKeyDown}
              placeholder="e.g. Sprint Review" />
          </p>
          <p>
            <label htmlFor="ep-date">πŸ“… Date:</label>
            <input id="ep-date" type="date" value={date}
              onChange={e => setDate(e.target.value)}
              onKeyDown={onInputKeyDown} />
          </p>
          <p>
            <label htmlFor="ep-time">⏰ Time:</label>
            <input id="ep-time" type="time" value={time}
              onChange={e => setTime(e.target.value)}
              onKeyDown={onInputKeyDown} />
          </p>
          <fieldset>
            <legend>πŸ‘€ Organizer</legend>
            <p>
              <label htmlFor="ep-org-name">Name:</label>
              <input id="ep-org-name" type="text" value={organizer.name}
                onChange={setOrg('name')}
                onKeyDown={onInputKeyDown} />
            </p>
            <p>
              <label htmlFor="ep-org-email">Email:</label>
              <input id="ep-org-email" type="email" value={organizer.email}
                onChange={setOrg('email')}
                onKeyDown={onInputKeyDown} />
            </p>
          </fieldset>
          <div className="ep-list">
            <div>
              <button onClick={pruneEmpty} title="Remove empty rows">🧹</button>
              <button onClick={() => addAfter(attendees.length - 1)}
                data-hotkey="+" title="Add attendee">βž•</button>
              <strong>πŸ‘₯ Attendees:</strong>
            </div>
            <ul>
              {attendees.map((att, i) => (
                <li key={att.id} data-ai={i}>
                  <details>
                    <summary>
                      <span>{i + 1}.</span>
                      <input type="text" value={att.name}
                        onChange={setAtt(i, 'name')}
                        onKeyDown={onInputKeyDown}
                        placeholder="Name" />
                      <button onClick={() => removeAt(i)}
                        data-hotkey="-" title="Remove">βž–</button>
                      <button onClick={() => addAfter(i)}
                        data-hotkey="+" title="Insert here">βž•</button>
                    </summary>
                    <div className="ep-attendee">
                      <input type="email" value={att.email}
                        onChange={setAtt(i, 'email')}
                        onKeyDown={onInputKeyDown}
                        placeholder="Email" />
                      <input type="tel" value={att.phone}
                        onChange={setAtt(i, 'phone')}
                        onKeyDown={onInputKeyDown}
                        placeholder="Phone" />
                    </div>
                  </details>
                </li>
              ))}
            </ul>
          </div>
          <p className="ep-hint">πŸ’‘ Hold <kbd>Ctrl</kbd> to reveal shortcuts</p>
        </div>
      );
    }

    ReactDOM.createRoot(document.getElementById('root')).render(<EventPlanner />);
  </script>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 1em; margin: 0; }
    button { padding: .3em .6em; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; }
    button:hover { background: #e9ecef; }
    .ep { display: flex; flex-direction: column; gap: .4em; max-width: 460px; font-size: .95em; }
    .ep p { display: flex; align-items: center; gap: .5em; margin: 0; }
    .ep label { min-width: 5em; font-weight: 500; white-space: nowrap; }
    .ep input { padding: .3em .5em; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
    .ep fieldset { border: 1px solid #ddd; border-radius: 6px; padding: .4em .8em .6em; margin: 0; display: flex; flex-direction: column; gap: .3em; }
    .ep fieldset legend { font-weight: bold; padding: 0 .3em; }
    .ep-list > div { display: flex; align-items: center; gap: .4em; margin-bottom: .2em; }
    .ep-list ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25em; }
    .ep-list li details { border: 1px solid transparent; border-radius: 4px; }
    .ep-list li details[open] { border-color: #ccc; padding-bottom: 4px; }
    .ep-list li summary { display: flex; align-items: center; gap: .4em; cursor: pointer; padding: .1em .2em; list-style: none; user-select: none; }
    .ep-list li summary::before { content: "β–Ά"; font-size: .75em; transition: transform .15s; flex-shrink: 0; }
    .ep-list li details[open] > summary::before { transform: rotate(90deg); }
    .ep-attendee { display: flex; flex-wrap: wrap; gap: .4em; padding: .3em .4em .1em 1.4em; }
    .ep-attendee input { flex: 1; min-width: 110px; }
    .ep-hint { font-size: .8em; color: #888; margin: .1em 0 0; }
    /* Hotkey hints β€” revealed when body has class show-hotkeys */
    [data-hotkey] { position: relative; }
    body.show-hotkeys [data-hotkey]::after {
      content: "Ctrl+" attr(data-hotkey);
      position: absolute; top: -1.6em; left: 0;
      font-size: .7em; background: #333; color: #fff;
      padding: 1px 4px; border-radius: 3px;
      white-space: nowrap; pointer-events: none;
    }
  </style>
</body>
</html>

Vue

The same form using Vue 3 (from CDN). v-model reduces binding boilerplate compared to React, but state and methods still need to be declared explicitly in JavaScript.

πŸ“„ Source
πŸ‘οΈ Preview
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Event Planner – Vue</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
</head>
<body>
  <div id="app"></div>
  <script>
    const { createApp, ref, reactive, onMounted, onBeforeUnmount, nextTick } = Vue;

    // Returns only inputs not inside a closed <details> element.
    // Inputs inside <summary> are always treated as visible even when the row is collapsed.
    function getVisibleInputs() {
      return Array.from(document.querySelectorAll('input')).filter(inp => {
        let n = inp.parentElement;
        while (n) {
          if (n.tagName === 'SUMMARY') return true; // inside <summary>: always visible
          if (n.tagName === 'DETAILS' && !n.open) return false;
          n = n.parentElement;
        }
        return true;
      });
    }

    createApp({
      setup() {
        const title     = ref('');
        const date      = ref('');
        const time      = ref('');
        const organizer = reactive({ name: '', email: '' });
        const attendees = ref([{ id: 1, name: '', email: '', phone: '' }]);

        // Stable id counter β€” prevents fold/unfold state from shifting on add/remove.
        let nextId = 2;
        const mkItem = () => ({ id: nextId++, name: '', email: '', phone: '' });

        // Focus the name input of attendee row idx after the next DOM update.
        function focusAt(idx) {
          nextTick(() => {
            const li = document.querySelector(`[data-ai="${idx}"]`);
            if (li) (li.querySelector('summary input') || li.querySelector('input'))?.focus();
          });
        }

        function addAfter(i) {
          attendees.value.splice(i + 1, 0, mkItem());
          focusAt(i + 1);
        }
        function removeAt(i) {
          const newLen = attendees.value.length - 1;
          attendees.value.splice(i, 1);
          if (newLen > 0) focusAt(Math.min(i, newLen - 1));
        }
        function pruneEmpty() {
          const filled = attendees.value.filter(a => a.name || a.email || a.phone);
          attendees.value = filled.length
            ? filled
            : [mkItem()];
        }

        // Enter/Shift+Enter: navigate between VISIBLE inputs only.
        // Space: type normally (fold/unfold blocked via onSummaryClick).
        // Shift+Space: toggle fold/unfold of the closest <details> row.
        function onInputKeyDown(e) {
          if (e.key === ' ') {
            if (e.shiftKey) {
              const details = e.target.closest('details');
              if (details) { e.preventDefault(); details.open = !details.open; }
            } else { e.stopPropagation(); }
            return;
          }
          if (e.key === 'Enter') {
            e.preventDefault();
            e.stopPropagation();
            const inputs = getVisibleInputs();
            const idx = inputs.indexOf(e.target);
            if (idx < 0) return;
            const next = e.shiftKey ? inputs[idx - 1] : inputs[idx + 1];
            if (next) next.focus();
          }
        }

        // Global Ctrl key: reveal hotkeys + Ctrl+= / Ctrl+- shortcuts.
        function onKeyDown(e) {
          if (e.key === 'Control') document.body.classList.add('show-hotkeys');
          if (!e.ctrlKey) return;
          const li = document.activeElement && document.activeElement.closest('[data-ai]');
          const idx = li ? +li.dataset.ai : -1;
          if (e.key === '+' || e.key === '=') {
            e.preventDefault();
            addAfter(idx >= 0 ? idx : attendees.value.length - 1);
          } else if (e.key === '-' && idx >= 0) {
            e.preventDefault();
            removeAt(idx);
          }
        }
        function onKeyUp(e) {
          if (e.key === 'Control') document.body.classList.remove('show-hotkeys');
        }
        // Block keyboard-synthesised click (e.detail===0) on <summary> when an
        // input inside it has focus, so Space types in the input without toggling.
        function onSummaryClick(e) {
          if (e.detail) return;
          const s = e.target.closest && e.target.closest('summary');
          if (s && s.contains(document.activeElement) && document.activeElement.tagName === 'INPUT')
            { e.preventDefault(); e.stopImmediatePropagation(); }
        }

        onMounted(() => {
          document.addEventListener('keydown', onKeyDown);
          document.addEventListener('keyup', onKeyUp);
          document.addEventListener('click', onSummaryClick, { capture: true });
        });
        onBeforeUnmount(() => {
          document.removeEventListener('keydown', onKeyDown);
          document.removeEventListener('keyup', onKeyUp);
          document.removeEventListener('click', onSummaryClick, { capture: true });
        });

        return { title, date, time, organizer, attendees, addAfter, removeAt, pruneEmpty, onInputKeyDown };
      },
      template: `
        <div class="ep">
          <p>
            <label for="ep-title">πŸ“‹ Event:</label>
            <input id="ep-title" v-model="title" type="text" placeholder="e.g. Sprint Review"
              @keydown="onInputKeyDown">
          </p>
          <p>
            <label for="ep-date">πŸ“… Date:</label>
            <input id="ep-date" v-model="date" type="date" @keydown="onInputKeyDown">
          </p>
          <p>
            <label for="ep-time">⏰ Time:</label>
            <input id="ep-time" v-model="time" type="time" @keydown="onInputKeyDown">
          </p>
          <fieldset>
            <legend>πŸ‘€ Organizer</legend>
            <p>
              <label for="ep-org-name">Name:</label>
              <input id="ep-org-name" v-model="organizer.name" type="text" @keydown="onInputKeyDown">
            </p>
            <p>
              <label for="ep-org-email">Email:</label>
              <input id="ep-org-email" v-model="organizer.email" type="email" @keydown="onInputKeyDown">
            </p>
          </fieldset>
          <div class="ep-list">
            <div>
              <button @click="pruneEmpty" title="Remove empty rows">🧹</button>
              <button @click="addAfter(attendees.length - 1)"
                data-hotkey="+" title="Add attendee">βž•</button>
              <strong>πŸ‘₯ Attendees:</strong>
            </div>
            <ul>
              <li v-for="(att, i) in attendees" :key="att.id" :data-ai="i">
                <details>
                  <summary>
                    <span v-text="(i + 1) + '.'"></span>
                    <input v-model="att.name" type="text" placeholder="Name"
                      @keydown="onInputKeyDown">
                    <button @click="removeAt(i)"
                      data-hotkey="-" title="Remove">βž–</button>
                    <button @click="addAfter(i)"
                      data-hotkey="+" title="Insert here">βž•</button>
                  </summary>
                  <div class="ep-attendee">
                    <input v-model="att.email" type="email" placeholder="Email"
                      @keydown="onInputKeyDown">
                    <input v-model="att.phone" type="tel" placeholder="Phone"
                      @keydown="onInputKeyDown">
                  </div>
                </details>
              </li>
            </ul>
          </div>
          <p class="ep-hint">πŸ’‘ Hold <kbd>Ctrl</kbd> to reveal shortcuts</p>
        </div>
      `
    }).mount('#app');
  </script>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 1em; margin: 0; }
    button { padding: .3em .6em; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; }
    button:hover { background: #e9ecef; }
    .ep { display: flex; flex-direction: column; gap: .4em; max-width: 460px; font-size: .95em; }
    .ep p { display: flex; align-items: center; gap: .5em; margin: 0; }
    .ep label { min-width: 5em; font-weight: 500; white-space: nowrap; }
    .ep input { padding: .3em .5em; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
    .ep fieldset { border: 1px solid #ddd; border-radius: 6px; padding: .4em .8em .6em; margin: 0; display: flex; flex-direction: column; gap: .3em; }
    .ep fieldset legend { font-weight: bold; padding: 0 .3em; }
    .ep-list > div { display: flex; align-items: center; gap: .4em; margin-bottom: .2em; }
    .ep-list ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25em; }
    .ep-list li details { border: 1px solid transparent; border-radius: 4px; }
    .ep-list li details[open] { border-color: #ccc; padding-bottom: 4px; }
    .ep-list li summary { display: flex; align-items: center; gap: .4em; cursor: pointer; padding: .1em .2em; list-style: none; user-select: none; }
    .ep-list li summary::before { content: "β–Ά"; font-size: .75em; transition: transform .15s; flex-shrink: 0; }
    .ep-list li details[open] > summary::before { transform: rotate(90deg); }
    .ep-attendee { display: flex; flex-wrap: wrap; gap: .4em; padding: .3em .4em .1em 1.4em; }
    .ep-attendee input { flex: 1; min-width: 110px; }
    .ep-hint { font-size: .8em; color: #888; margin: .1em 0 0; }
    /* Hotkey hints β€” revealed when body has class show-hotkeys */
    [data-hotkey] { position: relative; }
    body.show-hotkeys [data-hotkey]::after {
      content: "Ctrl+" attr(data-hotkey);
      position: absolute; top: -1.6em; left: 0;
      font-size: .7em; background: #333; color: #fff;
      padding: 1px 4px; border-radius: 3px;
      white-space: nowrap; pointer-events: none;
    }
  </style>
</body>
</html>

Gotchas

  • Both React and Vue implementations:
    • Hotkeys implementation is limited: hints are shown for all visible hotkey buttons simultaneously (not context-aware), and multiple βž• / βž– hints may appear at once even though only one applies. Fixing this properly would require SmarkForm-level integration.
    • Drag to reorder is not implemented. Adding it properly β€” including a dedicated drag handle so that mouse gestures inside inputs still select text rather than starting a drag β€” would require custom drag-event handlers and state-reorder logic (~30–40 additional lines of JavaScript per implementation). SmarkForm handles this automatically when sortable: true is set and SmarkForm label components are present in the list item template.

What the numbers say

Metrics

MetricSmarkFormReactVue
Dependencies loaded1 (SmarkForm UMD, ~19 kB gz)2 (React + ReactDOM β‰ˆ44 kB gz)(1)1 (Vue global, ~33 kB gz)
JavaScript written1 line~100 lines~70 lines
HTML / template markup lines~50 lines~44 lines (JSX)~44 lines (template)
State managementInternalManually handledManually handled
Two-way bindingbuilt-inmanual (value + onChange)v-model
Add / remove itemsdeclarative attributesplice helperssplice helpers
Fold / Unfold itemsbuilt-innative <details>native <details>
Position counterdeclarative attributearray indexarray index
JSON import / exportbuilt-inmanual serialisationmanual serialisation
Label ↔ field wiringautomatichtmlFor + idfor + id (or wrapping)
Smooth field navigation (Enter / Shift+Enter)built-in (zero JS)manual (~15 lines)manual (~12 lines)
Keyboard shortcuts (Ctrl+= / Ctrl+-)built-in, context-aware(2)manual (~20 lines) ‼️manual (~18 lines) ‼️
Drag to reorderbuilt-in (label drag handle, zero JS)not implemented ‼️not implemented ‼️

The comparison is intentionally narrow. React and Vue are general-purpose UI frameworks β€” they excel at component composition, routing, complex rendering, and vast ecosystems. SmarkForm is purpose-built for HTML forms. The take-away is not β€œSmarkForm replaces React”, but β€œfor forms, SmarkForm lets you stay in HTML”.

On β€œState management”: in Virtual DOM frameworks, explicitly declaring and syncing every piece of form state is an obligation imposed by the architecture, not a feature. SmarkForm manages state internally and exposes it through export() / import() β€” so it is always inspectable, but never a burden to maintain.

Implementation time

The following are estimations of the time it took to implement each version of the demo, including time spent prompting Copilot and manually reviewing / refining its suggestions. They’re not real measurements, just estimations made by Copilot itself retrospectively (no human adjustment) no matter they are exact or not. But I reckon they’re close enough.

MetricSmarkFormReactVue
Copilot time (this demo)~5 min~2.5 h~2 h
Reviewer time (this demo)~20 min~1.25 h~1.25 h
Total dev effort (this demo)~25 min ⚑~3.75 h πŸ•’~3.25 h πŸ•’

(1): The React demo also loads Babel Standalone for in-browser JSX compilation (no build step needed). It is excluded from the CDN dependency count because a real-world React project would use a bundler (Webpack, Vite, …) and Babel Standalone would not be part of the runtime bundle.

(2): SmarkForm’s hotkey reveal is context-aware: it computes which buttons are reachable from the currently focused field and displays only their shortcuts. The React and Vue implementations show all data-hotkey badges simultaneously whenever Ctrl is held β€” a simpler but visually broader reveal.