Skip to content
ilinxa/pro-ui

JSON Form

alphav0.2.8

Schema-driven form engine — a field DSL compiled to Zod, 25 field types including rich text, conditional and computed fields, on React Hook Form.

Category: FormsUpdated: 2026-08-11Created: 2026-05-12Author: ilinxa

Context

Generic 'turn JSON into a form' substrate for back-office UIs, admin tools, and AI-tooling agents that drive UIs from schema. Hand-rolled forms (the properties-form pattern) remain the right choice for one-off, deeply-bespoke flows; json-form takes over when the same surface needs to render dozens of variants or be driven by a backend / LLM. Built on react-hook-form v7 + @hookform/resolvers/zod + zod v4. The field DSL compiles to a ZodObject at mount via a two-stage pipeline (v0.1.7 — `compileStructural` is schema-keyed, `injectStrings` is strings-keyed); consumer-provided `zodSchema` wins per-key (escape hatch). 25 built-in field types: text family (text/email/password/url/tel/textarea/number), choice family (select/multi-select/radio-group/checkbox/checkbox-group/switch), date/time (date/date-range/time/datetime), rich/composite (code via @ilinxa/code-block lazy-loaded, slider, rating, richtext via @ilinxa/rich-text-editor lazy-loaded), special (computed/hidden/section/divider). Conditional logic via an 11-operator Condition DSL plus function escape hatch — covers visibleWhen / enabledWhen / requiredWhen, all with v0.1.6 narrow-deps subscriptions. Computed fields via pure `expression: '{firstName} {lastName}'` interpolation or `compute: (args) => ...` escape hatch. Renderer registry is extensible (`fieldRegistry` prop merges over defaults), with typed authoring via `defineFieldRenderer<TValue, TConfig>(...)` (v0.1.7); the form-level `renderField` slot intercepts every field. Standalone parts exported (`<JsonFormField>`, `<JsonFormSubmitButton>`, `<JsonFormDevtools>`, etc.) for fully-headless layouts via `<JsonFormProvider>` + `useJsonForm()` factory + `useJsonFormFieldValue<T>(name)` / `useJsonFormFieldsValue<T>(names)` narrow-deps hooks (v0.1.7). v0.2.0 lifts the per-keystroke render ceiling: built-in default renderers (audited not to read `allValues`) skip the FieldWrapper-level subscription; `field.dependsOn` opts custom renderers into narrow-deps (`[]` = explicit no-watch; `['a','b']` = narrow watch). The whitelist check resolves on the FINAL renderer identity, so consumer-registered renderers at built-in slots correctly opt back into full-bag unless they declare `dependsOn`. Form-level `defaultValues` deep-merges per leaf instead of replacing per top-level key. v0.2.4 wraps children in `<FormProvider {...value.rhf}>` inside `<JsonFormProvider>` so headless trees no longer need to wrap with both providers. v0.2.5 keeps `<FieldRadioGroup>` always-controlled (`value=""` for "no selection" instead of `undefined`). Object-shape callbacks throughout (F-cross-12).

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/json-form

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/json-form-fixtures

Preview

Registration form

Five fields, required-with-min-length validation, password masking, ToS acceptance gate.

Create your account

All fields are required. We'll send a verification email to the address below.

8+ characters. Mix in a number or symbol for extra strength.

Nothing submitted yet.

Live playground

json-form renders a form entirely from a JSON FormSchema. Edit it on the left — when it's valid, press Submit to render the live form on the right (fields, validators, conditional visibility all working). Submitting the form shows the collected values.

FormSchema · JSON
valid

46 lines · valid

Live preview

Nothing rendered yet

Edit the JSON on the left, then press Submit to render the live result on the right.

Demo source

demo.tsxtsx

Usage

When to use

Reach for JsonForm when the same surface needs to render many variants of a form, when the schema is driven by a backend or an AI agent, or when you want declarative validation without hand-rolling a RHF form. For one-off, deeply bespoke flows the existing properties-form pattern is still the right choice.

Basic example

import { JsonForm, type FormSchema } from "@ilinxa/json-form";

const schema: FormSchema = {
  meta: { title: "Create account" },
  fields: [
    { name: "email", type: "email", label: "Email", validators: { required: true } },
    { name: "password", type: "password", label: "Password", validators: { required: true, minLength: 8 } },
  ],
};

export function Signup() {
  return (
    <JsonForm
      schema={schema}
      onSubmit={({ values }) => console.log(values)}
    />
  );
}

Validators

