Skip to content

From React/CSS to Pulp

A mapping guide for developers who know React and CSS. Pulp provides two JS authoring approaches: a web-compat layer that feels like the browser DOM, and a native bridge for direct widget control.

Two Approaches

Write familiar document.createElement / element.style / addEventListener code:

const panel = document.createElement('div');
panel.style.display = 'flex';
panel.style.gap = '8px';
panel.style.padding = '16px';
panel.style.backgroundColor = '#1a1a2e';

const title = document.createElement('h2');
title.textContent = 'My Plugin';
panel.appendChild(title);
document.body.appendChild(panel);

See the web-compat guide for full API details including calc(), matchMedia(), closest(), innerHTML, CSS selectors, and 81 CSS properties.

Native Bridge (for audio-specific widgets)

Use Pulp's native API for audio widgets (knobs, meters, waveforms) that have no HTML equivalent:

createKnob('gain', 'panel');
setValue('gain', 0.75);
on('gain', 'change', function(val) { setParam('gain', val); });

Mix both freely — use web-compat for layout/styling and native bridge for audio controls.

Mental Model

React/CSS Pulp
JSX component tree document.createElement() + appendChild(), or createKnob() / createCol()
useState / useReducer getParam() / setParam() — plugin parameters are the state
CSS flexbox element.style.flexDirection = 'row' or setFlex(id, 'direction', 'row')
CSS Grid element.style.gridTemplateColumns = '1fr 2fr' or setGrid(id, 'template_columns', '1fr 2fr')
className / CSS file element.className = 'panel' + new StyleSheet({...})
CSS variables document.documentElement.style.setProperty('--accent', '#3b82f6')
calc() / clamp() element.style.width = 'calc(100% - 40px)' — full expression evaluator
@media queries window.matchMedia('(min-width: 600px)')
useEffect for animation animate(id, property, target, duration, easing)
Event handlers (onClick) element.addEventListener('click', fn) or on(id, 'click', fn)
Event delegation container.addEventListener('click', e => { if (e.target.closest('.item')) ... })
React DevTools enableInspectClick() + component inspector
Hot Module Replacement Built-in hot-reload in standalone host — save JS, see changes instantly. Plugin hosts load scripts at startup.

Component Mapping

React → Pulp Widgets

React / HTML Pulp Create Function
<div> Panel / View createPanel(id, parentId)
<div style={{display:'flex', flexDirection:'row'}}> Row createRow(id, parentId)
<div style={{display:'flex', flexDirection:'column'}}> Col createCol(id, parentId)
<div style={{display:'grid'}}> Grid createGrid(id, parentId)
<div style={{overflow:'scroll'}}> ScrollView createScrollView(id, parentId)
<span> / <p> / <h1> Label createLabel(id, text, parentId)
<input type="range"> Knob or Fader createKnob(id, parentId) / createFader(id, orientation, parentId)
<input type="checkbox"> Checkbox createCheckbox(id, parentId)
<button> (toggle) ToggleButton createToggleButton(id, parentId)
<select> Combo createCombo(id, parentId)
<input type="text"> TextEditor createTextEditor(id, parentId)
<progress> Progress createProgress(id, parentId)
<canvas> CanvasWidget createCanvas(id, parentId)
Custom meter component Meter createMeter(id, parentId)
Custom waveform component WaveformView createWaveform(id, parentId)
Custom spectrum component SpectrumView createSpectrum(id, parentId)
Custom XY pad component XYPad createXYPad(id, parentId)

No JSX — Imperative Creation

React:

function GainUI() {
    const [gain, setGain] = useState(0.5);
    return (
        <div className="column">
            <h1>MyGain</h1>
            <Knob value={gain} onChange={setGain} label="Gain" />
            <span>{formatDb(gain)}</span>
        </div>
    );
}

Pulp:

const root = createCol("root");
setFlex("root", "gap", 12);

createLabel("title", "MyGain", "root");
setFontSize("title", 18);
setFontWeight("title", 700);

const knob = createKnob("gain", "root");
setFlex("gain", "width", 80);
setFlex("gain", "height", 80);
setValue("gain", getParam("Gain"));
setLabel("gain", "Gain");

const readout = createLabel("readout", "0.0 dB", "root");

on("gain", "change", (v) => {
    setParam("Gain", v);
    setText("readout", formatDb(v));
});

Key differences: - No virtual DOM — widgets are created once, mutated in place - No re-renders — event callbacks update specific widgets directly - String IDs instead of refs — every widget gets a unique ID at creation - Parent specified at creation time, not via nesting

CSS Property Mapping

Flexbox

CSS Pulp
display: flex Implicit — createRow/createCol are flex containers
flex-direction: row setFlex(id, "direction", "row") or use createRow
flex-direction: column setFlex(id, "direction", "column") or use createCol
gap: 8px setFlex(id, "gap", 8)
padding: 16px setFlex(id, "padding_top", 16) (per-side)
margin: 8px setFlex(id, "margin_top", 8) (per-side)
width: 200px setFlex(id, "width", 200)
height: 40px setFlex(id, "height", 40)
flex-grow: 1 setFlex(id, "flex_grow", 1)
flex-shrink: 0 setFlex(id, "flex_shrink", 0)
justify-content: center setFlex(id, "justify_content", "center")
align-items: center setFlex(id, "align_items", "center")

Grid

CSS Pulp
display: grid createGrid(id, parentId)
grid-template-columns: 1fr 2fr setGrid(id, "template_columns", "1fr 2fr")
grid-template-rows: auto 1fr setGrid(id, "template_rows", "auto 1fr")
column-gap: 8px setGrid(id, "column_gap", 8)
row-gap: 8px setGrid(id, "row_gap", 8)
grid-column: 1 / 3 setGrid(id, "column_start", 1) + setGrid(id, "column_end", 3)

Typography

CSS Pulp
font-size: 14px setFontSize(id, 14)
font-weight: 700 setFontWeight(id, 700)
font-style: italic setFontStyle(id, "italic")
letter-spacing: 0.5px setLetterSpacing(id, 0.5)
line-height: 1.5 setLineHeight(id, 1.5)
text-align: center setTextAlign(id, "center")
color: #fff setTextColor(id, "#ffffff")
text-transform: uppercase setTextTransform(id, "uppercase")
text-decoration: underline setTextDecoration(id, "underline")
text-overflow: ellipsis setTextOverflow(id, "ellipsis")

Visual Styling

CSS Pulp
background: #1a1a2e setBackground(id, "#1a1a2e")
background: linear-gradient(...) setBackgroundGradient(id, stops)
border: 1px solid #333 setBorder(id, "#333333", 1, 0)
border-radius: 8px setBorder(id, color, width, 8)
opacity: 0.5 setOpacity(id, 0.5)
z-index: 10 setZIndex(id, 10)
box-shadow: 0 4px 8px rgba(...) setBoxShadow(id, 0, 4, 8, 0, "rgba(0,0,0,0.3)")
filter: blur(4px) setFilter(id, "blur(4px)")
overflow: hidden setOverflow(id, "hidden")
cursor: pointer setCursor(id, "pointer")
visibility: hidden setVisible(id, false)

Transforms

CSS Pulp
transform: translate(10px, 20px) setTranslate(id, 10, 20)
transform: scale(1.5) setScale(id, 1.5, 1.5)
transform: rotate(45deg) setRotation(id, 45)
transform-origin: center setTransformOrigin(id, 0.5, 0.5)

Transitions & Animation

CSS Pulp
transition: all 200ms ease setTransitionDuration(id, 200)
animation: pulse 1s infinite setAnimation(id, "pulse", 1000)
@keyframes pulse { ... } defineKeyframes("pulse", [...])
Imperative animation animate(id, "opacity", 1.0, 300, "ease_out_cubic")

State Management

React: Component State + Context

const [gain, setGain] = useState(0.5);
const [bypass, setBypass] = useState(false);

<Knob value={gain} onChange={(v) => setGain(v)} />
<Toggle checked={bypass} onChange={(v) => setBypass(v)} />

Pulp: Parameter Store

// Parameters are defined in C++, accessed from JS
setValue("gain-knob", getParam("Gain"));
setValue("bypass-toggle", getParam("Bypass"));

on("gain-knob", "change", (v) => setParam("Gain", v));
on("bypass-toggle", "toggle", (v) => setParam("Bypass", v ? 1.0 : 0.0));

Key differences: - State lives in the StateStore, not in JS — it's shared with the audio thread - Parameters are defined in C++ (define_parameters), not JS - No prop drilling or context — getParam/setParam are global - Undo/redo grouping is automatic via Binding gesture begin/end

Event Handling

React: Inline Handlers

<button onClick={() => console.log("clicked")}>Click</button>
<div onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}>

Pulp: Register + Listen

const btn = createToggleButton("btn", "root");
registerClick("btn");
on("btn", "click", () => { /* handle click */ });

registerHover("panel");
on("panel", "mouseenter", () => setOpacity("panel", 1.0));
on("panel", "mouseleave", () => setOpacity("panel", 0.7));

Key difference: you must call registerClick/registerHover before on() will fire. This is an opt-in performance optimization — only widgets that need events get the overhead.

What Breaks

Things that don't transfer from React/CSS:

Concept Why
Virtual DOM / reconciliation No VDOM. Widgets are created once and mutated.
Component composition / children No nesting syntax. Use parentId parameter.
useEffect / lifecycle hooks No lifecycle. Use on() for events.
CSS selectors / cascading Selectors and class-based styling exist via StyleSheet/querySelector, but there is no browser stylesheet loader or full CSS cascade stack.
className / CSS modules className is usable for StyleSheet matching, but CSS modules/build tooling do not exist in the runtime.
Media queries / responsive Fixed-size plugin windows. Use flex layout to fill available space.
Server-side rendering Runs in plugin process only.
npm / bundlers Single JS file, no build step.

What Transfers

Skills that map directly:

Skill How It Applies
Flexbox layout Same mental model — setFlex() uses the same properties
CSS Grid Same mental model — setGrid() uses template columns/rows
Color values Hex strings work ("#1a1a2e", "rgba(0,0,0,0.5)")
Design tokens Theme system is similar to CSS custom properties
Event-driven UI Same pattern — register handler, respond to events
Canvas 2D drawing Same API shape — beginPath, moveTo, lineTo, fill, stroke
Animation/easing curves Same easing names and keyframe concepts

What's Different

Audio plugin UIs have unique requirements:

Requirement How Pulp Handles It
Real-time audio thread Parameters use std::atomic — JS reads/writes are lock-free
DAW parameter automation setParam() triggers host notification automatically
Undo/redo in DAW Gesture begin/end via Binding — grouped automatically
Level meters at 60fps Meter widget polls AudioBridge via TripleBuffer — no JS polling needed
Multiple plugin formats Same UI JS works in VST3, AU, CLAP, and Standalone
GPU-accelerated rendering Skia/Dawn backend — Canvas API maps to GPU draw calls
Hot-reload during development Save JS file → UI updates instantly in standalone host, no rebuild