Learn Custom form fields

A custom control like a date picker, a tag editor or a set of clickable pills can take part in Unpoly's form features the same way a native <input> or <select> does. There are four ways to build one. They differ in how the value reaches the server, and in how much Unpoly needs to know about your control.

Pattern Needs JS Submitted Watched, validated, switched Disabled
A hidden native field No Yes Yes Yes
A form-associated element Yes Yes With value prop Yes
A registered element Yes With value prop With value prop Yes
A formdata listener Yes Yes No No

If you can keep a native field in the form, do. It is the only pattern where nothing about Unpoly, or about the browser's form APIs, needs to be considered at all, and it keeps working without JavaScript.

Option 1 Keeping a hidden native field

Start with a native field that carries the value. A compiler hides it and renders your control in its place. The form submits the hidden field; the control only sets its value.

<select name="size" class="pills">
  <option value="s">Small</option>
  <option value="m">Medium</option>
</select>
up.compiler('select.pills', function(select) {
  up.element.hide(select)

  for (let option of select.options) {
    let pill = up.element.affix(select, 'beforebegin', 'button', { type: 'button', text: option.text })
    pill.addEventListener('click', () => {
      select.value = option.value
      up.emit(select, 'change') // let watchers and validations see the change
    })
  }
})

This is the only option that still works without JavaScript. The user then sees the native <select>, and the form submits it.

Because a real form field carries the value, everything works with no further configuration: the field is submitted, watched, validated, switched and disabled like any other.

Don't forget the change event. Setting select.value from a script does not emit one, so nothing would notice the new value.

Option 2 Making a custom element form-associated

A custom element can join a form the way a native control does, by declaring itself form-associated and reporting its value to the browser:

class DatePicker extends HTMLElement {
  static formAssociated = true // mark: formAssociated
  #value = ''

  constructor() {
    super()
    this.internals = this.attachInternals()
  }

  get value() {
    return this.#value
  }

  set value(newValue) {
    this.#value = newValue
    this.internals.setFormValue(newValue) // mark: setFormValue
  }

  // Called when the element is inserted into the page
  connectedCallback() {
    // Render a calendar with a button for each day
    this.innerHTML = `
      <button type="button" data-day="2026-11-01">1</button>
      <button type="button" data-day="2026-11-02">2</button>
      …
    `
    this.addEventListener('click', (event) => {
      let dayButton = event.target.closest('[data-day]')
      if (dayButton) this.onDayPicked(dayButton.dataset.day)
    })
  }

  // Called when the user clicks a day
  onDayPicked(day) {
    this.value = day
    this.dispatchEvent(new Event('change', { bubbles: true })) // mark: change
  }
}

customElements.define('date-picker', DatePicker)
<date-picker name="starts_at"></date-picker>

The browser includes such an element in its own form data, so Unpoly submits it without being told anything about it. You also get <label> association, :disabled, and formDisabledCallback() for free.

Important

A form-associated element is only submitted if it calls setFormValue(). Declaring formAssociated alone makes the browser aware of your element, but with no value to send.

Unpoly reads your element's value through its value property, because a script cannot read back what you passed to setFormValue(). Emit a change event whenever the value changes, so watchers and validations notice it.

An element that is defined after the form was rendered, e.g. by a lazily loaded component, is picked up automatically.

Option 3 Registering any element as a field

Any element can act as a field, even a plain <div>. Tell Unpoly which elements to treat as fields, and give them a value property with a compiler:

<div class="rating" name="stars"></div>
up.form.config.fieldSelectors.push('.rating') // mark: fieldSelectors

up.compiler('.rating', function(rating) {
  rating.value = '' // mark: rating.value

  for (let stars of ['1', '2', '3', '4', '5']) {
    let button = up.element.affix(rating, 'button', { type: 'button', text: '★' })
    button.addEventListener('click', () => {
      rating.value = stars
      up.emit(rating, 'change') // let watchers and validations see the change
    })
  }
})

Unpoly then treats the <div> as a field everywhere: it is submitted, watched, validated, switched and disabled. Its name comes from the [name] attribute, as on an <input>.

The browser knows nothing about this element, so Unpoly uses the value property to submit it. Emit a change event whenever the value changes.

Prefer a form-associated element for a control you are writing today. It needs no configuration, and it works in forms that are submitted without Unpoly.

Option 4 Adding values with a formdata listener

To add a value that no element carries, append it while the form data is being built:

up.on(form, 'formdata', function(event) {
  event.formData.append('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone)
})

This is a standard DOM event, and Unpoly honors it because it builds request params with the browser's own form-data algorithm.

Important

Values appended here belong to the whole form, so watchers and validations cannot attribute them to a field. Use it for values your code computes.