Skip to content

src/forms - 14 files

Forms

Every control below is the real thing, not a picture of one. Type in them, drag them, open the picker, submit the form and watch it refuse. The markup under each one is read out of the example above it while the page loads, so what you copy is what produced what you just used.

Native controls, kept native. The checkbox, radio, range and select are themed with accent-color and color-scheme rather than rebuilt, because the platform owns the keyboard behaviour, the high-contrast rendering and the phone's picker wheel, and a custom control gives all three away in exchange for a differently shaped tick.

This page links the fourteen files of the group one at a time rather than the barrel, so the head of the document is itself the answer to what each example costs. A page with one search box links src/forms/input.css and pays for one file.

The markup panels below are read out of the live examples by site.js. With scripting off the examples still render and stay operable, the four behaviour modules do nothing, and the panels stay empty.

Button

The element decides the semantics, the class only decides the paint. type is not optional on a button inside a form: the default is submit, which is how a Cancel button ends up posting the form.

src/forms/button.css, adds 3,067 B gzip
A link that looks like one
Show codeHide code

Press each one. The four variants take the press, the disabled one does not and is skipped by Tab, and the anchor is a link the whole way down: it follows on Enter, it has a context menu, and it can be opened in a new tab. Nothing in this file changes any of that.

Loading

Press Save below and it goes busy for a second and a half. The state is three things at once, and each one alone is a bug: the class is paint, so on its own it is a picture of a spinner on a button that still submits; disabled is what stops the second press; aria-busy is what makes it read as working rather than as permanently unavailable.

button.css plus behaviour/button-pending.js
Show codeHide code

The second button is the state written by hand, held open so it can be looked at. The first is the same three attributes set by setPending(), which a real page calls with true before the request and false when it settles; the data-bs-pending duration here exists because a demonstration has no request to wait for. The label keeps its space while its ink goes, which is what stops the button resizing under a pointer that is already moving toward it.

Full width

At 320px this is usually the right answer for a form's primary action, and it is a modifier rather than the default because a Cancel button that spans the viewport is a target the thumb cannot miss.

bs-btn--block
Show codeHide code

Button group

A run of buttons joined into one object, for actions that are alternatives to each other. The group needs a name: visually it reads as one control, and a screen reader user gets three unrelated buttons otherwise. Tab through it and the ring is drawn around whichever member has focus, over the seam and not under it.

src/forms/button-group.css, adds 1,124 B gzip
Show codeHide code

The stacked modifier is the same object turned through ninety degrees, for a narrow column where three labels of that length would otherwise be three lines of wrapped text inside one row. If the buttons are a choice where exactly one stays selected, they are radios, not buttons: a row of buttons cannot express a selected state to assistive technology without aria-pressed on every one of them.

Field, label and help

The field owns the vertical relationship between a label, its control and the messages that describe it. It carries no colour of its own: it exists so the gap between those parts is decided in one place instead of by whichever margin wins the collapse. Press the word Handle and the caret lands in the box, which is the association doing its job.

field 1,042 B, label 872 B, help 599 B gzip

Letters, numbers and hyphens. It cannot be changed later.

Show codeHide code

Every message under a control must be referenced from the control with aria-describedby. Without it a screen reader announces the label and nothing else, and the help text the sighted reader is looking at does not exist. The required marker is generated content and decoration only: the required attribute is what assistive technology reads.

Input

Everything that is a box you type into: text, email, url, tel, password, search, number, and the date and time family. Type in all three below. On a phone the email box brings up a keyboard with an at sign on it and the password box does not echo, and neither of those is anything this stylesheet did.

src/forms/input.css, adds 1,587 B gzip

At least 12 characters. Length beats punctuation.

Show codeHide code

A placeholder is a hint, never a label: it is gone the moment there is a value, so a form labelled by placeholders cannot be checked over before it is sent. autocomplete is not optional either, and it is the one attribute on this page that a user with a motor impairment notices immediately.

Readonly and disabled

Two different sentences. Readonly means this value is real but not yours to change: it is still focusable, still selectable, still copyable, and it is still submitted. Disabled means this control is not part of the conversation right now: it takes no focus, it copies nothing, and its value never leaves the page. Try to select the text in each.

same file, two attributes
Show codeHide code

The invalid state is styled from aria-invalid rather than :invalid, because :invalid matches an empty required field before anything has been typed and turns a blank form red on load. The form at the end of this page shows it being set by a real check.

Textarea

The same shell as the input, but a different reading surface: text here is read in lines rather than scanned as a value, so it gets looser leading and a measure that stops a full-width field from running to 140 characters.

On a pointer device, drag the grip in the bottom corner of the first one. It moves down and it does not move sideways, because the reset pins resize to the block axis: a control the reader can drag wider than its column is how a page ends up scrolling horizontally on a phone that was never near the grip. There is no grip on a touch screen, which is the other half of the same reason the sizing has to be right without one.

src/forms/textarea.css, adds 1,242 B gzip

Resize is pinned to the block axis by the reset.

Show codeHide code

Growing with its content