The validators block compiles to a Zod chain at mount. Each rule accepts either a primitive value or a { value, message } object for custom messages.

  • requiredboolean | string (string overrides default message)
  • min / max — number bounds (for number, slider, rating)
  • minLength / maxLength — string-length bounds
  • pattern — regex source string
  • email / url — built-in format checks

For custom logic, use validate (sync) or validateAsync (debounced; default 400ms). Sync runs first and short-circuits async on failure.

Conditional fields

Use the 11-operator Condition DSL, or a function for anything beyond the operators.

{
  name: "vatId",
  type: "text",
  label: "VAT ID",
  visibleWhen: { field: "country", in: ["DE", "FR", "IT", "ES"] },
  requiredWhen: ({ values }) => values.cadence === "annually",
}

When visibleWhen flips to false the field unmounts and its value is dropped from submission unless keepValueWhenHidden: true. type: "hidden" fields ALWAYS submit (CSRF-token use case).

Computed fields

{ name: "displayName", type: "computed", label: "Display name", expression: "{firstName} {lastName}" }

// or with a function:
{
  name: "total",
  type: "computed",
  label: "Total",
  compute: ({ values }) => Number(values.qty) * Number(values.unitPrice),
}

expression is pure interpolation — no operators, no conditionals. compute is the escape hatch for anything richer. Both are deps-tracked: only re-runs when referenced fields change.

Custom field types

import { defaultJsonFormRegistry, type FieldRenderer } from "@ilinxa/json-form";

const MyColor: FieldRenderer = ({ value, onChange, disabled }) => (
  <input type="color" value={String(value ?? "#000")} onChange={(e) => onChange(e.target.value)} disabled={disabled} />
);

const registry = { ...defaultJsonFormRegistry, color: MyColor };

<JsonForm schema={schema} fieldRegistry={registry} onSubmit={...} />

Field renderers receive { field, value, onChange, onBlur, error, disabled, readOnly, allValues, formApi, ariaProps } and return a ReactNode (single element, fragment, or array — whatever fits). Spread ariaProps.id and the aria-* attributes onto the native form control so label[for] binds correctly; for group-style controls (role="radiogroup" / "group"), attach aria-labelledby={ariaProps.labelledBy} to the group root. The ariaProps bridge replaced the v0.1.0Slot.Root strategy in v0.1.2 — Slot.Root silently failed on Popover-wrapped controls and on non-form group roots.

Performance — narrow-deps with dependsOn

Field renderers receive an allValues snapshot of the whole form. In v0.1.x every FieldWrapper subscribed to the full values bag to keep that snapshot live, so every field re-rendered on every keystroke anywhere in the form. Custom renderers that don't read allValues — most of them — paid this cost for nothing. As of v0.2.0 the subscription is resolved per-field:

  • Built-in renderers (text, radio-group, checkbox, etc.) — audited not to read allValues, so they skip the subscription automatically; allValues is a getValues() snapshot taken at render. No dependsOn declaration needed.
  • Custom renderer with dependsOn: ["a", "b"] — narrow watch. Re-renders only when those paths change. The renderer's allValues contains exactly the named paths, rebuilt via setByPath.
  • Custom renderer with dependsOn: [] — explicit opt-out. Skip the watch entirely; allValues is a getValues() snapshot.
  • Custom renderer without dependsOn — legacy full-bag watch. Preserves v0.1.x behavior so unmigrated renderers keep working.
{
  name: "summary",
  type: "summary",  // custom renderer reading firstName + lastName
  dependsOn: ["firstName", "lastName"],
}

{
  name: "color",
  type: "color",    // custom renderer doesn't read allValues at all
  dependsOn: [],    // → no subscription; receives a getValues() snapshot
}

The whitelist check resolves on the final renderer identity, not on field.type alone — a custom renderer registered at the text slot correctly opts back into full-bag unless it sets dependsOn. validateSchemaDev warns when dependsOn references a field name that doesn't exist in schema.fields[].name.

What changed in v0.2

The v0.2 arc is the behavioral + ergonomic counterpart to the additive v0.1.7 substrate work. All changes are auto-applied — zero consumer code changes required.

v0.2.0

  • Default-registry watch drop (A2). Every built-in renderer skips the FieldWrapper-level useWatch({ control }) subscription (audited against BUILTIN_RENDERER_TYPES_SKIPPING_ALL_VALUES; identity-checked against defaultJsonFormRegistry). On a 20-field form, FieldWrapper renders per keystroke drop from 20 down to 1–4 (only fields whose useConditional / useComputed narrow-deps reference the typed field). Custom renderers without dependsOn are unchanged.
  • Deep-merge defaultValues (B1). Form-level defaultValues now overlays per-field defaults per leaf, not per top-level key. defaultValues = { address: { city: 'NYC' } } no longer drops sibling per-field defaults at address.country, address.zip, etc.
  • Conditional-count warn threshold bumped from 50 → 200 in validateSchemaDev; the per-keystroke render count no longer scales with conditional count.

