One form, seven components

An expense claim, built the way you would build it: seven custom elements inside a plain <form>, no framework and no wiring. Fill it in and submit — the readout underneath is the real FormData the server would receive.

£
Submit claim Reset
Submit the form to see exactly what your server would receive.
Submit it empty and every field speaks up at once — including the ones you never visited, because a submitted form has asked for everything wrong with it. Reset puts all seven back where they started.

Nothing here is intercepted, adapted or bridged. Each element is form-associated through ElementInternals, so the browser collects the values, runs the constraints and fires reset exactly as it does for <input> — which is why the date submits an ISO string, the amount submits its raw digits, and the receipt arrives as a file under its own name. A React form library would receive the same events.

Input

The first of the form family. A real <input> in a shadow root that still takes part in its form, with one message at a time underneath it.

The trio — label, description, and one error at a time
Tab out of the empty email field to see when it speaks up.
Masking — a pattern, and a formatter
Backspace over a separator and it takes a character with it. Paste a value with the wrong punctuation and it is reformatted.
Appearances, and content inside the field
kg
Sizes and states
A real form — nothing here is intercepted
Submit
Submit to see what the form receives. The date field submits 01011990, not 01/01/1990submit-value decides, and defaults to the raw value.

A required field is invalid from the moment it renders, and says nothing about it until you have left it. Once it has complained, it re-checks on every keystroke rather than making you tab away to find out whether you have fixed it.

Textarea

The same chrome as the input, and the one thing every application ends up writing itself: a box that grows with the text in it.

Autosizing, bounded by rows It shrinks back, which is the half most hand-rolled versions get wrong.
Counter, and a fixed box with a drag handle
States

No mask and no prefix or suffix slots, deliberately: caret arithmetic has no meaning across line breaks, and an icon pinned beside a block of text has nothing to line up with.

Select

One choice or several, grouped or flat, and windowed — so a list of five thousand costs about twenty elements. The options are data rather than markup, which is what makes that possible.

One choice, with groups
Typeahead works with the list closed, as a native select does — and pressing the same letter again cycles through the matches rather than searching for a repeated letter.
Several choices, as tags
The value keeps the option list's order, not the order you clicked — so two people choosing the same three things submit the same payload.
Five thousand options
Open it, press End, and watch the count: the scrollbar is honest about the length while only the visible rows exist.
Sizes and states

The list is in the browser's top layer, so it escapes any overflow: hidden above it and needs no z-index. It caps its own height against the room actually on the page, flips above the control when there is more space there, and follows the control if a scrolling panel moves it.

Combobox

A text field that filters the same list. A separate element from the select rather than a flag on it — the trigger is an input, so every printable key is text and Home and End belong to the caret.

Type to find — and only the list may be left in the field
This is the "searchable select" people usually mean: allow-custom is off, so text that matches nothing is discarded when you leave the field rather than becoming a value nobody chose.
The same component as a tag input
One property, and the difference is decided at exactly one moment: blur.
Filtering somewhere else
Without server-filter the results would be filtered a second time by the same query — which works for a substring search and quietly discards everything a fuzzy one was for.

The result count is announced through a live region, because a list that silently shrinks as you type tells a sighted user everything and a screen reader user nothing. Only when the count changes: a live region re-reading the same sentence on every keystroke is worse than saying nothing.

Date picker

A date typed into a field or chosen from a calendar. The first component here that is a grid rather than a list — and the one where the locale decides more than the month names.

Typed or chosen, in the locale's own order
09/05/2026 is the 9th of May in London and the 5th of September in New York, and both are real dates — so reading it wrong does not throw, it books the wrong day. The order comes from Intl, and the mask, the placeholder and the parser are all built from it.
A range, with shortcuts
Bounds, blocked days and a time
The time is a native <input type="time"> — it brings its own keyboard, its own locale formatting and its own spinner, none of which a custom control would do better. The slider could not do that because there is no native two-ended slider; there is a native time field.

Arrow keys move a day, up and down a week, Page Up and Down a month, and with Shift a year — which is the only way to reach a date of birth without forty clicks. The grid has one tab stop, not forty-two. And not one Date object crosses into the component: new Date('2026-03-29') is the 28th in Honolulu, and adding a day across a spring-forward lands on the day it started.

Upload

The last of the fields, and the one where choosing the file is the easy half. Everything worth having is what comes after: a row per file, a bar per file, and a way to retry the one that failed without starting the other thirty-nine again.

Dropped or chosen — and by default, no request at all
Set neither url nor uploader and the field simply holds the files, submitting one FormData entry each under its own name — exactly what <input type="file" multiple> does, so a handler already written for one needs no change.
Uploading, cancelling, retrying
Two at a time, not all at once: forty transfers started together open forty connections, and the file the user is watching finishes last. The overall bar is weighted by bytes rather than by file, so a 2 kB logo beside a 400 MB video does not read as half the work.
What it refuses, and what it says about it
A refused file is named and the reason given — “design.psd is not a file type this accepts”, not “some files were not accepted”, which leaves the user looking at forty rows with no way to tell which sentence is about which. Refusals never enter the list: a row that cannot be uploaded, cannot be retried and can only be deleted is furniture.

Dragging is never the only way to do anything — the button opens the platform's own picker, which is how most people will use it regardless. The highlight is driven by a depth count, because dragenter and dragleave fire for every descendant and the naive version flickers the moment the pointer crosses a child. A dropped folder is refused by name rather than uploaded: it arrives as a zero-byte, typeless file, and sending it writes an empty file onto the server with a convincing name on it.