Handheld Device

The shell of a physical gadget: a breathalyzer, a fingerprint scanner, a pager. Dark moulded plastic with a phosphor LCD, hardware keys, status LEDs, a press-and-hold sensor pad and a thermal printer that feeds a receipt out of its base. The same object in every theme.

Alco-testReady

0.00mg/L

Press power to start
Power / Test
Print
Koala

Next result

Press Power to run a test, then Print to feed the ticket out of the base.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add handheld-device

Usage#

usage.tsxTSX
import { Power } from "@phosphor-icons/react"import {  HandheldDevice,  HandheldDeviceBrand,  HandheldDeviceGrille,  HandheldDeviceKey,  HandheldDeviceKeys,  HandheldDeviceScreen,  HandheldDeviceScreenBody,  HandheldDeviceScreenFooter,  HandheldDeviceScreenHeader,} from "@/components/ui/handheld-device"export function Pager() {  return (    <HandheldDevice>      <HandheldDeviceGrille />      <HandheldDeviceScreen>        <HandheldDeviceScreenHeader>          <span>Page</span>          <span>21:42</span>        </HandheldDeviceScreenHeader>        <HandheldDeviceScreenBody className="text-2xl">CALL 4471</HandheldDeviceScreenBody>        <HandheldDeviceScreenFooter>Press OK to clear</HandheldDeviceScreenFooter>      </HandheldDeviceScreen>      <HandheldDeviceKeys>        <HandheldDeviceKey label="OK">          <Power weight="bold" />        </HandheldDeviceKey>      </HandheldDeviceKeys>      <HandheldDeviceBrand>Koala</HandheldDeviceBrand>    </HandheldDevice>  )}

The shell stacks its parts in a column, and every part can be left out: a pager is a screen and a key, a scanner is LEDs and a sensor pad. Size the shell with className (w-80 by default, its height from its content); HandheldDeviceBrand sinks to the bottom of a shell taller than its parts. Everything is presentational: your app owns the state machine (idle, measuring, result) and the parts paint it.

Anatomy#

import {  HandheldDevice,  HandheldDeviceBrand,  HandheldDeviceGrille,  HandheldDeviceKey,  HandheldDeviceKeys,  HandheldDeviceLed,  HandheldDeviceLeds,  HandheldDeviceMeter,  HandheldDevicePrinter,  HandheldDeviceReadout,  HandheldDeviceReceipt,  HandheldDeviceScreen,  HandheldDeviceScreenBody,  HandheldDeviceScreenFooter,  HandheldDeviceScreenHeader,  HandheldDeviceSensor,} from "@/components/ui/handheld-device"<HandheldDevice>  <HandheldDeviceGrille />  <HandheldDeviceScreen>    <HandheldDeviceScreenHeader />    <HandheldDeviceScreenBody>      <HandheldDeviceReadout />      <HandheldDeviceMeter value={…} />    </HandheldDeviceScreenBody>    <HandheldDeviceScreenFooter />  </HandheldDeviceScreen>  <HandheldDeviceLeds>    <HandheldDeviceLed />  </HandheldDeviceLeds>  <HandheldDeviceKeys>    <HandheldDeviceKey />  </HandheldDeviceKeys>  <HandheldDeviceSensor />  <HandheldDeviceBrand />  <HandheldDevicePrinter>    <HandheldDeviceReceipt />  </HandheldDevicePrinter></HandheldDevice>

Tones#

HandheldDeviceScreen takes tone: green (the default phosphor, ready or ok), yellow (attention, a new record), orange (a read error) and red (an alarm, a positive). The tone is an ink variable that cascades: every line on the screen glows in it, and a header, a body, a footer or a readout can take its own over it.

Readygreen
0.00
Under the limit
Recordyellow
NEW
Stored in database
Errororange
Err
Read error
Resultred
0.48
Over the limit

Faults#

fault makes the picture fail while the bezel holds still. flicker is a loose contact: the picture dips a few times every second and a half. glitch is a broken panel: it jumps, skews and drops out, its color swims, and torn bands run across it. Under reduced motion both hold still, and a broken panel keeps its bands, so the fault still reads.

Alco-testResult
Err
Read error
Alco-testResult
8.88
Device fault

Readouts#

