Skip to content

Interaction primitives

@dashhaus/core/primitives holds the behaviour every skin’s controls share, without markup or styles. Each is a hook whose result you spread onto your own elements. Orb’s round button and Field’s key are both usePress; both skins’ brightness tracks are useSlider.

The feel of a primitive depends on its skin’s CSS as much as its code: a track’s scroll-or-set on touch needs touch-action: pan-y, a press needs its :active style. Try them on a phone in any card’s story in Storybook.

A button: a tap, an optional press and hold, an optional latch, and an unavailable state. The click that ends a hold doesn’t also press. An unavailable control stays focusable and says so with aria-disabled; a disabled one is out of reach. A plain one-shot button doesn’t need it: use a <button>.

import { usePress } from "@dashhaus/core/primitives";
interface KeyProps {
label: string;
on: boolean;
unavailable: boolean;
onToggle: () => void;
onOpenDetails: () => void;
}
/** A latching key: a tap toggles, press and hold opens details, and an unavailable entity stays focusable. */
export function Key({ label, on, unavailable, onToggle, onOpenDetails }: KeyProps) {
const press = usePress({ onPress: onToggle, onHold: onOpenDetails, pressed: on, unavailable });
return (
<button className="key" {...press}>
{label}
</button>
);
}
onPress() => void
A tap, a click, or Enter or Space on the focused button.
onHold?() => void
Press and hold. The click that ends the hold doesn't also press.
pressed?boolean
A latching control's state, announced as pressed. Leave it out for a button that acts once.
disabled?boolean
Out of reach: native `disabled`, so no tap, hold or focus.
unavailable?boolean
The entity can't act: it stays focusable and says so (`aria-disabled`); a tap does nothing, a hold still works.

A horizontal track. Pointer input goes to a hit layer you draw over the track: a mouse sets the value on press, a touch only on a tap or once the finger moves sideways, so scrolling the page past a slider never changes it. A hidden native range input carries the keyboard and the screen reader, with the value text you give. useRangeSlider is the same with two thumbs, a low and a high, as for a heat and cool range.

import { useSlider } from "@dashhaus/core/primitives";
interface TrackProps {
label: string;
value: number;
onChange: (percent: number) => void;
}
/**
* A track you draw yourself: the pointer goes to a hit layer (a touch only sets a value on a tap or a sideways
* drag, so scrolling past never changes it), and a hidden native range input carries the keyboard and screen reader.
*/
export function Track({ label, value, onChange }: TrackProps) {
const slider = useSlider<HTMLDivElement>({
value,
min: 0,
max: 100,
step: 1,
inset: 12,
valueText: (percent) => `${percent} percent`,
onChange,
});
return (
<div className="track" {...slider.rootProps} style={{ touchAction: "pan-y" }}>
<div className="track-fill" style={{ width: `${slider.fraction(slider.value) * 100}%` }} />
<div className="track-hit" {...slider.hitProps} />
<input className="track-input" aria-label={label} {...slider.inputProps} />
</div>
);
}
valuenumber
The value the track shows.
minnumber
The low end.
maxnumber
The high end.
step?number
The step a pointer snaps to, counted from min.
insetInset<T>
The thumb's half width: the track's usable length is its box less this on each side.
disabled?boolean
No input at all.
along?(clientX: number, rect: DOMRect, inset: number) => number
Where a pointer x lies along the track, 0-1. Default: left to right over the box less `inset` each side.
snap?(raw: number) => number
A pointer value onto the grid. Default: the nearest step counted from `min`. The result is kept in range.
reportUnchanged?boolean
A tap where the thumb already sits still reports, e.g. so a light that's off turns on at its level.
valueText(value: number) => string
What the value means, read out for it ("Off. Last set to 40 percent").
onChange(value: number) => void
Called with each new value.

A native modal <dialog> for details: it mounts and shows, locks the page’s scroll, holds a data-closing attribute while your exit transition runs, closes on Escape and on the backdrop, and on a phone drags down to close from its header. Orb’s details sheet and Field’s detail panel are both on it.

openboolean
Whether the sheet is open.
labelstring
The dialog's accessible name, e.g. "Kitchen details".
onClose() => void
Called when the person closes it: Escape, the backdrop or a drag down.
closeMsnumber
The skin's longest exit transition plus a frame, in ms: how long `data-closing` stays before the dialog closes.
phonestring
Where the sheet sits at the bottom of the screen and can be dragged down, e.g. "(max-width: 600px)".
handlestring
A selector for the part that drags the sheet (its header); buttons and inputs inside it don't.
snapBackstring
The transition a short drag snaps back with, e.g. "translate 300ms var(--ease-drawer)".

A code keypad for an alarm: the code typed so far, digits, delete and enter from its keys or a physical keyboard, and the answer. It is busy until onSubmit answers; a wrong code (false) is cleared for another try, and a failed call (a throw) keeps the code so Enter tries again. That is the shape of an alarm hook’s onArm and onDisarm.

A text code (text: true, for a codeFormat of text) can’t be typed on digit keys, and a phone or wall panel has no physical keyboard. For one, the hook also returns inputProps: spread them onto an <input> and it becomes the field the code is typed into. It takes the focus in the keypad’s place, so a touch screen brings up its own keyboard, and Enter in it submits; your digit keys still type into the same code.

text?boolean
Letters as well as digits (Home Assistant's `code_format: text`).
onSubmit(code: string) => boolean | Promise<boolean>
Try the code. May answer later (Home Assistant does): the keypad is busy until it does. True: accepted. False: a wrong code, cleared for another try. A throw: it didn't go through, and the code stays, so Enter tries again.