Give your model an interface.

Describe inputs and reports in one TypeScript schema. MLForm renders an accessible form, validates every value and sends your model exactly the payload it expects.

npm install mlform
mountForm(container, { schema, layout, transport })
Real MLForm components with the eshkra theme. Predictions come from a local example transport.

Every input a model needs.

Twelve field kinds. Each one validates, formats and serializes its own value.

Try them in the playground
  • text

  • numberValidated as you type.

  • mapped-categoryEach option fills linked fields.

  • numberOptional, with min, max and a default: a slider.

  • category

  • single-choice

  • multi-choice

  • long-text

  • boolean

  • rating

  • date

series

Tables of typed rows.

Add and remove rows with their own columns and validation. Aggregations such as mean, minimum and maximum are computed and mapped to your model.

Series example
onehot-category

One choice, one column per option.

People pick a label. Your model receives a 0/1 column for every option, named by its mappedTo. The selected option sends 1, the others 0.

One-hot reference
The transport receives
OptionmappedToValue
Clearweather_clear0
Cloudyweather_cloudy0
Rainweather_rain1

Four layouts. One schema.

Change layout.kind. Fields, validation and reports stay exactly the same.

Layout schema
  • kind: "stacked"Everything on one page.
  • kind: "wizard"Each step validates before the next.
  • kind: "tabs"Move freely between tabs.
  • kind: "split"Inputs left, reports right.

Group fields. Collapse sections.

Groups place fields in one, two or three columns. Sections fold away. Both nest inside any layout, including wizard steps and tabs.

  1. { kind: "group", columns: 2 }Two columns
  2. { kind: "group", columns: 3 }Three columns
  3. { kind: "section", defaultOpen: false }Starts collapsed

Add your own kinds.

When your model needs an input or a result MLForm doesn't ship, define it once. Plugin kinds use the same normalization, validation, serialization and rendering as built-ins.

import { css, html } from "lit";
import { PrimitiveFieldElement } from "mlform/primitives";
import { baseFieldConfigSchema, defineFieldDefinition } from "mlform/schema";
import type { MLFormFieldKind } from "mlform/view";
import { z } from "zod";

const points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];

// A compass rose of native radios, so arrow keys already move between points.
class WindDirectionField extends PrimitiveFieldElement {
  static styles = [
    PrimitiveFieldElement.styles,
    css`
      fieldset {
        display: grid;
        grid-template: "NW N NE" "W C E" "SW S SE" / 1fr 1fr 1fr;
        gap: 0.375rem;
        width: 11rem;
        margin: 0;
        padding: 0;
        border: 0;
      }
      label {
        display: grid;
        place-items: center;
        height: 2.25rem;
        border: 1px solid var(--mlf-color-border);
        border-radius: var(--mlf-radius-md);
        cursor: pointer;
      }
      label:has(:checked) {
        background: var(--mlf-color-accent);
        color: var(--mlf-color-text-inverse);
      }
      label:has(:focus-visible) {
        outline: 2px solid var(--mlf-color-focus-ring);
      }
      input {
        position: absolute;
        opacity: 0;
      }
      output {
        grid-area: C;
        place-self: center;
        color: var(--mlf-color-accent);
        font-size: 1.25rem;
      }
    `,
  ];

  render() {
    const value = this.props.value as string;
    const { controlId, describedBy } = this.getControlAttributes();
    return html`
      <fieldset aria-describedby=${describedBy ?? ""}>
        <legend class="sr-only">${this.context?.label}</legend>
        ${points.map(
          (point) => html`
            <label style="grid-area: ${point}">
              <input
                type="radio"
                name=${controlId ?? "wind-direction"}
                .checked=${value === point}
                @change=${() => this.commitValue(point)}
                @blur=${() => this.commitBlur()}
              />${point}
            </label>
          `,
        )}
        <output style="rotate: ${points.indexOf(value) * 45}deg">↑</output>
      </fieldset>
    `;
  }
}
customElements.define("wind-direction-field", WindDirectionField);

const definition = defineFieldDefinition({
  kind: "wind-direction",
  schema: baseFieldConfigSchema.extend({ kind: z.literal("wind-direction") }),
  getDefaultValue: () => "N",
  normalizeValue: (value) => (typeof value === "string" && points.includes(value) ? value : "N"),
  // The model receives degrees clockwise from north.
  serializeValue: (value) => points.indexOf(value as string) * 45,
});

export const windDirection: MLFormFieldKind = {
  category: "field",
  kind: "wind-direction",
  register(registry, descriptors) {
    registry.registerField(definition);
    descriptors.registerField({
      kind: "wind-direction",
      describe: (_config, { fieldId, state }) => ({
        component: "wind-direction-field",
        props: { id: fieldId, value: state.value },
      }),
    });
  },
};
  1. defineFieldDefinitionA new input with its own element. Kinds that reuse a built-in widget only need defineFieldKind.
  2. defineReportKindA new result view, here an SVG chart drawn from the payload.
  3. defineMLFormPluginBundles both kinds for mountForm({ plugins, primitiveRegistry }).

One form. Several models.

Send the same inputs to more than one backend. Every report names the backend and the target it reads, so a result never lands in the wrong place.

  1. formtemperature, humidity
  2. transportcreateFanoutTransport
  3. backend"forecast"mappedTo "estimate"regressorForecast model
  4. backend"alerts"mappedTo "condition"classifierAlert model
backends.ts
const transport = createFanoutTransport({
  targets: ["forecast", "alerts"],
  // clients: your own HTTP or SDK calls
  submit: (backend, request) => clients[backend].predict(request),
  merge: (outcomes) => ({
    reports: outcomes.flatMap((outcome) =>
      outcome.status === "fulfilled" ? outcome.value.reports : [],
    ),
  }),
});

const reports = [
  { id: "forecast", kind: "regressor", mappedTo: { forecast: "estimate" } },
  { id: "alert", kind: "classifier", mappedTo: { alerts: "condition" } },
];
  • collect keeps every backend's result by default; fail-fast cancels the rest after the first failure.
  • executeMultiBackendPipeline returns results and errors per backend.
  • Requests, retries and authentication stay in your own clients.

Use as much as you need.

Take the whole kit, or keep the rules and bring your own interface.

  • mlformComplete kitMount a finished form with layout, theme and lifecycle.mountForm(element, { schema, transport })
  • mlform/primitivesWeb ComponentsRender the built-in elements inside your own shell.mountPrimitiveForm(element, form, options)
  • mlform/runtimeHeadless runtimeKeep state, validation and submission. Draw your own UI.createForm({ schema, registry, transport })