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:

Hi-Fi Knob

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.

Radial primitives diagram

ValueRing

Renders a circular arc indicator showing value progress.

ValueRingBasic.tsx
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.

RotaryImageBasic.tsx
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.

RadialImageBasic.tsx
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.

TickRingBasic.tsx
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).

LabelRingBasic.tsx
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).

Linear primitives diagram

LinearStrip

Renders a rectangular strip (background track).

LinearStripBasic.tsx
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.

ValueStripBasic.tsx
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:

ValueStripBipolar.tsx
<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.

LinearCursorBasic.tsx
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:

LinearCursorImage.tsx
<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.

ImageBasic.tsx
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 imageHref for bitmap images or children for SVG (e.g. icons)
  • Optional transform for SVG transform attribute
  • style.color supports icon theming when using currentColor

RevealingPath

Reveals an SVG path from start to end based on a normalized value. Uses pathLength and stroke-dashoffset for performant fill animations.

RevealingPathBasic.tsx
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:

  • normalizedValue 0 = hidden, 1 = fully visible
  • Optional resolution for 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.

FilmstripImageBasic.tsx
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, frameCount define the strip layout
  • orientation: "vertical" (default) or "horizontal"
  • Optional frameRotation (degrees), invertValue for 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).

RadialHtmlOverlayBasic.tsx
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 children of the control primitive (HTML overlay outside SVG) instead of RadialHtmlOverlay for 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.

HifiKnobStep1.tsx
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).

HifiKnobStep2.tsx
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.

HifiKnobStep3.tsx
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.

HifiKnobStep4.tsx
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:

HifiKnobUsage.tsx
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

SliderStep1.tsx
function CustomSliderView({ normalizedValue }: ControlComponentViewProps) {
  return (
    <LinearStrip
      cx={50}
      cy={150}
      length={260}
      thickness={6}
      roundness={0.3}
    />
  );
}

Step 2: Value Fill

SliderStep2.tsx
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

SliderStep3.tsx
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

CompleteCustomSlider.tsx
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}:

HorizontalSlider.tsx
<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:

CenterContentExample.tsx
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