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/knobUsage
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 codeHide code
"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.
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue / onValueChange | number / number / (value) => void | — | Controlled and uncontrolled usage. |
| min / max / step | number / number / number | 0 / 100 / 1 | Range and step of the dial. |
| getValueText | (value) => string | — | Formats the announced value, e.g. degrees or decibels. |
| size | "md" | "lg" | "md" | Diameter of the recess. |
| disabled | boolean | false | Disables interaction. |
| Data attribute | Description |
|---|---|
| data-disabled | Present while disabled. |
Keyboard
| Keys | Action |
|---|---|
| ArrowUpArrowRight | Raises the value by one step. |
| ArrowDownArrowLeft | Lowers the value by one step. |
| ShiftArrowUp | Coarse move: ten steps at once. |
| PageUpPageDown | Jumps ten steps. |
| HomeEnd | Jumps to min or max. |
Source
View source — knob.tsxHide source — 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 };