Native Interactive Elements 2: select, datalist, Customizable Select and output/progress/meter
Use select for a choice and datalist for suggestions: datalist submits values outside its list, and a required select treats only a first option with value="" as a placeholder. In this post we measure a catalog filter, a customizable select with cover images via appearance: base-select (Chrome 135 and Safari 27, not Firefox) and output/progress/meter in Chromium and WebKit, and show the disabled placeholder trap and the output that is never submitted.
Version note: This post follows the November 2026 state of the HTML (HyperText Markup Language) Living Standard and Baseline; the examples were tested with Chrome 158, WebKit 27.2 (Playwright 1.65) and Nu Html Checker 26.11.9, and Firefox support is taken from web-features data.
Guess First
Bookstore's catalog page has a filter form: sort order, category, format and author. All of it is native elements:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Catalog | Bookstore</title>
</head>
<body>
<main>
<h1>Catalog</h1>
<form action="/catalog" method="get">
<label for="sort">Sort by</label>
<select id="sort" name="sort">
<option value="relevance">Relevance</option>
<option value="price-asc">Price: low to high</option>
<option value="newest" selected>Newest first</option>
</select>
<label for="category">Category</label>
<select id="category" name="category" required>
<option value="">Choose a category</option>
<optgroup label="Software">
<option value="architecture">Architecture</option>
<option value="dotnet">.NET</option>
</optgroup>
<optgroup label="Fiction">
<option>Science fiction</option>
<option>Crime</option>
</optgroup>
</select>
<label for="format">Formats</label>
<select id="format" name="format" multiple size="3">
<option value="hardcover">Hardcover</option>
<option value="paperback">Paperback</option>
<option value="ebook">E-book</option>
</select>
<label for="author">Author</label>
<input id="author" name="author" list="author-list" autocomplete="off">
<datalist id="author-list">
<option value="Robert C. Martin"></option>
<option value="Martin Fowler"></option>
<option value="Eric Evans" label="Domain-Driven Design"></option>
</datalist>
<button type="submit">Filter</button>
</form>
</main>
</body>
</html>
What happens if the user presses "Filter" without choosing anything? Then, if they pick "Crime" as the category, "Paperback" and "E-book" as formats and type "Uncle Bob", which is not in the list, into the author field, what exactly does the URL (Uniform Resource Locator) sent to the server look like? The "Crime" option has no value; what gets sent? How do two formats travel? And is "Uncle Bob" rejected because it is not in the list?
In the previous post, "Native Interactive Elements 1: details/summary, dialog, popover and Invoker Commands", we saw the windows and panels the browser gives you without JavaScript. This post is the second half: the elements that make a choice (select and datalist), the version of select that can finally be designed with CSS (Cascading Style Sheets) after decades of being replaced by libraries because it "can't be styled", and three small elements that display a value: output, progress, meter.
select, optgroup and multiple: The Catalog Filter
Here is a Playwright script that answers the guessing question in Chromium; it serves the page from a small Node server and prints every request the server receives:
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
if (req.url !== '/catalog') console.log(`server: GET ${req.url}`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(fs.readFileSync('catalog.html'));
}).listen(18882);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:18882/catalog');
// required select: a first option with value="" counts as a placeholder
console.log('category valid:', await page.locator('#category').evaluate(s => s.validity.valid),
'| value:', JSON.stringify(await page.locator('#category').inputValue()));
await page.getByRole('button', { name: 'Filter' }).click();
console.log('url after submit:', new URL(page.url()).search || '(no navigation)');
await page.locator('#category').selectOption({ label: 'Crime' });
await page.locator('#format').selectOption(['paperback', 'ebook']);
// datalist only suggests: a value outside the list is submitted too
await page.locator('#author').fill('Uncle Bob');
await page.getByRole('button', { name: 'Filter' }).click();
await page.waitForURL('**/catalog?*');
console.log('datalist options:', await page.locator('#author').evaluate(i =>
[...i.list.options].map(o => `${o.value}|${o.label}`).join(', ')));
await browser.close();
server.close();
})();
category valid: false | value: ""
url after submit: (no navigation)
server: GET /catalog?sort=newest&category=Crime&format=paperback&format=ebook&author=Uncle+Bob
datalist options: Robert C. Martin|, Martin Fowler|, Eric Evans|Domain-Driven Design
The third line is the query string the server saw. Let's go through it. sort=newest: the user never touched the sort order, but the option carrying the selected attribute became the default choice and was submitted. Without selected, the first option starts selected; in a single-choice select there is no such state as "the user chose nothing".
category=Crime: if an option has no value attribute, the browser submits its text. It looks convenient, but whoever later changes the text to "Crime & Thriller" breaks the filter on the server. Write a value on every option to separate the displayed text from the submitted key; the text belongs to the translation team, the value to the backend.
optgroup groups options under a heading and a screen reader reads the group's name, but it adds nothing to the submitted value: the server never saw the word "Fiction". The label of an optgroup is not a selectable option.
format=paperback&format=ebook: in a multiple select, every selected option is sent as a separate pair with the same name. On the server you receive it as an array; in ASP.NET Core it is enough to make an action parameter string[] format, and the model binding details are in the ASP.NET Core MVC (Model-View-Controller) and Razor Pages series. But think twice before using multiple: on desktop, selecting more than one item requires Ctrl-clicking (Cmd on a Mac), and few users know that; someone who accidentally clicks without Ctrl silently loses their previous choices. With three to five options, the fieldset and checkbox group from "HTML Forms 1" is clearer and produces the same query string.
A Required select and the Placeholder Option
The first two lines of the output answer the first guessing question: the category field is required, and when the user pressed "Filter" without choosing anything, the form was not submitted and the page address did not change. But why did the browser treat the "Choose a category" option as invalid? It is an option too, and it is selected.
The spec calls this option a placeholder label option. The conditions: the select is required, not multiple and shown as a single line (size 1 or absent), the first option's value is the empty string, and that option is a direct child of the select, not inside an optgroup. Then the field counts as valueMissing while the first option is selected. The :user-invalid styling from "HTML Forms 2" works here just the same.
Missing one of the conditions is a silent bug, and its two most common forms are opposites. A script that tries four placeholder variants on the same required select:
const { chromium } = require('playwright');
const variants = [
'<option value="">Choose a category</option>',
'<option value="" disabled>Choose a category</option>',
'<option value="" disabled selected>Choose a category</option>',
'<option value="none">Choose a category</option>',
];
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
for (const placeholder of variants) {
await page.setContent(`<select required>${placeholder}<option value="crime">Crime</option></select>`);
const [value, valid] = await page.evaluate(() => {
const s = document.querySelector('select');
return [s.value, s.validity.valid];
});
console.log(`${placeholder.padEnd(62)} value="${value}" valid=${valid}`);
}
await browser.close();
})();
<option value="">Choose a category</option> value="" valid=false
<option value="" disabled>Choose a category</option> value="crime" valid=true
<option value="" disabled selected>Choose a category</option> value="" valid=false
<option value="none">Choose a category</option> value="none" valid=true
The second line is the sneakiest: disabled was added so that "the user can't pick the placeholder", but selected was forgotten. In a single-line select with no selected option, the browser selects the first option that is not disabled; the placeholder was never selected, "Crime" appeared straight away and required became useless. With disabled selected written together (third line), the rule works again. In the fourth line, value="none" makes the field always valid and the server receives category=none. My preference is the first line: value="", no disabled. If the user picks a category by mistake, they can go back and say "I didn't choose".
datalist: A List of Suggestions, Not a Choice
The input in the author field is tied to a datalist through list="author-list". As the user types, the browser suggests matching options from the list. The key point is author=Uncle+Bob in the output: a value that is not in the list was not rejected. A datalist is not a select; it only suggests, and the user can type anything. If the value must come from the list, either use a select or check it on the server.
The script's last line shows the two attributes of a datalist option: what gets submitted is always value, while label appears as an extra description in the suggestion row. The "Eric Evans" option carries the label "Domain-Driven Design"; the user sees the book's title, and the author's name goes into the input. Browsers draw label in the suggestion row in different ways; don't build your UI on it. The author input has autocomplete="off" because we don't want the browser's own form history mixed into this suggestion list.
The limits of datalist are worth knowing. The look of the suggestion popup belongs entirely to the browser and cannot be changed with CSS. The matching rule (prefix or substring, case sensitivity) differs between browsers. There is no grouping. And according to web-features data the feature is not Baseline: Chrome, Edge, Safari and desktop Firefox support it, but Firefox for Android does not, which has held up Baseline since February 2023. In a browser without support the field is a plain text box, so all you lose is the suggestions. That makes datalist a good fit for nice-to-have help and a poor fit for an autocomplete that searches thousands of records on the server. The latter needs a component written in JavaScript that follows the ARIA (Accessible Rich Internet Applications) combobox pattern, which is a topic for the upcoming Accessibility (a11y) series.
Customizable Select: appearance: base-select
For more than a decade, design teams fought the same battle with select: you can style the closed state a little, but the dropdown list belongs to the operating system. You can't put an image inside, you can't right-align the price. The result was a dropdown built from divs in every project, with its keyboard, focus and screen reader bugs. Customizable select changes this. Let's show the format choice on Bookstore's book page with a cover image and a price:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Clean Architecture | Bookstore</title>
<style>
select,
::picker(select) {
appearance: base-select;
}
option img {
width: 24px;
height: 36px;
}
option:checked {
font-weight: bold;
}
select:open::picker-icon {
rotate: 180deg;
}
</style>
</head>
<body>
<main>
<h1>Clean Architecture</h1>
<form action="/cart" method="post">
<label for="format">Format</label>
<select id="format" name="format">
<button>
<selectedcontent></selectedcontent>
</button>
<option value="hardcover">
<img src="/covers/42-hardcover.webp" alt="" width="24" height="36">
<span>Hardcover</span>
<span class="price">49.90 USD</span>
</option>
<option value="paperback" selected>
<img src="/covers/42-paperback.webp" alt="" width="24" height="36">
<span>Paperback</span>
<span class="price">34.90 USD</span>
</option>
<option value="ebook">
<img src="/covers/42-ebook.webp" alt="" width="24" height="36">
<span>E-book</span>
<span class="price">19.90 USD</span>
</option>
</select>
<button type="submit">Add to cart</button>
</form>
</main>
</body>
</html>
There are three parts. First, the opt-in in CSS: appearance: base-select is set on the select and on the ::picker(select) pseudo-element, which represents the dropdown list. You need both; if you set it only on the select, the list keeps the operating system's look. Second, the button that is the first child of the select, with a selectedcontent inside: this button is the visible part of the closed select, and selectedcontent shows a copy of the selected option's content. Third, the inside of the options: non-interactive elements such as img and span are now allowed. option:checked selects the chosen option and select:open the state where the list is open (we saw :open for dialog and details in the previous post); ::picker-icon styles the arrow and ::checkmark the mark next to the selected option. When the list opens, it is drawn in the top layer like the popover in the previous post; changing its position with anchor positioning is a topic for the upcoming CSS3: Flexbox, Grid and Modern CSS series.
A script that measures this behaviour in two engines:
const pw = require('playwright');
const fs = require('fs');
const source = fs.readFileSync('format.html', 'utf8');
(async () => {
for (const name of ['chromium', 'webkit']) {
const browser = await pw[name].launch();
const page = await browser.newPage();
await page.setContent(source);
const select = page.locator('#format');
const report = () => select.evaluate(s => ({
open: s.matches(':open'),
value: s.value,
focus: document.activeElement.tagName.toLowerCase(),
shown: [...s.querySelector('selectedcontent').children].map(c => c.textContent || c.tagName.toLowerCase()).join(' | '),
}));
console.log(`--- ${name} ${browser.version()}`);
console.log('base-select supported:', await page.evaluate(() => CSS.supports('appearance', 'base-select')));
// did the parser keep the img and span elements inside option in the DOM?
console.log('option children:', await select.evaluate(s =>
[...s.options[0].children].map(c => c.tagName.toLowerCase()).join(',')));
console.log('initial:', JSON.stringify(await report()));
await select.focus();
await page.keyboard.press('Space');
console.log('after Space:', JSON.stringify(await report()));
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
console.log('after ArrowDown+Enter:', JSON.stringify(await report()));
if (name === 'chromium') {
// is the select still a combobox in the accessibility tree?
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const combo = nodes.find(n => n.role?.value === 'combobox');
console.log(`ax: combobox "${combo.name.value}" value="${combo.value.value}"`);
}
await browser.close();
}
})();
--- chromium 158.0.8127.18
base-select supported: true
option children: img,span,span
initial: {"open":false,"value":"paperback","focus":"body","shown":"img | Paperback | 34.90 USD"}
after Space: {"open":true,"value":"paperback","focus":"option","shown":"img | Paperback | 34.90 USD"}
after ArrowDown+Enter: {"open":false,"value":"ebook","focus":"select","shown":"img | E-book | 19.90 USD"}
ax: combobox "Format" value=" E-book 19.90 USD"
--- webkit 27.2
base-select supported: true
option children: img,span,span
initial: {"open":false,"value":"paperback","focus":"body","shown":"img | Paperback | 34.90 USD"}
after Space: {"open":true,"value":"paperback","focus":"option","shown":"img | Paperback | 34.90 USD"}
after ArrowDown+Enter: {"open":false,"value":"ebook","focus":"select","shown":"img | E-book | 19.90 USD"}
Chromium and WebKit gave the same result. The option children line shows that the parser kept the img and span elements in the DOM (Document Object Model); the old parser would have dropped them and kept only the text. The Space key opened the list and focus moved to the selected option; ArrowDown and Enter chose "E-book", the list closed and focus returned to the select. selectedcontent now holds the E-book option's img, name and price: the browser copies the content itself whenever the selection changes. When the form is submitted, what goes out is still the value, that is format=ebook.
The browser manages the keyboard, focus and role: in Chromium's accessibility tree this is still a combobox named "Format". In a dropdown you write yourself, you would have to build every one of these by hand. But look at the value on the last line: the value a screen reader will read is not just "E-book" but " E-book 19.90 USD". Every piece of text you put in an option becomes part of the selected value. Here, reading the price is useful; if you add a badge such as "Bestseller" or a note such as "in stock" to an option, that is read together with the value every time as well.
What Happens in a Browser Without Support?
As of November 2026, web-features data says: Chrome and Edge support it since 135 (April 2025), Safari since 27 (September 2026); Firefox does not, so the feature is limited availability. With Firefox 153, Firefox removed the parser restrictions of select, so button, img and span enter the DOM; but appearance: base-select is not there yet. I couldn't run Firefox for this post; according to MDN (MDN Web Docs), a browser without support ignores the button and selectedcontent, shows only text in the options, and you end up with an ordinary, fully working select.
The condition for progressive enhancement being this clean is the rule the WebKit team called the "golden rule" in its June 2026 post: every option must have text. An image, icon or colour swatch complements the text, it does not replace it. In our example every option has text such as "Paperback", and the cover images are marked decorative with alt="" because the text carries the meaning. Had you written alt="Paperback cover", a screen reader would read the format name twice for every option.
One more thing changes in the fallback: the price. In a browser without support, the option text appears as a run-together string such as "Paperback 34.90 USD". That is acceptable; what is not acceptable is carrying information only in an image or only in ::before content added with CSS.
output, progress and meter
The last three elements display a value, and all three are often used in the wrong place. Bookstore's order page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order #1042 | Bookstore</title>
</head>
<body>
<main>
<h1>Order #1042</h1>
<p>
<label for="order-step">Order status</label>
<progress id="order-step" max="4" value="2"></progress>
Step 2 of 4: Packed
</p>
<p>
<label for="upload">Uploading invoice</label>
<progress id="upload"></progress>
</p>
<p>
<label for="stock">Stock</label>
<meter id="stock" min="0" max="50" low="5" high="20" optimum="50" value="3"></meter>
3 left
</p>
<form action="/cart" method="get" oninput="total.value = (qty.valueAsNumber * 34.90).toFixed(2)">
<label for="qty">Quantity</label>
<input id="qty" name="qty" type="number" min="1" max="3" value="1">
<p><label for="total">Total</label> <output id="total" name="total" for="qty">34.90</output> USD</p>
<button type="submit">Update cart</button>
</form>
</main>
</body>
</html>
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
if (req.url !== '/orders/1042') console.log(`server: GET ${req.url}`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(fs.readFileSync('order.html'));
}).listen(18883);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:18883/orders/1042');
// Accessibility tree: which role do progress, meter and output get?
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
for (const n of nodes.filter(n => ['progressbar', 'meter', 'status'].includes(n.role?.value))) {
const prop = k => n.properties?.find(p => p.name === k)?.value.value;
console.log(`${n.role.value} "${n.name?.value}" value=${n.value?.value} max=${prop('valuemax')}` +
(prop('live') ? ` live=${prop('live')}` : ''));
}
console.log(':indeterminate ->', await page.evaluate(() =>
[...document.querySelectorAll(':indeterminate')].map(e => e.id).join(', ')));
await page.getByLabel('Quantity').fill('3');
console.log('output value:', await page.locator('#total').textContent());
await page.getByRole('button', { name: 'Update cart' }).click();
await page.waitForURL('**/cart?*');
await browser.close();
server.close();
})();
progressbar "Order status" value=2 max=4
progressbar "Uploading invoice" value=undefined max=1
meter "Stock" value=3 max=50
status "Total" value=undefined max=undefined live=polite
:indeterminate -> upload
output value: 104.70
server: GET /cart?qty=3
progress tells how much of a task is done: "2 of 4 steps" with value and max. In the accessibility tree it appears with the progressbar role, its value and its maximum. If you leave out value, the progress is indeterminate, meaning "in progress, but we don't know how much is left"; the :indeterminate line in the output selects it, and that is the right display for tasks of unknown duration such as an invoice upload. I also wrote the text "Step 2 of 4: Packed" next to the progress; the bar shows a ratio, but the text says which step you are at.
meter, on the other hand, displays a measurement within a known range: a stock level, disk usage, a rating. It is not progress; stock does not "complete" from 3 to 50. low, high and optimum split the range into regions. Here optimum="50" is in the high region and the value 3 is below low; according to the spec this is the region farthest from the optimum, and the browser colours the bar accordingly. The colour varies by browser, which is why the "3 left" text stays visible. In the accessibility tree it appears with the meter role.
output is the result of a calculation. In the example, the form's oninput writes the total when the quantity changes; with a quantity of 3 it became 104.70. In the accessibility tree its role is status with live=polite: when the value changes, a screen reader reads the new total without interrupting what the user is doing. Doing this with a span would mean building a live region by hand. The for attribute says which fields the result was calculated from, and an output can be named with a label; the name "Total" in the tree comes from there.
The last line of the output is the most surprising: the server received only qty=3. Even though the output has a name, it is not submitted with the form; it is part of the form's element list but it is not submittable. If you were thinking of sending the total to the server, you are already on the wrong path: taking the price from the client is another form of the client validation mistake we discussed in "HTML Forms 2". The total is recalculated on the server. Attaching event handlers from a script file instead of writing them inline, and APIs such as valueAsNumber (API: Application Programming Interface), are covered in the DOM and event posts of the JavaScript Temel series.
In Production
Before adding customizable select to a design system, check your CI (Continuous Integration) tools. I ran the three pages from this post through the Nu Html Checker:
docker run --rm -v "${PWD}/site:/site" ghcr.io/validator/validator:latest vnu --errors-only /site
"file:/site/format.html":8.24-8.24: error: CSS: Parse Error.
"file:/site/format.html":9.25-9.35: error: CSS: “appearance”: “base-select” is not a “appearance” value.
"file:/site/format.html":18.22-18.32: error: CSS: Unknown pseudo-element or pseudo-class “::picker-icon”.
The checker found no problem on the HTML side: the button and selectedcontent inside the select and the img inside an option are valid. But its built-in CSS validator does not know base-select, ::picker(select) or ::picker-icon, and reports three errors. They are not your mistake, the tool is behind; but if you set the checker up in CI to fail the build on errors (as I suggested in the first post of the series), the build fails. The fix is to move the styles into a separate .css file or to filter these messages deliberately with --filterpattern, not to silence messages blindly.
On the framework side: MDN notes that some JavaScript frameworks block the button inside a select, and that some server-side rendering ones can hit hydration errors. Before using it in Angular or Blazor, render a small page with your framework version and check the DOM. And keep testing the fallback look for a while, even after Firefox support arrives, not only with every release that improves support.
Finally, think about datalist together with your analytics: even if Firefox for Android's share of Bookstore's mobile traffic is small, if it is not zero, a flow that depends on suggestions ("pick from the list, otherwise it can't be found") breaks for those users. A suggestion should stay a convenience.
Common Pitfalls
First, replacing the native select with a dropdown library just for its looks. A library usually loses the operating system's picker on mobile, works only partly with a keyboard and needs a hidden input to take part in the form. If looks are the only reason, first try customizable select, and accept the classic select in browsers without support. If you need a real combobox that searches, fetches results from the server or shows multiple choices as tags, don't write it from scratch; in Angular, a headless directive library such as Angular Aria gives you the keyboard and ARIA side ready-made. Which one is needed when is covered in the Accessibility (a11y) series.
Second, treating datalist as validation. A value that is not in the list is submitted; the server must check it.
Third, writing the placeholder option wrong: with value="0" or value="none", required never fires.
Fourth, mixing up progress and meter. Stock, ratings and quotas are meter; the progress of a task is progress. Also, writing value="0" for a task whose value is unknown: the user sees "stuck at zero percent". If the value is unknown, leave out value entirely.
Fifth, putting information only in the image in a customizable select. If an option has no text, the fallback shows empty rows and a screen reader gets unnamed choices.
A Small Experiment
★☆☆ Change the category placeholder in catalog.html to value="none" and press "Filter" without choosing anything. See what reaches the server, then fix it. Repeat the experiment with the placeholder moved inside an optgroup.
★★☆ Wrap the format choice in @supports (appearance: base-select): in a supporting browser the price sits in a separate right-aligned column, in a non-supporting one the option text is a readable string such as "Paperback (34.90 USD)". Compare the two states in Chrome by turning appearance off in DevTools.
★★★ Find a custom dropdown component in your project and put a customizable select with the same options next to it. Compare the two with the keyboard (Space, arrow keys, Esc, jumping by letter), then look at the role and name of each in the Chrome DevTools accessibility tree. Write down which features of the library you really need.
That completes the elements that make a choice or display a value, and the native interactive elements. In the next post we move to the heaviest part of the page: images and media. Preventing layout shift with width/height, writing good alt text, srcset and sizes, format fallback with picture, adding subtitles to a video, and iframe.