DOCS / UI REFERENCE
Reference-art workflow
Some panels shouldn't be built from components — they are the designer's render, shipped pixel-exact, with live parts patched on top. This guide distils that workflow: it's how you turn a Figma export, a web prototype, or a photograph of real hardware into a working plugin UI. It's how StashTrack ships in production.
The idea
One full-panel image (the plate) is the layout, the lighting, the texture — everything static. On top of it you place exactly three kinds of things: invisible hit zones that take the gestures, covers that hide a baked control at its default pose, and live parts (an indicator, a film-strip knob face, a readout) drawn where the covered pixels were. Everything is positioned in plate space — the image's own pixel coordinates — and multiplied by one scale factor at paint time, so measurements taken in your design tool transfer directly.
const S = 0.5; // 1536x1024 plate -> 768x512 editor
const W = 1536 * S, H = 1024 * S;
const px = (n: number) => n * S; // plate-px -> editor-px
<View style={{ width: W, height: H, position: "relative" }}>
<Plate /> {/* the art, whole */}
{/* live parts, positioned in plate space */}
{/* invisible hit zones LAST, so they sit on top */}
</View>Ship the plate
Export the panel at exactly the pixel size you'll draw it at (a 1:1 blit always beats runtime rescaling; 2x and S = 0.5 is the usual choice), encode it as lossless WebP, and inline it as a data URI at build time — a plugin has no file server to fetch from. Then intern it with registerImage: the bitmap decodes once into native memory and the bridge only ever carries a short "img:N" handle instead of megabytes of base64.
import { registerImage } from "@vsreact/core";
import { assets } from "./_assets"; // generated: build.ts inlines src/assets/
const plate = registerImage(assets["plate.webp"]);
function Plate() {
return <Image src={plate} style={{
position: "absolute", left: 0, top: 0, width: W, height: H, objectFit: "fill",
}} />;
}Export the plate with every control at its default pose — the UI then only has to draw differences, and a freshly opened editor is mostly just the photograph.
Invisible hit zones
The visible knob is a photograph; the thing that takes the drag is a transparent View over it, bound through useParamGestures — vertical drag, double-click reset and wheel nudge, inside the begin()/end() automation gesture hosts need. Make hit zones larger and squarer than the drawn control; nobody ever complained a knob was too easy to grab.
function KnobHit({ id, cx, cy, size }: { id: string; cx: number; cy: number; size: number }) {
const p = useParameter(id);
return (
<View
style={{ position: "absolute", left: px(cx - size / 2), top: px(cy - size / 2),
width: px(size), height: px(size), cursor: "ns-resize" }}
{...useParamGestures(p)}
/>
);
}Render hit zones after everything else so no later sibling covers them — a panel can render perfectly and still be dead if a decorative layer eats the drags. The examples' interaction tests exist precisely because this failure is invisible in a screenshot.
Cover the moving parts
When a control moves, the plate's baked version of it is wrong. The trick is a Cover: a clipped window onto a shifted copy of the plate, so clean panel pixels from a blank region nearby paint over the baked mark — grain, lighting and all. Then draw the live part on top.
// A resampled patch of the plate clipped to rect (x,y,w,h) in plate space,
// shifted by (ox,oy) plate-px so clean pixels cover a baked mark underneath.
function Cover({ x, y, w, h, ox = 0, oy = 0 }) {
return (
<View style={{ position: "absolute", left: px(x), top: px(y),
width: px(w), height: px(h), overflow: "hidden" }}>
<Image src={plate} style={{ position: "absolute",
left: px(-x + ox), top: px(-y + oy), width: W, height: H, objectFit: "fill" }} />
</View>
);
}Pick the shift so the source region is genuinely blank and shares the local lighting. When no blank region exists — the delay example's LED glass has a strong brightness gradient — erase the baked mark from the asset once at build time instead (the examples do per-column interpolation in a small sharp script) and skip the runtime cover entirely.
Film-strip knobs
A photoreal knob face that turns is a film strip: N pre-rendered rotation frames stacked in one lossless-WebP sprite, one frame shown at runtime. Bake it with bakeKnobStrip (or export frames from your DSCC/renderer), and show it with the built-in FilmStripKnob — which interns the strip via registerImage, so turning it ships one number over the bridge.
# build time — writes knob-strip.webp + knob-strip.json
bun run vsreact/js/src/tools/bakeKnobStrip.ts <outDir> 90 180
// runtime — the visible face; a KnobHit over it takes the gestures
<FilmStripKnob rotation={-135 + 270 * p.value} strip={knobStrip} displaySize={disc} />Why not shade the knob live in JS? Measured: ~217ms per 180x180 frame under QuickJS. Bake at build time; the runtime shows pixels.
Readouts & fonts
Live values need the design's typeface: registerFont a bundled OTF/TTF (also inlined as a data URI) and it resolves through fontFamily before any system lookup. Two style keys exist for exactly this job: textLength scales a line to a fixed width so digit readouts never shift layout, and textStroke* outlines glyphs like the printed panel. Derive the displayed value from the parameter's own host range — normalizedToNatural(p.value, p) — so nothing mirrors the APVTS in TS.
registerFont({ family: "Panel Narrow", src: assets["narrow.otf"] });
const ms = normalizedToNatural(time.value, time); // 1..1000 from the C++ range
<Text style={{ fontFamily: "Panel Narrow", fontSize: px(40),
textLength: px(62), textAlign: "center", color: "#eee1d4" }}>
{Math.round(ms)}
</Text>Verify it moves
Screenshot-compare the editor against the design export — but don't stop there: hit-test each control point and assert the parameter actually changed, with the param:begin/param:end bracket around it. The examples' ExampleInteractionTests are a ready-made template: they drive vsreact::hitTest, dispatch drag events, and check the APVTS write — because a reference-art panel can look perfect and be completely dead.
For working code at scale, read StashTrack — a production VST whose whole UI is this workflow. The bundled examples were also built this way before 0.0.33 (their plate versions live in git log; gain's was the smallest complete instance of everything above) before moving to the stock-component style most plugins start from.