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.
Installation#
Usage#
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "green" | "yellow" | "orange" | "red" | "green" | The ink of anything lit outside a screen (LEDs, lit keys, the sensor). |
| className | string | - | Sizes the shell (w-80 h-[34rem]), merged last. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device". |
| data-tone | The tone, when set. |
| CSS variable | Description |
|---|---|
| --handheld-tone | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | 4 | How many speaker slots. |
| Data attribute | Description |
|---|---|
| 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| className | string | - | Sizes the bezel: 4:3 at full width by default, aspect-auto h-64 for a fixed height. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-screen"; the glass is "handheld-device-screen-panel". |
| data-tone | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "green" | "yellow" | "orange" | "red" | - | This row's own ink, over the screen's. |
| divided | boolean | false | A hairline in the ink between this row and the body. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-screen-header". |
| data-tone | The tone, when set. |
HandheldDeviceScreenBody
The main read-out, a <div>: takes the room between the header and the footer, centered.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "green" | "yellow" | "orange" | "red" | - | The body's own ink, over the screen's. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-screen-body". |
| data-tone | The tone, when set. |
HandheldDeviceScreenFooter
The prompt along the bottom of the glass, a <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "green" | "yellow" | "orange" | "red" | - | This row's own ink, over the screen's. |
| divided | boolean | false | A hairline in the ink between this row and the body. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-screen-footer". |
| data-tone | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "green" | "yellow" | "orange" | "red" | - | Its own ink, over whatever it sits in. |
| blink | boolean | false | Pulse: the busy signal. Steady under reduced motion. |
| dim | boolean | false | Backlight off: half-strength ink with a faint halo. |
| inverse | boolean | false | A lit cell with the text knocked out of it (a highlighted verdict). |
| asChild | boolean | false | Renders the child element instead (a <p>, a heading), merging the styles onto it. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-readout". |
| data-tone | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | - | 0 to 100, clamped and rounded. |
| blocks | number | 10 | How many cells the bar has. |
| label | string | "Progress" | The meter's accessible name. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-meter", with "handheld-device-meter-bar" and "handheld-device-meter-value" inside. |
| data-value | The clamped, rounded value. |
HandheldDeviceLeds
A row of LEDs across the shell, a <div>, spread evenly.
| Data attribute | Description |
|---|---|
| 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "green" | "yellow" | "orange" | "red" | - | The lens color. Inherits the shell's when unset. |
| lit | boolean | false | On: the ink with a halo. Off: a dark lens with a hint of its color. |
| blink | boolean | false | Pulse while lit. Steady under reduced motion. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-led", with "handheld-device-led-lens" and "handheld-device-led-label" inside. |
| data-tone | The tone, when set. |
| data-lit | Present when lit. |
HandheldDeviceKeys
A row of hardware keys, a <div>, spread evenly and aligned on their legends.
| Data attribute | Description |
|---|---|
| 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "primary" | "secondary" | "primary" | primary is the big round key (64px), secondary the small slate one (56x44). |
| label | React.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. |
| blink | boolean | false | Pulses the lit glyph: the busy signal. |
| static | boolean | false | No give on press (the press still sinks the shadow). |
| className | string | - | Goes to the key itself, so size-20 makes a bigger primary key. |
| containerClassName | string | - | 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 attribute | Description |
|---|---|
| data-slot | "handheld-device-key"; the column is "handheld-device-key-container", the legend "handheld-device-key-label". |
| data-variant | "primary" or "secondary". |
| data-tone | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| progress | number | 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. |
| lit | boolean | true | The glyph glows in the ink. Off, it is the dead grey of a locked pad. |
| blink | boolean | false | Pulses the glyph: the busy signal (analyzing). |
| disabled | boolean | false | Locks the pad; a press in progress ends. |
| aria-label | string | "Hold to scan" | The pad's accessible name. The hold is reported with aria-pressed. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-sensor"; the read is "handheld-device-sensor-fill". |
| data-tone | The tone, when set. |
| data-pressed | Present while the pad is held. |
| data-progress | The clamped read, while there is one. |
| CSS variable | Description |
|---|---|
| --handheld-progress | The 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 attribute | Description |
|---|---|
| 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| printed | boolean | false | Feeds the paper out. Back to false pulls it in again; to clear a receipt at once, stop rendering the children. |
| Data attribute | Description |
|---|---|
| data-slot | "handheld-device-printer", with "handheld-device-printer-slot" and "handheld-device-printer-paper" inside. |
| data-printed | Present when printed. |
| CSS variable | Description |
|---|---|
| --handheld-paper-width | The 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 attribute | Description |
|---|---|
| data-slot | "handheld-device-receipt". |