Quantity
A count field, not a currency one — see Money for
that. A real type="number" input, optionally joined to a unit
select and optionally flanked by stepper buttons;
min/max/step are native attributes, so
validation, keyboard behavior, and any buttons' clamping all read from the
same source of truth.
Not for a number that isn't a count — a percentage, an index,
a value with no meaningful unit or step: the joined unit field and stepper
buttons imply affordances that number doesn't have, and a plain
.bo-input--numeric is the honest answer.
The rule for this family: displaying a value is Amount's job; capturing one is Money's (currency) or Quantity's (count). One display component for every numeric value; two entry components, split by what the value is — and when it is neither a selectable currency nor a steppable count (a rate, a fixed-currency price, a percentage), a plain .bo-input--numeric is the whole answer: no fourth component, just the input modifier.
Basic — ( qty | unit )
View code
<!-- desktop-first: no buttons, unit joined — the mirror of Money's ( currency | amount ) -->
<div class="bo-quantity">
<input class="bo-quantity__input" type="number" value="2.50" min="0" step="0.01" inputmode="decimal" aria-label="Quantity" />
<select class="bo-select bo-quantity__unit-select" aria-label="Unit">
<option>kg</option>
<option>g</option>
<option>each</option>
</select>
</div> A quantity input joined to its unit select —
the exact mirror of the Money
field's ( currency | amount ) shape, and the natural desktop form.
↑/↓ and typing work
natively; initQuantity() makes precision follow the
selected unit (kg 2.50 → each, step flips to 1). No
stepper buttons needed — they're the opt-in touch affordance, next
section.
With stepper buttons — touch & warehouse
View code
<div class="bo-quantity">
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">−</button>
<input class="bo-quantity__input" type="number" value="4" min="0" max="99" step="1" inputmode="numeric" aria-label="Quantity" />
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
</div> Requires initQuantity() —
without it the buttons are inert, but the input itself still works (native number
keyboard behavior, and it's a real form field either way). The buttons are
pointer/touch affordances (warehouse gloves, tablets): they carry
tabindex="-1" and cost no tab stops, since
↑/↓ on the input
already steps. With buttons present a __unit-select sits
after the + with a gap rather than joining; the static
text __unit span is deliberately never boxed — it's an
annotation, not a control.
With a unit
View code
<!-- no stepper buttons — this section is about __unit, not touch input -->
<div class="bo-quantity">
<input class="bo-quantity__input" type="number" value="12" min="0" max="500" step="1" inputmode="numeric" aria-label="Quantity" />
<span class="bo-quantity__unit">kg</span>
</div> __unit is optional, plain text — an ISO/standard
unit (kg, m, L) or an app-defined custom UOM
(box, pallet), same as Amount's
__currency — no allowlist, just text.
Read-only display — use Amount
View code
<!-- a count, read-only: Amount with a unit -->
<span class="bo-amount">
<span class="bo-amount__value">1,250</span>
<span class="bo-amount__unit">ea</span>
</span> There is no .bo-quantity--display.
One existed briefly, purely to mirror this component's name, and no screen
ever used one — every screen that shows a count uses
Amount with its
__unit part, which already aligns and tabulates every numeric
value in a column. It was removed before it reached a release rather than
kept for symmetry (grill 2026-08-19). The visual difference is the
affordance: a bordered box says "you can type here", plain text says
"this is a fact" — the two are meant to look unrelated.
Interactive unit — precision follows it
View code
<!-- no stepper buttons — this section is about the unit select driving
precision, not touch input; the desktop-first (joined) shape composes
with __unit-select exactly like it does with __unit -->
<div class="bo-quantity">
<input class="bo-quantity__input" type="number" value="2.50" min="0" step="0.01" inputmode="decimal" aria-label="Quantity" />
<select class="bo-select bo-quantity__unit-select" aria-label="Unit">
<option>kg</option>
<option>g</option>
<option>each</option>
<option>box</option>
</select>
</div> Swap the static __unit span for a
real <select class="bo-select bo-quantity__unit-select">:
changing unit re-derives the input's step/decimals from the
built-in unit table (kg 2.50 → each 3 → g 3.00) and reformats the value —
the same mechanics as the Money
field's currency select. data-decimals on an option or
the container overrides the table.
Fractional units — step IS the precision
View code
<!-- fractional units (weight, volume, time) need a decimal step AND
inputmode="decimal" — "numeric" implies an integer-only keypad on mobile.
No stepper buttons — this section is about step/precision, not touch input. -->
<div class="bo-quantity">
<input class="bo-quantity__input" type="number" value="2.50" min="0" max="500" step="0.25" inputmode="decimal" aria-label="Quantity" />
<span class="bo-quantity__unit">kg</span>
</div> Whole-count units (ea, box) use
step="1" with inputmode="numeric"; fractional units (weight,
volume, time) need a decimal step — 0.25, 0.01 —
AND inputmode="decimal", since numeric implies an
integer-only keypad on mobile. There is no --decimals modifier —
a static field's precision is the step you write; a
__unit-select field derives it from the built-in unit table
below, with data-decimals overriding either way.
Grouped display — data-grouped
View code
<!-- data-grouped: a large stock count reads as 1,250,000 —
grouped ON BLUR; the generated hidden input submits the raw number. -->
<div class="bo-quantity" style="max-inline-size: 16rem"><!-- the default
12rem cap fits "9,999"; a grouped 7-digit count needs more room -->
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">−</button>
<input class="bo-input bo-quantity__input" type="number" inputmode="numeric"
value="1250000" min="0" step="1" name="stock-count" data-grouped
data-locale="en-US" aria-label="Quantity">
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
<span class="bo-quantity__unit" aria-hidden="true">ea</span>
<span class="bo-visually-hidden">each</span>
</div>
<!-- + initGroupedNumber() once, alongside initQuantity() --> Warehouse-scale counts group their thousands
on blur — the steppers keep working on the machine
value, and the generated hidden input submits the raw number. The same
mechanism as Money's grouped
amount (one behavior, three controls); locale comes from
data-locale, precision from step or
data-decimals as everywhere else.
Unit decimals reference — the complete built-in table
Every unit the built-in table knows, case-insensitive.
Anything not listed leaves the field's precision entirely
alone — real master-data UOM codes (MT,
ltr, cbm, bundle…) are almost
never these exact strings, and an unknown unit must never rewrite your
value (Slice 19 hardening: the earlier default-0 silently rounded
fractional quantities). Unlike currencies there is no
ISO-closed list of units, so this is a pragmatic common-ERP set —
your data-decimals override extends it per field without
waiting for the table.
| Category | Units | Decimals | Example |
|---|---|---|---|
| Whole counts | ea each pc pcs unit item box carton pallet | 0 | 4 ea |
| Weight | kg g t lb oz | 2 | 2.50 kg — bulk trade often quotes t at 3 dp, pharma/food kg at 3: override per field |
| Weight (fine) | mg | 3 | 12.500 mg |
| Volume | L mL gal | 2 | 3.75 L |
| Length / area / cubic | m cm mm ft in m² m³ (m2 m3) | 2 | 1.20 m |
| Time — labor/machine | hr hrs h | 2 | 1.75 hrs |
| Time — whole minutes | min | 0 | 45 min |
| Everything else | any app-defined UOM | left alone | 2.5 MT stays 2.5 |
Programmatic access:
unitDecimals('kg') → 2 (undefined for unknown units), exported next to
initQuantity — the same shape as the Money field's
currencyDecimals.
At a boundary
View code
<!-- render the correct disabled state server-side when the starting value is already at a boundary -->
<div class="bo-quantity">
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity" disabled>−</button>
<input class="bo-quantity__input" type="number" value="0" min="0" max="10" step="1" inputmode="numeric" aria-label="Quantity" />
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
</div>
<div class="bo-quantity">
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">−</button>
<input class="bo-quantity__input" type="number" value="10" min="0" max="10" step="1" inputmode="numeric" aria-label="Quantity (at max)" />
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity" disabled>+</button>
</div> Buttons disable at min/max —
render the correct disabled server-side for the starting value;
initQuantity() keeps it in sync reactively after that (clicking,
typing, or the native spinner keys).
Inside a form field (validation)
View code
<!-- no stepper buttons — this section is about validation, not touch input -->
<div class="bo-form-field">
<label class="bo-form-field__label" for="qf-1">Quantity</label>
<div class="bo-quantity">
<input class="bo-quantity__input" type="number" id="qf-1" value="150" min="0" max="100" step="1" inputmode="numeric" aria-invalid="true" aria-describedby="qf-1-msg" />
<span class="bo-quantity__unit">ea</span>
</div>
<p class="bo-form-field__message" id="qf-1-msg">Only 100 in stock.</p>
</div> Composes with .bo-form-field like any other
control — the same two-channel error state (border + message + aria-invalid
or :user-invalid) applies for free, nothing quantity-specific needed.
In a table column
| Item | Qty | Unit |
|---|---|---|
| Standing desk |
|
ea |
| Bulk cable |
|
kg |
View code
<!-- In a line table the unit is its own COLUMN, fixed per row — so each
qty cell is just the input (no unit-select, no steppers), with
step/inputmode server-rendered per that row's unit. -->
<table class="bo-data-table">
<thead><tr>
<th>Item</th>
<th class="bo-data-table__col--numeric">Qty</th>
<th>Unit</th>
</tr></thead>
<tbody>
<tr>
<td>Standing desk</td>
<td class="bo-data-table__col--numeric">
<div class="bo-quantity">
<input class="bo-quantity__input" type="number" value="4" min="0" max="99" step="1" inputmode="numeric" aria-label="Quantity, standing desk" />
</div>
</td>
<td class="bo-data-table__col--code">ea</td>
</tr>
<tr>
<td>Bulk cable</td>
<td class="bo-data-table__col--numeric">
<div class="bo-quantity">
<input class="bo-quantity__input" type="number" value="2.50" min="0" max="500" step="0.25" inputmode="decimal" aria-label="Quantity, bulk cable" />
</div>
</td>
<td class="bo-data-table__col--code">kg</td>
</tr>
</tbody>
</table> In a line table, split the pair: the unit is
its own column, fixed per row, and each qty cell is
just the input — no unit-select, no steppers. step/
inputmode are server-rendered per that row's unit (whole
ea vs fractional kg here). The joined
( qty | unit ) control is the form shape — it belongs in a
cell only in the rare case the user must change a row's unit inline;
steppers belong on touch screens, not dense line grids.
Large-target (warehouse-floor) variant
View code
<div data-density="spacious">
<div class="bo-quantity">
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">−</button>
<input class="bo-quantity__input" type="number" value="4" min="0" max="99" step="1" inputmode="numeric" aria-label="Quantity" />
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
</div>
</div> There's no separate "large" modifier — density IS the
size variant. data-density="spacious" (44px controls) already meets the
WCAG 2.5.8 minimum target size; scope it to a warehouse-floor screen the same way
you'd scope compact to a dense table.
Markup
<!-- basic: ( qty | unit ), joined — no buttons needed -->
<div class="bo-quantity">
<input class="bo-quantity__input" type="number" min="0" step="0.01" inputmode="decimal" aria-label="Quantity" />
<select class="bo-select bo-quantity__unit-select" aria-label="Unit">
<option>kg</option> <option>each</option>
</select>
</div>
<!-- with stepper buttons (touch / warehouse) — unit becomes a gapped affix -->
<div class="bo-quantity">
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">−</button>
<input class="bo-quantity__input" type="number" min="0" max="99" step="1" inputmode="numeric" />
<button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
<span class="bo-quantity__unit">ea</span> <!-- optional -->
</div>
<script type="module">
import { initQuantity } from "@busy-office/ui/js";
initQuantity(); // once — unit-select precision + step buttons; ArrowUp/Down + typing already work natively
</script> Quick reference generated
| Class | Role |
|---|---|
| .bo-combobox | block (root) |
| .bo-input | block (root) |
| .bo-quantity | block (root) |
| .bo-quantity__input | part: input |
| .bo-quantity__step | part: step |
| .bo-quantity__unit | part: unit |
| .bo-quantity__unit-select | part: unit-select |
API generated from CSS
| Blocks | bo-comboboxbo-inputbo-quantity |
|---|---|
| Parts | bo-quantity__inputbo-quantity__stepbo-quantity__unitbo-quantity__unit-select |
| JS required | Optional: initQuantity() — the +/− buttons and their disabled state at min/max. The input is a real type=number, so typing and Up/Down already work without it. initGroupedNumber() is separate and opt-in per input via data-grouped. |
- A count, not currency — see Amount for money values.
- The +/− buttons are optional pointer/touch affordances carrying
tabindex="-1"— ↑/↓ on the input steps natively, so each field costs one tab stop, not three. Omitted entirely, the unit-select joins the input into ( qty | unit ). min/max/steplive on the native<input type="number">; the step buttons read the same attributes, so there is only one source of truth for the valid range.- Button disabled state at a boundary is kept in sync reactively by
initQuantity()— render the correct initialdisabledserver-side if the starting value is already at min/max. - Composes with
.bo-form-fieldfor validation — no quantity-specific error styling. __unitaccepts an ISO/standard unit or an app-defined custom UOM — no allowlist, same convention as Amount's__currency.- Decimal precision is expressed via the native
stepattribute, not a CSS modifier — fractional units needinputmode="decimal", not the defaultnumeric. - With a
__unit-select, precision follows the built-in unit table (see the reference above) — a deliberate, overridable exception to "your app owns the data", mirroring the Money field's currency table;data-decimalsalways wins.
Contrast — 5 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| border-control | bg-surface | 4.83 | 6.65 |
| border-control | bg-hover | 4.39 | 5.66 |
| border-control | bg-muted | 4.39 | 5.66 |
Maturity generated
- Introduced
- v0.1.0
- CSS floor
- Chrome/Edge 105 · Firefox 121 · Safari 15.4
- Alignment scored
- 2026-08-21
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.1.0.
The floor is derived from this component's stylesheet alone — set by
:has() — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | sizes from density tokens; tabular figures on the input (77.1) |
| Colour | 3 / 3 | zero raw hex in quantity.css; inherits gated .bo-input pairs |
| Spacing | 3 / 3 | 3rem floor and 12rem cap both carry their reason; the joint uses --bo-border-width, not a raw 1px (94.2) |
| Interaction | 3 / 3 | native type=number first (arrows/typing work JS-free); boundary sync is progressive enhancement |
| Content | 3 / 3 | the opener says a number that isn't a count (a percentage, an index, no meaningful unit/step) is wrong for this — .bo-input--numeric is the honest answer (102.10) |
| Fit | 3 / 3 | matrix contexts named; steppers explicitly scoped to touch, not dense grids (80.2) |