Skip to content
EMBOSS
Docs menu

Knob

A rotary control machined into a recess — drag vertically to turn it, pro-audio style, or drive it entirely from the keyboard.

@emboss/knob

cutoff 64

Installation

pnpm dlx shadcn@latest add @emboss/knob

Usage

example.tsx
import { Knob } from "@/components/ui/knob";

export function Example() {
  return (
    <Knob defaultValue={64} min={0} max={127} aria-label="Filter cutoff" />
  );
}

Examples

The channel strip

Every hardware control on one plate: knob, fader, meter, trim stepper, width segments, and the power switch.

Show code
channel-strip.tsx
"use client";

import * as React from "react";
import { Knob } from "@/components/ui/knob";
import { Meter } from "@/components/ui/meter";
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/segmented-control";
import { Slider } from "@/components/ui/slider";
import { Stepper } from "@/components/ui/stepper";
import { Switch } from "@/components/ui/switch";

export default function ChannelStrip() {
  const [drive, setDrive] = React.useState(35);
  const [fader, setFader] = React.useState([72]);
  const level = Math.round((drive * 0.4 + (fader[0] ?? 0) * 0.6) * 0.95);

  return (
    <div className="flex w-full max-w-md flex-col gap-5 rounded-lg bg-surface-2 p-5 shadow-emboss-1">
      <div className="flex items-center justify-between">
        <p className="tracking-label font-mono text-label text-ink-faint uppercase text-engraved">
          CH 3 · Drum bus
        </p>
        <Switch aria-label="Channel on" defaultChecked />
      </div>
      <div className="flex items-end justify-between gap-4">
        <div className="flex flex-col items-center gap-1.5">
          <Knob
            value={drive}
            onValueChange={setDrive}
            aria-label="Drive"
            getValueText={(v) => `${v}%`}
          />
          <span className="tracking-label font-mono text-label text-ink-faint uppercase">
            Drive
          </span>
        </div>
        <div className="flex flex-1 flex-col gap-1.5">
          <Slider
            value={fader}
            onValueChange={(value) => {
              setFader(Array.isArray(value) ? value : [value]);
            }}
            aria-label="Fader"
          />
          <Meter value={level} aria-label="Channel level" />
        </div>
        <Stepper
          defaultValue={0}
          min={-24}
          max={24}
          aria-label="Trim, decibels"
        />
      </div>
      <SegmentedControl defaultValue="stereo" aria-label="Channel width">
        <SegmentedControlItem value="mono">Mono</SegmentedControlItem>
        <SegmentedControlItem value="stereo">Stereo</SegmentedControlItem>
        <SegmentedControlItem value="wide">Wide</SegmentedControlItem>
      </SegmentedControl>
    </div>
  );
}

API reference

Knob

A custom slider: role=slider with full value semantics. Always provide aria-label or aria-labelledby.

PropTypeDefaultDescription
value / defaultValue / onValueChangenumber / number / (value) => voidControlled and uncontrolled usage.
min / max / stepnumber / number / number0 / 100 / 1Range and step of the dial.
getValueText(value) => stringFormats the announced value, e.g. degrees or decibels.
size"md" | "lg""md"Diameter of the recess.
disabledbooleanfalseDisables interaction.
Data attributeDescription
data-disabledPresent while disabled.

Keyboard

KeysAction
ArrowUpArrowRightRaises the value by one step.
ArrowDownArrowLeftLowers the value by one step.
ShiftArrowUpCoarse move: ten steps at once.
PageUpPageDownJumps ten steps.
HomeEndJumps to min or max.

Source

View source — knob.tsx
knob.tsx
"use client";

import { motion, useReducedMotion, useSpring } from "motion/react";
import * as React from "react";
import { springs } from "@/lib/springs";
import { cn } from "@/lib/utils";

const SWEEP_DEGREES = 270;
const START_ANGLE = -135;
/** Vertical pixels of drag that cover the full range, pro-audio style. */
const DRAG_RANGE_PX = 200;

export interface KnobProps extends Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange" | "role"
> {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  disabled?: boolean;
  /** Formats the announced value, e.g. (v) => `${v} dB`. */
  getValueText?: (value: number) => string;
  size?: "md" | "lg";
}

function clamp(value: number, min: number, max: number) {
  return Math.min(max, Math.max(min, value));
}

