View System and View Primitives
Learn how to compose custom controls using AudioUI's view primitives—the building blocks for creating unique visual designs (including vector primitives for SVG).
Vector primitives are low-level building blocks for composing custom radial and linear controls. They provide a consistent API for positioning content, rendering value indicators, and creating scale decorations.
These view primitives, together with the Control Primitives, allow you to create entirely new components, possibly independent of the theming and size system of the built-in components (built-in theming is described in Theming Features), like this:
Creating the view represents 99% of the work when building a new custom component in AudioUI, because the Control Primitives handle layout, the parameter model (conversions, units, scaling, …), user interactions, and more.
Overview
AudioUI provides three categories of vector primitives:
- Radial Primitives: For rotary controls (knobs, dials, encoders)
- Linear Primitives: For linear controls (sliders, faders, pitch/mod wheels)
- Other Primitives: Rectangular or path-based building blocks (images, filmstrips, path reveal, HTML overlay)
All primitives share a common coordinate system and work together to create cohesive control designs.
Radial Primitives
Radial primitives use a center-point coordinate system: cx, cy (center coordinates) and radius (distance from center). In this system, views are composed of several radial primitives that share a common center point and use increasing radius values.
ValueRing
Renders a circular arc indicator showing value progress.
import { ValueRing } from "@cutoff/audio-ui-react";
function CustomKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<ValueRing
cx={50}
cy={50}
radius={45}
normalizedValue={normalizedValue}
thickness={4}
openness={90}
fgArcStyle={{ stroke: "currentColor" }}
bgArcStyle={{ stroke: "rgba(0,0,0,0.2)" }}
/>
</g>
);
}Key Features:
- Bipolar mode support (starts from center)
- Configurable thickness, roundness, openness
- Background and foreground arc styling
RotaryImage
Rotates children based on normalized value. Shares angle logic with ValueRing.
import { RotaryImage } from "@cutoff/audio-ui-react";
function CustomKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<RotaryImage
cx={50}
cy={50}
radius={35}
normalizedValue={normalizedValue}
openness={90}
>
<line
x1="50%"
y1="15%"
x2="50%"
y2="5%"
stroke="currentColor"
strokeWidth={3}
/>
</RotaryImage>
</g>
);
}RadialImage
Displays static content (image or SVG) at radial coordinates.
import { RadialImage } from "@cutoff/audio-ui-react";
function CustomKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
{/* Static background texture */}
<RadialImage
cx={50}
cy={50}
radius={40}
imageHref="/knob-texture.png"
/>
{/* Rotating indicator */}
<RotaryImage
cx={50}
cy={50}
radius={35}
normalizedValue={normalizedValue}
>
<line x1="50%" y1="10%" x2="50%" y2="0%" stroke="white" strokeWidth={2} />
</RotaryImage>
</g>
);
}TickRing
Renders a ring of tick marks for scale decoration.
import { TickRing } from "@cutoff/audio-ui-react";
function CustomKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<TickRing
cx={50}
cy={50}
radius={45}
thickness={3}
count={11}
openness={90}
style={{ stroke: "currentColor", strokeWidth: 1 }}
/>
<ValueRing
cx={50}
cy={50}
radius={45}
normalizedValue={normalizedValue}
thickness={4}
/>
</g>
);
}LabelRing
Renders text or icons at radial positions (wrapper around TickRing).
import { LabelRing } from "@cutoff/audio-ui-react";
function CustomKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<LabelRing
cx={50}
cy={50}
radius={45}
labels={[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
labelClassName="text-xs font-medium"
/>
<ValueRing
cx={50}
cy={50}
radius={45}
normalizedValue={normalizedValue}
thickness={4}
/>
</g>
);
}Linear Primitives
Linear primitives use a strip model: cx, cy (center point), length, and rotation. In this system, views are composed of several linear primitives that share a common length and cy value but may use different cx values.
For example, the central strip of a fader (at the center point) may be surrounded by parallel thick strips and label strips to form the scale and markers of the fader.
The center/length model is used rather than a perhaps more intuitive Cartesian system because it fits components that can have different orientations (e.g. horizontal or vertical sliders).
Typically, linear views are created in a vertical layout; their orientation is then set in the control primitive via the rotation attribute (e.g. 270° or -90° for a horizontal slider that grows from left to right).
LinearStrip
Renders a rectangular strip (background track).
import { LinearStrip, ValueStrip, LinearCursor } from "@cutoff/audio-ui-react";
function CustomSliderView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
{/* Background track */}
<LinearStrip
cx={50}
cy={150}
length={260}
thickness={6}
roundness={0.3}
/>
{/* Value fill */}
<ValueStrip
cx={50}
cy={150}
length={260}
thickness={6}
normalizedValue={normalizedValue}
roundness={0.3}
/>
{/* Cursor */}
<LinearCursor
cx={50}
cy={150}
length={260}
normalizedValue={normalizedValue}
width={8}
aspectRatio={1}
roundness={1}
/>
</g>
);
}ValueStrip
Renders the active (foreground) portion of a linear strip.
import { LinearStrip, ValueStrip } from "@cutoff/audio-ui-react";
function CustomFaderView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<LinearStrip cx={50} cy={150} length={260} thickness={8} />
<ValueStrip
cx={50}
cy={150}
length={260}
thickness={8}
normalizedValue={normalizedValue}
bipolar={false} // Unipolar: fills from bottom
/>
</g>
);
}Bipolar Mode:
<ValueStrip
cx={50}
cy={150}
length={260}
thickness={8}
normalizedValue={normalizedValue}
bipolar={true} // Fills from center
/>LinearCursor
Renders a cursor that slides along a linear strip.
import { LinearCursor } from "@cutoff/audio-ui-react";
function CustomSliderView({ normalizedValue }: ControlComponentViewProps) {
return (
<LinearCursor
cx={50}
cy={150}
length={260}
normalizedValue={normalizedValue}
width={10}
aspectRatio={1.5} // Height = width / 1.5
roundness={0.5}
/>
);
}Image-based cursor:
<LinearCursor
cx={50}
cy={150}
length={260}
normalizedValue={normalizedValue}
width={20}
imageHref="/cursor-handle.png" // Preserves natural aspect ratio
/>Other Primitives
These primitives are neither radial nor linear. They use rectangular coordinates or arbitrary paths and are useful for static images, value-driven path reveals, filmstrip frames, and HTML overlays.
Image
Displays static content (image or SVG) at rectangular coordinates. Position is defined by top-left corner (x, y) and width / height.
import { Image } from "@cutoff/audio-ui-react";
function CustomView() {
return (
<g>
<Image
x={10}
y={10}
width={80}
height={40}
imageHref="/icon.png"
/>
{/* Or SVG content via children */}
<Image x={10} y={60} width={24} height={24}>
<path d="M12 2L2 7v10l10 5 10-5V7L12 2z" fill="currentColor" />
</Image>
</g>
);
}Key Features:
- Optional
imageHreffor bitmap images orchildrenfor SVG (e.g. icons) - Optional
transformfor SVG transform attribute style.colorsupports icon theming when usingcurrentColor
RevealingPath
Reveals an SVG path from start to end based on a normalized value. Uses pathLength and stroke-dashoffset for performant fill animations.
import { RevealingPath } from "@cutoff/audio-ui-react";
function CustomView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<RevealingPath
d="M 10 50 Q 50 10 90 50 T 170 50"
normalizedValue={normalizedValue}
stroke="currentColor"
strokeWidth={4}
fill="none"
/>
</g>
);
}Key Features:
normalizedValue0 = hidden, 1 = fully visible- Optional
resolutionfor path length calculation (default 100) - Accepts standard SVG path props (
stroke,fill,strokeWidth, etc.)
FilmstripImage
Displays a single frame from a sprite sheet (filmstrip) based on a normalized value. Frames are stacked vertically or horizontally; the component uses a shifted viewBox for hardware-accelerated scrubbing.
import { FilmstripImage } from "@cutoff/audio-ui-react";
function CustomView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<FilmstripImage
x={0}
y={0}
frameWidth={100}
frameHeight={100}
frameCount={64}
normalizedValue={normalizedValue}
imageHref="/knob-filmstrip.png"
orientation="vertical"
/>
</g>
);
}Key Features:
frameWidth,frameHeight,frameCountdefine the strip layoutorientation:"vertical"(default) or"horizontal"- Optional
frameRotation(degrees),invertValuefor reversed mapping
RadialHtmlOverlay
Renders HTML content inside the SVG at a radial position. It creates a square foreignObject centered at (cx, cy) with side length radius * 2. Use it for text or rich content in knobs when you need HTML layout (e.g. Flexbox, fonts).
import { RadialHtmlOverlay } from "@cutoff/audio-ui-react";
function CustomKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
{/* SVG layers (ValueRing, etc.) */}
<RadialHtmlOverlay cx={50} cy={50} radius={30} pointerEvents="none">
<div style={{ fontSize: 14, fontWeight: 700, textAlign: "center" }}>
{Math.round(normalizedValue * 100)}%
</div>
</RadialHtmlOverlay>
</g>
);
}Key Features:
pointerEvents:"none"(default) lets interactions pass through to the control; use"auto"if the overlay is interactive- Safari: For complex layouts, the docs recommend rendering center content as
childrenof the control primitive (HTML overlay outside SVG) instead ofRadialHtmlOverlayfor better compatibility. See Center Content below.
Composing Custom Knobs
This section walks through building the Hi-Fi Knob (the demo above) step by step. You combine vector primitives with ContinuousControl to get a fully interactive knob that is independent of the built-in theming and sizing.
Step 1: Scale Decoration
Start with the scale: two TickRings give a hi-fi style dial—a subtle inner ring and a bolder outer ring.
import { TickRing } from "@cutoff/audio-ui-react";
function HifiKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<TickRing
cx={50} cy={50} radius={50} thickness={5} openness={90}
style={{ stroke: "#51452D", strokeWidth: 0.5 }}
step={3}
/>
<TickRing
cx={50} cy={50} radius={50} thickness={5} openness={90}
style={{ stroke: "#FCC969", strokeWidth: 1 }}
count={3}
/>
</g>
);
}Step 2: Value Indicator
Add a ValueRing so the current value is visible. Place it so it sits above the scale rings but below the background image and indicator (layer order matters).
import { TickRing, ValueRing } from "@cutoff/audio-ui-react";
function HifiKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<TickRing
cx={50} cy={50} radius={50} thickness={5} openness={90}
style={{ stroke: "#51452D", strokeWidth: 0.5 }}
step={3}
/>
<TickRing
cx={50} cy={50} radius={50} thickness={5} openness={90}
style={{ stroke: "#FCC969", strokeWidth: 1 }}
count={3}
/>
<ValueRing
cx={50}
cy={50}
radius={43}
normalizedValue={normalizedValue}
thickness={1}
roundness={true}
openness={90}
fgArcStyle={{ stroke: "#FCC969" }}
bgArcStyle={{ stroke: "currentColor", opacity: 0.15 }}
/>
</g>
);
}Step 3: Background Image
Add a static background with RadialImage. This gives the knob its “face” (e.g. metal texture). The image is drawn behind the value arc and scale.
import { TickRing, ValueRing, RadialImage } from "@cutoff/audio-ui-react";
function HifiKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<TickRing
cx={50} cy={50} radius={50} thickness={5} openness={90}
style={{ stroke: "#51452D", strokeWidth: 0.5 }}
step={3}
/>
<TickRing
cx={50} cy={50} radius={50} thickness={5} openness={90}
style={{ stroke: "#FCC969", strokeWidth: 1 }}
count={3}
/>
<ValueRing
cx={50} cy={50} radius={43}
normalizedValue={normalizedValue}
thickness={1} roundness={true} openness={90}
fgArcStyle={{ stroke: "#FCC969" }}
bgArcStyle={{ stroke: "currentColor", opacity: 0.15 }}
/>
<RadialImage
cx={50} cy={50} radius={40}
imageHref="/images/demo/knob-metal.png"
/>
</g>
);
}Step 4: Rotating Indicator
Add a RotaryImage with a line (or any SVG) as the needle. It rotates with normalizedValue and completes the Hi-Fi look.
import {
TickRing,
ValueRing,
RadialImage,
RotaryImage,
} from "@cutoff/audio-ui-react";
function HifiKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<TickRing
cx={50} cy={50} radius={50} thickness={5} openness={90}
style={{ stroke: "#51452D", strokeWidth: 0.5 }}
step={3}
/>
<TickRing
cx={50} cy={50} radius={50} thickness={5} openness={90}
style={{ stroke: "#FCC969", strokeWidth: 1 }}
count={3}
/>
<ValueRing
cx={50} cy={50} radius={43}
normalizedValue={normalizedValue}
thickness={1} roundness={true} openness={90}
fgArcStyle={{ stroke: "#FCC969" }}
bgArcStyle={{ stroke: "currentColor", opacity: 0.15 }}
/>
<RadialImage
cx={50} cy={50} radius={40}
imageHref="/images/demo/knob-metal.png"
/>
<RotaryImage
cx={50} cy={50} radius={43}
normalizedValue={normalizedValue}
openness={90}
>
<line
x1="43" y1="15" x2="43" y2="8"
stroke="white" strokeWidth={2} strokeLinecap="round"
/>
</RotaryImage>
</g>
);
}Complete Example
Attach the view to ContinuousControl and set viewBox, labelHeightUnits, and interaction on the view so the Control Primitive (ContinuousControl here) knows how to create the actual SVG parent component and which interactions to set on the AdaptiveBox containing it. Assuming HifiKnobView is already defined (e.g. from the steps above), wire it up like this:
import { useState } from "react";
import { ContinuousControl } from "@cutoff/audio-ui-react";
// HifiKnobView defined elsewhere (view + viewBox, labelHeightUnits, interaction)
export default function HifiKnobUsage() {
const [value, setValue] = useState(0);
return (
<ContinuousControl
view={HifiKnobView}
value={value}
onChange={(e) => setValue(e.value)}
min={-60}
max={6}
unit="dB"
scale="log"
label="Hi-Fi Knob"
valueAsLabel="interactive"
/>
);
}The demo at the top of this page is implemented this way; the source lives in the cutoff-dev app as a demo component (HifiKnobDemo).
Passing props to the view
You can define additional props on your view component and pass them via the viewProps prop. The primitive forwards these to the view alongside normalizedValue, className, and style:
// View accepts an optional accentColor
function MyKnobView({ normalizedValue, accentColor }: ControlComponentViewProps & { accentColor?: string }) {
return (/* ... */);
}
<ContinuousControl
view={MyKnobView}
viewProps={{ accentColor: "#FCC969" }}
value={value}
onChange={(e) => setValue(e.value)}
min={-60}
max={6}
unit="dB"
scale="log"
label="Knob"
valueAsLabel="interactive"
/>More examples:
For other customization examples (Guitar Knob, Selector Knob, etc.), see the source code of the audio-ui playground: apps/playground-react/components/examples.
Composing Custom Sliders
Building a custom slider follows a similar pattern:
Step 1: Background Track
function CustomSliderView({ normalizedValue }: ControlComponentViewProps) {
return (
<LinearStrip
cx={50}
cy={150}
length={260}
thickness={6}
roundness={0.3}
/>
);
}Step 2: Value Fill
function CustomSliderView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<LinearStrip cx={50} cy={150} length={260} thickness={6} roundness={0.3} />
<ValueStrip
cx={50}
cy={150}
length={260}
thickness={6}
normalizedValue={normalizedValue}
roundness={0.3}
/>
</g>
);
}Step 3: Cursor
function CustomSliderView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<LinearStrip cx={50} cy={150} length={260} thickness={6} roundness={0.3} />
<ValueStrip
cx={50}
cy={150}
length={260}
thickness={6}
normalizedValue={normalizedValue}
roundness={0.3}
/>
<LinearCursor
cx={50}
cy={150}
length={260}
normalizedValue={normalizedValue}
width={10}
aspectRatio={1}
roundness={1}
/>
</g>
);
}Complete Slider Example
import {
ContinuousControl,
ControlComponentViewProps,
LinearStrip,
ValueStrip,
LinearCursor,
} from "@cutoff/audio-ui-react";
import { useState } from "react";
function CustomSliderView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<LinearStrip
cx={50}
cy={150}
length={260}
thickness={6}
roundness={0.3}
style={{ fill: "rgba(0,0,0,0.1)" }}
/>
<ValueStrip
cx={50}
cy={150}
length={260}
thickness={6}
normalizedValue={normalizedValue}
roundness={0.3}
style={{ fill: "currentColor" }}
/>
<LinearCursor
cx={50}
cy={150}
length={260}
normalizedValue={normalizedValue}
width={12}
aspectRatio={1.2}
roundness={0.5}
style={{ fill: "currentColor", stroke: "white", strokeWidth: 2 }}
/>
</g>
);
}
CustomSliderView.viewBox = { width: 100, height: 300 };
CustomSliderView.labelHeightUnits = 15;
CustomSliderView.interaction = {
mode: "both",
direction: "vertical",
};
export default function CompleteCustomSliderExample() {
const [value, setValue] = useState(0.5);
return (
<ContinuousControl
view={CustomSliderView}
value={value}
onChange={(e) => setValue(e.value)}
min={0}
max={1}
label="Custom Slider"
/>
);
}Rotation Behavior
All vector primitives use consistent rotation semantics:
- Positive rotation: Counter-clockwise (left)
- Negative rotation: Clockwise (right)
For horizontal orientation (vertical strip rotated to horizontal), use rotation={-90} or rotation={270}:
<LinearStrip
cx={150}
cy={50}
length={260}
thickness={6}
rotation={-90} // Rotate to horizontal
/>Center Content
Center content is text or icons displayed in the middle of a radial or linear control (e.g. current value and unit). The control primitive renders it as an HTML overlay above the SVG view, not inside the view component.
Example: a minimal knob with the current value and unit in the center. The view draws only the ring; the value and "dB" are passed as children to ContinuousControl:
import { useState } from "react";
import {
ContinuousControl,
ValueRing,
type ControlComponentViewProps,
type ControlComponent,
} from "@cutoff/audio-ui-react";
function SimpleKnobView({ normalizedValue }: ControlComponentViewProps) {
return (
<g>
<ValueRing
cx={50}
cy={50}
radius={45}
normalizedValue={normalizedValue}
thickness={4}
openness={90}
fgArcStyle={{ stroke: "currentColor" }}
bgArcStyle={{ stroke: "rgba(0,0,0,0.2)" }}
/>
</g>
);
}
SimpleKnobView.viewBox = { width: 100, height: 100 };
SimpleKnobView.labelHeightUnits = 10;
SimpleKnobView.interaction = { mode: "both", direction: "circular" };
export default function KnobWithCenterContent() {
const [value, setValue] = useState(-12);
const formattedValue = `${value.toFixed(1)}`;
return (
<ContinuousControl
view={SimpleKnobView as ControlComponent}
value={value}
onChange={(e) => setValue(e.value)}
min={-60}
max={6}
unit="dB"
scale="log"
label="Gain"
>
<div style={{ fontSize: "20px", fontWeight: 700 }}>{formattedValue}</div>
<div style={{ fontSize: "10px", opacity: 0.6 }}>dB</div>
</ContinuousControl>
);
}Important:
Do not render center content (text, icons) inside the SVG view component. Pass it as children (or htmlOverlay) to the control primitive so it is rendered as an HTML overlay for Safari compatibility. For the htmlOverlay prop and full API details, see ContinuousControl – Center Content.
Performance Considerations
- Memoization: Primitives are memoized with
React.memo - Single-path rendering: TickRing uses optimized single-path rendering when possible
- GPU acceleration: CSS transforms and SVG rendering are hardware-accelerated
- DOM optimization: Avoid unnecessary
<g>wrappers for single children
Next Steps
- Explore ContinuousControl Primitive for building custom continuous controls
- Check out DiscreteControl Primitive for custom discrete controls
- Learn about BooleanControl Primitive for custom boolean controls