Control Primitives and Views
Introduction to AudioUI's control primitives and view system - the building blocks for creating custom controls with full library features.
Control primitives are low-level components that separate behavior from visualization. They provide all the interaction logic, parameter handling, and accessibility features while letting you define the visual appearance.
What Are Control Primitives?
Control primitives are generic components that handle:
- Interaction logic: Drag, wheel, keyboard input (see Interaction System)
- Parameter management: Value conversion, quantization, MIDI
- Accessibility: ARIA attributes, focus management, keyboard navigation
- Layout system: AdaptiveBox integration, sizing, labels
You provide the visualization (the view component), and the primitive handles everything else.
Architecture: Behavior vs Visualization
The key insight is separation of concerns:
Control Primitive (Behavior)
↓
View Component (Visualization)
↓
SVG Primitives (Graphics)
This architecture allows you to:
- Use any visualization you want (SVG, bitmap, custom)
- Reuse interaction logic across different visual styles
- Build custom controls without reimplementing complex behavior
The Three Primitives
AudioUI provides three control primitives:
ContinuousControl
For continuous value controls (knobs, sliders, faders).
import { ContinuousControl } from "@cutoff/audio-ui-react";
import { useState } from "react";
// Simple view component
function SimpleKnobView({ normalizedValue }: { normalizedValue: number }) {
const angle = normalizedValue * 270 - 135; // 270 degree arc
return (
<g>
<circle cx="50" cy="50" r="45" fill="none" stroke="#ccc" strokeWidth="4" />
<line
x1="50"
y1="50"
x2={50 + 40 * Math.cos((angle * Math.PI) / 180)}
y2={50 + 40 * Math.sin((angle * Math.PI) / 180)}
stroke="currentColor"
strokeWidth="3"
/>
</g>
);
}
export default function ContinuousControlBasicExample() {
const [value, setValue] = useState(0.5);
return (
<ContinuousControl
view={SimpleKnobView}
viewBoxWidth={100}
viewBoxHeight={100}
value={value}
onChange={(e) => setValue(e.value)}
min={0}
max={1}
label="Custom Knob"
/>
);
}DiscreteControl
For discrete/enum value controls (option selectors, rotary switches).
import { DiscreteControl, OptionView } from "@cutoff/audio-ui-react";
import { useState } from "react";
function SimpleSelectorView({ normalizedValue }: { normalizedValue: number }) {
const positions = 4;
const currentPosition = Math.round(normalizedValue * (positions - 1));
const angle = (currentPosition / positions) * 360;
return (
<g>
<circle cx="50" cy="50" r="45" fill="none" stroke="#ccc" strokeWidth="4" />
<line
x1="50"
y1="50"
x2={50 + 35 * Math.cos((angle * Math.PI) / 180)}
y2={50 + 35 * Math.sin((angle * Math.PI) / 180)}
stroke="currentColor"
strokeWidth="4"
/>
</g>
);
}
export default function DiscreteControlBasicExample() {
const [value, setValue] = useState("option1");
return (
<DiscreteControl
view={SimpleSelectorView}
viewBoxWidth={100}
viewBoxHeight={100}
value={value}
onChange={(e) => setValue(e.value)}
label="Selector"
>
<OptionView value="option1">1</OptionView>
<OptionView value="option2">2</OptionView>
<OptionView value="option3">3</OptionView>
<OptionView value="option4">4</OptionView>
</DiscreteControl>
);
}BooleanControl
For boolean (on/off) controls (switches, buttons).
import { BooleanControl } from "@cutoff/audio-ui-react";
import { useState } from "react";
function SimpleSwitchView({ normalizedValue }: { normalizedValue: number }) {
const isOn = normalizedValue > 0.5;
return (
<g>
<rect
x="20"
y="40"
width="60"
height="20"
rx="10"
fill={isOn ? "currentColor" : "#ccc"}
/>
<circle
cx={isOn ? 70 : 30}
cy="50"
r="8"
fill="white"
/>
</g>
);
}
export default function BooleanControlBasicExample() {
const [value, setValue] = useState(false);
return (
<BooleanControl
view={SimpleSwitchView}
viewBoxWidth={100}
viewBoxHeight={100}
value={value}
onChange={(e) => setValue(e.value)}
latch={true}
label="Power"
/>
);
}When to Use Primitives
Use Built-in Components When:
- You need standard audio UI controls (Knob, Slider, Button, CycleButton)
- You want multiple visual variants out of the box
- You need theming support (color, roundness); use the vector components instead; see Theming Features for built-in color and roundness
- You're building a standard audio interface
Use Primitives When:
- You need a completely custom visual design
- You want to match existing plugin aesthetics
- You're building bitmap-based controls (filmstrips, images)
- You need specialized interaction patterns
- You're creating a unique control type
The View Component Contract
View components must implement the ControlComponentView interface:
interface ControlComponentView {
viewBox: { width: number; height: number };
labelHeightUnits?: number; // Default: 20
interaction?: {
mode: "drag" | "wheel" | "both";
direction: "vertical" | "horizontal" | "circular" | "both";
};
}Static Properties
Define these as static properties on your view component:
function MyCustomView({ normalizedValue }: ControlComponentViewProps) {
// Your visualization code
return <g>{/* SVG content */}</g>;
}
// Required static properties
MyCustomView.viewBox = { width: 100, height: 100 };
MyCustomView.labelHeightUnits = 15; // Optional, defaults to 20
MyCustomView.interaction = {
mode: "both",
direction: "circular",
}; // Optional, provides defaultsView Props
Your view component receives these props:
type ControlComponentViewProps = {
normalizedValue: number; // 0.0 to 1.0
children?: React.ReactNode; // For center content (HTML overlay)
className?: string;
style?: React.CSSProperties;
// ... any custom props you define
};Simple Custom Control Example
Here's a complete example of a custom knob:
import { ContinuousControl, ControlComponentViewProps } from "@cutoff/audio-ui-react";
import { useState } from "react";
function CustomKnobView({ normalizedValue }: ControlComponentViewProps) {
// Calculate angle (270 degree arc, starting at -135 degrees)
const angle = normalizedValue * 270 - 135;
const radians = (angle * Math.PI) / 180;
return (
<g>
{/* Background arc */}
<path
d={`M ${50 + 45 * Math.cos((-135 * Math.PI) / 180)} ${
50 + 45 * Math.sin((-135 * Math.PI) / 180)
} A 45 45 0 1 1 ${50 + 45 * Math.cos((135 * Math.PI) / 180)} ${
50 + 45 * Math.sin((135 * Math.PI) / 180)
}`}
fill="none"
stroke="rgba(0,0,0,0.2)"
strokeWidth="4"
/>
{/* Value arc */}
<path
d={`M ${50 + 45 * Math.cos((-135 * Math.PI) / 180)} ${
50 + 45 * Math.sin((-135 * Math.PI) / 180)
} A 45 45 0 ${normalizedValue > 0.5 ? 1 : 0} 1 ${
50 + 45 * Math.cos(radians)
} ${50 + 45 * Math.sin(radians)}`}
fill="none"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
/>
{/* Indicator line */}
<line
x1="50"
y1="50"
x2={50 + 40 * Math.cos(radians)}
y2={50 + 40 * Math.sin(radians)}
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</g>
);
}
// Define static properties
CustomKnobView.viewBox = { width: 100, height: 100 };
CustomKnobView.labelHeightUnits = 15;
CustomKnobView.interaction = {
mode: "both",
direction: "circular",
};
export default function CustomKnobExample() {
const [value, setValue] = useState(0.5);
return (
<ContinuousControl
view={CustomKnobView}
value={value}
onChange={(e) => setValue(e.value)}
min={0}
max={1}
label="Custom Knob"
/>
);
}What You Get for Free
When you use control primitives, you automatically get:
- Drag interaction: Mouse and touch support
- Wheel interaction: Scroll to adjust values
- Keyboard support: Arrow keys, Home/End
- Focus management: Proper focus handling and visual feedback
- Accessibility: ARIA attributes and keyboard navigation
- Parameter integration: Full Audio Parameter support
- Layout system: AdaptiveBox integration
- Sizing: Fixed and adaptive sizing support
- Labels: Automatic label positioning and alignment
Next Steps
For detailed information on each primitive:
- ContinuousControl Primitive - Complete API reference
- DiscreteControl Primitive - Options, modes, and advanced usage
- BooleanControl Primitive - Latch, momentary, and drag-in/out
For building custom visualizations:
- View System and View Primitives - View building blocks for custom controls