function snap(value: number, min: number, step: number) {
  const steps = Math.round((value - min) / step);
  return min + steps * step;
}

/**
 * A rotary control machined into a recess. Drag vertically to turn it —
 * up raises, down lowers — or use the arrow keys; Shift makes coarse moves
 * and PageUp/PageDown jump by ten steps. The cap settles onto its new
 * angle with a sprung detent.
 *
 * @example
 * <Knob defaultValue={64} min={0} max={127} aria-label="Filter cutoff" />
 */
function Knob({
  className,
  value: valueProp,
  defaultValue,
  onValueChange,
  min = 0,
  max = 100,
  step = 1,
  disabled = false,
  getValueText,
  size = "md",
  onKeyDown,
  onPointerDown,
  ...props
}: KnobProps) {
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? min);
  const value = clamp(valueProp ?? internalValue, min, max);
  const reduceMotion = useReducedMotion() ?? false;

  const angle =
    START_ANGLE + ((value - min) / (max - min || 1)) * SWEEP_DEGREES;
  const springRotation = useSpring(angle, {
    stiffness: springs.settle.stiffness,
    damping: springs.settle.damping,
    mass: springs.settle.mass,
  });

  React.useEffect(() => {
    if (reduceMotion) springRotation.jump(angle);
    else springRotation.set(angle);
  }, [angle, reduceMotion, springRotation]);

  const dragState = React.useRef<{ startY: number; startValue: number } | null>(
    null,
  );

  const setValue = React.useCallback(
    (next: number) => {
      const clamped = clamp(snap(next, min, step), min, max);
      if (valueProp === undefined) setInternalValue(clamped);
      if (clamped !== value) onValueChange?.(clamped);
    },
    [max, min, onValueChange, step, value, valueProp],
  );

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerDown?.(event);
    if (disabled || event.button !== 0 || event.defaultPrevented) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    event.currentTarget.focus();
    dragState.current = { startY: event.clientY, startValue: value };
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const drag = dragState.current;
    if (!drag) return;
    const fine = event.shiftKey ? 0.25 : 1;
    const deltaValue =
      ((drag.startY - event.clientY) / DRAG_RANGE_PX) * (max - min) * fine;
    setValue(drag.startValue + deltaValue);
  };

  const handlePointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
    if (dragState.current) {
      event.currentTarget.releasePointerCapture(event.pointerId);
      dragState.current = null;
    }
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event);
    if (disabled || event.defaultPrevented) return;
    const coarse = step * 10;
    let next: number | null = null;
    switch (event.key) {
      case "ArrowUp":
      case "ArrowRight":
        next = value + (event.shiftKey ? coarse : step);
        break;
      case "ArrowDown":
      case "ArrowLeft":
        next = value - (event.shiftKey ? coarse : step);
        break;
      case "PageUp":
        next = value + coarse;
        break;
      case "PageDown":
        next = value - coarse;
        break;
      case "Home":
        next = min;
        break;
      case "End":
        next = max;
        break;
      default:
        return;
    }
    event.preventDefault();
    setValue(next);
  };

  return (
    <div
      data-slot="knob"
      role="slider"
      tabIndex={disabled ? -1 : 0}
      aria-valuemin={min}
      aria-valuemax={max}
      aria-valuenow={value}
      aria-valuetext={getValueText?.(value)}
      aria-disabled={disabled || undefined}
      data-disabled={disabled ? "" : undefined}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerEnd}
      onPointerCancel={handlePointerEnd}
      onKeyDown={handleKeyDown}
      className={cn(
        "relative inline-flex cursor-ns-resize touch-none items-center justify-center rounded-full bg-well shadow-deboss-2 focus-ring select-none data-disabled:pointer-events-none data-disabled:opacity-50",
        size === "md" ? "size-16 p-1.5" : "size-24 p-2",
        className,
      )}
      {...props}
    >
      <span
        aria-hidden
        className="absolute bottom-1 left-1/2 size-0.5 -translate-x-1/2 rounded-full bg-edge-strong"
      />
      <motion.span
        aria-hidden
        style={{ rotate: springRotation }}
        className="relative flex size-full items-start justify-center rounded-full bg-surface-2 shadow-emboss-2"
      >
        <span
          className={cn(
            "mt-1 w-0.5 rounded-full bg-accent",
            size === "md" ? "h-2.5" : "h-4",
          )}
        />
      </motion.span>
    </div>
  );
}

export { Knob };