The --auto modifier takes the height of what is in it, so the "type into a three-line window" problem stops existing. Type several lines into the box below and watch it follow. It is inside a feature query on field-sizing, so where that is unsupported the control keeps its scrollbar rather than silently clipping, and there is a cap so a pasted essay cannot push the submit button off the screen.

bs-textarea--auto
Show codeHide code

Set rows on both, even though a minimum height is applied here, because rows is what the control falls back to before the stylesheet loads.

Select

Only the closed control is styled. Open it: the list is drawn by the platform, which is what makes it usable with a screen reader, a hardware keyboard and a phone's picker wheel without a line of script. The group headings are optgroup and they are the platform's too.

src/forms/select.css, adds 1,946 B gzip
Show codeHide code

The block is bs-dropdown, not bs-select, because --bs-select-bg and --bs-select-fg are already the text-selection tokens in core: a component named select would collide on its two most obvious hook names and silently inherit the selection fill.

Checkbox

The label wraps the control, so the text is part of the target and the pair needs no id. Press the words rather than the box to prove it. A group of checkboxes belongs in a fieldset whose legend carries the question the group answers.

src/forms/checkbox.css, adds 1,238 B gzip
Receipt
Show codeHide code

Known limit, stated rather than hidden: the unchecked box is drawn by the engine and its border contrast cannot be measured from CSS. The checked fill is the accent, which is 2.79:1 against the hover surface in dark, so a checkbox that has to sit on that surface needs --bs-checkbox-accent set to something stronger.

Radio

Kept native for one argument that settles it: the platform keeps arrow-key roving between the members of a group. Tab into the group below and press the down arrow. The selection moves and the tab stop moves with it. That is behaviour, not paint, and a custom control has to rebuild it in script or lose it.

src/forms/radio.css, adds 987 B gzip
Billing period
Show codeHide code

Without the shared name the controls are not a group: arrow keys stop working, both can be selected, and a screen reader announces "1 of 1" for each.

Switch

A checkbox that reads as an on and off setting rather than as a choice to be submitted. Press it, or focus it and press space. There is no native switch, so this is the one control in the group that is drawn rather than themed, and the drawing is done on a sibling element because a checkbox is a replaced element and does not render pseudo-elements in every engine.

src/forms/switch.css, adds 1,856 B gzip
Show codeHide code

role="switch" is what makes a screen reader say "on" and "off" instead of "checked" and "not checked". The state is carried by the position of the thumb as well as by the fill, so the control still reads with colour removed.

Range

Drag the slider, or focus it and hold an arrow key. The number under it is an output, and it is the one part of a slider the platform does not supply: an output derives nothing on its own, so src/behaviour/range.js writes the value into it on every input event. Everything else here, including every key, is native.

range.css 866 B gzip, plus behaviour/range.js
40
3
Show codeHide code

aria-live="off" on the output is deliberate. An output is a live region by default, and the slider already announces its own value on every step, so without it the number is read twice for every arrow key. The value still reaches a screen reader, through aria-describedby on the control. Restyling the track by hand would mean four engine-specific rules that cannot be grouped, break silently when an engine renames its internals, and buy a slightly different track height.

File

Choose a file. Two controls are in that one box: a button the platform opens the picker from, and a piece of text reporting what was chosen. Both are the engine's, and ::file-selector-button is the standard hook into the first of them.

file.css 1,352 B gzip, plus behaviour/file.js
Nothing attached yet.
Show codeHide code

The line under the control is the page's own report of what is held, written by src/behaviour/file.js. The engine writes its own version inside the control, but that one is truncated hard in a narrow box and is not announced on change by every screen reader, which matters because closing a picker is a moment when nothing else on the page has spoken. It is an output here for the opposite reason to the slider above: an output is a live region by default and this time that is exactly what is wanted. accept is a hint the picker uses, not validation, so the type still has to be checked on the server.

Error

Press Save on the form below with it empty, or with a half-written address in it. The red is not a class someone typed: the engine checks the constraints in the markup, src/behaviour/validate.js reads which rule failed and writes the sentence that names the fix, and the field turns as a consequence. Fix a field and it clears itself as you type. Press Reset and the whole form forgets it was ever asked.

error.css 1,304 B gzip, plus behaviour/validate.js

The receipt goes here, nothing else does.

Terms

Show codeHide code

Three things have to be true at once and CSS can supply none of them: the control is marked invalid, the message is bound to it with aria-describedby, and the message names the fix. "Invalid" is not a message. It tells the reader only that the software disagrees with them, which is why each control here carries its own wording per rule and falls back to the engine's sentence when it has none.

Two attributes on the form are doing work. novalidate turns off the engine's own bubble without turning off the checking, because that bubble vanishes on the next keypress, shows one field at a time and cannot be reworded. method="dialog" is here only because this is a page about forms rather than a page that wants your password: a form submitted with that method outside a dialog goes nowhere, which is what lets the passing case be tried without navigating away from the example. Delete that attribute in a real form.

Colour is not doing the work. The message is text, the icon is a second visual channel, and the control carries a doubled border. Any one of the three read on its own still says the field failed. Nothing is marked before the first Save: the :invalid selector matches an empty required field on load, and a form that is red before it has been read teaches the reader to ignore red.