HandheldDeviceReadout is a run of lit text anywhere on the device. It takes a tone of its own, blink for the busy signal, inverse for a lit cell with the text knocked out of it (the LCD's highlight), and dim for a backlight that is off. It is a <span>; asChild renders your own element instead.

AFIS terminalBatt 98%
Match found

ID: KX-40211

Warrant on filePrinting…Sensor locked

Block meter#

HandheldDeviceMeter draws the bar an LCD can: [█████─────] over the percentage, from a value of 0 to 100 (blocks cells, 10 by default). Its size is the text size you give it. To assistive tech it is a meter named by label, and the drawing is hidden.

SampleFilling

LEDs and keys#

HandheldDeviceLed is a lens with its label printed under it: off it is dark with a hint of its tone, lit it glows, and blink pulses it. HandheldDeviceKey is a real <button> with an engraved label under it, which also names it. primary is the big round key (64px), secondary the small slate one (56 by 44); both press in and give a little (static keeps them from giving). A tone lights the glyph, the way an armed key glows.

Lock
Scan
Busy
OK
Scan
Test
Print
Off

Sensor pad#

HandheldDeviceSensor is a press-and-hold pad: a recessed glass with a glyph (its svg child, sized for you). It reports the hold through onPressStart and onPressEnd: a pointer going down and coming up, sliding off the pad (the way lifting a finger off a reader does), Space or Enter held on the focused pad, the window lost, or the pad disabled or unmounted mid-press. Every start gets exactly one end. It does not time the read: the app does, and hands it back as progress, drawn as a tint rising with a scan line on its edge. Anything else you put on the pad (an ERROR verdict over the glyph) is laid out against the pad: position it absolute inset-0. Hold the pad below until the read completes.

Lock
Scan
OK
Press and hold
Koala

Scanner terminal#

The other end of the reader: a terminal with its LEDs, a screen that changes with the state, and a divided header that stays green whatever the body says. Pick a state to see how each one paints.

Lock
Scan
OK
AFIS terminal v2Batt 98%
Match found

ID: KX-40211

NAME: J. DOE

PRINT: 7F3A-91C2

On file since 2024-03-11
Scan
Print
Reset
Koala

Printer#

HandheldDevicePrinter puts a slot in the base of the shell and feeds its children out of it when printed: a slow 2.5s feed at a motor's pace (duration-feed), pulled back quicker when it turns false, and instant under reduced motion. Until then the paper is inside the device: clipped at the slot, inert and hidden from assistive tech, however long it is. To clear a receipt at once instead of pulling it back, stop rendering it.

The feed is a pure slot: it moves whatever you give it. HandheldDeviceReceipt is the thermal paper if you don't bring your own: a warm white strip (the cream theme's ground) in mono, torn along the bottom. The paper hangs below the shell, out of flow, so leave room under the device for it.

Ticket ready
Koala

A physical object#

A gadget in your hand does not change color with the room, so the shell pins the dark theme (dark): its plastic, keys and legends read the same under light, cream, dark and moonlight. The materials are the dark theme's ground and ink mixed by the shell itself (the --handheld-* variables on its root, with a breath of --info for the cool cast of the plastic). The display glows in the four phosphor tokens, --phosphor, --phosphor-yellow, --phosphor-orange and --phosphor-red: light sources rather than text on a ground, so like --rating they are the same in every theme. They are also utilities (text-phosphor, bg-phosphor-red/10) for anything you lay over the glass yourself.

Display typeface#

The glass sets its text in font-lcd, which reads the --font-lcd variable and falls back to the mono stack. These docs load Share Tech Mono into it; load the face you want and point the variable at it:

app/layout.tsxTSX
import { Share_Tech_Mono } from "next/font/google"const lcd = Share_Tech_Mono({ variable: "--font-lcd", weight: "400", subsets: ["latin"], preload: false })// <html className={lcd.variable}>

Old engines#

The device is built to run in the embedded Chromium of a game or a desktop shell (103 and up): no container queries, no :has(), no subgrid, and no standalone translate, scale or rotate. The key press, the sensor's read and the paper feed all ride transform; every gradient is a plain linear-gradient() with no interpolation space; and every color mix is one color-mix() of two colors, which a build step targeting that engine can lower to rgb().

Accessibility#

Keys and the sensor are real buttons with a visible focus ring, and each is at least 44px tall. A key is named by its legend. The sensor is named by aria-label ("Hold to scan" by default) and reports the hold with aria-pressed. LEDs, the grille, the scanlines and the printer slot are decoration: say what a state means in words on the screen. The screen is not a live region, because a meter filling inside it would be read out on every step; put aria-live="polite" on the line whose change matters, like the verdict.

API reference#

HandheldDevice