v0.2.1

  • JSDoc + devtools patch: dependsOn stable-identity guidance, allValues snapshot-freshness contract documented across the three subscription modes, <JsonFormDevtools> renders a soft-warning panel when no <JsonFormProvider> is in the tree (instead of crashing), BigInt-safe prettyReplacer in the devtools body.

v0.2.3

  • Richtext controlled-mode echo loop fixed at the substrate — depends on @ilinxa/rich-text-editor@^0.2.2, which now uses content-equality (JSON-stringified Plate-tree key) in its sync effect instead of reference-equality. The earlier v0.2.2 consumer-side band-aid in parts/field-richtext.tsx was reverted; the field is back to its simple v0.2.1 shape.

v0.2.4

  • Headless <FormProvider> bridge. <JsonFormProvider> now wraps children in <FormProvider {...value.rhf}> internally. The documented headless pattern (<JsonFormProvider value={{ ...handle, rhf: form, ... }}>) no longer requires consumers to also wrap with RHF's <FormProvider>. Pre-v0.2.4 code that wraps with both still works — the inner wins, identical form instance.

v0.2.5

  • <FieldRadioGroup> always-controlled. Renderer now passes value="" (Radix-canonical "no selection") instead of undefined when the field has no value, so the underlying RadioGroup stays controlled. React no longer logs "RadioGroup is changing from uncontrolled to controlled" on the first interaction of a radio-group field without a defaultValue.

If you were relying on the v0.1.x shallow defaultValues replace to clear nested per-field defaults, see the FAQ entry below.

Typed renderer authoring — defineFieldRenderer<T>

import { defineFieldRenderer, defaultJsonFormRegistry } from "@ilinxa/json-form";

interface ColorConfig {
  palette?: string[];
}

const ColorSwatch = defineFieldRenderer<string, ColorConfig>({
  displayName: "ColorSwatch",
  impl: ({ value, onChange, field, disabled }) => {
    const palette = field.config?.color?.palette ?? ["#FF595E", "#FFCA3A"];
    return (
      <div className="flex gap-1">
        {palette.map((c) => (
          <button key={c} type="button" disabled={disabled}
            onClick={() => onChange(c)}
            style={{ background: c, opacity: value === c ? 1 : 0.5 }}
          />
        ))}
      </div>
    );
  },
});

const registry = { ...defaultJsonFormRegistry, color: ColorSwatch };

defineFieldRenderer<TValue, TConfig> narrows the renderer args at the type level. It's type-narrowing only — there’s no runtime narrowing, because RHF values aren’t statically known. The factory attaches a displayName (used by <JsonFormDevtools>) as a non-enumerable property.

Headless single-field reads — useJsonFormFieldValue

import { useJsonFormFieldValue, useJsonFormFieldsValue } from "@ilinxa/json-form";

function Summary() {
  // Single-field, narrow-deps (re-renders only when "country" changes)
  const country = useJsonFormFieldValue<string>("country");
  return <p>You selected {country}</p>;
}

function NamePreview() {
  // Multi-field, narrow-deps (re-renders only when one of these changes)
  const { firstName, lastName } = useJsonFormFieldsValue<{
    firstName: string;
    lastName: string;
  }>(["firstName", "lastName"]);
  return <p>{firstName} {lastName}</p>;
}

Both hooks are pure ergonomic wrappers around RHF's useWatch scoped to the active <JsonFormProvider>. The generic <T> is consumer-asserted — RHF values aren’t statically known, so this is typed convenience, not a runtime guarantee.

Devtools panel — <JsonFormDevtools>

import { JsonForm, JsonFormDevtools } from "@ilinxa/json-form";

export function MyForm() {
  return (
    <>
      <JsonForm schema={schema} onSubmit={...} />
      <JsonFormDevtools />
    </>
  );
}

Floating panel with four tabs: Schema (collapsible JSON), Values (live RHF values), Conditionals (per-field visible / enabled / required booleans), Errors. Toggle the floating panel with Ctrl+Shift+J (override via shortcut prop). Use <JsonFormDevtools inline /> for inline-block placement.

Prod no-op: in production builds, the component returns null automatically (gated on process.env.NODE_ENV). For true bundler-level dead-code-elimination, wrap the usage:

