Scan feedback
The visible half of a barcode scan, for a user whose eyes are on the
rack: a ~600ms viewport wash — green on capture, red when your
validation rejects the code — inside a neutral frame that carries the
verdict without colour: one thin band when accepted, a thick doubled band
when rejected. It is paired, always, with the
data-scan-status live region
the RF patterns already
link, so the cue is two-channel by construction. The device's beep and
LED stay the device's job; this component never plays audio.
Not for form validation on an ordinary screen — a
seated user reading a form gets the field's own
message beside the control it
concerns; a full-viewport flash there is noise aimed at nobody. Reach
for this only when the user is physically away from the screen between
inputs — which is the RF floor's whole context.
Try it — capture flashes ok, your validation flashes error
View code
<!-- Type a code and press Enter (a hardware scanner does exactly
this, faster). data-scan-flash paints the viewport ok-wash on capture;
REJECT* codes demo YOUR validation calling flashScanResult('error'). -->
<div class="bo-form-field">
<label class="bo-form-field__label" for="scan-demo">Scan barcode</label>
<input class="bo-input bo-input--code" id="scan-demo" data-scan-input data-scan-flash
placeholder="Try 4006381333931, then REJECT-1" aria-describedby="scan-demo-status" />
<p id="scan-demo-status" data-scan-status aria-live="polite" class="bo-visually-hidden"></p>
</div> Capture ≠ validity: data-scan-flash
makes the behavior paint ok the moment the terminator arrives
(the scanner said "I read it"), and flashScanResult('error',
reason) is your validation's verdict — the framework cannot know
whether a perfectly-scanned code belongs on this PO. Under reduced
motion the wash is static for the stamp's lifetime; under forced colors
it becomes a Highlight frame, since a repainted background would vanish
exactly when contrast matters most.
Markup
<input class="bo-input bo-input--code" data-scan-input data-scan-flash
autofocus aria-describedby="scan-status" />
<p id="scan-status" data-scan-status aria-live="polite" class="bo-visually-hidden"></p>
<script type="module">
import { initScanInput, flashScanResult } from "@busy-office/ui/js";
initScanInput(); // capture -> ok flash (data-scan-flash) + live-region announce
document.addEventListener("bo:scan", (e) => {
// Capture is not validity. YOUR check decides, then paints the verdict:
if (!isOnThisPo(e.detail.value)) flashScanResult("error", "Not on this PO");
});
</script> What the platform gives you, and what the behavior adds
Without initScanInput() | What the behavior adds |
|---|---|
| A keyboard-wedge scanner types the code and sends Enter, so a plain input inside a form posts the scan. The screen still works | the bo:scan event, so a screen can react without a round trip |
| Nothing announces the capture — the field just changes | the [data-scan-status] live region announcement |
No flash: the CSS keys off data-scan-result, which only the behavior stamps | the flash, and flashScanResult() for your verdict |
| A trailing terminator character can land in the value | data-scan-terminator stripping |
Unlike dialog or combobox, there is no native element underneath this one — a scanner is a keyboard, and the platform has no idea a scan happened. That makes the boundary more important here, not less: the no-JS path is a real form post and a server re-render, which every RF pattern page documents as its No-JS row. Build that path first and treat the flash as the enhancement it is.
Quick reference generated
| Class | Role |
|---|
State attributes: data-scan-flashdata-scan-inputdata-scan-resultdata-scan-statusdata-scan-terminator
API generated from CSS
| data-* hooks | data-scan-flashdata-scan-inputdata-scan-result="error|ok"data-scan-statusdata-scan-terminator |
|---|---|
| JS required | initScanInput() owns the moment: with data-scan-flash on the input, every capture stamps data-scan-result="ok" on <body> for ~700ms (scan.css paints the wash; a timer clears the stamp, so the reduced-motion static wash cannot linger). flashScanResult(kind, message?) is exported for YOUR validation path — 'error' overrides a live ok, and a message lands in the [data-scan-status] live region. |
- Two-channel by contract: the wash is never the only signal — every capture and rejection also reaches the
data-scan-statuslive region. That is the deliberate improvement over device-SDK overlays, which are colour-only. - The overlay is pointer-inert and sits below toasts: a flash must never eat a tap or cover an error a consumer just raised.
- RF-profile member (its whole audience): counted inside rf-essentials' 41 kB budget gate (40 until roadmap 388.1).
- No audio, ever — beeps and LEDs belong to the device service; pairing with them is configuration, not CSS.
Contrast — 8 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| bg-surface | text-primary | 17.74 | 16.15 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| danger | bg-muted | 4.39 | 5.19 |
| text-primary | bg-muted | 16.12 | 13.75 |
Maturity generated
- Introduced
- v0.5.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-28
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.5.0.
The floor is derived from this component's stylesheet alone — set by
@layer — 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-28.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | sets no type at all — zero font-size declarations in scan.css; the component is a frame and a wash over whatever the page already renders |
| Colour | 3 / 3 | zero raw colour (success/danger tokens; Highlight only inside @media forced-colors), and two-channel WITHOUT hue: the verdict is carried by frame geometry — accepted 6px solid, rejected 18px double — plus the [data-scan-status] live region. check:claims compares the two states' computed border width/style and never their colour, in normal rendering AND under CDP forced-colors emulation |
| Spacing | 3 / 3 | every literal states its reason in place: the 6px and 18px frames (peripheral vision reads mass before detail), the 600ms explicit over the 300ms token, and the z-index calc naming the toast layer it sits below |
| Interaction | 3 / 3 | the page draws the platform-vs-behavior line as a four-row table plus the no-JS path — and says why the line matters MORE here: there is no native element underneath, a scanner is just a keyboard |
| Content | 3 / 3 | the opener carries the required wrong-choice clause — "Not for form validation on an ordinary screen" — and links the alternative (the field's own message), naming the boundary as physical distance from the screen |
| Fit | 3 / 3 | prescribed in the RF/warehouse screens and nowhere else — NOT by the field matrix, which covers field types and has no scan row (corrected 2026-09-06, roadmap 286.4; the clause replaced here read "prescribed where the field matrix puts it" while `scan` appears 0 times on /concepts/design-language): 4 of 39 pattern screens run a live [data-scan-input] — goods-receipt, rf-count, rf-pick, rf-putaway — and every one of the four now links it from its own Components-used badges; adopted by po-app's own receive screen (data-scan-input + data-scan-flash), and counted inside rf-essentials' 40 kB RF budget gate (RF_BUDGET_KB = 40). Corrected 2026-09-05 (roadmap 279.1): this read "linked from /patterns/goods-receipt and /patterns/rf-landing", which is the component page's own OUTBOUND Related list published as an inbound fact — neither of those two linked it, and all three that did went unnamed. Count ELEMENTS in the DOM across frames; grepping the built HTML counts an inline <code>data-scan-input</code> in prose as a use. |