A dropdown with no select element in it at all.
The name is the spec. unselect2 renders a styleable dropdown and the rendered
DOM contains zero select elements — not a visible one, not a hidden one, not
an aria-hidden one, not one per field. Nothing is built from a native control and
then hidden, because there is none to build from: the markup you author declares
its options directly, and the library renders the control plus one
<input type="hidden"> per field, which is what posts.
This is a return to what the project was before 2017. The original was a jQuery page with no native control anywhere: a layer showed the chosen text, a panel of rows sat under it, and clicking a row wrote that row's value into a hidden input. That mechanism is back. What is not back are the original's limits.
For a while this project did the opposite thing — it enhanced a native
select, leaving the real element in the DOM, clipped to 1×1 and marked
aria-hidden, to keep native form semantics. That kept submission and
constraint validation for free, and it was defensible, but it meant the library
called unselect2 rendered one select per field. It now renders none.
Removing the native element is not free. Three things go, and they are stated here rather than discovered later:
- Native constraint validation is gone.
requiredis this library's check and your server's. The browser enforces nothing. The client check is a convenience, not a boundary — validate on the server. - With JavaScript off there is no field at all. The authored markup is inert
<div>s until the script runs, and nothing is submitted for them. There is no degraded mode; there is no mode. - On touch the platform picker never appears. No iOS wheel, no Android sheet — this panel is what a phone gets.
Everything the enhancement-era version gained, none of which needed a native element:
- ARIA 1.2 select-only combobox semantics —
role="combobox"witharia-expanded,aria-activedescendant, and announced state. - Full keyboard support — ↓ ↑ Home End PageUp PageDown Enter Esc, plus type-ahead that cycles on a repeated letter.
- Grouped options, announced through
role="group"andaria-labelledby. - Disabled options that are rendered and announced but never selectable, and are skipped by every movement key.
- Themeable through CSS custom properties, inheriting a host design system when one is present.
Copy unselect2.css and unselect2.js into your project.
<link rel="stylesheet" href="unselect2.css">
<script src="unselect2.js" defer></script>unselect2.js is a classic script, not an ES module. It defines one global,
window.Unselect2, and re-running it is a no-op.
Author a container with data-unselect2. Everything with that attribute is
mounted automatically on DOMContentLoaded.
<label for="city-combobox">City</label>
<div data-unselect2 data-name="city" data-placeholder="Select a city" data-required>
<div data-value="1">Greater Metropolitan Los Angeles</div>
<div data-value="2">New York</div>
<div data-group="Europe">
<div data-value="uk">United Kingdom</div>
<div data-value="pl" data-disabled>Poland — at capacity</div>
</div>
</div>That renders a combobox, a listbox, and <input type="hidden" name="city">. The
hidden input is the only thing that posts.
| attribute | meaning |
|---|---|
data-unselect2 |
mount this container |
data-name |
the submitted field name — required |
data-placeholder |
shown before a choice is made |
data-required |
the field must be answered |
data-selected |
a value to preselect, for a server re-render |
| attribute | meaning |
|---|---|
data-value |
the value posted when this row is chosen |
data-disabled |
shown and announced, never selectable |
data-group |
on a wrapper: its rows form a labelled group |
The generated button's id is <name>-combobox, so a plain
<label for="city-combobox"> works and the browser handles click-to-focus
itself.
Unselect2.mount(elOrSelector) // mount one container, returns the instance
Unselect2.mountAll(root) // mount every [data-unselect2] under root
Unselect2.get(elOrSelector) // the instance for a container, or null
Unselect2.validate(form) // the invalid fields in a form, and paint themAn instance exposes value (get/set), isValid(), and destroy().
change and input are dispatched on the hidden input and bubble, so a
form-level listener sees them exactly as it would on an ordinary field.
unselect2:change, unselect2:open and unselect2:close are dispatched on the
container.
The library binds one submit listener per form. If a data-required field is
empty it calls preventDefault(), marks the field with aria-invalid and the
us2--invalid class, and focuses the first offender. The message text is
yours — the library marks, your page says why.
node test/no-select.mjs
Fails if a select element is authored in any page, if the library gains a code path that could create or find one, or if the stylesheet still carries a rule that only made sense while one existed. It carries its own self-test, so it cannot pass by silently stopping to match.
It is a source check — this repo has no dependencies and so no browser to
render in. The rendered-DOM assertion lives with a consumer that has one:
pigfox2's Playwright suite counts document.querySelectorAll('select') on the
live page across every field and requires zero.
MIT.