{process.env.NODE_ENV !== "production" && <JsonFormDevtools />}

The panel body itself is React.lazy()-boundary-isolated — the ~250 LOC body chunk only fetches when the component actually mounts to a non-null state, so even without the consumer-side guard the body never ships to prod users. Override via force prop for prod-debug.

Headless usage

v0.2.4: <JsonFormProvider> bridges RHF's <FormProvider> internally, so you do not need to wrap with both providers. Construct a ctx object that satisfies JsonFormContextValue and hand it to <JsonFormProvider value={ctx}>; the standalone parts resolve their RHF context through that bridge.

import {
  useJsonForm,
  JsonFormProvider,
  JsonFormField,
  JsonFormSubmitButton,
  defaultJsonFormRegistry,
  defaultJsonFormStrings,
} from "@ilinxa/json-form";

function Custom({ schema, onValid }) {
  const { form, zodSchema, handle } = useJsonForm(schema);
  const ctx = {
    ...handle,
    rhf: form,
    schema,
    zodSchema,
    strings: defaultJsonFormStrings,
    formId: "my-form",
    hasSubmitted: false,
    fieldRegistry: defaultJsonFormRegistry,
  };
  return (
    <JsonFormProvider value={ctx}>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          void form.handleSubmit(onValid)(e);
        }}
      >
        <JsonFormField name="email" />
        <JsonFormField name="password" />
        <JsonFormSubmitButton />
      </form>
    </JsonFormProvider>
  );
}

Accessibility

  • Deterministic SSR-stable field ids via React 19 useId(); label[for] matches the input id
  • aria-required, aria-invalid, aria-describedby forwarded onto the control via the ariaProps bridge passed in FieldRendererArgs (v0.1.2 replaced the original Slot.Root strategy)
  • role="alert" on every error message + on the error summary; summary is aria-live="polite"
  • Focus moves to the first invalid field on submit failure (DOM order)
  • Radio groups + checkbox groups have keyboard nav (arrow keys, space) via the underlying Radix primitive
  • Rating widget is role="radiogroup"; arrow keys cycle, number keys jump, Home/End jump to first/last

Value-shape per type

TypeSubmitted value
text / email / password / url / tel / textareastring
number / slider / ratingnumber
checkbox / switchboolean
select / radio-groupunknown (option.value, type preserved)
multi-select / checkbox-groupunknown[]
date / time / datetimestring (ISO 8601)
date-range{ start: string; end: string }
codestring
richtextArray<{ type, children }> (Plate JSON). For the canonical empty default, import RICH_TEXT_EMPTY_VALUE from @ilinxa/rich-text-editor. Serialize via serializeRichTextToHtml at export boundaries.
computedwhatever expression / compute returns
hiddendefaultValue (unchanged)
section / divider— (excluded from submission)

FAQ

Why isn't the submit button disabled when the form is invalid?
That's an a11y anti-pattern — users get no feedback on what's wrong. The button is enabled; submit triggers validation, errors surface inline + in the summary. Opt in via submitButton: { disableWhenInvalid: true }.
Why does submit fire on Enter inside <input> but not <textarea>?
Standard browser behavior — Enter in textarea inserts a newline. Enter in any other text input fires the form's <button type="submit">.
Can I pin zod@^3 in my app?
No. JsonForm requires zod@^4 (the resolver chain uses v4 APIs). Upgrade or build your form by hand.
Why does my form re-mount when the schema reference changes?
RHF re-initializes on schema identity change. Memoize your schema (useMemo or module-scope const) so the reference is stable across renders.
When does requiredWhen surface its error?
requiredWhen is enforced via a Zod superRefine, which only re-runs when RHF triggers validation. Under the default validationMode: "onTouched" that means the error appears on the field's next blur or on the next submit attempt — flipping the trigger field doesn't synchronously re-flag the conditional field. Set validationMode="onChange" for eager surfacing, or call formApi.trigger(name) from a custom watcher.
Does onChange loop when piped back through values?
No. JsonForm hashes the values bag before invoking onChange and skips structurally-identical re-emissions, so the controlled-mode round-trip (consumer onChangesetState → new values prop → RHF re-sync) terminates after one tick. The default onChangeDebounce of 100ms is an additional smoothing layer, not the loop-breaker.
Do I still need to wrap headless trees with RHF's <FormProvider>?
No, as of v0.2.4 <JsonFormProvider> wraps its children in <FormProvider {...value.rhf}> internally. The documented headless pattern (<JsonFormProvider value={{ ...handle, rhf: form, ... }}>) is now self-contained — standalone parts (<JsonFormField>, <JsonFormSubmitButton>, etc.) see RHF's context through the bridge. Pre-v0.2.4 code that wraps with both providers still works (the inner wins; same form instance).
What changed about Zod compilation in v0.1.7?
Internal refactor only. The single compileSchema(schema, strings) call is now split behind the scenes into compileStructural(schema) (cheap, schema-keyed) + injectStrings(structural, strings) (Zod chain construction, strings-keyed). useJsonForm caches each step independently, so changing only the strings prop (locale switch, error-message overrides) skips the structural re-walk. No public API change — the helpers are internal and not exported from the package barrel.
My v0.1.x form passed defaultValues = { address: {} } to clear nested per-field defaults — what do I do in v0.2.0?
v0.2.0 deep-merges defaultValues per leaf instead of replacing per top-level key, so an empty object at address no longer wipes address.country / address.zip defaults. To clear them you now need to be explicit per leaf — either via a nested defaultValues shape ({ address: { country: '', zip: '' } }) or by setting the per-field defaultValue to "" directly on the schema field. The audit found no shipping consumer relying on the old shallow-replace, but if you hit this case, an empty string per leaf is the safest port.

