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.
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.
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(). DeclaringformAssociatedalone 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.
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.
formdata listenerTo 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.
Tip Your coding agent can search these docs locally with the Unpoly agent skill