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 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.
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.
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.
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.
allow-custom is off, so text that matches nothing is discarded when you leave the field rather than becoming a value nobody chose.
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 "ang" 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.
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.
<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.
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.
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>