Skip to content

custom

The escape hatch: you render the content, the form manages the value. Use it for a colour picker, a map, a live preview, or any widget the built-in kinds do not cover.

ts
{
  fields: [
    { name: 'format', displayName: 'Format', defaultValue: 'pdf' },
    {
      name: 'colour',
      kind: 'custom',
      displayName: 'Accent colour',
      render: (ctx) => {
        ctx.element.replaceChildren();

        for (const colour of ['#e5484d', '#30a46c', '#0091ff']) {
          const swatch = document.createElement('button');
          swatch.type = 'button';
          swatch.setAttribute('aria-label', colour);
          swatch.style.cssText =
            'width:28px;height:28px;margin-right:6px;border-radius:4px;cursor:pointer;background:' +
            colour +
            ';border:' +
            (ctx.data['colour'] === colour ? '3px solid #333' : '1px solid #999');
          swatch.onclick = () => ctx.setValue(colour);
          ctx.element.appendChild(swatch);
        }
      },
    },
  ],
}
Rendered form

Values

Options

OptionTypeNotes
render(ctx: RenderContext) => voidRequired

Plus everything in the shared options.

The render context

render receives the usual field context plus three things:

ts
interface RenderContext extends FieldContext {
  element: HTMLElement; // render into this
  requestUpdate(force?: boolean): void; // re-run the form update cycle
  setValue(value: unknown): void; // set this field's value + update
}

element is owned by the field, so you may freely replace its children.

It re-renders on every update

render is called once on mount and again on every form update. Write it as a pure function of ctx.data rather than assuming it runs once:

ts
{
  name: 'picker',
  kind: 'custom',
  render: (ctx) => {
    ctx.element.replaceChildren(); // idempotent: clear, then build

    for (const colour of ['red', 'green', 'blue']) {
      const swatch = document.createElement('button');
      swatch.type = 'button';
      swatch.style.background = colour;
      swatch.setAttribute('aria-label', colour);
      swatch.onclick = () => ctx.setValue(colour);
      ctx.element.appendChild(swatch);
    }
  },
}

Do not attach state to the element

Anything you hang on element survives re-renders only by accident. Keep state in your own closure, or in the form value via setValue.

Setting values

ctx.setValue(value) stores the value and triggers a form update, so dependent fields react immediately. The value keeps its type — it is not coerced to a string.

If you mutate something outside the form's knowledge and need a refresh without changing this field's value, call ctx.requestUpdate() instead. Pass true to force an update even when nothing appears to have changed.

Performance

Because render runs on every update, avoid rebuilding expensive DOM unconditionally. Either diff cheaply:

ts
render: (ctx) => {
  const next = String(ctx.data['title'] ?? '');
  if (ctx.element.textContent !== next) ctx.element.textContent = next;
},

…or build once into a stable child and update only its content.

When to reach for a custom field kind instead

If you find yourself writing the same custom field in several forms, promote it to a registered kind so it gets a proper descriptor type and can be reused declaratively. See Custom field kinds.

Released under the MIT License.