@designcompute/aerial
v0.1.0
Published
Controls you can grab, for React apps. Trackball, xy pad, timeline, slider.
Maintainers
Readme
aerial
Rotation and position are hard to get right by typing numbers at them. Aerial gives you controls you can grab and drag instead, and it drops into any React app.
npm i @designcompute/aerial # or: pnpm add / yarn add / bun addReact 18 or 19 as a peer dependency. Nothing else at runtime.
import { useAerial, AerialPanel, trackball, xy, timeline, slider } from "@designcompute/aerial";
function Scene() {
const { rot, pan, t, gain } = useAerial({
rot: trackball({ value: [0, 0, 0] }),
pan: xy({ x: [-1, 1], y: [-1, 1] }),
t: timeline({ duration: 12, loop: true }),
gain: slider({ min: 0, max: 1, step: 0.01 }),
});
return <mesh rotation={rot} position={[pan[0], pan[1], t.time]} scale={gain} />;
}
// once, anywhere. there is one store, so nothing gets passed around
<AerialPanel />Four controls
| | |
|---|---|
| trackball() | Rotation you can grab. Drag near the middle and it tumbles, near the edge and it rolls. Shift rolls from anywhere. It stops where you let go. |
| xy() | Two numbers that move together. Shift stays on one axis, Alt slows it down. |
| timeline() | Scrub through time. The value is { time, playing }. Scrubbing takes over playback, and the clock keeps running with the panel closed. |
| slider() | One number. Drag the bar, or click the readout and type. |
All of them: double-click to reset, arrow keys to nudge, Shift and Alt to change how fine the movement is.
Reading values
useAerial re-renders your component when its values change, which is what you want for anything that drives React. If something redraws every frame, read it without subscribing instead:
const aerial = useAerialRef();
useFrame(() => {
mesh.rotation.set(...aerial.get("rot"));
});Paths are the keys you wrote. The schema is flat, so there is nothing to nest and nothing to mistype. Two components using the same key share one control, which is usually what you want in a dev panel and the one thing to watch for.
Dragging a control only redraws that control, not the tree that declared it.
Not just for dev
Every row in the panel is a thin wrapper around a plain controlled component, so the same controls work in your product UI:
<Trackball value={rot} onChange={setRot} label="rotation" />
<XYPad defaultValue={[0, 0]} x={[-1, 1]} y={[-1, 1]} />
<Timeline duration={30} fps={24} markers={[{ time: 4.5 }]} />
<Slider value={gain} onChange={setGain} min={0} max={1} step={0.01} />Theming
No stylesheet to import and no CSS framework. Styles inject themselves once, and every color is a custom property that the canvas controls read back out, so a theme reaches inside the drawings too.
setTheme({ accent: "oklch(65% 0.17 255)", bg: "oklch(20% 0.01 285 / 0.8)" });<AerialPanel theme="light" width={300} vars={{ "--aerial-accent": "oklch(65% 0.17 255)" }} />Panel
<AerialPanel
title="controls"
side="right" // or "left". sticks to that top corner
hotkey="`" // toggles visibility, false to disable
hidden={!import.meta.env.DEV}
/>Writing a control
A control is a plain object. There is no registry, no type tag, and nothing in the panel that knows what a slider is:
type Control<V> = {
value: V // initial
render: (path: string) => ReactNode // its own row, label included
sanitize?: (v: V) => V // clamp or quantize on the way in
attach?: (path, store) => () => void // optional driver, like the timeline clock
label?: string
hidden?: boolean
}So slider() is a closure over its options:
export function slider(opts: SliderOpts = {}): Control<number> {
const { min = 0, max = 1, step } = opts;
const sanitize = (v: number) => clamp(step ? quantize(v, step, min) : v, min, max);
return {
value: sanitize(opts.value ?? clamp(0, min, max)),
sanitize,
render: (path) => <SliderRow key={path} path={path} opts={opts} />,
};
}useControl(path) is the only bridge between a component and the store. useDrag, useCanvas and usePalette are exported too, so a new control gets the same pointer, DPR and theming handling as the built-ins.
Development
pnpm install
pnpm dev # docs site at :5173, library aliased to source with HMR
pnpm build # library to packages/aerial/dist
pnpm typecheckpackages/aerialis the libraryapps/docsis the landing page and visual dev environment
