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

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

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

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

ViewComponentContract.tsx
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 defaults

View 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:

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

For building custom visualizations: