diff --git a/CLAUDE.md b/CLAUDE.md
index ae787d3..ebf318a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,6 +43,8 @@ Fonts are NOT in the core stylesheet. `tokens.css` defines `--font-display/sans/
Exception — text inputs (`text-field/`): `TextField`/`TextArea`/`SearchField` animate letters in/out (osu!-lazer style). Native fields can't animate per-glyph, so the real element renders with transparent text (`modern-sk-field--animated`, caret stays visible) over a mirrored per-character `` overlay that plays the `modern-sk-char-in/out` keyframes; an LCS diff preserves letter identity so only inserted/removed glyphs animate. The overlay still derives all appearance from `modern-sk-*` classes/tokens — the only JS-set styles are the per-letter pin offset and scroll-sync transform. Pass `animated={false}` to opt out and render the plain native field.
+Exception — knob (`knob/`): a rotary circular slider (`Knob`), ported from the design handoff. Two visuals move at different speeds — the **dial** is bound 1:1 to the pointer while dragging (instant, continuous angle via inline `transform: rotate`), while the **gauge fill + value** snap to detents and *glide* between them (like the stepped Slider). It tracks two values: a continuous `visual` (drives the dial mid-drag) and the snapped `committed` (drives fill/ticks/aria); `dialValue = dragging ? visual : committed`, so on drag-release the dial settles to the detent. Three non-obvious constraints, all already tried-and-rejected — don't regress them: (1) the gauge **fill must be the full arc revealed via `strokeDashoffset`** set as a *plain number* — `pathLength=1` + CSS `var()`/`calc` silently collapsed to a full ring, and `transition: d` on a swept arc interpolates the endpoint as a straight chord and distorts the arc mid-glide; dashoffset eases along the true circle. (2) the dial transition is disabled via `.is-dragging` so the mouse-bound dial never lags. (3) wheel-to-change uses a **native non-passive `wheel` listener** (via `useEffect` + a ref-held handler) because React's synthetic `onWheel` is passive and `preventDefault` can't block page scroll. `animated` defaults `true` when `step > 0`.
+
### Styling system (`src/styles/`)
- `tokens.css` — single source of truth: color/type CSS custom properties (no font loading — see Fonts above). Every component reads from here.
diff --git a/src/App.tsx b/src/App.tsx
index 5e36c7a..1df7c9a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -30,6 +30,7 @@ import {
Dialog,
DialogClose,
IconButton,
+ Knob,
List,
MenuRow,
MenuSeparator,
@@ -248,6 +249,14 @@ const App = () => {
+
+
Knobs — circular sliders
+
+
+
+
+
+
diff --git a/src/components/knob/index.tsx b/src/components/knob/index.tsx
new file mode 100644
index 0000000..48f86d3
--- /dev/null
+++ b/src/components/knob/index.tsx
@@ -0,0 +1,253 @@
+import {
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ type CSSProperties,
+ type KeyboardEvent,
+ type PointerEvent,
+} from 'react';
+import { cx } from '../utils';
+
+type KnobAccent = 'lime' | 'ember';
+
+type KnobProps = {
+ /** Controlled value. */
+ value?: number;
+ /** Uncontrolled starting value. Defaults to `min`. */
+ defaultValue?: number;
+ /** Fires with the snapped value on every change. */
+ onValueChange?: (value: number) => void;
+ min?: number;
+ max?: number;
+ /** Snap increment. `0`/omitted = continuous (no detents, no ticks). */
+ step?: number;
+ /** Accent colour for the gauge fill + pointer. `'lime'` (default) or `'ember'`. */
+ accent?: KnobAccent;
+ /** Diameter in px. Drives every internal measurement via `--knob-size`. */
+ size?: number;
+ /**
+ * Glide the gauge fill + pointer between detents. The dial always tracks the
+ * pointer 1:1 while dragging; this only eases the *value* settle (keyboard,
+ * wheel, drag-release) — like the stepped Slider. Defaults to `true` when
+ * `step > 0`, `false` otherwise.
+ */
+ animated?: boolean;
+ disabled?: boolean;
+ className?: string;
+ id?: string;
+ 'aria-label'?: string;
+ 'aria-labelledby'?: string;
+};
+
+// Gauge geometry: 270° sweep starting at the 7-o'clock position. R is the arc
+// radius in the 100×100 SVG viewBox.
+const START = -135;
+const SWEEP = 270;
+const R = 43;
+
+const polar = (cx: number, cy: number, r: number, aDeg: number): [number, number] => {
+ const t = (aDeg * Math.PI) / 180;
+ return [cx + r * Math.sin(t), cy - r * Math.cos(t)];
+};
+
+const arcPath = (a0: number, a1: number) => {
+ if (a1 - a0 < 0.01) a1 = a0 + 0.01;
+ const [x0, y0] = polar(50, 50, R, a0);
+ const [x1, y1] = polar(50, 50, R, a1);
+ return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
+};
+
+const TRACK_PATH = arcPath(START, START + SWEEP);
+// Arc length of the gauge. The fill is the full arc revealed via dashoffset, so
+// the glide eases ALONG the circle (transitioning `d` interpolates endpoints in
+// a straight chord and distorts the arc mid-animation).
+const ARC_LEN = (R * SWEEP * Math.PI) / 180;
+
+export const Knob = ({
+ value: valueProp,
+ defaultValue,
+ onValueChange,
+ min = 0,
+ max = 100,
+ step = 0,
+ accent = 'lime',
+ size,
+ animated,
+ disabled = false,
+ className,
+ id,
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+}: KnobProps) => {
+ const span = max - min || 1;
+ const stepped = step > 0;
+ const isAnimated = animated !== undefined ? animated : stepped;
+
+ const clamp = (v: number) => Math.min(max, Math.max(min, v));
+ const snap = (v: number) =>
+ stepped ? clamp(Math.round((v - min) / step) * step + min) : clamp(v);
+
+ const isControlled = valueProp !== undefined;
+ const [internal, setInternal] = useState(() => snap(defaultValue ?? min));
+ const committed = isControlled ? snap(valueProp) : internal;
+
+ // Continuous, pointer-bound position for the dial; only meaningful mid-drag.
+ const [visual, setVisual] = useState(committed);
+ const [dragging, setDragging] = useState(false);
+
+ const elRef = useRef(null);
+ const a0 = useRef(0);
+ const v0 = useRef(0);
+
+ const reactId = useId();
+ const knobId = id ?? reactId;
+
+ // `continuous` keeps the dial on the raw pointer angle; otherwise the dial
+ // settles (and glides) to the snapped detent.
+ const commit = (raw: number, continuous: boolean) => {
+ const c = clamp(raw);
+ const snapped = snap(c);
+ setVisual(continuous ? c : snapped);
+ if (!isControlled) setInternal(snapped);
+ if (snapped !== committed) onValueChange?.(snapped);
+ };
+
+ const angleAt = (clientX: number, clientY: number) => {
+ const el = elRef.current;
+ if (!el) return 0;
+ const r = el.getBoundingClientRect();
+ return (
+ (Math.atan2(clientX - (r.left + r.width / 2), -(clientY - (r.top + r.height / 2))) * 180) /
+ Math.PI
+ );
+ };
+
+ const onPointerDown = (e: PointerEvent) => {
+ if (disabled) return;
+ e.preventDefault();
+ elRef.current?.focus();
+ elRef.current?.setPointerCapture(e.pointerId);
+ setDragging(true);
+ a0.current = angleAt(e.clientX, e.clientY);
+ v0.current = committed;
+ };
+
+ const onPointerMove = (e: PointerEvent) => {
+ if (!dragging) return;
+ let d = angleAt(e.clientX, e.clientY) - a0.current;
+ if (d > 180) d -= 360;
+ if (d < -180) d += 360;
+ commit(v0.current + (d / SWEEP) * span, true);
+ };
+
+ const endDrag = (e: PointerEvent) => {
+ if (!dragging) return;
+ elRef.current?.releasePointerCapture(e.pointerId);
+ setDragging(false);
+ };
+
+ // React's synthetic onWheel is passive, so preventDefault can't block page
+ // scroll — attach a native non-passive listener. A ref keeps it current
+ // without re-binding every render.
+ const wheelRef = useRef<(e: WheelEvent) => void>(() => {});
+ wheelRef.current = (e) => {
+ if (disabled) return;
+ e.preventDefault();
+ commit(committed - (step || span / 100) * Math.sign(e.deltaY), false);
+ };
+ useEffect(() => {
+ const el = elRef.current;
+ if (!el) return;
+ const handler = (e: WheelEvent) => wheelRef.current(e);
+ el.addEventListener('wheel', handler, { passive: false });
+ return () => el.removeEventListener('wheel', handler);
+ }, []);
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (disabled) return;
+ const big = step || span / 10;
+ const sm = step || span / 100;
+ const k = e.key;
+ if (k === 'ArrowUp' || k === 'ArrowRight') commit(committed + sm, false);
+ else if (k === 'ArrowDown' || k === 'ArrowLeft') commit(committed - sm, false);
+ else if (k === 'PageUp') commit(committed + big, false);
+ else if (k === 'PageDown') commit(committed - big, false);
+ else if (k === 'Home') commit(min, false);
+ else if (k === 'End') commit(max, false);
+ else return;
+ e.preventDefault();
+ };
+
+ const dialValue = dragging ? visual : committed;
+ const dialAngle = START + ((clamp(dialValue) - min) / span) * SWEEP;
+ const fillOffset = ARC_LEN * (1 - (committed - min) / span);
+ const valueNow = stepped ? committed : Math.round(committed);
+
+ const ticks = stepped
+ ? Array.from({ length: Math.round(span / step) + 1 }, (_, i) => {
+ const a = START + (i / Math.round(span / step)) * SWEEP;
+ const [x1, y1] = polar(50, 50, R + 6, a);
+ const [x2, y2] = polar(50, 50, R + 10.5, a);
+ const val = min + (i / Math.round(span / step)) * span;
+ return { x1, y1, x2, y2, on: val <= committed + 1e-6, key: i };
+ })
+ : [];
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/ui.tsx b/src/components/ui.tsx
index e9bbc53..44194ff 100644
--- a/src/components/ui.tsx
+++ b/src/components/ui.tsx
@@ -5,6 +5,7 @@ export * from './select';
export * from './selection';
export * from './segmented-control';
export * from './slider';
+export * from './knob';
export * from './tabs';
export * from './progress';
export * from './badge';
diff --git a/src/stories/Knob.stories.tsx b/src/stories/Knob.stories.tsx
new file mode 100644
index 0000000..26ee0db
--- /dev/null
+++ b/src/stories/Knob.stories.tsx
@@ -0,0 +1,81 @@
+import type { Meta, StoryObj } from 'storybook-react-rsbuild';
+import { useState } from 'react';
+import { Knob } from '../components/ui';
+
+const meta = {
+ title: 'Inputs/Knob',
+ component: Knob,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Skeuomorphic rotary control — a circular slider. Drag anywhere on the cap (relative angular drag, no jump-to-pointer), scroll to nudge, or focus and use the arrow keys. The dial tracks the pointer 1:1 while dragging; the gauge fill + value snap to detents and *glide* between them when `animated` (on by default for stepped knobs), exactly like the stepped Slider.',
+ },
+ },
+ },
+ args: { defaultValue: 62, min: 0, max: 100, step: 0, accent: 'lime' },
+ argTypes: {
+ defaultValue: { control: 'number', description: 'Uncontrolled starting value.' },
+ min: { control: 'number' },
+ max: { control: 'number' },
+ step: { control: 'number', description: '`0` = continuous; `> 0` adds detents + ticks.' },
+ accent: { control: 'inline-radio', options: ['lime', 'ember'] },
+ size: { control: 'number', description: 'Diameter in px (default 108).' },
+ animated: {
+ control: 'boolean',
+ description: 'Glide the fill/value between detents. Defaults to `true` when `step > 0`.',
+ },
+ disabled: { control: 'boolean' },
+ className: { control: 'text' },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Playground: Story = {};
+
+/** Continuous — the dial and gauge follow the pointer with no snapping. */
+export const Continuous: Story = {
+ args: { defaultValue: 62, step: 0 },
+};
+
+/** Stepped — the dial stays bound to the mouse while the fill/value snap and glide into detents. */
+export const Stepped: Story = {
+ args: { defaultValue: 3, min: 1, max: 5, step: 1 },
+};
+
+/** Ember accent variant. */
+export const Ember: Story = {
+ args: { defaultValue: 40, accent: 'ember' },
+};
+
+export const Disabled: Story = {
+ args: { defaultValue: 50, disabled: true },
+};
+
+const Readout = (args: React.ComponentProps) => {
+ const [v, setV] = useState(62);
+ return (
+