Builder

A drag-and-drop authoring UI for building a FormDefinition without writing JSON by hand. Try it live in the builder demo.

Install and use

Builder.tsx
import { Builder, createBlankDefinition } from "@hardikrastogi/builder";
import "@hardikrastogi/builder/styles.css";

// Or pass an existing FormDefinition to edit one that already exists.
const initial = createBlankDefinition("form_1", "My form");

<Builder initialDefinition={initial} />

Builder holds its own state internally — you don't manage the definition yourself. Read the current definition back out with useBuilder((s) => s.definition) from inside a BuilderProvider, or wrap your own save button around it.

Layout

Three panels, matching the classic form-builder shape:

  • Palette (left) — field types grouped by category, with search. Click a field to add it, or drag it onto the canvas.
  • Canvas (center) — the form as it's being built. Click a field to select it. Drag the ⠿ handle to reorder fields.
  • Inspector (right) — settings for whatever is selected. With nothing selected, it shows the form-wide theme instead.

A field's inspector has four tabs: Basic (label, description, required, placeholder or options), Validation (length or range rules, matching the field type), Logic (conditional visibility and, for number fields, a calculated value — see below), and Style (width in grid columns, border colour, corner radius).

Conditional visibility and calculated fields

The Logic tab lets a field's visibility depend on another field's answer: "only show this field if…", with one or more conditions (equals, contains, greater/less than, is empty/not empty), combined with either all or any. A hidden field is never required and never submitted — a question the respondent was never asked isn't part of their answer.

A number field can also be set to calculate its own value from other number fields — a formula like seats * price_per_seat using the other fields' ids (click a field's chip in the Logic tab to insert its id). Supports +, −, ×, ÷, and parentheses. The field becomes read-only and updates live as its inputs change. The server always recomputes the value itself before saving a submission, so a tampered request can never post a fake total.

Undo, redo, and the Theme button

Every edit is undoable. The Theme button next to Undo/Redo clears the current selection and returns the inspector to the form-wide theme — the same as clicking empty space on the canvas.

Accessibility contrast check

Changing the theme's primary colour checks its contrast against white button text. Below the WCAG AA minimum of 4.5:1, an inline warning explains the actual ratio and suggests a darker colour.

Preview

The Preview button swaps the three-panel editor for the real, working form — the same FormRenderer your visitors would see, with live validation.

Publishing and sharing

The builder never talks to a server itself, so the package stays free of any HTTP or framework code. Publishing is a set of callbacks your app supplies. Pass them and the top bar gains a Publish button, a live link, Share, and Unpublish. Leave them out and none of those appear.

publishing.tsx
import { Builder, type PublishResult } from "@hardikrastogi/builder";

// Your code does the real work. The builder only calls these and shows the outcome.
async function publish(definition): Promise<PublishResult> {
  const res = await fetch("/api/forms/publish", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ definition }),
  });
  if (!res.ok) throw new Error("Could not publish this form.");
  const { url, slug } = await res.json();
  return { url, slug };
}

async function unpublish(published: PublishResult) {
  const res = await fetch("/api/forms/" + published.slug + "/unpublish", { method: "POST" });
  if (!res.ok) throw new Error("Could not unpublish this form.");
}

<Builder
  initialDefinition={initial}
  onPublish={publish}
  onUnpublish={unpublish}
  initialPublished={rememberedFromLastVisit}   // optional, see below
/>
PropWhat it does
onSave(definition)Autosave to your own backend. Called (debounced) after edits; throw to show "Could not save". Without it, edits are saved to the browser's local storage instead.
onPublish(definition)Called when the user clicks Publish. Resolve with { url, slug? } (a PublishResult); throw to show an error and leave the form untouched.
onUnpublish(published)Called when the user clicks Unpublish, with the last PublishResult so you know which form to switch off. Optional; without it there is no Unpublish button.
initialPublishedIf the form is already live when the builder opens, pass its PublishResult so the link, Share and Unpublish are there immediately.
  • After publishing, the top bar shows "Live at /f/…", and Publish becomes Republish. Republishing creates a fresh version; it never alters the one people already answered.
  • Share opens the device's share sheet where the browser supports it, and otherwise copies the link.
  • Unpublish is reversible: the link stops accepting responses, and Publish brings it back. Existing responses are kept.
  • Publish and Unpublish both disable while running, so nothing can be sent twice by a double click.

The builder does not remember by itself that a form is published, since it does not own storage. Keep the PublishResult somewhere (the demo uses local storage) and pass it back as initialPublished next time, or Unpublish will only be available in the session that published.

Autosave

The hosted builder demo autosaves to your browser's local storage only, as a stand-in for the real backend autosave arriving with hosted forms. It is not shared between devices or browsers, and clearing site data will lose it.

In your own app, pass a storageKey to scope this per form, or swap it out entirely for a real save request — Builder doesn't require the built-in autosave at all.

What's not built yet

  • Calculated fields are arithmetic only (+, −, ×, ÷) and only target a number field — no string concatenation, dates, or cross-field lookups beyond simple formulas.
  • Fields are one per row for now; multi-column layouts are set through a field's Style tab (a numeric width from 1 to 12), not by dragging fields side by side.
  • Creator accounts are not built yet, so nothing stops someone who knows a form's link name from republishing over it. Ownership checks and syncing your work across devices are planned next.