diff --git a/README.md b/README.md
index 6909549..40376d0 100644
--- a/README.md
+++ b/README.md
@@ -27,16 +27,18 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
```
-- **Feel**: grab-and-rotate rotary tracking (rc-knob style), or relative vertical /
- horizontal drag (DAW plugin style), or absolute track dragging for faders. Scroll-wheel
- nudge, full keyboard support, Shift for fine control, double-click reset, touch via
- pointer capture, proper `role="slider"` accessibility.
+- **Feel**: grab-and-rotate rotary tracking (rc-knob style) with pickup/relative modes,
+ or relative vertical / horizontal drag (DAW plugin style), or absolute track dragging
+ for faders. Scroll-wheel nudge, full keyboard support, Shift+scroll for fine control,
+ double-click reset, touch via pointer capture, proper `role="slider"` accessibility.
- **Numbers**: any range (negative, fractional, huge), decimal `step` with float-drift-safe
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
power / custom tapers.
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
- `SteppedKnob`, `Fader`, `LEDFader`, `SegmentDisplay` β every color themeable per instance.
-- **Composable**: `` + primitives (`Arc`, `Pointer`, `Ticks`, `Face`, `KnobValue`,
- `KnobLabel`) for custom designs, or go fully headless with `useKnob`.
+ `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `XYPad`,
+ `PushButton`, `SegmentDisplay`, `AlphaDisplay` β every color themeable per instance.
+- **Composable**: `` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
+ `KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
+ `useKnob`.
See `packages/dreamknob/README.md` and the docs app for the full API.
diff --git a/apps/docs/src/App.tsx b/apps/docs/src/App.tsx
index dc47f1c..4610a69 100644
--- a/apps/docs/src/App.tsx
+++ b/apps/docs/src/App.tsx
@@ -1,9 +1,12 @@
import React from 'react';
+import { AdvancedGuide } from './sections/AdvancedGuide';
import { ApiDocs } from './sections/ApiDocs';
import { Console } from './sections/Console';
import { Gallery } from './sections/Gallery';
+import { GettingStarted } from './sections/GettingStarted';
import { Hero } from './sections/Hero';
import { Playground } from './sections/Playground';
+import { Synth } from './sections/Synth';
const Logo: React.FC = () => (
@@ -21,17 +24,22 @@ export const App: React.FC = () => (
dreamknob
+
+
+
diff --git a/apps/docs/src/sections/AdvancedGuide.tsx b/apps/docs/src/sections/AdvancedGuide.tsx
new file mode 100644
index 0000000..6a4d2b6
--- /dev/null
+++ b/apps/docs/src/sections/AdvancedGuide.tsx
@@ -0,0 +1,444 @@
+import React from 'react';
+import {
+ AlphaDisplay,
+ Arc,
+ DreamknobProvider,
+ Fader,
+ FlatKnob,
+ Knob,
+ KnobValue,
+ Meter,
+ Pointer,
+ PushButton,
+ useKnob,
+} from 'dreamknob';
+import { CodeBlock } from '../components/CodeBlock';
+
+const Step: React.FC<{ n: number; title: string; children: React.ReactNode }> = ({
+ n,
+ title,
+ children,
+}) => (
+
+
+
+ {String(n).padStart(2, '0')}
+
+ {title}
+
+ {children}
+
+);
+
+// ---------------------------------------------------------------------------
+// The tutorial's finished product β a live channel strip with undo.
+// ---------------------------------------------------------------------------
+
+const ZONES = [
+ { upTo: 0.72, color: '#3df2ad' },
+ { upTo: 0.9, color: '#ffd23e' },
+ { upTo: 1, color: '#ff4d6b' },
+];
+
+interface StripState {
+ gain: number;
+ pan: number;
+ drive: number;
+ level: number;
+ mute: boolean;
+}
+
+const INITIAL: StripState = { gain: 0, pan: 0, drive: 25, level: -10, mute: false };
+
+const LiveStrip: React.FC = () => {
+ const [state, setState] = React.useState
(INITIAL);
+ const [undoStack, setUndoStack] = React.useState([]);
+ const gestureStart = React.useRef(null);
+
+ // Undo pattern: snapshot on gesture start, push it on gesture end.
+ const beginGesture = () => {
+ gestureStart.current = state;
+ };
+ const endGesture = () => {
+ if (gestureStart.current) {
+ const snap = gestureStart.current;
+ setUndoStack(s => [...s.slice(-19), snap]);
+ gestureStart.current = null;
+ }
+ };
+ const undo = () => {
+ setUndoStack(s => {
+ if (s.length === 0) return s;
+ setState(s[s.length - 1]);
+ return s.slice(0, -1);
+ });
+ };
+
+ const setPart = (key: K, v: StripState[K]) =>
+ setState(p => ({ ...p, [key]: v }));
+
+ // A fake signal for the meter: level + drive, dancing a little.
+ const [signal, setSignal] = React.useState(-60);
+ React.useEffect(() => {
+ let t = 0;
+ const id = setInterval(() => {
+ t += 0.13;
+ const base = state.mute ? -60 : state.level + state.drive * 0.1;
+ setSignal(base + Math.sin(t) * 2 + Math.random() * 3);
+ }, 90);
+ return () => clearInterval(id);
+ }, [state.level, state.drive, state.mute]);
+
+ const db = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)}`;
+
+ return (
+
+
+
setPart('gain', v)}
+ onChangeStart={beginGesture}
+ onChangeEnd={endGesture}
+ min={-24}
+ max={24}
+ step={0.5}
+ origin={0}
+ detents={[0]}
+ arcFrom="center"
+ editable
+ format={db}
+ label="Gain"
+ aria-label="Strip gain"
+ />
+ setPart('pan', v)}
+ onChangeStart={beginGesture}
+ onChangeEnd={endGesture}
+ min={-50}
+ max={50}
+ step={1}
+ origin={0}
+ detents={[0]}
+ arcFrom="center"
+ color="#4cc2ff"
+ format={v => (v === 0 ? 'C' : v < 0 ? `L${-v}` : `R${v}`)}
+ label="Pan"
+ aria-label="Strip pan"
+ />
+ setPart('drive', v)} onStart={beginGesture} onEnd={endGesture} />
+ setPart('level', v)}
+ onChangeStart={beginGesture}
+ onChangeEnd={endGesture}
+ min={-60}
+ max={12}
+ step={0.5}
+ origin={0}
+ editable
+ format={v => `${db(v)} dB`}
+ label="Level"
+ aria-label="Strip level"
+ />
+
+
+
+
{
+ beginGesture();
+ setPart('mute', m);
+ endGesture();
+ }}
+ color="#ff4d6b"
+ aria-label="Mute"
+ >
+ Mute
+
+
+ Undo ({undoStack.length})
+
+
+
+
+ );
+};
+
+/** Tutorial step 4's custom composed knob, used in the live strip. */
+const DriveKnob: React.FC<{
+ value: number;
+ onChange: (v: number) => void;
+ onStart: () => void;
+ onEnd: () => void;
+}> = ({ value, onChange, onStart, onEnd }) => (
+
+
+
+
+
+ Drive
+
+
+);
+
+// ---------------------------------------------------------------------------
+// A tiny headless example rendered live.
+// ---------------------------------------------------------------------------
+const HeadlessDemo: React.FC = () => {
+ const knob = useKnob({ min: 0, max: 11, step: 0.5, defaultValue: 11, 'aria-label': 'Volume (headless)' });
+ return (
+
+
+ ποΈ
+
+
+ {knob.value}
+
+
+ );
+};
+
+export const AdvancedGuide: React.FC = () => (
+
+
+
Advanced guide
+
Tutorial: build a channel strip
+
+ Eight steps from a blank file to a themed, undoable channel strip with a custom
+ composed knob and a live meter. The finished result is running at the bottom.
+
+
+
+
+ Wrap the strip in a provider so every control shares one accent, track, text
+ and font set. Individual props still override.
+
+
+ {/* controls go here */}
+
+ )
+}`}
+ />
+
+
+
+
+ A Β±24 dB trim should fill its arc from 0 dB , snap magnetically at 0
+ while dragging, and accept a typed value on double-click. Three props.
+
+ \`\${v > 0 ? '+' : ''}\${v.toFixed(1)}\`}
+ label="Gain" aria-label="Gain"
+/>`}
+ />
+
+
+
+
+ format owns the readout entirely β emit L20 / C / R20{' '}
+ while the value stays a clean number for your engine.
+
+ (v === 0 ? 'C' : v < 0 ? \`L\${-v}\` : \`R\${v}\`)}
+ label="Pan" aria-label="Pan"
+/>`}
+ />
+
+
+
+
+ When no skin fits, <Knob> wires the interaction and the
+ primitives draw. This drive knob uses a gradient arc that heats up toward red β
+ the colors stay anchored in place, only the sweep grows.
+
+
+
+
+
+`}
+ />
+
+
+
+
+ The fader is the control; the Meter is the display. Feed the meter
+ your engine's level (here we fake one) β peakHold keeps the
+ loudest recent segment lit.
+
+ \`\${v.toFixed(1)} dB\`}
+ label="Level" aria-label="Level"
+/>
+ `}
+ />
+
+
+
+
+ Mute
+
+ `}
+ />
+
+
+
+
+ onChangeStart/onChangeEnd bracket every gesture β
+ drag, wheel burst, key press, reset. Snapshot on start, push on end: one undo
+ entry per gesture instead of one per pixel. meta.source tells you
+ which input caused it if you need finer policy.
+
+ { gestureStart.current = state }
+const endGesture = () => {
+ if (gestureStart.current) {
+ pushUndo(gestureStart.current)
+ gestureStart.current = null
+ }
+}
+
+ setState(s => ({ ...s, gain: v }))}
+ onChangeStart={beginGesture}
+ onChangeEnd={endGesture}
+ ...
+/>`}
+ />
+
+
+
+
+ useKnob gives you the state and the event bindings; you bring the
+ DOM. Everything β rotary drag, wheel, keyboard, Escape, a11y β still works.
+ This emoji is a fully functional knob:
+
+
+ ποΈ
+ {knob.value}
+ `}
+ />
+
+
+
+
+
+ The finished strip
+
+ Everything above, assembled β themed, undoable (try dragging then hitting Undo),
+ with a live meter. All state lives in one plain React object.
+
+
+
+
+);
diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx
index 12d3dbb..ff391c8 100644
--- a/apps/docs/src/sections/ApiDocs.tsx
+++ b/apps/docs/src/sections/ApiDocs.tsx
@@ -36,8 +36,9 @@ export const ApiDocs: React.FC = () => (
API reference
Everything is built on one interaction core. The props below are shared by{' '}
- useKnob, <Knob>, every prebuilt skin and{' '}
- <Fader>.
+ useKnob, <Knob>, every prebuilt knob skin and the
+ faders. Rows marked β are component-level (handled by the wrappers, not by{' '}
+ useKnob).
Core value & interaction props
@@ -46,7 +47,7 @@ export const ApiDocs: React.FC = () => (
-
+
@@ -54,24 +55,25 @@ export const ApiDocs: React.FC = () => (
-
+
-
+
-
+
-
+
-
-
+
+
+
@@ -80,9 +82,9 @@ export const ApiDocs: React.FC = () => (
β/β and β/β step Β· Shift+arrows jump 10Γ
Β· PageUp/PageDown move 10% Β· Home/End jump to min/max Β·
Esc cancels a drag and restores the start value Β· scroll wheel nudges
- (Shift = fine) Β· double-click resets (or opens the editor when{' '}
- editable) Β· touch works via pointer capture Β· every control shows a
- keyboard-only focus ring.
+ (Shift = fine on continuous values; snapped controls always move whole steps) Β·
+ double-click resets (or opens the editor when editable) Β· touch works
+ via pointer capture Β· every control shows a keyboard-only focus ring.
Prebuilt skins
@@ -104,12 +106,16 @@ export const ApiDocs: React.FC = () => (
- All skins also take label, showValue, unit,{' '}
- format, className, style, name{' '}
- plus every core prop above, and forward a ref to their root element. Inside the
- SVG, parts carry data-part attributes (arc,{' '}
- track, pointer, ticks, face,{' '}
- value, label) and the root exposes{' '}
+ Every knob skin and fader also takes label, showValue,{' '}
+ unit, format, className,{' '}
+ style, name plus every core prop above, and forwards a
+ ref to its root element (VintageKnob has no readout, so it omits{' '}
+ showValue/unit/format; the display
+ components Meter/SegmentDisplay/AlphaDisplay{' '}
+ have their own smaller prop sets). Primitive-drawn parts carry{' '}
+ data-part attributes (arc, track,{' '}
+ pointer, ticks, face, value,{' '}
+ label) and every interactive root exposes{' '}
data-dragging/data-disabled/data-focus-visible{' '}
β style any state or part with plain CSS. <Arc> additionally
accepts gradient (position-anchored color stops).
diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx
index 5782530..4564d20 100644
--- a/apps/docs/src/sections/Gallery.tsx
+++ b/apps/docs/src/sections/Gallery.tsx
@@ -165,9 +165,9 @@ export const Gallery: React.FC = () => (
Gallery
Every style in the studio
- Nine prebuilt skins β every color themeable per instance. All of them share the
- same interaction core: rotary or drag adjustment, scroll-wheel nudge, keyboard,
- fine control and double-click reset.
+ A full rack of prebuilt controls β every color themeable per instance. All of
+ them share the same interaction core: rotary or drag adjustment, scroll-wheel
+ nudge, keyboard, fine control and double-click reset.
@@ -341,7 +341,7 @@ export const Gallery: React.FC = () => (
-
+
diff --git a/apps/docs/src/sections/GettingStarted.tsx b/apps/docs/src/sections/GettingStarted.tsx
new file mode 100644
index 0000000..27edaee
--- /dev/null
+++ b/apps/docs/src/sections/GettingStarted.tsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import { FlatKnob, logTaper } from 'dreamknob';
+import { CodeBlock } from '../components/CodeBlock';
+
+const Step: React.FC<{ n: number; title: string; children: React.ReactNode }> = ({
+ n,
+ title,
+ children,
+}) => (
+
+
+
+ {String(n).padStart(2, '0')}
+
+ {title}
+
+ {children}
+
+);
+
+export const GettingStarted: React.FC = () => (
+
+
+
Getting started
+
Your first knob in five minutes
+
+ Everything below is copy-pasteable. The only rule to remember: every control needs
+ an aria-label (or a visible label wired via aria-labelledby).
+
+
+
+ = 18, react-dom >= 18`} />
+
+ Zero runtime dependencies, ESM + CJS, full TypeScript types. No CSS file to
+ import β everything is SVG and inline styles.
+
+
+
+
+
+ Pass defaultValue and the knob manages its own state.{' '}
+ defaultValue is also the double-click reset target.
+
+ `}
+ />
+
+
+
+
+
+
+
+ For app state, pass value + onChange. The second
+ argument tells you what caused the change β useful later for undo/automation.
+
+ setGain(v)} // meta.source: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'
+ min={-60} max={12} step={0.5}
+ unit=" dB"
+ aria-label="Gain"
+/>`}
+ />
+
+
+
+
+ The default is grab-and-rotate (the pointer angle drives the value). Prefer the
+ up/down plugin feel, or MIDI-style pickup? One prop each. Users always get
+ scroll-wheel, arrow keys, Shift-fine, Escape-cancel and double-click reset for
+ free.
+
+ // drag up/down like a plugin
+ // never jumps when grabbed off-position
+ // angle deltas, no jump either`}
+ />
+
+
+
+
+ step snaps and sets display precision (float-safe β no{' '}
+ 0.30000000000000004). For audio ranges, use a taper:{' '}
+ logTaper gives equal ratios per turn, so 20 Hzβ20 kHz feels right.
+ format controls display without touching the emitted value.
+
+ 0 for log
+ defaultValue={440}
+ decimals={0}
+ format={v => v >= 1000 ? \`\${(v / 1000).toFixed(1)}k\` : \`\${Math.round(v)}\`}
+ label="Freq"
+ aria-label="Frequency"
+/>`}
+ />
+
+ (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
+ label="Freq"
+ aria-label="Frequency example"
+ />
+
+
+
+
+
+ {/* every knob/fader/meter below inherits the tokens */}
+`}
+ />
+
+ Use base="light" on light backgrounds. Per-instance color props
+ always win over the theme.
+
+
+
+
+
+ β Always pass aria-label. β‘ logTaper needs{' '}
+ min > 0 (it falls back to linear otherwise). β’ Controlled mode
+ means you own the value β if you don't call setState in{' '}
+ onChange, the knob won't move. β£ Hovering captures the scroll
+ wheel by default; pass wheelRequiresFocus if your UI scrolls. β€
+ The knob is a div, not an input β use name="β¦" if you
+ need it in a plain <form> post.
+
+
+
+
+);
diff --git a/apps/docs/src/sections/Hero.tsx b/apps/docs/src/sections/Hero.tsx
index e6286a0..84cc66a 100644
--- a/apps/docs/src/sections/Hero.tsx
+++ b/apps/docs/src/sections/Hero.tsx
@@ -64,8 +64,8 @@ export const Hero: React.FC = () => {
/>
- drag to rotate Β· scroll to nudge Β· β§ for fine control Β· double-click to
- reset Β· arrow keys work too
+ drag to rotate Β· scroll to nudge Β· β§ +scroll for fine control Β·
+ double-click to reset Β· arrow keys work too
diff --git a/apps/docs/src/sections/Playground.tsx b/apps/docs/src/sections/Playground.tsx
index 00e73a2..0cd1a69 100644
--- a/apps/docs/src/sections/Playground.tsx
+++ b/apps/docs/src/sections/Playground.tsx
@@ -11,10 +11,22 @@ import {
VintageKnob,
logTaper,
powTaper,
- type InteractionMode,
} from 'dreamknob';
import { CodeBlock } from '../components/CodeBlock';
+/** Playground "drag feel" β maps to interaction + rotaryMode core props. */
+type Feel = 'rotary' | 'pickup' | 'relative' | 'vertical' | 'horizontal' | 'both';
+
+const feelToProps = (feel: Feel) => ({
+ interaction: (feel === 'vertical' || feel === 'horizontal' || feel === 'both'
+ ? feel
+ : 'rotary') as 'rotary' | 'vertical' | 'horizontal' | 'both',
+ rotaryMode: (feel === 'pickup' || feel === 'relative' ? feel : 'absolute') as
+ | 'absolute'
+ | 'pickup'
+ | 'relative',
+});
+
type SkinId =
| 'flat'
| 'metal'
@@ -44,7 +56,7 @@ interface Config {
min: number;
max: number;
step: number;
- interaction: InteractionMode;
+ feel: Feel;
angleRange: number;
taper: 'linear' | 'log' | 'pow2';
color: string;
@@ -61,7 +73,7 @@ const DEFAULTS: Config = {
min: 0,
max: 100,
step: 0.5,
- interaction: 'rotary',
+ feel: 'rotary',
angleRange: 270,
taper: 'linear',
color: '#4cc2ff',
@@ -73,8 +85,10 @@ const DEFAULTS: Config = {
};
const buildCode = (c: Config, controlled: boolean): string => {
+ const linear = c.skin === 'fader' || c.skin === 'ledfader';
+ const noReadout = c.skin === 'vintage';
const props: string[] = [];
- if (c.skin === 'fader' || c.skin === 'ledfader') props.push(`length={${c.size * 1.4}}`);
+ if (linear) props.push(`length={${c.size * 1.4}}`);
else props.push(`size={${c.size}}`);
if (controlled) props.push('value={value}', 'onChange={setValue}');
else props.push(`defaultValue={${(c.min + c.max) / 2}}`);
@@ -83,12 +97,18 @@ const buildCode = (c: Config, controlled: boolean): string => {
if (c.step > 0) props.push(`step={${c.step}}`);
if (c.taper === 'log') props.push('taper={logTaper}');
if (c.taper === 'pow2') props.push('taper={powTaper(2)}');
- if (c.interaction !== 'rotary') props.push(`interaction="${c.interaction}"`);
- if (c.angleRange !== 270) props.push(`angleRange={${c.angleRange}}`);
+ // Linear controls own their interaction and geometry β those props don't apply.
+ if (!linear) {
+ const feel = feelToProps(c.feel);
+ if (feel.interaction !== 'rotary') props.push(`interaction="${feel.interaction}"`);
+ if (feel.rotaryMode !== 'absolute') props.push(`rotaryMode="${feel.rotaryMode}"`);
+ if (c.angleRange !== 270) props.push(`angleRange={${c.angleRange}}`);
+ }
props.push(`color="${c.color}"`);
if (c.label) props.push(`label="${c.label}"`);
- if (c.unit) props.push(`unit="${c.unit}"`);
- if (c.editable) props.push('editable');
+ if (c.unit && !noReadout) props.push(`unit="${c.unit}"`);
+ if (!c.showValue && !noReadout) props.push('showValue={false}');
+ if (c.editable && !noReadout) props.push('editable');
if (c.skin === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
if (c.skin === 'stepped') props.push(`steps={5}`);
const name = SKINS[c.skin];
@@ -112,6 +132,7 @@ export const Playground: React.FC = () => {
const taper = cfg.taper === 'log' ? logTaper : cfg.taper === 'pow2' ? powTaper(2) : undefined;
const logInvalid = cfg.taper === 'log' && cfg.min <= 0;
+ const linear = cfg.skin === 'fader' || cfg.skin === 'ledfader';
const common = {
value,
@@ -121,7 +142,7 @@ export const Playground: React.FC = () => {
max: cfg.max,
step: cfg.step > 0 ? cfg.step : undefined,
taper: logInvalid ? undefined : taper,
- interaction: cfg.interaction,
+ ...feelToProps(cfg.feel),
angleRange: cfg.angleRange,
color: cfg.color,
label: cfg.label || undefined,
@@ -218,12 +239,15 @@ export const Playground: React.FC = () => {
-
Drag feel
+
Drag feel {linear && 'Β· n/a for faders'}
set('interaction', e.target.value as InteractionMode)}
+ value={cfg.feel}
+ disabled={linear}
+ onChange={e => set('feel', e.target.value as Feel)}
>
rotary (grab & turn)
+ rotary Β· pickup (no jump)
+ rotary Β· relative
vertical drag
horizontal drag
vertical + horizontal
diff --git a/apps/docs/src/sections/Synth.tsx b/apps/docs/src/sections/Synth.tsx
new file mode 100644
index 0000000..4d23592
--- /dev/null
+++ b/apps/docs/src/sections/Synth.tsx
@@ -0,0 +1,330 @@
+import React from 'react';
+import {
+ AlphaDisplay,
+ Fader,
+ FlatKnob,
+ LEDKnob,
+ Meter,
+ NeonKnob,
+ PushButton,
+ RubberKnob,
+ SteppedKnob,
+ logTaper,
+ powTaper,
+} from 'dreamknob';
+
+const NOTES = [
+ { name: 'C3', freq: 130.81 },
+ { name: 'D3', freq: 146.83 },
+ { name: 'E3', freq: 164.81 },
+ { name: 'G3', freq: 196.0 },
+ { name: 'A3', freq: 220.0 },
+ { name: 'C4', freq: 261.63 },
+ { name: 'D4', freq: 293.66 },
+ { name: 'E4', freq: 329.63 },
+];
+
+const WAVES: OscillatorType[] = ['sawtooth', 'square', 'triangle'];
+
+interface Params {
+ wave: number;
+ cutoff: number;
+ res: number;
+ attack: number; // ms
+ release: number; // ms
+ lfoRate: number;
+ lfoDepth: number;
+ master: number; // dB
+}
+
+interface Engine {
+ ctx: AudioContext;
+ osc: OscillatorNode;
+ env: GainNode;
+ filter: BiquadFilterNode;
+ lfo: OscillatorNode;
+ lfoGain: GainNode;
+ master: GainNode;
+ analyser: AnalyserNode;
+}
+
+export const Synth: React.FC = () => {
+ const [powered, setPowered] = React.useState(false);
+ const [params, setParams] = React.useState({
+ wave: 0,
+ cutoff: 1200,
+ res: 6,
+ attack: 12,
+ release: 260,
+ lfoRate: 4,
+ lfoDepth: 15,
+ master: -12,
+ });
+ const [note, setNote] = React.useState(null);
+ const [meterDb, setMeterDb] = React.useState(-60);
+ const engine = React.useRef(null);
+ const paramsRef = React.useRef(params);
+ paramsRef.current = params;
+
+ const set = (key: K, v: Params[K]) =>
+ setParams(p => ({ ...p, [key]: v }));
+
+ // Build / tear down the audio graph with power.
+ React.useEffect(() => {
+ if (!powered) return;
+ const ctx = new AudioContext();
+ const p = paramsRef.current;
+ const osc = ctx.createOscillator();
+ osc.type = WAVES[p.wave];
+ osc.frequency.value = 220;
+ const env = ctx.createGain();
+ env.gain.value = 0;
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.value = p.cutoff;
+ filter.Q.value = p.res;
+ const lfo = ctx.createOscillator();
+ lfo.frequency.value = p.lfoRate;
+ const lfoGain = ctx.createGain();
+ lfoGain.gain.value = p.lfoDepth * 12; // depth% -> Hz swing on the cutoff
+ const master = ctx.createGain();
+ master.gain.value = Math.pow(10, p.master / 20);
+ const analyser = ctx.createAnalyser();
+ analyser.fftSize = 1024;
+
+ osc.connect(env);
+ env.connect(filter);
+ filter.connect(master);
+ master.connect(analyser);
+ analyser.connect(ctx.destination);
+ lfo.connect(lfoGain);
+ lfoGain.connect(filter.frequency);
+ osc.start();
+ lfo.start();
+ engine.current = { ctx, osc, env, filter, lfo, lfoGain, master, analyser };
+
+ // Meter: RMS of the output, throttled to ~25 fps.
+ const buf = new Float32Array(analyser.fftSize);
+ let raf = 0;
+ let last = 0;
+ const tick = (t: number) => {
+ raf = requestAnimationFrame(tick);
+ if (t - last < 40) return;
+ last = t;
+ analyser.getFloatTimeDomainData(buf);
+ let sum = 0;
+ for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
+ const rms = Math.sqrt(sum / buf.length);
+ setMeterDb(rms > 0 ? Math.max(-60, 20 * Math.log10(rms)) : -60);
+ };
+ raf = requestAnimationFrame(tick);
+
+ return () => {
+ cancelAnimationFrame(raf);
+ engine.current = null;
+ void ctx.close();
+ setMeterDb(-60);
+ setNote(null);
+ };
+ }, [powered]);
+
+ // Live parameter updates.
+ React.useEffect(() => {
+ const e = engine.current;
+ if (!e) return;
+ const t = e.ctx.currentTime;
+ e.osc.type = WAVES[params.wave];
+ e.filter.frequency.setTargetAtTime(params.cutoff, t, 0.015);
+ e.filter.Q.setTargetAtTime(params.res, t, 0.015);
+ e.lfo.frequency.setTargetAtTime(params.lfoRate, t, 0.015);
+ e.lfoGain.gain.setTargetAtTime(params.lfoDepth * 12, t, 0.015);
+ e.master.gain.setTargetAtTime(Math.pow(10, params.master / 20), t, 0.015);
+ }, [params]);
+
+ const noteOn = (freq: number, name: string) => {
+ const e = engine.current;
+ if (!e) return;
+ const t = e.ctx.currentTime;
+ e.osc.frequency.setTargetAtTime(freq, t, 0.002);
+ e.env.gain.cancelScheduledValues(t);
+ e.env.gain.setValueAtTime(e.env.gain.value, t);
+ e.env.gain.linearRampToValueAtTime(0.5, t + paramsRef.current.attack / 1000);
+ setNote(name);
+ };
+
+ const noteOff = () => {
+ const e = engine.current;
+ if (!e) return;
+ const t = e.ctx.currentTime;
+ e.env.gain.cancelScheduledValues(t);
+ e.env.gain.setValueAtTime(e.env.gain.value, t);
+ e.env.gain.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000);
+ setNote(null);
+ };
+
+ const hz = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`);
+
+ return (
+
+
+
Play it
+
A real synth, all dreamknob controls
+
+ Every control drives an actual Web Audio graph β power it on, hold the pads, and
+ sweep the filter. The meter reads the analyser; the display shows the note.
+
+
+
+
+ Power
+
+
+
set('wave', v)}
+ color="#ffd23e"
+ label="Osc"
+ aria-label="Oscillator wave"
+ />
+ set('cutoff', v)}
+ showValue
+ format={hz}
+ color="#ff9640"
+ label="Cutoff"
+ aria-label="Filter cutoff"
+ />
+ set('res', v)}
+ color="#ff9640"
+ label="Res"
+ aria-label="Filter resonance"
+ />
+ set('attack', v)}
+ unit="ms"
+ color="#4cc2ff"
+ label="Attack"
+ aria-label="Envelope attack"
+ />
+ set('release', v)}
+ unit="ms"
+ color="#4cc2ff"
+ label="Release"
+ aria-label="Envelope release"
+ />
+ set('lfoRate', v)}
+ digits={4}
+ displayDecimals={1}
+ color="#3df2ad"
+ label="LFO Hz"
+ aria-label="LFO rate"
+ />
+ set('lfoDepth', v)}
+ color="#e44cff"
+ label="Depth"
+ aria-label="LFO depth"
+ />
+ set('master', v)}
+ origin={-60}
+ editable
+ unit=" dB"
+ label="Master"
+ aria-label="Master volume"
+ />
+
+
+
+ {NOTES.map(n => (
+
(on ? noteOn(n.freq, n.name) : noteOff())}
+ color="#3df2ad"
+ size={44}
+ disabled={!powered}
+ aria-label={`Play ${n.name}`}
+ >
+ {n.name}
+
+ ))}
+
+
+ power on Β· hold a pad Β· sweep the cutoff while a note rings
+
+
+
+
+ );
+};
diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md
index febe38e..b4f67a1 100644
--- a/packages/dreamknob/README.md
+++ b/packages/dreamknob/README.md
@@ -41,7 +41,7 @@ Every component shares one core:
| Gesture | Behaviour |
| --- | --- |
| Drag | `rotary` (grab & turn, follows the pointer angle), `vertical`, `horizontal`, `both`, or absolute `track-*` (faders) |
-| Scroll wheel | one `step` per notch, `Shift` = fine |
+| Scroll wheel | one `step` per notch (else 1% of range); `Shift` = fine on continuous values β snapped controls always move whole steps |
| `β β / β β` | Β± one step (`Shift` = 10Γ) |
| `PageUp/PageDown` | Β± 10 % of range |
| `Home/End` | min / max |
@@ -49,7 +49,8 @@ Every component shares one core:
| `Escape` | cancel an in-flight drag, restoring the start value |
| Touch / pen | pointer capture, `touch-action: none` |
-All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
+All knobs render `role="slider"` with `aria-valuemin/max/now` (and `aria-valuetext`
+when you provide `getAriaValueText`).
## Value handling
@@ -76,8 +77,10 @@ All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
- `animateChanges` β tween the pointer on programmatic changes (preset loads);
gestures never animate and `prefers-reduced-motion` is honored.
- `valueBubble` β floating readout above the control while dragging.
-- `name` β hidden form input for plain `
{label && (
diff --git a/packages/dreamknob/src/skins/ImageKnob.tsx b/packages/dreamknob/src/skins/ImageKnob.tsx
index 8edadc5..811189f 100644
--- a/packages/dreamknob/src/skins/ImageKnob.tsx
+++ b/packages/dreamknob/src/skins/ImageKnob.tsx
@@ -27,6 +27,8 @@ export interface ImageKnobProps extends KnobCoreProps {
/** Show a floating value bubble above the knob while dragging. */
valueBubble?: boolean;
bubbleFormat?: (value: number) => string;
+ /** Render a hidden form input carrying the current value. */
+ name?: string;
/** Extra styles for the image layer (e.g. filter, borderRadius). */
imageStyle?: React.CSSProperties;
className?: string;
@@ -53,6 +55,7 @@ export const ImageKnob = React.forwardRef(functi
parseValue,
valueBubble = false,
bubbleFormat,
+ name,
imageStyle,
className,
style,
@@ -158,6 +161,7 @@ export const ImageKnob = React.forwardRef(functi
{(bubbleFormat ?? (() => text))(knob.value)}
)}
+ {name && }
{showValue && (
diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx
index 9d7add1..a57f78a 100644
--- a/packages/dreamknob/src/skins/LEDFader.tsx
+++ b/packages/dreamknob/src/skins/LEDFader.tsx
@@ -45,6 +45,8 @@ export interface LEDFaderProps extends Omit {
editable?: boolean;
/** Custom parser for typed input. */
parseValue?: (text: string) => number | null;
+ /** Render a hidden form input carrying the current value. */
+ name?: string;
className?: string;
style?: React.CSSProperties;
}
@@ -68,6 +70,7 @@ export const LEDFader = React.forwardRef(function
labelColor,
editable = false,
parseValue,
+ name,
className,
style,
...core
@@ -231,6 +234,7 @@ export const LEDFader = React.forwardRef(function
/>
{leds}
+ {name && }
{label && (
diff --git a/packages/dreamknob/src/skins/PushButton.tsx b/packages/dreamknob/src/skins/PushButton.tsx
index f6039da..005bacc 100644
--- a/packages/dreamknob/src/skins/PushButton.tsx
+++ b/packages/dreamknob/src/skins/PushButton.tsx
@@ -51,6 +51,7 @@ export const PushButton = React.forwardRef(
const accent = color ?? theme.accent ?? '#4cc2ff';
const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed);
+ const [focusVisible, setFocusVisible] = React.useState(false);
const on = isControlled ? (pressed as boolean) : internal;
const set = (next: boolean) => {
@@ -85,7 +86,16 @@ export const PushButton = React.forwardRef(
aria-pressed={on}
aria-label={aria['aria-label']}
data-pressed={on ? '' : undefined}
+ data-focus-visible={focusVisible ? '' : undefined}
className={className}
+ onFocus={e => {
+ try {
+ setFocusVisible(e.currentTarget.matches(':focus-visible'));
+ } catch {
+ setFocusVisible(true);
+ }
+ }}
+ onBlur={() => setFocusVisible(false)}
{...momentaryProps}
style={{
position: 'relative',
@@ -103,9 +113,12 @@ export const PushButton = React.forwardRef(
background: on
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
- boxShadow: on
- ? 'inset 0 2px 5px rgba(0,0,0,0.65)'
- : 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)',
+ outline: 'none',
+ boxShadow: `${
+ on
+ ? 'inset 0 2px 5px rgba(0,0,0,0.65)'
+ : 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)'
+ }${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
color: on ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.28),