The shell: a <div>, a column of parts (w-80 p-6 gap-6, 40px corners at the default radius), pinned dark. Every div prop is forwarded. Its corner is the screen's (16px) plus its padding, so a tighter shell takes a tighter corner: p-4 rounded-4xl. Every part forwards its element's props and merges className last.

PropTypeDefaultDescription
tone"green" | "yellow" | "orange" | "red""green"The ink of anything lit outside a screen (LEDs, lit keys, the sensor).
classNamestring-Sizes the shell (w-80 h-[34rem]), merged last.
Data attributeDescription
data-slot"handheld-device".
data-toneThe tone, when set.
CSS variableDescription
--handheld-toneThe phosphor ink a tone sets; every part inside reads it.

HandheldDeviceGrille

The speaker grille along the top of the shell, a <div> hidden from assistive tech.

PropTypeDefaultDescription
countnumber4How many speaker slots.
Data attributeDescription
data-slot"handheld-device-grille".

HandheldDeviceScreen

The phosphor LCD: a recessed bezel, dark glass, scanlines and glare, and a glow on every line. The children are laid out in a column on the glass.

PropTypeDefaultDescription
tone"green" | "yellow" | "orange" | "red"-The ink of everything on the screen. Inherits the shell's when unset.
fault"none" | "flicker" | "glitch""none"A failing picture: flicker is a loose contact, glitch a broken panel.
classNamestring-Sizes the bezel: 4:3 at full width by default, aspect-auto h-64 for a fixed height.
Data attributeDescription
data-slot"handheld-device-screen"; the glass is "handheld-device-screen-panel".
data-toneThe tone, when set.
data-fault"flicker" or "glitch". Absent at none.

HandheldDeviceScreenHeader

The status row along the top of the glass, a <div>: its children spread to the two ends.

PropTypeDefaultDescription
tone"green" | "yellow" | "orange" | "red"-This row's own ink, over the screen's.
dividedbooleanfalseA hairline in the ink between this row and the body.
Data attributeDescription
data-slot"handheld-device-screen-header".
data-toneThe tone, when set.

HandheldDeviceScreenBody

The main read-out, a <div>: takes the room between the header and the footer, centered.

PropTypeDefaultDescription
tone"green" | "yellow" | "orange" | "red"-The body's own ink, over the screen's.
Data attributeDescription
data-slot"handheld-device-screen-body".
data-toneThe tone, when set.

HandheldDeviceScreenFooter

The prompt along the bottom of the glass, a <div>.

PropTypeDefaultDescription
tone"green" | "yellow" | "orange" | "red"-This row's own ink, over the screen's.
dividedbooleanfalseA hairline in the ink between this row and the body.
Data attributeDescription
data-slot"handheld-device-screen-footer".
data-toneThe tone, when set.

HandheldDeviceReadout

A run of lit text, a <span>: a line on the glass in a tone of its own, a blinking prompt, a highlighted verdict, or a status line glowing straight on the plastic.

PropTypeDefaultDescription
tone"green" | "yellow" | "orange" | "red"-Its own ink, over whatever it sits in.
blinkbooleanfalsePulse: the busy signal. Steady under reduced motion.
dimbooleanfalseBacklight off: half-strength ink with a faint halo.
inversebooleanfalseA lit cell with the text knocked out of it (a highlighted verdict).
asChildbooleanfalseRenders the child element instead (a <p>, a heading), merging the styles onto it.
Data attributeDescription
data-slot"handheld-device-readout".
data-toneThe tone, when set.

HandheldDeviceMeter

The block meter an LCD draws, [█████─────] over the percentage: a meter to assistive tech, the drawing hidden from it. Size it with the text size of className.

PropTypeDefaultDescription
value*number-0 to 100, clamped and rounded.
blocksnumber10How many cells the bar has.
labelstring"Progress"The meter's accessible name.
Data attributeDescription
data-slot"handheld-device-meter", with "handheld-device-meter-bar" and "handheld-device-meter-value" inside.
data-valueThe clamped, rounded value.

HandheldDeviceLeds

A row of LEDs across the shell, a <div>, spread evenly.

Data attributeDescription
data-slot"handheld-device-leds".

HandheldDeviceLed

A status LED with its label printed under it (the children), a <div>. The lens is decoration: say what the state means in words on the screen.