Features

  • Declarative field DSL — 25 built-in types, compiled to Zod at mount
  • Conditional fields — 11-operator Condition DSL + function escape hatch covers visibleWhen / enabledWhen / requiredWhen
  • Computed fields — pure `{interpolation}` template OR `compute: (args) => ...` function
  • Extensible renderer registry — `fieldRegistry` spread + extend; `renderField` form-level slot
  • Typed renderer factory `defineFieldRenderer<TValue, TConfig>(...)` for narrowed custom-renderer authoring (v0.1.7)
  • Headless narrow-deps hooks: `useJsonFormFieldValue<T>(name)` + `useJsonFormFieldsValue<T>(names)` for fully-custom layouts subscribing to one or more fields without re-rendering on unrelated changes (v0.1.7)
  • `<JsonFormDevtools>` — floating-by-default panel with schema / values / conditionals / errors tabs; lazy-loaded body chunk + prod no-op return (v0.1.7)
  • Per-field subscription gate — built-in default renderers skip the FieldWrapper-level watch (audited; identity-checked against the default registry); custom renderers opt in via `field.dependsOn` (`[]` = no watch, `['a','b']` = narrow watch, omitted = legacy full-bag) (v0.2.0)
  • Deep-merge `defaultValues` per leaf — form-level overrides no longer drop sibling per-field defaults at shared parent paths (v0.2.0)
  • `<JsonFormProvider>` bridges RHF's `<FormProvider {...value.rhf}>` internally — the documented headless pattern (`<JsonFormProvider value={{ ...handle, rhf: form, ... }}>`) no longer requires consumers to wrap with both providers (v0.2.4)
  • `<FieldRadioGroup>` always-controlled — passes `value=""` for the "no selection" state instead of `undefined`, so Radix RadioGroup no longer flips uncontrolled→controlled on the first interaction of a radio-group field without a `defaultValue` (v0.2.5)
  • Headless factory hook (`useJsonForm`) for fully-headless layouts via `<JsonFormProvider>` + standalone parts
  • Imperative handle: submit / reset / setValue / getValue / setError / trigger / focus / isDirty / isValid / isSubmitting
  • Cross-registry deps on `@ilinxa/code-block` (for `code`) and `@ilinxa/rich-text-editor` (for `richtext`), both lazy-loaded via `React.lazy`
  • Consumer-supplied `zodSchema` escape hatch — wins per-key over the DSL-generated chain
  • `onTouched` validation by default (error after first blur OR submit); overridable via `validationMode`
  • Object-shape callbacks throughout (per F-cross-12)
  • Accessibility: deterministic SSR-stable field ids (`useId()`), `ariaProps` bridge passed to every renderer for correct `id` / `aria-labelledby` / `aria-required` / `aria-invalid` / `aria-describedby` wiring across native form controls AND Popover-wrapped / group-style controls, role='alert' error summary with anchor links + setFocus, focus-walks to first focusable error on submit failure

Tags

json-formformschemareact-hook-formzodvalidationconditionalcomputedrichtextbackend-drivenheadless

Dependencies

shadcn primitives: radio-group, slider, label, input, textarea, select, checkbox, switch, command, popover, calendar, button, separator, badge
npm peer deps: react-hook-form@^7.75.0, @hookform/resolvers@^5.2.2, zod@^4.4.3, lucide-react@^1.11.0
internal: code-block, rich-text-editor