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.

Get the code
<script src="/assets/bmx-components.min.js"></script>

<bmx-input name="claimant" label="Claimant" required="true" value="A. Subramaniam" autocomplete="name" style="inline-size: 15rem"></bmx-input>
<bmx-combobox id="claim-centre" name="centre" label="Cost centre" required="true" placeholder="Start typing…" style="inline-size: 15rem"></bmx-combobox>
<bmx-select id="claim-category" name="category" label="Category" required="true" placeholder="Choose one" style="inline-size: 13rem"></bmx-select>
<bmx-date-picker name="spent_on" label="Date of expense" required="true" style="inline-size: 13rem"></bmx-date-picker>
<bmx-input name="amount" label="Amount" inputmode="decimal" required="true" value="128.40" style="inline-size: 9rem"><span slot="prefix">£</span></bmx-input>
<bmx-textarea name="reason" label="What was it for?" min-rows="2" max-rows="5" counter="true" max-length="200" value="Rail fare, Leeds client review." style="inline-size: 100%"></bmx-textarea>
<bmx-upload name="receipt" label="Receipt" accept=".pdf,image/*" max-size="5242880" required="true" description="Drag one in, or browse." style="inline-size: 22rem"></bmx-upload>

<script>
  const combobox = document.getElementById('claim-centre');
  combobox.options = [ /* your options */ ];
  const select = document.getElementById('claim-category');
  select.options = [ /* your options */ ];
</script>

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/1990 — submit-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.

Get the code
<script src="/assets/bmx-components.min.js"></script>

<bmx-input label="Email address" type="email" placeholder="you@example.com" description="We only use this to send your receipt." required="true" autocomplete="email"></bmx-input>
<bmx-input label="Display name" counter="true" max-length="24" description="Shown on your public profile."></bmx-input>
<bmx-input id="mask-date" label="Date of birth" mask="##/##/####" placeholder="DD/MM/YYYY"></bmx-input>
<bmx-input id="mask-phone" label="Telephone" mask="+44 #### ######" placeholder="+44 7700 900000"></bmx-input>
<bmx-input id="mask-money" label="Amount" inputmode="decimal" placeholder="£0"></bmx-input>
<bmx-input label="Outline" appearance="outline" value="Default"></bmx-input>
<bmx-input label="Filled" appearance="filled" value="Sunken"></bmx-input>
<bmx-input label="Underline" appearance="underline" value="Minimal"></bmx-input>
<!-- …and 13 more like these -->

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.

Get the code
<script src="/assets/bmx-components.min.js"></script>

<bmx-textarea label="Delivery notes" placeholder="Type a few lines and watch it grow — then delete them again" description="Between three and eight rows." min-rows="3" max-rows="8"></bmx-textarea>
<bmx-textarea label="Feedback" counter="true" max-length="280" min-rows="3" description="Tell us what went wrong." style="inline-size: 22rem"></bmx-textarea>
<bmx-textarea label="Release notes" appearance="filled" autosize="false" resize="vertical" min-rows="4" description="Drag the corner." style="inline-size: 22rem"></bmx-textarea>
<bmx-textarea label="Disabled" disabled="true" value="Cannot be edited" min-rows="2" style="inline-size: 18rem"></bmx-textarea>
<bmx-textarea label="Read-only" readonly="true" value="Can be copied" min-rows="2" style="inline-size: 18rem"></bmx-textarea>
<bmx-textarea label="In error" error-text="This is required before you can continue." min-rows="2" style="inline-size: 18rem"></bmx-textarea>

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.

Get the code
<script src="/assets/bmx-components.min.js"></script>