PropTypeDefaultDescription
tone"green" | "yellow" | "orange" | "red"-The lens color. Inherits the shell's when unset.
litbooleanfalseOn: the ink with a halo. Off: a dark lens with a hint of its color.
blinkbooleanfalsePulse while lit. Steady under reduced motion.
Data attributeDescription
data-slot"handheld-device-led", with "handheld-device-led-lens" and "handheld-device-led-label" inside.
data-toneThe tone, when set.
data-litPresent when lit.

HandheldDeviceKeys

A row of hardware keys, a <div>, spread evenly and aligned on their legends.

Data attributeDescription
data-slot"handheld-device-keys".

HandheldDeviceKey

A hardware key: a real <button> (the children are its glyph) with its legend under it. Every button prop is forwarded. 64px and 44px tall, both past the 40px hit area on their own.

PropTypeDefaultDescription
variant"primary" | "secondary""primary"primary is the big round key (64px), secondary the small slate one (56x44).
labelReact.ReactNode-The legend engraved under the key. It also names the key, unless you pass aria-label.
tone"green" | "yellow" | "orange" | "red"-Lights the glyph in this ink (an armed key). Unlit, the glyph is the plastic's grey.
blinkbooleanfalsePulses the lit glyph: the busy signal.
staticbooleanfalseNo give on press (the press still sinks the shadow).
classNamestring-Goes to the key itself, so size-20 makes a bigger primary key.
containerClassNamestring-Classes for the column that holds the key and its legend (to place or hide the whole key).
type"button" | "submit" | "reset""button"The button's type.
Data attributeDescription
data-slot"handheld-device-key"; the column is "handheld-device-key-container", the legend "handheld-device-key-label".
data-variant"primary" or "secondary".
data-toneThe tone, when lit.

HandheldDeviceSensor

A press-and-hold sensor pad (a fingerprint reader), a real <button>: the children are its glyph (an svg, sized for you) and anything else laid over the pad. Every button prop is forwarded. It reports the hold, it does not time it; sliding off the pad ends the press, and Space or Enter held on the focused pad press it too.

PropTypeDefaultDescription
onPressStart() => void-The pad was pressed: a pointer went down on it, or Space or Enter while it has focus.
onPressEnd() => void-The press ended: released, slid off the pad, cancelled, the window lost, or the pad disabled or unmounted mid-press. Every onPressStart gets exactly one.
progressnumber | null-0 to 100: the read so far, a tint rising with a scan line on its edge. null or omitted for no read.
tone"green" | "yellow" | "orange" | "red"-The ink of the glyph, the halo and the read. Inherits the shell's when unset.
litbooleantrueThe glyph glows in the ink. Off, it is the dead grey of a locked pad.
blinkbooleanfalsePulses the glyph: the busy signal (analyzing).
disabledbooleanfalseLocks the pad; a press in progress ends.
aria-labelstring"Hold to scan"The pad's accessible name. The hold is reported with aria-pressed.
Data attributeDescription
data-slot"handheld-device-sensor"; the read is "handheld-device-sensor-fill".
data-toneThe tone, when set.
data-pressedPresent while the pad is held.
data-progressThe clamped read, while there is one.
CSS variableDescription
--handheld-progressThe read, 0 to 100, set inline from progress.

HandheldDeviceBrand

The brand debossed into the base of the shell, a <div>. Sinks to the bottom of a tall shell.

Data attributeDescription
data-slot"handheld-device-brand".

HandheldDevicePrinter

The thermal printer, a <div>: a slot in the base of the shell and, under it, the paper (the children) fed out over --duration-feed (2.5s), instant under reduced motion. Until printed the paper is clipped at the slot, inert and hidden from assistive tech. The feed hangs below the shell, out of flow: leave room under the device.

PropTypeDefaultDescription
printedbooleanfalseFeeds the paper out. Back to false pulls it in again; to clear a receipt at once, stop rendering the children.
Data attributeDescription
data-slot"handheld-device-printer", with "handheld-device-printer-slot" and "handheld-device-printer-paper" inside.
data-printedPresent when printed.
CSS variableDescription
--handheld-paper-widthThe paper's width, 14rem. The slot is cut a little wider; your own paper takes w-(--handheld-paper-width).

HandheldDeviceReceipt

A strip of thermal paper for the printer, a <div>: warm white, set in mono, torn along the bottom, as wide as --handheld-paper-width (w-56). Bring your own receipt instead if you have one.

Data attributeDescription
data-slot"handheld-device-receipt".

FAQ#

It is a picture of a physical object, and a breathalyzer is the same black plastic in a bright office and a dark one. It pins the dark theme for its materials, and its display inks are theme-agnostic tokens.