<bmx-select id="sel-country" label="Country" placeholder="Choose a country" clearable="true" description="Tab in, then type a letter."></bmx-select>
<bmx-select id="sel-speed" label="Delivery" placeholder="Choose a speed" appearance="filled"></bmx-select>
<bmx-select id="sel-team" label="Assign to" multiple="true" clearable="true" max-tags="2" style="inline-size: 20rem" description="Backspace removes the last one."></bmx-select>
<bmx-select id="sel-big" label="Reference" placeholder="One of five thousand" style="inline-size: 18rem"></bmx-select>
<bmx-select id="sel-sm" label="Small" size="sm" style="inline-size: 11rem"></bmx-select>
<bmx-select id="sel-lg" label="Large" size="lg" style="inline-size: 13rem"></bmx-select>
<bmx-select id="sel-disabled" label="Disabled" disabled="true" style="inline-size: 11rem"></bmx-select>
<bmx-select id="sel-error" label="In error" error-text="That option is no longer available." style="inline-size: 13rem"></bmx-select>

<script>
  const select = document.getElementById('sel-country');
  select.options = [ /* your options */ ];
  const select2 = document.getElementById('sel-speed');
  select2.options = [ /* your options */ ];
  const select3 = document.getElementById('sel-team');
  select3.options = [ /* your options */ ];
  const select4 = document.getElementById('sel-big');
  select4.options = [ /* your options */ ];
  const select5 = document.getElementById('sel-sm');
  select5.options = [ /* your options */ ];
  const select6 = document.getElementById('sel-lg');
  select6.options = [ /* your options */ ];
  const select7 = document.getElementById('sel-disabled');
  select7.options = [ /* your options */ ];
  const select8 = document.getElementById('sel-error');
  select8.options = [ /* your options */ ];
</script>

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.

Get the code
<script src="/assets/bmx-components.min.js"></script>

<bmx-combobox id="cb-country" label="Country" placeholder="Start typing…" clearable="true" style="inline-size: 18rem" description="Type &quot;ang&quot; and Ångström is found — accents fold both ways."></bmx-combobox>
<bmx-combobox id="cb-tags" label="Labels" multiple="true" allow-custom="true" placeholder="Choose or invent…" style="inline-size: 22rem" description="Press Enter on text that matches nothing."></bmx-combobox>
<bmx-combobox id="cb-remote" label="Reference" server-filter="true" placeholder="Search five thousand…" style="inline-size: 18rem" description="Answered by a fake API with a delay, and not filtered again on the way in."></bmx-combobox>

<script>
  const combobox = document.getElementById('cb-country');
  combobox.options = [ /* your options */ ];
  const combobox2 = document.getElementById('cb-tags');
  combobox2.options = [ /* your options */ ];
  const combobox3 = document.getElementById('cb-remote');
  combobox3.options = [ /* your options */ ];
</script>

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.

Get the code
<script src="/assets/bmx-components.min.js"></script>

<bmx-date-picker id="dp-start" label="Start date" description="Press Down to open it."></bmx-date-picker>
<bmx-date-picker id="dp-us" label="Start date (en-US)" locale="en-US" value="2026-09-05" description="The same value, written the way New York writes it."></bmx-date-picker>
<bmx-date-picker id="dp-stay" label="Stay" range="true" description="Two clicks — and a date before the start begins again."></bmx-date-picker>
<bmx-date-picker id="dp-appt" label="Appointment" with-time="true" description="Weekdays only, and never a bank holiday."></bmx-date-picker>
<bmx-date-picker label="Disabled" value="2026-09-05" disabled="true"></bmx-date-picker>
<bmx-date-picker label="In error" error-text="That date is fully booked."></bmx-date-picker>

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.

Get the code
<script src="/assets/bmx-components.min.js"></script>

<bmx-upload name="proof" label="Proof of address" accept=".pdf,image/*" max-size="5242880" description="No url, no uploader — the files go with the form."></bmx-upload>
<bmx-upload id="up-form" name="docs" label="Supporting documents" multiple="true" max-files="4" accept=".pdf,.docx,image/*" description="The same, holding several."></bmx-upload>
<bmx-upload id="up-live" label="Attachments" multiple="true" concurrency="2" description="Drop several. Anything with “fail” in its name will." style="inline-size: 26rem"></bmx-upload>
<bmx-upload label="Signed contract" accept=".pdf" max-size="102400" multiple="true" max-files="2" description="PDFs only, 100 kB each, two at most."></bmx-upload>
<bmx-upload id="up-sent" label="Already sent" description="Read-only: still listed, still submitted, nothing to add or remove."></bmx-upload>