Initial release: dreamknob library + showcase

React + TypeScript knob/fader library with a headless interaction core
(rotary, relative-drag and track modes, wheel, keyboard, a11y), SVG
primitives, nine prebuilt skins, seven-segment display engine, and a
Vite showcase with gallery, live playground and API docs.
This commit is contained in:
Dreamodus 2026-07-12 14:26:52 -07:00
commit 60a02cb1db
46 changed files with 6186 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
dist/
*.local
.DS_Store
*.tsbuildinfo
coverage/

42
README.md Normal file
View file

@ -0,0 +1,42 @@
# dreamknob
A feature-full, studio-grade **React + TypeScript knob & fader library** with a headless
interaction core — plus a showcase/documentation site.
```
pnpm install # install everything
pnpm dev # run the showcase at http://localhost:5173
pnpm test # library unit tests
pnpm build # build library + docs
```
## Workspace layout
| Path | What it is |
| --- | --- |
| `packages/dreamknob` | The library. Zero runtime dependencies, ESM + CJS + `.d.ts`. |
| `apps/docs` | Vite showcase: gallery, live playground with code generation, API docs. |
## The library in 10 seconds
```tsx
import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
<MetalKnob size={90} min={0} max={100} step={0.5} defaultValue={64} label="Gain" />
<LEDKnob min={20} max={20000} taper={logTaper} defaultValue={632} label="Freq" />
<Fader min={-60} max={12} step={0.5} unit=" dB" defaultValue={-6} label="Out" />
```
- **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.
- **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**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `Face`, `KnobValue`,
`KnobLabel`) for custom designs, or go fully headless with `useKnob`.
See `packages/dreamknob/README.md` and the docs app for the full API.

22
apps/docs/index.html Normal file
View file

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dreamknob — studio-grade React knobs &amp; faders</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='42' fill='%2318191f' stroke='%234cc2ff' stroke-width='6'/%3E%3Cline x1='50' y1='50' x2='50' y2='16' stroke='%234cc2ff' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
apps/docs/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "docs",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dreamknob": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.3"
}
}

42
apps/docs/src/App.tsx Normal file
View file

@ -0,0 +1,42 @@
import React from 'react';
import { ApiDocs } from './sections/ApiDocs';
import { Console } from './sections/Console';
import { Gallery } from './sections/Gallery';
import { Hero } from './sections/Hero';
import { Playground } from './sections/Playground';
const Logo: React.FC = () => (
<svg width="22" height="22" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="#18191f" stroke="#4cc2ff" strokeWidth="7" />
<line x1="50" y1="50" x2="50" y2="15" stroke="#4cc2ff" strokeWidth="9" strokeLinecap="round" />
</svg>
);
export const App: React.FC = () => (
<>
<nav className="nav">
<div className="container nav-inner">
<a className="nav-logo" href="#">
<Logo />
dreamknob
</a>
<div className="nav-links">
<a href="#gallery">Gallery</a>
<a href="#console">Demo</a>
<a href="#playground">Playground</a>
<a href="#docs">Docs</a>
</div>
</div>
</nav>
<Hero />
<Gallery />
<Console />
<Playground />
<ApiDocs />
<footer className="footer">
<div className="container">
dreamknob · MIT · built with a headless core, SVG and zero runtime dependencies
</div>
</footer>
</>
);

View file

@ -0,0 +1,43 @@
import React from 'react';
/** Tiny JSX-ish token colorizer — good enough for docs snippets. */
const highlight = (code: string): React.ReactNode[] => {
const pattern =
/(\/\/[^\n]*)|("[^"]*"|'[^']*'|`[^`]*`)|(\{[^{}]*\})|(<\/?[A-Za-z][\w.]*)|(\b\d+(?:\.\d+)?\b)|([a-zA-Z-]+)(?==)/g;
const out: React.ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = pattern.exec(code))) {
if (m.index > last) out.push(code.slice(last, m.index));
const [full, com, str, expr, tag, num, attr] = m;
if (com) out.push(<span key={key++} className="tok-com">{full}</span>);
else if (str) out.push(<span key={key++} className="tok-str">{full}</span>);
else if (expr) out.push(<span key={key++} className="tok-num">{full}</span>);
else if (tag) out.push(<span key={key++} className="tok-tag">{full}</span>);
else if (num) out.push(<span key={key++} className="tok-num">{full}</span>);
else if (attr) out.push(<span key={key++} className="tok-key">{full}</span>);
last = m.index + full.length;
}
out.push(code.slice(last));
return out;
};
export const CodeBlock: React.FC<{ code: string }> = ({ code }) => {
const [copied, setCopied] = React.useState(false);
return (
<div className="codeblock">
<button
className="copy"
onClick={() => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
>
{copied ? 'copied ✓' : 'copy'}
</button>
<pre>{highlight(code)}</pre>
</div>
);
};

10
apps/docs/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,148 @@
import React from 'react';
import { CodeBlock } from '../components/CodeBlock';
const Row: React.FC<{ name: string; type: string; def?: string; desc: string }> = ({
name,
type,
def,
desc,
}) => (
<tr>
<td>{name}</td>
<td>{type}</td>
<td>{def ?? '—'}</td>
<td>{desc}</td>
</tr>
);
const Table: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<table className="api-table">
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>{children}</tbody>
</table>
);
export const ApiDocs: React.FC = () => (
<section className="block" id="docs">
<div className="container prose">
<div className="section-kicker">Documentation</div>
<h2 className="section-title">API reference</h2>
<p className="section-sub">
Everything is built on one interaction core. The props below are shared by{' '}
<code>useKnob</code>, <code>&lt;Knob&gt;</code>, every prebuilt skin and{' '}
<code>&lt;Fader&gt;</code>.
</p>
<h3 className="api-h">Core value &amp; interaction props</h3>
<Table>
<Row name="value" type="number" desc="Controlled value. Pair with onChange." />
<Row name="defaultValue" type="number" desc="Uncontrolled initial value; also the double-click reset target." />
<Row name="min / max" type="number" def="0 / 100" desc="Value range. Any numbers, including negative and fractional." />
<Row name="step" type="number" def="0 (continuous)" desc="Snap increment, e.g. 1, 0.5, 0.001. Decimal steps set display precision automatically." />
<Row name="decimals" type="number" def="from step" desc="Decimal places of emitted values (float-drift-safe rounding)." />
<Row name="values" type="number[]" desc="Restrict to a discrete list, e.g. [0.25, 0.5, 1, 2, 4]. Overrides step." />
<Row name="steps" type="number" desc="Number of evenly spaced detents across the travel (selector knobs)." />
<Row name="taper" type="Taper" def="linearTaper" desc="Travel curve: linearTaper, logTaper (frequencies), powTaper(n) (gain), or your own." />
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during drags and wheel." />
<Row name="angleOffset / angleRange" type="number" def="225 / 270" desc="Where travel starts and how far it sweeps, in degrees clockwise from 12 o'clock." />
<Row name="enableWheel" type="boolean" def="true" desc="Scroll-wheel nudging (one step per notch)." />
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
<Row name="onChange" type="(v: number) => void" desc="Fires for every distinct snapped value." />
<Row name="onChangeStart / onChangeEnd" type="(v: number) => void" desc="Gesture boundaries — ideal for undo history or parameter automation." />
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
</Table>
<h3 className="api-h">Keyboard &amp; gestures</h3>
<p className="api-note">
<code>/</code> and <code>/</code> step · <code>Shift</code>+arrows jump 10×
· <code>PageUp/PageDown</code> move 10% · <code>Home/End</code> jump to min/max ·
scroll wheel nudges (Shift = fine) · double-click resets · touch works via pointer
capture.
</p>
<h3 className="api-h">Prebuilt skins</h3>
<Table>
<Row name="<FlatKnob />" type="2D" desc="Modern arc knob. color, trackColor, faceColor, pointerColor, textColor, arcFrom ('min' | 'center'), arcThickness." />
<Row name="<MetalKnob />" type="3D" desc="Brushed aluminum. tone ('silver' | 'dark'), color, tickCount, showArc, indicatorColor." />
<Row name="<RubberKnob />" type="3D" desc="Soft-touch synth knob with glow halo. color, trackColor, arcFrom." />
<Row name="<VintageKnob />" type="analog" desc="Chicken-head pointer. bodyColor (cream/bakelite/any), scaleColor, scaleLabels." />
<Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals." />
<Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." />
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." />
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
</Table>
<p className="api-note">
All skins also take <code>label</code>, <code>showValue</code>, <code>unit</code>,{' '}
<code>format</code>, <code>className</code>, <code>style</code> plus every core prop
above.
</p>
<h3 className="api-h">Compose your own knob</h3>
<p className="api-note">
<code>&lt;Knob&gt;</code> wires up the interaction and provides a render context;
primitives draw into it. Use the built-ins or drop in raw SVG.
</p>
<CodeBlock
code={`import { Knob, Arc, Pointer, Ticks, KnobValue } from 'dreamknob'
<Knob size={96} min={-24} max={24} step={0.5} defaultValue={0} aria-label="Trim">
<Ticks count={25} activeColor="#ffd23e" />
<Arc radius={36} thickness={3} color="#ffd23e" from="center" />
<Pointer type="triangle" radius={33} length={9} width={8} color="#ffd23e" />
<KnobValue unit=" dB" />
</Knob>
// Or render-prop style for full control:
<Knob size={96} defaultValue={30} aria-label="Custom">
{({ normalized, angle, value, center }) => (
<circle cx={center} cy={center} r={30 + normalized * 8} fill="#4cc2ff33" />
)}
</Knob>`}
/>
<h3 className="api-h">Fully headless</h3>
<p className="api-note">
Skip our rendering entirely <code>useKnob</code> gives you the state and the
event bindings, you bring the DOM (or canvas, or WebGL).
</p>
<CodeBlock
code={`import { useKnob } from 'dreamknob'
function MyKnob() {
const { value, angle, isDragging, bind } = useKnob({
min: 0, max: 11, step: 0.1, defaultValue: 11,
interaction: 'rotary',
'aria-label': 'Volume',
})
return (
<div {...bind} style={{ ...bind.style, width: 80, height: 80 }}>
<div style={{ transform: \`rotate(\${angle}deg)\` }}>▲</div>
{isDragging && <span>{value}</span>}
</div>
)
}`}
/>
<h3 className="api-h">Precision &amp; number handling</h3>
<p className="api-note">
Values are snapped to <code>step</code> (anchored at <code>min</code>) and rounded
with a float-drift-safe algorithm, so <code>step=0.1</code> never produces{' '}
<code>0.30000000000000004</code>. Ranges can be negative, tiny (<code>1e-7</code>{' '}
steps) or huge; <code>decimals</code> caps emitted precision, and{' '}
<code>format</code> controls display independently.
</p>
</div>
</section>
);

View file

@ -0,0 +1,75 @@
import React from 'react';
import { Fader, FlatKnob, SegmentDisplay } from 'dreamknob';
const CHANNELS = [
{ name: 'Kick', color: '#4cc2ff', level: -4 },
{ name: 'Snare', color: '#3df2ad', level: -8 },
{ name: 'Bass', color: '#ff9640', level: -6 },
{ name: 'Keys', color: '#e44cff', level: -12 },
{ name: 'Vox', color: '#ffd23e', level: -3 },
];
const Channel: React.FC<{ name: string; color: string; level: number }> = ({
name,
color,
level,
}) => {
const [gain, setGain] = React.useState(level);
return (
<div className="channel">
<span className="ch-name">{name}</span>
<FlatKnob
size={56}
defaultValue={0}
min={-50}
max={50}
step={1}
arcFrom="center"
color={color}
showValue={false}
label="Pan"
aria-label={`${name} pan`}
/>
<FlatKnob
size={56}
defaultValue={20}
step={1}
color={color}
showValue={false}
label="Send"
aria-label={`${name} send`}
/>
<Fader
length={150}
breadth={38}
value={gain}
onChange={setGain}
min={-60}
max={12}
step={0.5}
color={color}
format={v => `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
aria-label={`${name} level`}
/>
<SegmentDisplay value={gain} digits={4} decimals={1} height={13} color={color} ghostOpacity={0.06} />
</div>
);
};
export const Console: React.FC = () => (
<section className="block" id="console">
<div className="container">
<div className="section-kicker">In context</div>
<h2 className="section-title">Build whole consoles</h2>
<p className="section-sub">
Controlled or uncontrolled, the components compose into full instrument panels.
This mixer is ~40 lines of JSX.
</p>
<div className="console">
{CHANNELS.map(ch => (
<Channel key={ch.name} {...ch} />
))}
</div>
</div>
</section>
);

View file

@ -0,0 +1,149 @@
import React from 'react';
import {
Arc,
Fader,
FlatKnob,
Knob,
KnobValue,
LEDFader,
LEDKnob,
MetalKnob,
NeonKnob,
Pointer,
RubberKnob,
SegmentDisplay,
SteppedKnob,
Ticks,
VintageKnob,
logTaper,
} from 'dreamknob';
const Card: React.FC<{
title: string;
desc: string;
tag: string;
children: React.ReactNode;
}> = ({ title, desc, tag, children }) => (
<div className="card">
<h3>{title}</h3>
<div className="card-desc">{desc}</div>
<div className="demo">{children}</div>
<span className="tag">{tag}</span>
</div>
);
export const Gallery: React.FC = () => (
<section className="block" id="gallery">
<div className="container">
<div className="section-kicker">Gallery</div>
<h2 className="section-title">Every style in the studio</h2>
<p className="section-sub">
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.
</p>
<div className="grid">
<Card title="Flat" desc="Clean 2D arc knob for modern plugin UIs." tag="<FlatKnob />">
<FlatKnob size={86} defaultValue={64} label="Level" aria-label="Flat demo" />
<FlatKnob size={86} defaultValue={0} min={-50} max={50} arcFrom="center" color="#3df2ad" label="Pan" aria-label="Pan demo" />
<FlatKnob size={86} defaultValue={30} color="#ffd23e" trackColor="rgba(255,210,62,0.15)" label="Send" aria-label="Send demo" />
</Card>
<Card title="Metal" desc="Brushed aluminum with a knurled rim — 3D hi-fi feel." tag="<MetalKnob />">
<MetalKnob size={92} defaultValue={40} label="Input" aria-label="Metal demo" />
<MetalKnob size={92} defaultValue={75} tone="dark" color="#ff9640" label="Drive" aria-label="Dark metal demo" />
</Card>
<Card title="Rubber" desc="Soft-touch synth knob with a glowing halo." tag="<RubberKnob />">
<RubberKnob size={92} defaultValue={72} label="Cutoff" aria-label="Rubber demo" />
<RubberKnob size={92} defaultValue={30} color="#4cc2ff" label="Res" aria-label="Rubber resonance demo" />
</Card>
<Card title="Vintage" desc="Chicken-head bakelite over a printed scale." tag="<VintageKnob />">
<VintageKnob size={96} defaultValue={7} min={0} max={10} step={0.5} label="Volume" scaleLabels={['0', '', '', '', '', '5', '', '', '', '', '10']} aria-label="Vintage demo" />
<VintageKnob size={96} defaultValue={4} min={0} max={10} bodyColor="#26221f" label="Tone" aria-label="Bakelite demo" />
</Card>
<Card title="LED" desc="Segmented ring + true seven-segment readout." tag="<LEDKnob />">
<LEDKnob size={96} defaultValue={120} min={40} max={240} step={1} label="BPM" aria-label="LED demo" />
<LEDKnob size={96} defaultValue={-12.5} min={-60} max={0} step={0.5} digits={4} color="#ff4d6b" label="Thresh" aria-label="Threshold demo" />
</Card>
<Card title="Neon" desc="A glowing arc for dark, futuristic interfaces." tag="<NeonKnob />">
<NeonKnob size={90} defaultValue={35} label="Space" aria-label="Neon demo" />
<NeonKnob size={90} defaultValue={80} color="#3df2ad" label="Shine" aria-label="Neon green demo" />
</Card>
<Card title="Stepped" desc="Detented selector with named or numeric positions." tag="<SteppedKnob />">
<SteppedKnob size={92} positions={['LP', 'BP', 'HP', 'NT']} defaultValue={0} label="Filter" aria-label="Filter type demo" />
<SteppedKnob size={92} min={-24} max={24} steps={9} color="#4cc2ff" defaultValue={0} label="Semi" aria-label="Semitones demo" />
</Card>
<Card title="Fader" desc="Absolute-position linear control, both orientations." tag="<Fader />">
<Fader length={130} defaultValue={-6} min={-60} max={12} step={0.5} unit=" dB" label="Main" aria-label="Fader demo" />
<Fader orientation="horizontal" length={150} defaultValue={35} step={1} color="#e44cff" label="X-Fade" aria-label="Crossfade demo" />
</Card>
<Card title="LED fader" desc="Segmented meter-style fader with color zones." tag="<LEDFader />">
<LEDFader
length={130}
defaultValue={70}
step={1}
zones={[
{ upTo: 0.6, color: '#3df2ad' },
{ upTo: 0.85, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
]}
label="Level"
aria-label="LED fader demo"
/>
<LEDFader
orientation="horizontal"
length={140}
defaultValue={40}
step={1}
color="#4cc2ff"
label="Pos"
aria-label="Horizontal LED fader demo"
/>
</Card>
<Card title="Segment display" desc="The seven-segment engine, standalone." tag="<SegmentDisplay />">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' }}>
<SegmentDisplay value={128.5} digits={4} decimals={1} />
<SegmentDisplay value={-42} digits={4} color="#ff4d6b" />
</div>
</Card>
<Card title="Log taper" desc="Audio-true frequency sweep: equal ratios per turn." tag="taper={logTaper}">
<FlatKnob
size={92}
min={20}
max={20000}
defaultValue={632}
taper={logTaper}
decimals={0}
color="#3df2ad"
format={v => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
label="Freq"
aria-label="Frequency demo"
/>
</Card>
<Card title="Compose your own" desc="Primitives + headless core = any design you want." tag="<Knob> + primitives">
<Knob size={96} defaultValue={42} aria-label="Composed demo">
<Ticks count={28} radius={47} length={5} width={1.5} color="rgba(255,255,255,0.12)" activeColor="#ffd23e" />
<Arc radius={36} thickness={2.5} color="#ffd23e" trackColor="rgba(255,255,255,0.08)" cap="butt" />
<Pointer type="triangle" radius={33} length={9} width={8} color="#ffd23e" />
<KnobValue color="#fff" fontSize={15} />
</Knob>
</Card>
<Card title="Vertical-drag mode" desc="Prefer the up/down plugin feel? One prop." tag="interaction='vertical'">
<RubberKnob size={92} defaultValue={50} interaction="vertical" color="#3df2ad" label="Depth" showValue aria-label="Vertical drag demo" />
</Card>
</div>
</div>
</section>
);

View file

@ -0,0 +1,74 @@
import React from 'react';
import {
Fader,
FlatKnob,
LEDFader,
LEDKnob,
MetalKnob,
NeonKnob,
RubberKnob,
SteppedKnob,
VintageKnob,
} from 'dreamknob';
export const Hero: React.FC = () => {
const [copied, setCopied] = React.useState(false);
return (
<header className="hero">
<div className="container">
<h1>
Knobs that feel like <span className="grad">hardware.</span>
</h1>
<p className="tagline">
<b>dreamknob</b> is a feature-full React + TypeScript knob &amp; fader library.
Rotary, linear, 2D, 3D, digital and analog with a headless core, any number
range, any decimal precision, and colors you fully control.
</p>
<div className="install">
<span>pnpm add dreamknob</span>
<button
onClick={() => {
navigator.clipboard.writeText('pnpm add dreamknob');
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
>
{copied ? '✓' : 'copy'}
</button>
</div>
<div className="rack">
<div className="rack-screws">
<span /><span /><span /><span />
</div>
<div className="rack-row">
<MetalKnob size={96} label="Gain" defaultValue={64} color="#4cc2ff" aria-label="Gain" />
<VintageKnob size={96} label="Drive" defaultValue={7} min={0} max={10} step={0.1} aria-label="Drive" />
<RubberKnob size={96} label="Cutoff" defaultValue={72} color="#ff9640" aria-label="Cutoff" />
<LEDKnob size={96} label="Rate" defaultValue={120} min={40} max={240} step={1} color="#3df2ad" aria-label="Rate" />
<NeonKnob size={96} label="Space" defaultValue={35} color="#e44cff" aria-label="Space" />
<SteppedKnob size={96} label="Mode" positions={['LP', 'BP', 'HP', 'NT']} defaultValue={0} aria-label="Mode" />
<FlatKnob size={96} label="Mix" defaultValue={50} unit="%" step={1} arcFrom="center" aria-label="Mix" />
<Fader length={128} breadth={40} label="Out" defaultValue={-6} min={-60} max={12} step={0.5} unit=" dB" aria-label="Output level" />
<LEDFader
length={128}
label="Level"
defaultValue={70}
step={1}
zones={[
{ upTo: 0.6, color: '#3df2ad' },
{ upTo: 0.85, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
]}
aria-label="Level meter"
/>
</div>
<div className="rack-hint">
drag to rotate · scroll to nudge · <kbd></kbd> for fine control · double-click to
reset · arrow keys work too
</div>
</div>
</div>
</header>
);
};

View file

@ -0,0 +1,275 @@
import React from 'react';
import {
Fader,
FlatKnob,
LEDFader,
LEDKnob,
MetalKnob,
NeonKnob,
RubberKnob,
SteppedKnob,
VintageKnob,
logTaper,
powTaper,
type InteractionMode,
} from 'dreamknob';
import { CodeBlock } from '../components/CodeBlock';
type SkinId =
| 'flat'
| 'metal'
| 'rubber'
| 'vintage'
| 'led'
| 'neon'
| 'stepped'
| 'fader'
| 'ledfader';
const SKINS: Record<SkinId, string> = {
flat: 'FlatKnob',
metal: 'MetalKnob',
rubber: 'RubberKnob',
vintage: 'VintageKnob',
led: 'LEDKnob',
neon: 'NeonKnob',
stepped: 'SteppedKnob',
fader: 'Fader',
ledfader: 'LEDFader',
};
interface Config {
skin: SkinId;
size: number;
min: number;
max: number;
step: number;
interaction: InteractionMode;
angleRange: number;
taper: 'linear' | 'log' | 'pow2';
color: string;
label: string;
unit: string;
showValue: boolean;
arcFrom: 'min' | 'center';
}
const DEFAULTS: Config = {
skin: 'flat',
size: 160,
min: 0,
max: 100,
step: 0.5,
interaction: 'rotary',
angleRange: 270,
taper: 'linear',
color: '#4cc2ff',
label: 'Level',
unit: '',
showValue: true,
arcFrom: 'min',
};
const buildCode = (c: Config, controlled: boolean): string => {
const props: string[] = [];
if (c.skin === 'fader' || c.skin === 'ledfader') 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}}`);
if (c.min !== 0) props.push(`min={${c.min}}`);
if (c.max !== 100) props.push(`max={${c.max}}`);
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}}`);
props.push(`color="${c.color}"`);
if (c.label) props.push(`label="${c.label}"`);
if (c.unit) props.push(`unit="${c.unit}"`);
if (c.skin === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
if (c.skin === 'stepped') props.push(`steps={5}`);
const name = SKINS[c.skin];
const body = props.map(p => ` ${p}`).join('\n');
const imports = [name, c.taper === 'log' ? 'logTaper' : '', c.taper === 'pow2' ? 'powTaper' : '']
.filter(Boolean)
.join(', ');
return `import { ${imports} } from 'dreamknob'\n\n<${name}\n${body}\n/>`;
};
const num = (v: string, fallback: number) => {
const n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
};
export const Playground: React.FC = () => {
const [cfg, setCfg] = React.useState<Config>(DEFAULTS);
const [value, setValue] = React.useState(50);
const [endValue, setEndValue] = React.useState<number | null>(null);
const set = <K extends keyof Config>(key: K, v: Config[K]) => setCfg(p => ({ ...p, [key]: v }));
const taper = cfg.taper === 'log' ? logTaper : cfg.taper === 'pow2' ? powTaper(2) : undefined;
const logInvalid = cfg.taper === 'log' && cfg.min <= 0;
const common = {
value,
onChange: setValue,
onChangeEnd: setEndValue,
min: cfg.min,
max: cfg.max,
step: cfg.step > 0 ? cfg.step : undefined,
taper: logInvalid ? undefined : taper,
interaction: cfg.interaction,
angleRange: cfg.angleRange,
color: cfg.color,
label: cfg.label || undefined,
unit: cfg.unit || undefined,
showValue: cfg.showValue,
'aria-label': cfg.label || 'playground knob',
};
const preview = (() => {
switch (cfg.skin) {
case 'flat':
return <FlatKnob size={cfg.size} arcFrom={cfg.arcFrom} {...common} />;
case 'metal':
return <MetalKnob size={cfg.size} {...common} />;
case 'rubber':
return <RubberKnob size={cfg.size} {...common} />;
case 'vintage':
return <VintageKnob size={cfg.size} {...common} />;
case 'led':
return <LEDKnob size={cfg.size} {...common} />;
case 'neon':
return <NeonKnob size={cfg.size} {...common} />;
case 'stepped':
return <SteppedKnob size={cfg.size} steps={5} {...common} />;
case 'fader':
return <Fader length={cfg.size * 1.4} breadth={48} {...common} />;
case 'ledfader':
return <LEDFader length={cfg.size * 1.4} breadth={40} {...common} />;
}
})();
return (
<section className="block" id="playground">
<div className="container">
<div className="section-kicker">Playground</div>
<h2 className="section-title">Turn it up. Then copy the code.</h2>
<p className="section-sub">
Every prop is live. The generated JSX below always matches what you're touching.
</p>
<div className="playground">
<div className="controls">
<div className="control">
<label>Component</label>
<select value={cfg.skin} onChange={e => set('skin', e.target.value as SkinId)}>
{Object.entries(SKINS).map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
</div>
<div className="control">
<label>Size · {cfg.size}px</label>
<input
type="range"
min={60}
max={260}
value={cfg.size}
onChange={e => set('size', +e.target.value)}
/>
</div>
<div className="control-row">
<div className="control">
<label>Min</label>
<input type="number" value={cfg.min} onChange={e => set('min', num(e.target.value, 0))} />
</div>
<div className="control">
<label>Max</label>
<input type="number" value={cfg.max} onChange={e => set('max', num(e.target.value, 100))} />
</div>
</div>
<div className="control-row">
<div className="control">
<label>Step</label>
<input
type="number"
step="any"
min={0}
value={cfg.step}
onChange={e => set('step', num(e.target.value, 0))}
/>
</div>
<div className="control">
<label>Sweep °</label>
<input
type="number"
min={45}
max={360}
value={cfg.angleRange}
onChange={e => set('angleRange', num(e.target.value, 270))}
/>
</div>
</div>
<div className="control">
<label>Drag feel</label>
<select
value={cfg.interaction}
onChange={e => set('interaction', e.target.value as InteractionMode)}
>
<option value="rotary">rotary (grab &amp; turn)</option>
<option value="vertical">vertical drag</option>
<option value="horizontal">horizontal drag</option>
<option value="both">vertical + horizontal</option>
</select>
</div>
<div className="control">
<label>Taper</label>
<select value={cfg.taper} onChange={e => set('taper', e.target.value as Config['taper'])}>
<option value="linear">linear</option>
<option value="log">logarithmic (min &gt; 0)</option>
<option value="pow2">power (x²)</option>
</select>
{logInvalid && (
<span style={{ fontSize: 11, color: '#ff9640' }}>log taper needs min &gt; 0 using linear</span>
)}
</div>
<div className="control">
<label>Accent color</label>
<input type="color" value={cfg.color} onChange={e => set('color', e.target.value)} />
</div>
<div className="control-row">
<div className="control">
<label>Label</label>
<input type="text" value={cfg.label} onChange={e => set('label', e.target.value)} />
</div>
<div className="control">
<label>Unit</label>
<input type="text" value={cfg.unit} placeholder="dB, %, Hz…" onChange={e => set('unit', e.target.value)} />
</div>
</div>
<div className="control">
<label className="check">
<input
type="checkbox"
checked={cfg.showValue}
onChange={e => set('showValue', e.target.checked)}
/>
show value readout
</label>
</div>
</div>
<div className="stage">
<div className="preview">{preview}</div>
<div className="readout">
<span>onChange <b>{value}</b></span>
<span>onChangeEnd <b>{endValue ?? '—'}</b></span>
</div>
<CodeBlock code={buildCode(cfg, true)} />
</div>
</div>
</div>
</section>
);
};

521
apps/docs/src/styles.css Normal file
View file

@ -0,0 +1,521 @@
:root {
--bg: #0a0b0e;
--bg-raised: #101218;
--panel: #13151c;
--panel-2: #171a22;
--line: rgba(255, 255, 255, 0.07);
--line-strong: rgba(255, 255, 255, 0.13);
--text: #e8eaf0;
--text-dim: #9aa0ad;
--text-faint: #5d6370;
--accent: #4cc2ff;
--accent-2: #3df2ad;
--accent-3: #ff9640;
--accent-4: #e44cff;
--font-ui: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 72px;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
line-height: 1.6;
}
::selection {
background: rgba(76, 194, 255, 0.3);
}
.container {
max-width: 1160px;
margin: 0 auto;
padding: 0 24px;
}
/* ---- nav ---- */
.nav {
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(14px);
background: rgba(10, 11, 14, 0.75);
border-bottom: 1px solid var(--line);
}
.nav-inner {
display: flex;
align-items: center;
gap: 28px;
height: 60px;
}
.nav-logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
font-size: 17px;
letter-spacing: -0.02em;
color: var(--text);
text-decoration: none;
}
.nav-logo svg {
display: block;
}
.nav-links {
display: flex;
gap: 4px;
margin-left: auto;
}
.nav-links a {
color: var(--text-dim);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
padding: 6px 12px;
border-radius: 8px;
transition: color 0.15s, background 0.15s;
}
.nav-links a:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
/* ---- hero ---- */
.hero {
padding: 84px 0 40px;
text-align: center;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: -40% -20% auto;
height: 130%;
background:
radial-gradient(600px 300px at 30% 20%, rgba(76, 194, 255, 0.12), transparent 70%),
radial-gradient(600px 300px at 70% 10%, rgba(228, 76, 255, 0.08), transparent 70%);
pointer-events: none;
}
.hero h1 {
font-size: clamp(40px, 6vw, 64px);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.05;
position: relative;
}
.hero h1 .grad {
background: linear-gradient(90deg, var(--accent), var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p.tagline {
margin: 18px auto 0;
max-width: 640px;
color: var(--text-dim);
font-size: 17px;
position: relative;
}
.hero .install {
margin: 26px auto 0;
display: inline-flex;
align-items: center;
gap: 12px;
background: var(--panel);
border: 1px solid var(--line-strong);
padding: 10px 16px;
border-radius: 10px;
font-family: var(--font-mono);
font-size: 14px;
color: var(--accent-2);
position: relative;
}
.hero .install button {
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
font-size: 13px;
padding: 2px 6px;
}
.hero .install button:hover {
color: var(--text);
}
/* ---- rack panel (hero demo) ---- */
.rack {
margin: 48px auto 0;
max-width: 980px;
border-radius: 16px;
border: 1px solid var(--line-strong);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 40%),
linear-gradient(180deg, #191b22, #101116);
box-shadow:
0 30px 80px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
padding: 30px 34px 24px;
position: relative;
}
.rack-screws span {
position: absolute;
width: 11px;
height: 11px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #6a6d78, #23252c 75%);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.8);
}
.rack-screws span:nth-child(1) { top: 12px; left: 12px; }
.rack-screws span:nth-child(2) { top: 12px; right: 12px; }
.rack-screws span:nth-child(3) { bottom: 12px; left: 12px; }
.rack-screws span:nth-child(4) { bottom: 12px; right: 12px; }
.rack-row {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 34px;
flex-wrap: wrap;
}
.rack-hint {
margin-top: 18px;
font-size: 12px;
color: var(--text-faint);
text-align: center;
}
.rack-hint kbd {
font-family: var(--font-mono);
font-size: 11px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid var(--line-strong);
border-radius: 4px;
padding: 1px 5px;
}
/* ---- sections ---- */
section.block {
padding: 72px 0 12px;
}
.section-kicker {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
h2.section-title {
font-size: 32px;
font-weight: 800;
letter-spacing: -0.03em;
margin: 6px 0 8px;
}
.section-sub {
color: var(--text-dim);
max-width: 640px;
margin-bottom: 34px;
}
/* ---- gallery ---- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 18px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 22px;
transition: border-color 0.2s, transform 0.2s;
}
.card:hover {
border-color: var(--line-strong);
}
.card h3 {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.card .card-desc {
font-size: 13px;
color: var(--text-dim);
margin: 2px 0 18px;
}
.card .demo {
display: flex;
align-items: center;
justify-content: center;
gap: 22px;
min-height: 130px;
flex-wrap: wrap;
border-radius: 10px;
background:
radial-gradient(400px 160px at 50% 0%, rgba(255, 255, 255, 0.03), transparent),
var(--bg-raised);
border: 1px solid var(--line);
padding: 18px 10px;
}
.card .tag {
display: inline-block;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-faint);
background: rgba(255, 255, 255, 0.05);
padding: 2px 8px;
border-radius: 999px;
margin-top: 14px;
}
/* ---- console demo ---- */
.console {
border-radius: 16px;
border: 1px solid var(--line-strong);
background: linear-gradient(180deg, #16181f, #0e0f14);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
padding: 26px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.channel {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 18px 18px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid var(--line);
min-width: 128px;
}
.channel .ch-name {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
color: var(--text-dim);
text-transform: uppercase;
}
/* ---- playground ---- */
.playground {
display: grid;
grid-template-columns: 300px 1fr;
gap: 18px;
align-items: start;
}
@media (max-width: 860px) {
.playground {
grid-template-columns: 1fr;
}
}
.controls {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
position: sticky;
top: 76px;
}
.control {
display: flex;
flex-direction: column;
gap: 5px;
}
.control > label {
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
}
.control input[type='text'],
.control input[type='number'],
.control select {
background: var(--bg-raised);
border: 1px solid var(--line-strong);
color: var(--text);
border-radius: 8px;
padding: 7px 10px;
font-size: 13.5px;
font-family: var(--font-mono);
outline: none;
width: 100%;
}
.control input:focus,
.control select:focus {
border-color: var(--accent);
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.control input[type='range'] {
accent-color: var(--accent);
}
.control input[type='color'] {
width: 100%;
height: 32px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--bg-raised);
padding: 3px;
cursor: pointer;
}
.control .check {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-dim);
text-transform: none;
letter-spacing: 0;
}
.stage {
display: flex;
flex-direction: column;
gap: 18px;
}
.preview {
background:
radial-gradient(500px 240px at 50% 10%, rgba(76, 194, 255, 0.05), transparent),
var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
padding: 30px;
}
.readout {
display: flex;
gap: 20px;
justify-content: center;
align-items: center;
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--text-faint);
}
.readout b {
color: var(--accent-2);
font-weight: 500;
}
/* ---- code ---- */
.codeblock {
position: relative;
background: #0d0e12;
border: 1px solid var(--line);
border-radius: 12px;
overflow: auto;
}
.codeblock pre {
padding: 18px 20px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.65;
color: #c8cede;
white-space: pre;
}
.codeblock .copy {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--line-strong);
color: var(--text-dim);
font-size: 12px;
border-radius: 7px;
padding: 4px 10px;
cursor: pointer;
}
.codeblock .copy:hover {
color: var(--text);
}
.tok-key { color: #7dd3fc; }
.tok-str { color: #86efac; }
.tok-num { color: #fca5a5; }
.tok-tag { color: #c4b5fd; }
.tok-com { color: #565d6b; font-style: italic; }
/* ---- docs / api ---- */
.api-table {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
margin: 14px 0 30px;
}
.api-table th {
text-align: left;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
padding: 8px 12px;
border-bottom: 1px solid var(--line-strong);
}
.api-table td {
padding: 9px 12px;
border-bottom: 1px solid var(--line);
vertical-align: top;
color: var(--text-dim);
}
.api-table td:first-child {
font-family: var(--font-mono);
color: var(--accent);
white-space: nowrap;
font-size: 12.5px;
}
.api-table td:nth-child(2) {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-3);
}
.api-table code,
.prose code {
font-family: var(--font-mono);
font-size: 0.9em;
background: rgba(255, 255, 255, 0.06);
padding: 1px 5px;
border-radius: 4px;
}
h3.api-h {
font-size: 19px;
font-weight: 700;
margin: 38px 0 4px;
letter-spacing: -0.01em;
}
.api-note {
font-size: 13.5px;
color: var(--text-dim);
max-width: 720px;
}
.footer {
margin-top: 80px;
border-top: 1px solid var(--line);
padding: 30px 0 40px;
color: var(--text-faint);
font-size: 13px;
text-align: center;
}
.footer a {
color: var(--text-dim);
}

21
apps/docs/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"paths": {
"dreamknob": ["../../packages/dreamknob/src/index.ts"]
}
},
"include": ["src", "vite.config.ts"]
}

13
apps/docs/vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// Use library sources directly for instant HMR while developing.
dreamknob: fileURLToPath(new URL('../../packages/dreamknob/src/index.ts', import.meta.url)),
},
},
});

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "dreamknob-monorepo",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "pnpm --filter dreamknob build && pnpm --filter docs build",
"dev": "pnpm --filter docs dev",
"test": "pnpm --filter dreamknob test",
"typecheck": "pnpm -r typecheck"
},
"packageManager": "pnpm@9.11.0"
}

View file

@ -0,0 +1,110 @@
# dreamknob
Studio-grade React knobs & faders. Rotary, linear, 2D, 3D, digital and analog — with a
headless core, any number range, any decimal precision, and colors you fully control.
Zero runtime dependencies.
```
npm install dreamknob
```
## Quick start
```tsx
import { FlatKnob } from 'dreamknob'
function Volume() {
const [value, setValue] = useState(64)
return (
<FlatKnob
size={90}
value={value}
onChange={setValue}
min={0}
max={100}
step={0.5}
label="Volume"
unit="%"
aria-label="Volume"
/>
)
}
```
Uncontrolled works too: pass `defaultValue` instead of `value`/`onChange`
(it also becomes the double-click reset target).
## Interaction model
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 |
| `↑ → / ↓ ←` | ± one step (`Shift` = 10×) |
| `PageUp/PageDown` | ± 10 % of range |
| `Home/End` | min / max |
| Double-click | reset to `defaultValue` |
| Touch / pen | pointer capture, `touch-action: none` |
All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
## Value handling
- `min` / `max` — any numbers (negative, fractional, huge).
- `step` — decimal snap increments (`0.5`, `0.001`, `1e-7`…) with float-drift-safe
rounding; display precision inferred automatically, overridable via `decimals`.
- `values={[0.25, 0.5, 1, 2, 4]}` — discrete allowed values.
- `steps={5}` — evenly spaced detents (selector knobs).
- `taper``linearTaper` (default), `logTaper` (frequencies), `powTaper(n)` (gain),
or your own `{ toNormalized, fromNormalized }`.
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks.
## Prebuilt skins
```tsx
import {
FlatKnob, // clean 2D arc knob (arcFrom="center" for pan knobs)
MetalKnob, // 3D brushed aluminum, knurled rim (tone="silver" | "dark")
RubberKnob, // 3D soft-touch synth knob with glow halo
VintageKnob, // chicken-head bakelite over a printed scale
LEDKnob, // segmented LED ring + true seven-segment readout
NeonKnob, // glowing arc for dark UIs
SteppedKnob, // detented selector (positions={['LP','BP','HP']})
Fader, // linear channel fader, vertical or horizontal
LEDFader, // segmented LED meter-fader with color zones
SegmentDisplay, // standalone seven-segment numeric display
} from 'dreamknob'
```
Every skin takes the core props plus `size`, `label`, `showValue`, `unit`, `format`,
and per-part color props (`color`, `trackColor`, `faceColor`, `bodyColor`, …).
## Compose your own
```tsx
import { Knob, Arc, Pointer, Ticks, KnobValue } from 'dreamknob'
<Knob size={96} min={-24} max={24} step={0.5} defaultValue={0} aria-label="Trim">
<Ticks count={25} activeColor="#ffd23e" />
<Arc radius={36} thickness={3} color="#ffd23e" from="center" />
<Pointer type="triangle" radius={33} length={9} width={8} color="#ffd23e" />
<KnobValue unit=" dB" />
</Knob>
```
`<Knob>` also accepts a render function: `{(ctx) => <YourSVG angle={ctx.angle} />}`.
## Fully headless
```tsx
import { useKnob } from 'dreamknob'
const { value, normalized, angle, isDragging, bind, setValue } = useKnob({
min: 0, max: 11, step: 0.1, defaultValue: 11, 'aria-label': 'Volume',
})
return <div {...bind}>…your own rendering…</div>
```
MIT

View file

@ -0,0 +1,51 @@
{
"name": "dreamknob",
"version": "0.1.0",
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
"license": "MIT",
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"keywords": [
"react",
"knob",
"fader",
"slider",
"rotary",
"audio",
"synth",
"dial",
"typescript"
],
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tsup": "^8.1.0",
"typescript": "^5.5.3",
"vitest": "^2.0.3"
}
}

View file

@ -0,0 +1,72 @@
import * as React from 'react';
import { KnobContextProvider, type KnobRenderContext } from '../core/context';
import type { KnobCoreProps } from '../core/types';
import { useKnob } from '../hooks/useKnob';
export interface KnobProps extends KnobCoreProps {
/** Square canvas size in px. Default: 80. */
size?: number;
className?: string;
style?: React.CSSProperties;
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
children?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode);
/** Extra content rendered inside the wrapper but outside the SVG (e.g. HTML labels). */
overlay?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode);
}
/**
* Headless-but-visual knob container: wires up the full interaction model
* (rotary/relative/track drag, wheel, keyboard, double-click reset, a11y)
* and provides a render context for composable SVG primitives.
*/
export const Knob: React.FC<KnobProps> = ({
size = 80,
className,
style,
children,
overlay,
...core
}) => {
const knob = useKnob(core);
const ctx: KnobRenderContext = {
value: knob.value,
normalized: knob.normalized,
angle: knob.angle,
isDragging: knob.isDragging,
min: knob.min,
max: knob.max,
decimals: knob.decimals,
angleOffset: knob.angleOffset,
angleRange: knob.angleRange,
size,
center: size / 2,
};
return (
<KnobContextProvider value={ctx}>
<div
{...knob.bind}
className={className}
style={{
display: 'inline-flex',
position: 'relative',
width: size,
height: size,
...knob.bind.style,
...style,
}}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
style={{ display: 'block', overflow: 'visible' }}
aria-hidden="true"
>
{typeof children === 'function' ? children(ctx) : children}
</svg>
{typeof overlay === 'function' ? overlay(ctx) : overlay}
</div>
</KnobContextProvider>
);
};

View file

@ -0,0 +1,23 @@
import * as React from 'react';
import type { KnobState } from './types';
export interface KnobRenderContext extends KnobState {
/** Square canvas size in px. */
size: number;
/** Center coordinate (size / 2). */
center: number;
}
const Ctx = React.createContext<KnobRenderContext | null>(null);
export const KnobContextProvider = Ctx.Provider;
export function useKnobContext(): KnobRenderContext {
const ctx = React.useContext(Ctx);
if (!ctx) {
throw new Error(
'dreamknob: this component must be rendered inside a <Knob> (or a prebuilt knob).',
);
}
return ctx;
}

View file

@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
angleFromNormalized,
angleFromPoint,
clamp,
decimalsFromStep,
describeArc,
findClosest,
linearTaper,
logTaper,
normalizedFromAngle,
powTaper,
roundTo,
snapToStep,
} from './math';
describe('clamp', () => {
it('clamps into range', () => {
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(-1, 0, 10)).toBe(0);
expect(clamp(11, 0, 10)).toBe(10);
});
});
describe('decimalsFromStep', () => {
it('infers decimal places', () => {
expect(decimalsFromStep(1)).toBe(0);
expect(decimalsFromStep(0.5)).toBe(1);
expect(decimalsFromStep(0.25)).toBe(2);
expect(decimalsFromStep(0.001)).toBe(3);
expect(decimalsFromStep(1e-7)).toBe(7);
});
it('falls back for continuous knobs', () => {
expect(decimalsFromStep(0)).toBe(3);
expect(decimalsFromStep(NaN)).toBe(3);
});
});
describe('roundTo', () => {
it('avoids float drift', () => {
expect(roundTo(0.1 + 0.2, 2)).toBe(0.3);
expect(roundTo(1.005, 2)).toBe(1.01);
expect(roundTo(123.456789, 4)).toBe(123.4568);
expect(roundTo(5, 0)).toBe(5);
});
});
describe('snapToStep', () => {
it('snaps anchored at min', () => {
expect(snapToStep(7.3, 0.5, 0)).toBe(7.5);
expect(snapToStep(7.2, 0.5, 0)).toBe(7.0);
expect(roundTo(snapToStep(0.07, 0.02, 0.01), 2)).toBe(0.07);
});
it('ignores non-positive steps', () => {
expect(snapToStep(7.3, 0, 0)).toBe(7.3);
});
});
describe('findClosest', () => {
it('finds the nearest entry regardless of order', () => {
expect(findClosest([1, 100, 5, 50], 42)).toBe(50);
expect(findClosest([0.25, 0.5, 1, 2, 4], 0.8)).toBe(1);
});
});
describe('tapers', () => {
it('linear round-trips', () => {
expect(linearTaper.fromNormalized(0.5, 0, 100)).toBe(50);
expect(linearTaper.toNormalized(50, 0, 100)).toBe(0.5);
});
it('log taper maps geometric midpoints', () => {
expect(roundTo(logTaper.fromNormalized(0.5, 20, 20000), 3)).toBe(632.456);
expect(roundTo(logTaper.toNormalized(2000, 20, 20000), 4)).toBe(
roundTo(Math.log(100) / Math.log(1000), 4),
);
});
it('pow taper round-trips', () => {
const t = powTaper(2);
const n = t.toNormalized(t.fromNormalized(0.3, 0, 10), 0, 10);
expect(roundTo(n, 10)).toBe(0.3);
});
});
describe('angles', () => {
it('maps pointer position to clockwise angle from 12 o\'clock', () => {
expect(angleFromPoint(50, 0, 50, 50)).toBe(0); // above center
expect(angleFromPoint(100, 50, 50, 50)).toBe(90); // right
expect(angleFromPoint(50, 100, 50, 50)).toBe(180); // below
expect(angleFromPoint(0, 50, 50, 50)).toBe(270); // left
});
it('maps angle to normalized travel (225°/270° audio knob)', () => {
expect(normalizedFromAngle(225, 225, 270)).toBe(0);
expect(normalizedFromAngle(0, 225, 270)).toBe(0.5); // 12 o'clock is mid travel
expect(normalizedFromAngle(135, 225, 270)).toBe(1);
});
it('snaps the dead zone to the nearest end (rc-knob feel)', () => {
// Dead zone spans 135..225; below its midpoint sticks to max.
expect(normalizedFromAngle(150, 225, 270)).toBe(1);
expect(normalizedFromAngle(210, 225, 270)).toBe(0);
});
it('round-trips normalized to angle', () => {
expect(angleFromNormalized(0.5, 225, 270)).toBe(360);
expect(angleFromNormalized(0, 225, 270)).toBe(225);
});
});
describe('describeArc', () => {
it('produces a drawable path', () => {
const d = describeArc(50, 50, 40, 225, 495);
expect(d.startsWith('M ')).toBe(true);
expect(d).toContain('A 40 40');
});
it('returns empty for zero sweep', () => {
expect(describeArc(50, 50, 40, 100, 100)).toBe('');
});
it('caps at just under a full circle', () => {
expect(describeArc(50, 50, 40, 0, 720)).not.toBe('');
});
});

View file

@ -0,0 +1,156 @@
/** Clamp `value` into the inclusive range [min, max]. */
export const clamp = (value: number, min: number, max: number): number =>
Math.max(min, Math.min(max, value));
/**
* Infer the number of decimal places implied by a step size,
* e.g. 0.25 -> 2, 1 -> 0, 1e-5 -> 5.
*/
export const decimalsFromStep = (step: number): number => {
if (!Number.isFinite(step) || step <= 0) return 3;
const s = step.toString();
const exp = s.indexOf('e-');
if (exp !== -1) return parseInt(s.slice(exp + 2), 10);
const dot = s.indexOf('.');
return dot === -1 ? 0 : s.length - dot - 1;
};
/** Round to a fixed number of decimal places without float drift (0.1 + 0.2 style). */
export const roundTo = (value: number, decimals: number): number => {
const d = clamp(Math.trunc(decimals), 0, 15);
return Number(`${Math.round(Number(`${value}e${d}`))}e-${d}`);
};
/** Snap `value` to the nearest multiple of `step`, anchored at `min`. */
export const snapToStep = (value: number, step: number, min: number): number => {
if (!Number.isFinite(step) || step <= 0) return value;
return min + Math.round((value - min) / step) * step;
};
/** Find the entry of `values` closest to `value`. */
export const findClosest = (values: readonly number[], value: number): number => {
let best = values[0];
let bestDelta = Infinity;
for (const v of values) {
const delta = Math.abs(v - value);
if (delta < bestDelta) {
best = v;
bestDelta = delta;
}
}
return best;
};
// ---------------------------------------------------------------------------
// Tapers — map between value space and the normalized [0, 1] travel of the
// control. Audio parameters are often logarithmic (frequency) or power-law
// (gain), so the travel-to-value curve is pluggable.
// ---------------------------------------------------------------------------
export interface Taper {
/** value in [min, max] -> normalized position in [0, 1] */
toNormalized(value: number, min: number, max: number): number;
/** normalized position in [0, 1] -> value in [min, max] */
fromNormalized(n: number, min: number, max: number): number;
}
export const linearTaper: Taper = {
toNormalized: (value, min, max) => (max === min ? 0 : (value - min) / (max - min)),
fromNormalized: (n, min, max) => min + (max - min) * n,
};
/**
* Logarithmic taper (equal ratios per travel). Requires min and max to be
* non-zero and share a sign ideal for frequency ranges like 20..20000 Hz.
*/
export const logTaper: Taper = {
toNormalized: (value, min, max) => {
if (min === max) return 0;
return Math.log(value / min) / Math.log(max / min);
},
fromNormalized: (n, min, max) => min * Math.pow(max / min, n),
};
/**
* Power-law taper. `exponent > 1` gives finer resolution near min (good for
* gain), `exponent < 1` near max.
*/
export const powTaper = (exponent: number): Taper => ({
toNormalized: (value, min, max) =>
max === min ? 0 : Math.pow((value - min) / (max - min), 1 / exponent),
fromNormalized: (n, min, max) => min + (max - min) * Math.pow(n, exponent),
});
// ---------------------------------------------------------------------------
// Angles. Convention: degrees measured CLOCKWISE from 12 o'clock.
// A typical audio knob starts at 225° (7:30) and sweeps 270° to 135° (4:30).
// ---------------------------------------------------------------------------
const DEG = Math.PI / 180;
export const polarToCartesian = (
cx: number,
cy: number,
radius: number,
angleDeg: number,
): { x: number; y: number } => ({
x: cx + radius * Math.sin(angleDeg * DEG),
y: cy - radius * Math.cos(angleDeg * DEG),
});
/**
* SVG path for a clockwise arc from `startAngle` to `endAngle`
* (degrees clockwise from 12 o'clock). Sweeps of >= 360° are capped just
* short of a full circle so the path stays drawable.
*/
export const describeArc = (
cx: number,
cy: number,
radius: number,
startAngle: number,
endAngle: number,
): string => {
const sweep = Math.min(endAngle - startAngle, 359.999);
if (sweep <= 0) return '';
const start = polarToCartesian(cx, cy, radius, startAngle);
const end = polarToCartesian(cx, cy, radius, startAngle + sweep);
const largeArc = sweep > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;
};
/**
* Angle of the pointer position relative to a center, in degrees clockwise
* from 12 o'clock, normalized to [0, 360).
*/
export const angleFromPoint = (
px: number,
py: number,
cx: number,
cy: number,
): number => {
const deg = Math.atan2(px - cx, cy - py) / DEG;
return (deg + 360) % 360;
};
/**
* Map an absolute pointer angle to a normalized position given the knob's
* travel (angleOffset..angleOffset+angleRange). Angles inside the dead zone
* snap to the nearest end this is the classic rc-knob grab-and-rotate feel.
*/
export const normalizedFromAngle = (
angle: number,
angleOffset: number,
angleRange: number,
): number => {
const rel = (((angle - angleOffset) % 360) + 360) % 360;
if (rel <= angleRange) return clamp(rel / angleRange, 0, 1);
// Dead zone: snap to whichever end of the travel is angularly closer.
return rel - angleRange < (360 - angleRange) / 2 ? 1 : 0;
};
/** Normalized position -> absolute display angle (deg clockwise from 12 o'clock). */
export const angleFromNormalized = (
n: number,
angleOffset: number,
angleRange: number,
): number => angleOffset + n * angleRange;

View file

@ -0,0 +1,82 @@
import type { Taper } from './math';
/** How pointer dragging maps to value changes. */
export type InteractionMode =
/** Track the pointer's absolute angle around the knob center (grab-and-rotate, rc-knob feel). */
| 'rotary'
/** Relative vertical drag: up increases (classic DAW plugin feel). */
| 'vertical'
/** Relative horizontal drag: right increases. */
| 'horizontal'
/** Combined vertical + horizontal relative drag. */
| 'both'
/** Absolute position along the element's main axis (fader/slider feel). */
| 'track-vertical'
| 'track-horizontal';
export interface KnobCoreProps {
/** Controlled value. Provide together with `onChange` for controlled mode. */
value?: number;
/** Initial value for uncontrolled mode; also the double-click reset target. */
defaultValue?: number;
min?: number;
max?: number;
/**
* Snap emitted values to multiples of `step` (anchored at `min`).
* May be fractional, e.g. 0.01. Omit (or 0) for continuous values.
*/
step?: number;
/**
* Number of decimal places for emitted values. Defaults to the precision
* implied by `step`, or 3 for continuous knobs.
*/
decimals?: number;
/** Restrict values to a discrete list (e.g. [0.25, 0.5, 1, 2, 4]). Overrides `step`. */
values?: readonly number[];
/** Number of evenly spaced detents across the travel. Overrides `step` snapping in normalized space. */
steps?: number;
/** Travel-to-value curve. Defaults to linear. Use `logTaper` / `powTaper(n)` for audio params. */
taper?: Taper;
/** Drag behaviour. Default: 'rotary'. */
interaction?: InteractionMode;
/** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */
dragSensitivity?: number;
/** Multiplier applied while Shift is held during relative drags / wheel. Default: 0.1. */
fineMultiplier?: number;
/** Angle (deg clockwise from 12 o'clock) where travel begins. Default: 225. */
angleOffset?: number;
/** Total sweep in degrees. Default: 270. */
angleRange?: number;
/** Enable mouse-wheel adjustment. Default: true. */
enableWheel?: boolean;
/** Reset to `defaultValue` on double click. Default: true when `defaultValue` is set. */
doubleClickReset?: boolean;
disabled?: boolean;
readOnly?: boolean;
/** Fired with each (rounded, snapped) value change. */
onChange?: (value: number) => void;
/** Fired when an adjustment gesture ends (pointer up, wheel settle, key release). */
onChangeEnd?: (value: number) => void;
/** Fired when a drag gesture starts. */
onChangeStart?: (value: number) => void;
/** Accessible name for the slider role. */
'aria-label'?: string;
'aria-labelledby'?: string;
/** Custom text for screen readers, e.g. `v => `${v} dB``. */
getAriaValueText?: (value: number) => string;
}
export interface KnobState {
/** Current (snapped, rounded) value. */
value: number;
/** Normalized travel position in [0, 1] (taper space). */
normalized: number;
/** Display angle in degrees clockwise from 12 o'clock. */
angle: number;
isDragging: boolean;
min: number;
max: number;
decimals: number;
angleOffset: number;
angleRange: number;
}

View file

@ -0,0 +1,88 @@
import * as React from 'react';
import { formatForDisplay, renderSegmentText } from './segments';
export interface SegmentDisplayProps {
value: number | string;
/** Number of digit cells (decimal points don't count). Default: 4. */
digits?: number;
decimals?: number;
/** Digit height in px. Default: 28. */
height?: number;
color?: string;
/** Panel background. Set to 'none' to disable. */
background?: string;
/** Opacity of unlit segments. Default: 0.09. */
ghostOpacity?: number;
/** Italic skew in degrees. Default: 6. */
skew?: number;
/** LED glow strength (0 disables). Default: 1.6. */
glow?: number;
padding?: number;
className?: string;
style?: React.CSSProperties;
}
/**
* A standalone seven-segment LED/LCD numeric display for readouts, meters
* and digital panels.
*/
export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
value,
digits = 4,
decimals = 0,
height = 28,
color = '#3df2ad',
background = '#0a0d0c',
ghostOpacity = 0.09,
skew = 6,
glow = 1.6,
padding = 6,
className,
style,
}) => {
const filterId = React.useId();
const text =
typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value;
const { nodes, width, height: cellH } = renderSegmentText(text, {
color,
ghostOpacity,
skew,
});
const scale = height / cellH;
const w = width * scale + padding * 2 + height * 0.15; // skew headroom
const h = height + padding * 2;
return (
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
className={className}
style={style}
role="img"
aria-label={typeof value === 'number' ? String(value) : value}
>
{glow > 0 && (
<defs>
<filter id={filterId} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={glow} result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
)}
{background !== 'none' && (
<rect x={0} y={0} width={w} height={h} rx={4} fill={background} />
)}
<g
transform={`translate(${padding + height * 0.12} ${padding}) scale(${scale})`}
filter={glow > 0 ? `url(#${filterId})` : undefined}
>
{nodes}
</g>
</svg>
);
};

View file

@ -0,0 +1,122 @@
import * as React from 'react';
// Seven-segment geometry in a local 10 x 18 digit cell.
// Segments: a top, b top-right, c bottom-right, d bottom, e bottom-left,
// f top-left, g middle.
const DIGIT_W = 10;
const DIGIT_H = 18;
const GAP = 3.2; // spacing between digit cells (leaves room for decimal points)
type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g';
const CHAR_SEGMENTS: Record<string, SegmentKey[]> = {
'0': ['a', 'b', 'c', 'd', 'e', 'f'],
'1': ['b', 'c'],
'2': ['a', 'b', 'g', 'e', 'd'],
'3': ['a', 'b', 'g', 'c', 'd'],
'4': ['f', 'g', 'b', 'c'],
'5': ['a', 'f', 'g', 'c', 'd'],
'6': ['a', 'f', 'g', 'e', 'c', 'd'],
'7': ['a', 'b', 'c'],
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
'9': ['a', 'b', 'c', 'd', 'f', 'g'],
'-': ['g'],
' ': [],
};
const H_HALF = 1.1; // half thickness
const INSET = 0.45; // gap between adjacent segments
const hSegment = (y: number): string => {
const x1 = 1 + INSET;
const x2 = DIGIT_W - 1 - INSET;
return `${x1},${y} ${x1 + H_HALF},${y - H_HALF} ${x2 - H_HALF},${y - H_HALF} ${x2},${y} ${x2 - H_HALF},${y + H_HALF} ${x1 + H_HALF},${y + H_HALF}`;
};
const vSegment = (x: number, y1: number, y2: number): string => {
const a = y1 + INSET;
const b = y2 - INSET;
return `${x},${a} ${x + H_HALF},${a + H_HALF} ${x + H_HALF},${b - H_HALF} ${x},${b} ${x - H_HALF},${b - H_HALF} ${x - H_HALF},${a + H_HALF}`;
};
const SEGMENT_POINTS: Record<SegmentKey, string> = {
a: hSegment(1),
g: hSegment(DIGIT_H / 2),
d: hSegment(DIGIT_H - 1),
f: vSegment(1, 1, DIGIT_H / 2),
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2),
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1),
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1),
};
export interface SegmentRenderOptions {
color: string;
/** Opacity of unlit "ghost" segments. 0 disables them. */
ghostOpacity: number;
/** Negative skew for the classic italic LCD look, in degrees. */
skew: number;
}
/**
* Render `text` (digits, '-', '.', ' ') as seven-segment polygons in local
* coordinates (digit height 18). Returns the nodes plus the total width.
*/
export const renderSegmentText = (
text: string,
{ color, ghostOpacity, skew }: SegmentRenderOptions,
): { nodes: React.ReactNode; width: number; height: number } => {
const cells: React.ReactNode[] = [];
let x = 0;
let key = 0;
for (const ch of text) {
if (ch === '.') {
// Decimal point sits in the gap after the previous digit.
cells.push(
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={DIGIT_H - 1.2} r={1.2} fill={color} />,
);
continue;
}
const lit = new Set(CHAR_SEGMENTS[ch] ?? []);
const digit: React.ReactNode[] = [];
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
const on = lit.has(seg);
if (!on && ghostOpacity <= 0) return;
digit.push(
<polygon
key={seg}
points={SEGMENT_POINTS[seg]}
fill={color}
opacity={on ? 1 : ghostOpacity}
/>,
);
});
cells.push(
<g key={key++} transform={`translate(${x} 0)`}>
{digit}
</g>,
);
x += DIGIT_W + GAP;
}
const width = Math.max(x - GAP, 0);
const nodes = skew ? (
<g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g>
) : (
<>{cells}</>
);
return { nodes, width, height: DIGIT_H };
};
/** Format a number for a fixed-width display, e.g. (3.5, 2, 1) -> " 3.5". */
export const formatForDisplay = (
value: number,
digits: number,
decimals: number,
): string => {
let text = value.toFixed(decimals);
const cellCount = (s: string) => s.replace(/\./g, '').length;
if (cellCount(text) > digits) text = ''.padStart(digits, '8'); // overflow
return text.padStart(digits + (text.includes('.') ? 1 : 0), ' ');
};

View file

@ -0,0 +1,350 @@
import * as React from 'react';
import {
angleFromNormalized,
angleFromPoint,
clamp,
decimalsFromStep,
findClosest,
linearTaper,
normalizedFromAngle,
roundTo,
snapToStep,
} from '../core/math';
import type { KnobCoreProps, KnobState } from '../core/types';
export interface UseKnobResult extends KnobState {
/** Ref for the interactive element. Required for wheel + rotary geometry. */
ref: React.RefObject<HTMLDivElement>;
/** Spread onto the interactive element. */
bind: {
ref: React.RefObject<HTMLDivElement>;
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
onPointerCancel: (e: React.PointerEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onKeyUp: (e: React.KeyboardEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
role: 'slider';
tabIndex: number;
'aria-valuemin': number;
'aria-valuemax': number;
'aria-valuenow': number;
'aria-valuetext'?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
'aria-disabled'?: boolean;
'aria-readonly'?: boolean;
style: React.CSSProperties;
};
/** Imperatively set the value (snapped + clamped). */
setValue: (value: number) => void;
}
interface DragSession {
pointerId: number;
centerX: number;
centerY: number;
rect: DOMRect;
lastX: number;
lastY: number;
/** Continuous normalized position, kept un-snapped for smooth relative drags. */
n: number;
}
export function useKnob(props: KnobCoreProps): UseKnobResult {
const {
min = 0,
max = 100,
step = 0,
values,
steps,
taper = linearTaper,
interaction = 'rotary',
dragSensitivity = 200,
fineMultiplier = 0.1,
angleOffset = 225,
angleRange = 270,
enableWheel = true,
disabled = false,
readOnly = false,
onChange,
onChangeEnd,
onChangeStart,
getAriaValueText,
} = props;
const decimals = props.decimals ?? (step > 0 ? decimalsFromStep(step) : 3);
const doubleClickReset =
props.doubleClickReset ?? props.defaultValue !== undefined;
const interactive = !disabled && !readOnly;
/** Clamp, snap and round a raw value into an emittable one. */
const constrain = React.useCallback(
(raw: number): number => {
let v = clamp(raw, Math.min(min, max), Math.max(min, max));
if (values && values.length > 0) {
v = findClosest(values, v);
} else if (steps && steps > 1) {
const n = taper.toNormalized(v, min, max);
const snapped = Math.round(n * (steps - 1)) / (steps - 1);
v = taper.fromNormalized(snapped, min, max);
} else if (step > 0) {
v = clamp(snapToStep(v, step, min), Math.min(min, max), Math.max(min, max));
}
return roundTo(v, decimals);
},
[min, max, step, values, steps, taper, decimals],
);
const isControlled = props.value !== undefined;
const [internalValue, setInternalValue] = React.useState<number>(() =>
constrain(props.defaultValue ?? min),
);
const value = constrain(isControlled ? (props.value as number) : internalValue);
const [isDragging, setIsDragging] = React.useState(false);
const valueRef = React.useRef(value);
valueRef.current = value;
const emit = React.useCallback(
(raw: number) => {
const next = constrain(raw);
if (next === valueRef.current) return;
valueRef.current = next;
if (!isControlled) setInternalValue(next);
onChange?.(next);
},
[constrain, isControlled, onChange],
);
const setFromNormalized = React.useCallback(
(n: number) => emit(taper.fromNormalized(clamp(n, 0, 1), min, max)),
[emit, taper, min, max],
);
// -------------------------------------------------------------------------
// Pointer dragging
// -------------------------------------------------------------------------
const ref = React.useRef<HTMLDivElement>(null);
const session = React.useRef<DragSession | null>(null);
const applyPointer = React.useCallback(
(clientX: number, clientY: number, fine: boolean) => {
const s = session.current;
if (!s) return;
switch (interaction) {
case 'rotary': {
const angle = angleFromPoint(clientX, clientY, s.centerX, s.centerY);
setFromNormalized(normalizedFromAngle(angle, angleOffset % 360, angleRange));
break;
}
case 'track-vertical': {
setFromNormalized((s.rect.bottom - clientY) / s.rect.height);
break;
}
case 'track-horizontal': {
setFromNormalized((clientX - s.rect.left) / s.rect.width);
break;
}
default: {
const scale = (fine ? fineMultiplier : 1) / dragSensitivity;
let dn = 0;
if (interaction === 'vertical' || interaction === 'both')
dn += (s.lastY - clientY) * scale;
if (interaction === 'horizontal' || interaction === 'both')
dn += (clientX - s.lastX) * scale;
s.n = clamp(s.n + dn, 0, 1);
setFromNormalized(s.n);
}
}
s.lastX = clientX;
s.lastY = clientY;
},
[interaction, angleOffset, angleRange, dragSensitivity, fineMultiplier, setFromNormalized],
);
const onPointerDown = React.useCallback(
(e: React.PointerEvent) => {
if (!interactive || !ref.current) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
ref.current.focus({ preventScroll: true });
ref.current.setPointerCapture(e.pointerId);
const rect = ref.current.getBoundingClientRect();
session.current = {
pointerId: e.pointerId,
rect,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
lastX: e.clientX,
lastY: e.clientY,
n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1),
};
setIsDragging(true);
onChangeStart?.(valueRef.current);
// Fader-style modes jump to the pressed position immediately.
if (interaction === 'track-vertical' || interaction === 'track-horizontal') {
applyPointer(e.clientX, e.clientY, e.shiftKey);
}
},
[interactive, interaction, taper, min, max, onChangeStart, applyPointer],
);
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (!session.current || e.pointerId !== session.current.pointerId) return;
applyPointer(e.clientX, e.clientY, e.shiftKey);
},
[applyPointer],
);
const endDrag = React.useCallback(
(e: React.PointerEvent) => {
if (!session.current || e.pointerId !== session.current.pointerId) return;
session.current = null;
setIsDragging(false);
onChangeEnd?.(valueRef.current);
},
[onChangeEnd],
);
// -------------------------------------------------------------------------
// Wheel — needs a non-passive listener so preventDefault stops page scroll.
// -------------------------------------------------------------------------
const wheelSettle = React.useRef<ReturnType<typeof setTimeout>>();
const nudge = React.useCallback(
(direction: number, multiplier = 1) => {
const current = valueRef.current;
if (values && values.length > 0) {
const sorted = [...values].sort((a, b) => a - b);
const idx = sorted.indexOf(findClosest(sorted, current));
emit(sorted[clamp(idx + direction, 0, sorted.length - 1)]);
} else if (steps && steps > 1) {
const n = taper.toNormalized(current, min, max);
setFromNormalized(n + direction / (steps - 1));
} else {
const base = step > 0 ? step : (max - min) / 100;
emit(current + direction * base * multiplier);
}
},
[values, steps, step, min, max, taper, emit, setFromNormalized],
);
const nudgeRef = React.useRef(nudge);
nudgeRef.current = nudge;
React.useEffect(() => {
const el = ref.current;
if (!el || !enableWheel || !interactive) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const direction = e.deltaY < 0 || e.deltaX > 0 ? 1 : -1;
nudgeRef.current(direction, e.shiftKey ? fineMultiplier : 1);
clearTimeout(wheelSettle.current);
wheelSettle.current = setTimeout(() => onChangeEnd?.(valueRef.current), 250);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => {
el.removeEventListener('wheel', onWheel);
clearTimeout(wheelSettle.current);
};
}, [enableWheel, interactive, fineMultiplier, onChangeEnd]);
// -------------------------------------------------------------------------
// Keyboard
// -------------------------------------------------------------------------
const keyAdjusted = React.useRef(false);
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (!interactive) return;
const span = max - min;
let handled = true;
switch (e.key) {
case 'ArrowUp':
case 'ArrowRight':
nudge(1, e.shiftKey ? 10 : 1);
break;
case 'ArrowDown':
case 'ArrowLeft':
nudge(-1, e.shiftKey ? 10 : 1);
break;
case 'PageUp':
emit(valueRef.current + span / 10);
break;
case 'PageDown':
emit(valueRef.current - span / 10);
break;
case 'Home':
emit(min);
break;
case 'End':
emit(max);
break;
default:
handled = false;
}
if (handled) {
e.preventDefault();
keyAdjusted.current = true;
}
},
[interactive, nudge, emit, min, max],
);
const onKeyUp = React.useCallback(() => {
if (keyAdjusted.current) {
keyAdjusted.current = false;
onChangeEnd?.(valueRef.current);
}
}, [onChangeEnd]);
const onDoubleClick = React.useCallback(() => {
if (!interactive || !doubleClickReset || props.defaultValue === undefined) return;
emit(props.defaultValue);
onChangeEnd?.(valueRef.current);
}, [interactive, doubleClickReset, props.defaultValue, emit, onChangeEnd]);
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
return {
value,
normalized,
angle,
isDragging,
min,
max,
decimals,
angleOffset,
angleRange,
ref,
setValue: emit,
bind: {
ref,
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
onKeyDown,
onKeyUp,
onDoubleClick,
role: 'slider',
tabIndex: disabled ? -1 : 0,
'aria-valuemin': min,
'aria-valuemax': max,
'aria-valuenow': value,
'aria-valuetext': getAriaValueText ? getAriaValueText(value) : undefined,
'aria-label': props['aria-label'],
'aria-labelledby': props['aria-labelledby'],
'aria-disabled': disabled || undefined,
'aria-readonly': readOnly || undefined,
style: {
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'grab',
outline: 'none',
},
},
};
}

View file

@ -0,0 +1,44 @@
// Core
export { useKnob, type UseKnobResult } from './hooks/useKnob';
export { Knob, type KnobProps } from './components/Knob';
export { useKnobContext, type KnobRenderContext } from './core/context';
export type { KnobCoreProps, KnobState, InteractionMode } from './core/types';
export {
clamp,
roundTo,
snapToStep,
decimalsFromStep,
findClosest,
linearTaper,
logTaper,
powTaper,
type Taper,
polarToCartesian,
describeArc,
angleFromPoint,
angleFromNormalized,
normalizedFromAngle,
} from './core/math';
// Composable primitives
export { Arc, type ArcProps } from './primitives/Arc';
export { Pointer, type PointerProps } from './primitives/Pointer';
export { Ticks, type TicksProps } from './primitives/Ticks';
export { Face, type FaceProps } from './primitives/Face';
export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from './primitives/Text';
// Digital
export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay';
// Prebuilt skins
export type { SkinProps } from './skins/shared';
export { FlatKnob, type FlatKnobProps } from './skins/FlatKnob';
export { MetalKnob, type MetalKnobProps } from './skins/MetalKnob';
export { RubberKnob, type RubberKnobProps } from './skins/RubberKnob';
export { VintageKnob, type VintageKnobProps } from './skins/VintageKnob';
export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob';
export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
export { Fader, type FaderProps } from './skins/Fader';
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';

View file

@ -0,0 +1,70 @@
import * as React from 'react';
import { useKnobContext } from '../core/context';
import { describeArc } from '../core/math';
export interface ArcProps {
/** Arc radius. Defaults to (size - thickness) / 2. */
radius?: number;
thickness?: number;
/** Value arc color. */
color?: string;
/** Background track color. Omit to skip the track. */
trackColor?: string;
/** Draw the value arc from the minimum ('min') or from the travel center ('center', pan-style). */
from?: 'min' | 'center';
cap?: 'butt' | 'round';
/** Extra SVG props for the value arc path (e.g. filter for glow). */
arcProps?: React.SVGProps<SVGPathElement>;
opacity?: number;
}
/** Background track + value arc following the knob's travel. */
export const Arc: React.FC<ArcProps> = ({
radius,
thickness = 4,
color = 'currentColor',
trackColor,
from = 'min',
cap = 'round',
arcProps,
opacity,
}) => {
const { size, center, normalized, angleOffset, angleRange } = useKnobContext();
const r = radius ?? (size - thickness) / 2;
let start: number;
let end: number;
if (from === 'center') {
const mid = angleOffset + angleRange / 2;
const now = angleOffset + normalized * angleRange;
start = Math.min(mid, now);
end = Math.max(mid, now);
} else {
start = angleOffset;
end = angleOffset + normalized * angleRange;
}
return (
<g opacity={opacity}>
{trackColor && (
<path
d={describeArc(center, center, r, angleOffset, angleOffset + angleRange)}
stroke={trackColor}
strokeWidth={thickness}
strokeLinecap={cap}
fill="none"
/>
)}
{end - start > 0.0001 && (
<path
d={describeArc(center, center, r, start, end)}
stroke={color}
strokeWidth={thickness}
strokeLinecap={cap}
fill="none"
{...arcProps}
/>
)}
</g>
);
};

View file

@ -0,0 +1,13 @@
import * as React from 'react';
import { useKnobContext } from '../core/context';
export interface FaceProps extends React.SVGProps<SVGCircleElement> {
/** Body radius. Defaults to size/2 - 6. */
radius?: number;
}
/** The knob body: a centered circle. Style via fill/stroke/filter props. */
export const Face: React.FC<FaceProps> = ({ radius, ...rest }) => {
const { size, center } = useKnobContext();
return <circle cx={center} cy={center} r={radius ?? size / 2 - 6} {...rest} />;
};

View file

@ -0,0 +1,35 @@
import * as React from 'react';
import { useKnobContext } from '../core/context';
export interface GlowFilterProps {
id: string;
/** Gaussian blur radius in px. */
blur: number;
}
/**
* LED-style glow filter with a userSpaceOnUse region covering the whole
* canvas. The default objectBoundingBox region collapses for thin stroked
* paths (a straight line has a zero-area geometric bbox), which clips the
* stroke and glow this avoids that entirely.
*/
export const GlowFilter: React.FC<GlowFilterProps> = ({ id, blur }) => {
const { size } = useKnobContext();
const pad = size * 0.3;
return (
<filter
id={id}
filterUnits="userSpaceOnUse"
x={-pad}
y={-pad}
width={size + pad * 2}
height={size + pad * 2}
>
<feGaussianBlur stdDeviation={blur} result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
);
};

View file

@ -0,0 +1,73 @@
import * as React from 'react';
import { useKnobContext } from '../core/context';
export interface PointerProps {
/** Built-in shapes. Provide children instead for a custom pointer. */
type?: 'line' | 'circle' | 'triangle';
/** Distance from center to the pointer's outer end. Defaults to size/2 - 4. */
radius?: number;
/** Length of the pointer along the radial axis. */
length?: number;
width?: number;
color?: string;
cap?: 'butt' | 'round';
/** Extra SVG props applied to the shape. */
shapeProps?: React.SVGProps<SVGElement>;
/** Custom pointer content, drawn pointing at 12 o'clock and rotated for you. */
children?: React.ReactNode;
}
/** Rotating indicator. Children (or the built-in shape) are authored pointing up. */
export const Pointer: React.FC<PointerProps> = ({
type = 'line',
radius,
length = 12,
width = 4,
color = 'currentColor',
cap = 'round',
shapeProps,
children,
}) => {
const { size, center, angle } = useKnobContext();
const r = radius ?? size / 2 - 4;
let shape: React.ReactNode = children;
if (!shape) {
if (type === 'line') {
shape = (
<line
x1={center}
y1={center - r}
x2={center}
y2={center - r + length}
stroke={color}
strokeWidth={width}
strokeLinecap={cap}
{...(shapeProps as React.SVGProps<SVGLineElement>)}
/>
);
} else if (type === 'circle') {
shape = (
<circle
cx={center}
cy={center - r + width / 2}
r={width}
fill={color}
{...(shapeProps as React.SVGProps<SVGCircleElement>)}
/>
);
} else {
const half = width / 2;
const tipY = center - r;
shape = (
<polygon
points={`${center},${tipY} ${center - half},${tipY + length} ${center + half},${tipY + length}`}
fill={color}
{...(shapeProps as React.SVGProps<SVGPolygonElement>)}
/>
);
}
}
return <g transform={`rotate(${angle} ${center} ${center})`}>{shape}</g>;
};

View file

@ -0,0 +1,97 @@
import * as React from 'react';
import { useKnobContext } from '../core/context';
/** "64.000" -> "64", "12.50" -> "12.5"; leaves integers untouched. */
const trimZeros = (s: string): string =>
s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
export interface KnobValueProps {
/** Fixed decimal places for display. Default: the knob's `decimals`, with trailing zeros trimmed. */
decimals?: number;
/** Full custom formatter — takes precedence over `decimals` / `unit`. */
format?: (value: number) => string;
unit?: string;
fontSize?: number;
color?: string;
fontFamily?: string;
fontWeight?: number | string;
/** Vertical offset from center. */
dy?: number;
textProps?: React.SVGProps<SVGTextElement>;
}
/** Numeric readout in the middle of the knob. */
export const KnobValue: React.FC<KnobValueProps> = ({
decimals,
format,
unit,
fontSize,
color = 'currentColor',
fontFamily,
fontWeight = 600,
dy = 0,
textProps,
}) => {
const ctx = useKnobContext();
const text = format
? format(ctx.value)
: `${
decimals !== undefined
? ctx.value.toFixed(decimals)
: trimZeros(ctx.value.toFixed(ctx.decimals))
}${unit ?? ''}`;
return (
<text
x={ctx.center}
y={ctx.center + dy}
textAnchor="middle"
dominantBaseline="central"
fontSize={fontSize ?? ctx.size * 0.18}
fill={color}
fontFamily={fontFamily}
fontWeight={fontWeight}
style={{ pointerEvents: 'none' }}
{...textProps}
>
{text}
</text>
);
};
export interface KnobLabelProps {
children: React.ReactNode;
fontSize?: number;
color?: string;
fontFamily?: string;
/** Vertical offset from center; defaults to just below the knob body. */
dy?: number;
textProps?: React.SVGProps<SVGTextElement>;
}
/** Small caption, e.g. the parameter name. */
export const KnobLabel: React.FC<KnobLabelProps> = ({
children,
fontSize,
color = 'currentColor',
fontFamily,
dy,
textProps,
}) => {
const { size, center } = useKnobContext();
return (
<text
x={center}
y={center + (dy ?? size * 0.38)}
textAnchor="middle"
dominantBaseline="central"
fontSize={fontSize ?? size * 0.12}
fill={color}
fontFamily={fontFamily}
letterSpacing="0.08em"
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
{...textProps}
>
{children}
</text>
);
};

View file

@ -0,0 +1,55 @@
import * as React from 'react';
import { useKnobContext } from '../core/context';
import { polarToCartesian } from '../core/math';
export interface TicksProps {
count?: number;
/** Outer radius of the ticks. Defaults to size/2. */
radius?: number;
length?: number;
width?: number;
color?: string;
/** Color for ticks at or below the current position. Enables "lit" scales. */
activeColor?: string;
cap?: 'butt' | 'round';
/** Extra props per tick line, by index. */
getTickProps?: (index: number, active: boolean) => React.SVGProps<SVGLineElement> | undefined;
}
/** Radial tick marks distributed across the knob's travel. */
export const Ticks: React.FC<TicksProps> = ({
count = 11,
radius,
length = 6,
width = 2,
color = 'currentColor',
activeColor,
cap = 'round',
getTickProps,
}) => {
const { size, center, normalized, angleOffset, angleRange } = useKnobContext();
const outer = radius ?? size / 2;
const ticks: React.ReactNode[] = [];
for (let i = 0; i < count; i++) {
const t = count === 1 ? 0 : i / (count - 1);
const angle = angleOffset + t * angleRange;
const p1 = polarToCartesian(center, center, outer, angle);
const p2 = polarToCartesian(center, center, outer - length, angle);
const active = t <= normalized + 1e-9;
ticks.push(
<line
key={i}
x1={p1.x}
y1={p1.y}
x2={p2.x}
y2={p2.y}
stroke={active && activeColor ? activeColor : color}
strokeWidth={width}
strokeLinecap={cap}
{...getTickProps?.(i, active)}
/>,
);
}
return <g>{ticks}</g>;
};

View file

@ -0,0 +1,193 @@
import * as React from 'react';
import { useKnob } from '../hooks/useKnob';
import type { KnobCoreProps } from '../core/types';
import { MONO_FONT, UI_FONT } from './shared';
export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
/** Cross-axis size in px. Default: 44. */
breadth?: number;
color?: string;
trackColor?: string;
/** Fill the travelled part of the track. Default: true. */
showFill?: boolean;
tickCount?: number;
tickColor?: string;
label?: string;
showValue?: boolean;
unit?: string;
format?: (value: number) => string;
textColor?: string;
labelColor?: string;
className?: string;
style?: React.CSSProperties;
}
/** Studio channel fader: absolute-position linear control with a cap handle. */
export const Fader: React.FC<FaderProps> = ({
orientation = 'vertical',
length = 160,
breadth = 44,
color = '#4cc2ff',
trackColor = 'rgba(255,255,255,0.12)',
showFill = true,
tickCount = 9,
tickColor = 'rgba(255,255,255,0.15)',
label,
showValue = true,
unit,
format,
textColor = 'rgba(255,255,255,0.92)',
labelColor = 'rgba(255,255,255,0.45)',
className,
style,
...core
}) => {
const vertical = orientation === 'vertical';
const knob = useKnob({
...core,
interaction: vertical ? 'track-vertical' : 'track-horizontal',
});
const id = React.useId();
const capMain = 20; // handle size along the travel axis
const capCross = breadth * 0.62;
const trackW = Math.max(4, breadth * 0.12);
const w = vertical ? breadth : length;
const h = vertical ? length : breadth;
const mid = breadth / 2;
// Handle center position along the travel axis.
const travel = length - capMain;
const pos = vertical
? length - capMain / 2 - knob.normalized * travel
: capMain / 2 + knob.normalized * travel;
const fixed = knob.value.toFixed(knob.decimals);
const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
const text = format ? format(knob.value) : `${trimmed}${unit ?? ''}`;
const ticks: React.ReactNode[] = [];
for (let i = 0; i < tickCount; i++) {
const t = tickCount === 1 ? 0 : i / (tickCount - 1);
const p = vertical ? length - capMain / 2 - t * travel : capMain / 2 + t * travel;
ticks.push(
vertical ? (
<React.Fragment key={i}>
<line x1={mid - breadth * 0.36} y1={p} x2={mid - breadth * 0.24} y2={p} stroke={tickColor} strokeWidth="1.4" />
<line x1={mid + breadth * 0.24} y1={p} x2={mid + breadth * 0.36} y2={p} stroke={tickColor} strokeWidth="1.4" />
</React.Fragment>
) : (
<React.Fragment key={i}>
<line x1={p} y1={mid - breadth * 0.36} x2={p} y2={mid - breadth * 0.24} stroke={tickColor} strokeWidth="1.4" />
<line x1={p} y1={mid + breadth * 0.24} x2={p} y2={mid + breadth * 0.36} stroke={tickColor} strokeWidth="1.4" />
</React.Fragment>
),
);
}
return (
<div
className={className}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
// Fixed width so a widening value readout never reflows the layout;
// overflowing text spills symmetrically via the centered flex rows.
width: w,
...style,
}}
>
{showValue && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<span
style={{
fontFamily: MONO_FONT,
fontSize: 12,
color: textColor,
minHeight: '1.2em',
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{text}
</span>
</div>
)}
<div {...knob.bind} style={{ ...knob.bind.style, width: w, height: h, display: 'inline-flex' }}>
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
<defs>
<linearGradient id={`${id}-cap`} x1="0" y1="0" x2={vertical ? '0' : '1'} y2={vertical ? '1' : '0'}>
<stop offset="0" stopColor="#43444b" />
<stop offset="0.45" stopColor="#26272c" />
<stop offset="0.5" stopColor="#0c0d10" />
<stop offset="0.55" stopColor="#26272c" />
<stop offset="1" stopColor="#16171b" />
</linearGradient>
</defs>
{ticks}
{vertical ? (
<>
<rect x={mid - trackW / 2} y={capMain / 2} width={trackW} height={travel} rx={trackW / 2} fill={trackColor} />
{showFill && (
<rect x={mid - trackW / 2} y={pos} width={trackW} height={length - capMain / 2 - pos} rx={trackW / 2} fill={color} />
)}
<g style={{ transition: knob.isDragging ? undefined : 'transform 40ms linear' }}>
<rect
x={mid - capCross / 2}
y={pos - capMain / 2}
width={capCross}
height={capMain}
rx={3}
fill={`url(#${id}-cap)`}
stroke="rgba(0,0,0,0.6)"
/>
<line x1={mid - capCross / 2 + 3} y1={pos} x2={mid + capCross / 2 - 3} y2={pos} stroke={color} strokeWidth="2" />
</g>
</>
) : (
<>
<rect x={capMain / 2} y={mid - trackW / 2} width={travel} height={trackW} rx={trackW / 2} fill={trackColor} />
{showFill && (
<rect x={capMain / 2} y={mid - trackW / 2} width={pos - capMain / 2} height={trackW} rx={trackW / 2} fill={color} />
)}
<g>
<rect
x={pos - capMain / 2}
y={mid - capCross / 2}
width={capMain}
height={capCross}
rx={3}
fill={`url(#${id}-cap)`}
stroke="rgba(0,0,0,0.6)"
/>
<line x1={pos} y1={mid - capCross / 2 + 3} x2={pos} y2={mid + capCross / 2 - 3} stroke={color} strokeWidth="2" />
</g>
</>
)}
</svg>
</div>
{label && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<span
style={{
fontFamily: UI_FONT,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: labelColor,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{label}
</span>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,69 @@
import * as React from 'react';
import { Knob } from '../components/Knob';
import { Arc } from '../primitives/Arc';
import { Face } from '../primitives/Face';
import { Pointer } from '../primitives/Pointer';
import { KnobLabel, KnobValue } from '../primitives/Text';
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
export interface FlatKnobProps extends SkinProps {
/** Accent color for the value arc and pointer. */
color?: string;
trackColor?: string;
faceColor?: string;
pointerColor?: string;
textColor?: string;
labelColor?: string;
/** Draw the value arc from 'min' or from 'center' (pan-style). */
arcFrom?: 'min' | 'center';
arcThickness?: number;
}
/** Clean 2D knob — the modern DAW/plugin look. */
export const FlatKnob: React.FC<FlatKnobProps> = ({
size = 80,
color = '#4cc2ff',
trackColor = 'rgba(255,255,255,0.12)',
faceColor = 'rgba(255,255,255,0.05)',
pointerColor,
textColor = 'rgba(255,255,255,0.92)',
labelColor = 'rgba(255,255,255,0.45)',
arcFrom = 'min',
arcThickness,
label,
showValue = true,
unit,
format,
className,
style,
...core
}) => {
const t = arcThickness ?? Math.max(3, size * 0.055);
return (
<Knob size={size} className={className} style={style} {...core}>
<Arc thickness={t} color={color} trackColor={trackColor} from={arcFrom} />
<Face radius={size / 2 - t - 5} fill={faceColor} />
<Pointer
type="line"
radius={size / 2 - t - 7}
length={size * 0.16}
width={Math.max(2.5, size * 0.04)}
color={pointerColor ?? color}
/>
{showValue && (
<KnobValue
color={textColor}
unit={unit}
format={format}
fontSize={size * 0.17}
fontFamily={MONO_FONT}
/>
)}
{label && (
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.42} fontSize={size * 0.1}>
{label}
</KnobLabel>
)}
</Knob>
);
};

View file

@ -0,0 +1,192 @@
import * as React from 'react';
import { useKnob } from '../hooks/useKnob';
import type { KnobCoreProps } from '../core/types';
import { SegmentDisplay } from '../digital/SegmentDisplay';
import { UI_FONT } from './shared';
export interface LEDFaderZone {
/** Upper bound of the zone as a normalized position (0..1]. */
upTo: number;
color: string;
}
export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
/** Cross-axis size in px. Default: 34. */
breadth?: number;
/** Number of LED segments. Default: 24. */
segments?: number;
/** LED color when no zones are given. */
color?: string;
/**
* Meter-style color zones by normalized position, e.g.
* [{ upTo: 0.6, color: '#3df2ad' }, { upTo: 0.85, color: '#ffd23e' }, { upTo: 1, color: '#ff4d6b' }]
*/
zones?: readonly LEDFaderZone[];
/** Opacity of unlit segments (they keep their zone color). Default: 0.13. */
offOpacity?: number;
glow?: boolean;
/** Well/panel color behind the LEDs. */
faceColor?: string;
label?: string;
/** Seven-segment readout above the bar. Default: true. */
showValue?: boolean;
/** Digit cells in the readout. Default: 4. */
digits?: number;
/** Decimals in the readout (defaults to the control's decimals, capped at 1). */
displayDecimals?: number;
labelColor?: string;
className?: string;
style?: React.CSSProperties;
}
/** Segmented LED fader — an interactive meter-style linear control. */
export const LEDFader: React.FC<LEDFaderProps> = ({
orientation = 'vertical',
length = 160,
breadth = 34,
segments = 24,
color = '#3df2ad',
zones,
offOpacity = 0.13,
glow = true,
faceColor = '#0b0d0e',
label,
showValue = true,
digits = 4,
displayDecimals,
labelColor = 'rgba(255,255,255,0.45)',
className,
style,
...core
}) => {
const vertical = orientation === 'vertical';
const knob = useKnob({
...core,
interaction: vertical ? 'track-vertical' : 'track-horizontal',
});
const id = React.useId();
const glowId = `${id}-glow`;
const w = vertical ? breadth : length;
const h = vertical ? length : breadth;
const pad = 5;
const zoneList: readonly LEDFaderZone[] = zones ?? [{ upTo: 1, color }];
const zoneFor = (t: number): string =>
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
const lit = Math.round(knob.normalized * segments);
const slot = (length - pad * 2) / segments;
const gap = Math.min(3, slot * 0.35);
const cross = breadth - pad * 2;
const leds: React.ReactNode[] = [];
for (let i = 0; i < segments; i++) {
const on = i < lit;
const c = zoneFor((i + 1) / segments);
const common = {
rx: 1.5,
fill: c,
opacity: on ? 1 : offOpacity,
filter: on && glow ? `url(#${glowId})` : undefined,
};
leds.push(
vertical ? (
<rect
key={i}
x={pad}
y={length - pad - (i + 1) * slot + gap / 2}
width={cross}
height={slot - gap}
{...common}
/>
) : (
<rect
key={i}
x={pad + i * slot + gap / 2}
y={pad}
width={slot - gap}
height={cross}
{...common}
/>
),
);
}
return (
<div
className={className}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
width: w,
...style,
}}
>
{showValue && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
<SegmentDisplay
value={knob.value}
digits={digits}
decimals={displayDecimals ?? Math.min(knob.decimals, 1)}
height={12}
color={zoneFor(knob.normalized)}
background="none"
ghostOpacity={0.07}
/>
</div>
)}
<div {...knob.bind} style={{ ...knob.bind.style, width: w, height: h, display: 'inline-flex' }}>
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
<defs>
<filter
id={glowId}
filterUnits="userSpaceOnUse"
x={-w * 0.3}
y={-h * 0.3}
width={w * 1.6}
height={h * 1.6}
>
<feGaussianBlur stdDeviation={1.2} result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<rect
x={0.5}
y={0.5}
width={w - 1}
height={h - 1}
rx={5}
fill={faceColor}
stroke="rgba(255,255,255,0.09)"
/>
{leds}
</svg>
</div>
{label && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<span
style={{
fontFamily: UI_FONT,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: labelColor,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{label}
</span>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,131 @@
import * as React from 'react';
import { Knob } from '../components/Knob';
import { GlowFilter } from '../primitives/GlowFilter';
import { KnobLabel } from '../primitives/Text';
import { describeArc } from '../core/math';
import { useKnobContext } from '../core/context';
import { formatForDisplay, renderSegmentText } from '../digital/segments';
import { UI_FONT, type SkinProps } from './shared';
export interface LEDKnobProps extends SkinProps {
/** LED color. */
color?: string;
/** Unlit segment color. */
offColor?: string;
/** Number of LED segments around the travel. */
segments?: number;
/** Digit cells in the center readout. */
digits?: number;
/** Decimals shown in the readout (defaults to the knob's decimals, capped at 2). */
displayDecimals?: number;
labelColor?: string;
faceColor?: string;
}
const SegmentRing: React.FC<{ color: string; offColor: string; segments: number; glowId: string }> = ({
color,
offColor,
segments,
glowId,
}) => {
const { size, center: c, normalized, angleOffset, angleRange } = useKnobContext();
const r = size / 2 - size * 0.065;
const thickness = size * 0.065;
const lit = Math.round(normalized * segments);
const gapDeg = Math.min(3, (angleRange / segments) * 0.3);
const segDeg = angleRange / segments - gapDeg;
return (
<g>
{Array.from({ length: segments }, (_, i) => {
const start = angleOffset + (i * angleRange) / segments + gapDeg / 2;
const on = i < lit;
return (
<path
key={i}
d={describeArc(c, c, r, start, start + segDeg)}
stroke={on ? color : offColor}
strokeWidth={thickness}
fill="none"
filter={on ? `url(#${glowId})` : undefined}
/>
);
})}
</g>
);
};
const CenterDisplay: React.FC<{
color: string;
digits: number;
decimals: number;
glowId: string;
}> = ({ color, digits, decimals, glowId }) => {
const { size, center: c, value } = useKnobContext();
const text = formatForDisplay(value, digits, decimals);
const { nodes, width, height } = renderSegmentText(text, {
color,
ghostOpacity: 0.1,
skew: 6,
});
const targetW = size * 0.46;
const scale = width > 0 ? Math.min(targetW / width, (size * 0.24) / height) : 1;
return (
<g
transform={`translate(${c - (width * scale) / 2} ${c - (height * scale) / 2}) scale(${scale})`}
filter={`url(#${glowId})`}
>
{nodes}
</g>
);
};
/** Digital knob: segmented LED ring around a real seven-segment readout. */
export const LEDKnob: React.FC<LEDKnobProps> = ({
size = 90,
color = '#3df2ad',
offColor = 'rgba(255,255,255,0.07)',
segments = 24,
digits = 3,
displayDecimals,
labelColor = 'rgba(255,255,255,0.45)',
faceColor = '#0b0d0e',
label,
className,
style,
...core
}) => {
const id = React.useId();
const glowId = `${id}-glow`;
return (
<Knob size={size} className={className} style={style} {...core}>
{ctx => (
<>
<defs>
<GlowFilter id={glowId} blur={size * 0.014} />
</defs>
<circle
cx={ctx.center}
cy={ctx.center}
r={size / 2 - size * 0.14}
fill={faceColor}
stroke="rgba(255,255,255,0.08)"
strokeWidth="1"
/>
<SegmentRing color={color} offColor={offColor} segments={segments} glowId={glowId} />
<CenterDisplay
color={color}
digits={digits}
decimals={displayDecimals ?? Math.min(ctx.decimals, 2)}
glowId={glowId}
/>
{label && (
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.095}>
{label}
</KnobLabel>
)}
</>
)}
</Knob>
);
};

View file

@ -0,0 +1,122 @@
import * as React from 'react';
import { Knob } from '../components/Knob';
import { Arc } from '../primitives/Arc';
import { Pointer } from '../primitives/Pointer';
import { Ticks } from '../primitives/Ticks';
import { KnobLabel } from '../primitives/Text';
import { UI_FONT, type SkinProps } from './shared';
export interface MetalKnobProps extends SkinProps {
/** Accent color for the active ticks / value arc. */
color?: string;
/** Overall metal tone. 'silver' | 'dark' or any base hex for the cap. */
tone?: 'silver' | 'dark';
tickColor?: string;
labelColor?: string;
indicatorColor?: string;
showArc?: boolean;
tickCount?: number;
}
/** 3D brushed-aluminum knob with a knurled rim — classic hi-fi hardware. */
export const MetalKnob: React.FC<MetalKnobProps> = ({
size = 90,
color = '#4cc2ff',
tone = 'silver',
tickColor = 'rgba(255,255,255,0.18)',
labelColor = 'rgba(255,255,255,0.45)',
indicatorColor,
showArc = true,
tickCount = 21,
label,
className,
style,
...core
}) => {
const id = React.useId();
const rimId = `${id}-rim`;
const faceId = `${id}-face`;
const shadowId = `${id}-shadow`;
const c = size / 2;
const rimR = size / 2 - size * 0.14;
const faceR = rimR * 0.78;
const silver = tone === 'silver';
const rimStops = silver
? ['#f4f5f7', '#b9bbc2', '#63656d', '#33343a']
: ['#6a6c74', '#43444b', '#232429', '#101114'];
const faceStops = silver
? ['#fbfcfd', '#d3d5da', '#9c9ea6']
: ['#585a63', '#33343b', '#1b1c21'];
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
return (
<Knob size={size} className={className} style={style} {...core}>
<defs>
<linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1">
<stop offset="0" stopColor={rimStops[0]} />
<stop offset="0.45" stopColor={rimStops[1]} />
<stop offset="0.8" stopColor={rimStops[2]} />
<stop offset="1" stopColor={rimStops[3]} />
</linearGradient>
<radialGradient id={faceId} cx="0.35" cy="0.28" r="0.9">
<stop offset="0" stopColor={faceStops[0]} />
<stop offset="0.6" stopColor={faceStops[1]} />
<stop offset="1" stopColor={faceStops[2]} />
</radialGradient>
<filter id={shadowId} x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow
dx="0"
dy={size * 0.02}
stdDeviation={size * 0.025}
floodColor="#000"
floodOpacity="0.55"
/>
</filter>
</defs>
{showArc && (
<Arc
radius={size / 2 - 2}
thickness={Math.max(2, size * 0.03)}
color={color}
trackColor="rgba(255,255,255,0.07)"
/>
)}
<Ticks
count={tickCount}
radius={size / 2 - size * 0.055}
length={size * 0.05}
width={Math.max(1, size * 0.015)}
color={tickColor}
activeColor={color}
/>
{/* cap */}
<g filter={`url(#${shadowId})`}>
<circle cx={c} cy={c} r={rimR} fill={`url(#${rimId})`} />
</g>
{/* knurled rim */}
<circle
cx={c}
cy={c}
r={rimR - size * 0.014}
fill="none"
stroke="rgba(0,0,0,0.35)"
strokeWidth={size * 0.028}
strokeDasharray={`${size * 0.014} ${size * 0.02}`}
/>
<circle cx={c} cy={c} r={faceR} fill={`url(#${faceId})`} />
{/* concentric machining lines */}
<circle cx={c} cy={c} r={faceR * 0.72} fill="none" stroke="rgba(255,255,255,0.25)" strokeWidth="0.6" />
<circle cx={c} cy={c} r={faceR * 0.5} fill="none" stroke="rgba(0,0,0,0.12)" strokeWidth="0.6" />
<Pointer radius={faceR - size * 0.02} length={faceR * 0.55} width={Math.max(2.5, size * 0.038)} color={indicator} />
{label && (
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
{label}
</KnobLabel>
)}
</Knob>
);
};

View file

@ -0,0 +1,86 @@
import * as React from 'react';
import { Knob } from '../components/Knob';
import { Arc } from '../primitives/Arc';
import { Pointer } from '../primitives/Pointer';
import { KnobLabel, KnobValue } from '../primitives/Text';
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
export interface NeonKnobProps extends SkinProps {
color?: string;
trackColor?: string;
textColor?: string;
labelColor?: string;
arcFrom?: 'min' | 'center';
}
/** Minimal glowing arc knob — futuristic / dark-mode DAW style. */
export const NeonKnob: React.FC<NeonKnobProps> = ({
size = 84,
color = '#e44cff',
trackColor = 'rgba(255,255,255,0.06)',
textColor,
labelColor = 'rgba(255,255,255,0.4)',
arcFrom = 'min',
label,
showValue = true,
unit,
format,
className,
style,
...core
}) => {
const id = React.useId();
const glowId = `${id}-glow`;
return (
<Knob size={size} className={className} style={style} {...core}>
<defs>
<filter
id={glowId}
filterUnits="userSpaceOnUse"
x={-size * 0.3}
y={-size * 0.3}
width={size * 1.6}
height={size * 1.6}
>
<feGaussianBlur stdDeviation={size * 0.035} result="b1" />
<feGaussianBlur in="SourceGraphic" stdDeviation={size * 0.012} result="b2" />
<feMerge>
<feMergeNode in="b1" />
<feMergeNode in="b2" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<Arc
radius={size / 2 - size * 0.09}
thickness={Math.max(2.5, size * 0.035)}
color={color}
trackColor={trackColor}
from={arcFrom}
arcProps={{ filter: `url(#${glowId})` }}
/>
<Pointer
type="circle"
radius={size / 2 - size * 0.09}
width={Math.max(2.5, size * 0.035)}
color="#ffffff"
shapeProps={{ filter: `url(#${glowId})` }}
/>
{showValue && (
<KnobValue
color={textColor ?? color}
unit={unit}
format={format}
fontSize={size * 0.17}
fontFamily={MONO_FONT}
textProps={{ filter: `url(#${glowId})` }}
/>
)}
{label && (
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.1}>
{label}
</KnobLabel>
)}
</Knob>
);
};

View file

@ -0,0 +1,104 @@
import * as React from 'react';
import { Knob } from '../components/Knob';
import { Arc } from '../primitives/Arc';
import { GlowFilter } from '../primitives/GlowFilter';
import { Pointer } from '../primitives/Pointer';
import { KnobLabel, KnobValue } from '../primitives/Text';
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
export interface RubberKnobProps extends SkinProps {
/** Glow/accent color. */
color?: string;
trackColor?: string;
textColor?: string;
labelColor?: string;
arcFrom?: 'min' | 'center';
}
/** Soft-touch rubber knob with a glowing halo — modern synth hardware. */
export const RubberKnob: React.FC<RubberKnobProps> = ({
size = 90,
color = '#ff9640',
trackColor = 'rgba(255,255,255,0.08)',
textColor = 'rgba(255,255,255,0.92)',
labelColor = 'rgba(255,255,255,0.45)',
arcFrom = 'min',
label,
showValue = false,
unit,
format,
className,
style,
...core
}) => {
const id = React.useId();
const bodyId = `${id}-body`;
const glowId = `${id}-glow`;
const c = size / 2;
const bodyR = size / 2 - size * 0.17;
return (
<Knob size={size} className={className} style={style} {...core}>
<defs>
<radialGradient id={bodyId} cx="0.38" cy="0.3" r="1">
<stop offset="0" stopColor="#3a3b41" />
<stop offset="0.55" stopColor="#232428" />
<stop offset="1" stopColor="#0e0f12" />
</radialGradient>
<GlowFilter id={glowId} blur={size * 0.02} />
</defs>
<Arc
radius={size / 2 - size * 0.05}
thickness={Math.max(3, size * 0.045)}
color={color}
trackColor={trackColor}
from={arcFrom}
arcProps={{ filter: `url(#${glowId})` }}
/>
<circle cx={c} cy={c} r={bodyR} fill={`url(#${bodyId})`} stroke="#000" strokeWidth="1" />
{/* rubber grip */}
<circle
cx={c}
cy={c}
r={bodyR - size * 0.03}
fill="none"
stroke="rgba(0,0,0,0.5)"
strokeWidth={size * 0.035}
strokeDasharray={`${size * 0.02} ${size * 0.03}`}
/>
{/* top sheen */}
<ellipse
cx={c}
cy={c - bodyR * 0.45}
rx={bodyR * 0.6}
ry={bodyR * 0.28}
fill="rgba(255,255,255,0.07)"
/>
<Pointer
radius={bodyR - size * 0.045}
length={bodyR * 0.5}
width={Math.max(2.5, size * 0.04)}
color={color}
shapeProps={{ filter: `url(#${glowId})` }}
/>
{showValue && (
<KnobValue
color={textColor}
unit={unit}
format={format}
fontSize={size * 0.15}
fontFamily={MONO_FONT}
dy={size * 0.02}
/>
)}
{label && (
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
{label}
</KnobLabel>
)}
</Knob>
);
};

View file

@ -0,0 +1,94 @@
import * as React from 'react';
import { Knob } from '../components/Knob';
import { Pointer } from '../primitives/Pointer';
import { Ticks } from '../primitives/Ticks';
import { KnobLabel } from '../primitives/Text';
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
export interface SteppedKnobProps extends SkinProps {
/** Position names, e.g. ['LP','BP','HP']. Sets the number of detents. */
positions?: readonly string[];
/** Number of detents when `positions` is not given. */
steps?: number;
color?: string;
tickColor?: string;
faceColor?: string;
textColor?: string;
labelColor?: string;
}
/** Detented selector knob — mode/range switches with hard stops. */
export const SteppedKnob: React.FC<SteppedKnobProps> = ({
size = 90,
positions,
steps,
color = '#ffd23e',
tickColor = 'rgba(255,255,255,0.18)',
faceColor = '#1c1d22',
textColor = 'rgba(255,255,255,0.92)',
labelColor = 'rgba(255,255,255,0.45)',
label,
showValue = true,
className,
style,
...core
}) => {
const detents = positions ? positions.length : steps ?? 5;
const min = core.min ?? 0;
const max = core.max ?? (positions ? positions.length - 1 : 100);
return (
<Knob size={size} className={className} style={style} {...core} min={min} max={max} steps={detents}>
{ctx => {
const index = Math.round(ctx.normalized * (detents - 1));
return (
<>
<Ticks
count={detents}
radius={size / 2 - 1}
length={size * 0.07}
width={Math.max(1.5, size * 0.022)}
color={tickColor}
getTickProps={i => (i === index ? { stroke: color } : undefined)}
/>
<circle
cx={ctx.center}
cy={ctx.center}
r={size / 2 - size * 0.14}
fill={faceColor}
stroke="rgba(255,255,255,0.1)"
strokeWidth="1"
/>
<Pointer
type="triangle"
radius={size / 2 - size * 0.15}
length={size * 0.14}
width={size * 0.09}
color={color}
/>
{showValue && (
<text
x={ctx.center}
y={ctx.center}
textAnchor="middle"
dominantBaseline="central"
fontSize={size * (positions ? 0.15 : 0.17)}
fill={textColor}
fontFamily={MONO_FONT}
fontWeight={600}
style={{ pointerEvents: 'none' }}
>
{positions ? positions[index] : ctx.value.toFixed(ctx.decimals)}
</text>
)}
{label && (
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.095}>
{label}
</KnobLabel>
)}
</>
);
}}
</Knob>
);
};

View file

@ -0,0 +1,156 @@
import * as React from 'react';
import { Knob } from '../components/Knob';
import { Ticks } from '../primitives/Ticks';
import { KnobLabel } from '../primitives/Text';
import { polarToCartesian } from '../core/math';
import { useKnobContext } from '../core/context';
import { UI_FONT, type SkinProps } from './shared';
export interface VintageKnobProps extends SkinProps {
/** Knob body color. Classic options: cream '#efe6d0' or bakelite '#26221f'. */
bodyColor?: string;
/** Tick/scale color. */
scaleColor?: string;
/** Accent for active ticks. */
color?: string;
labelColor?: string;
/** Draw numeric scale labels (0..10 style) around the knob. */
scaleLabels?: readonly string[];
/** Indicator line color. Defaults to a contrast pick based on bodyColor. */
indicatorColor?: string;
}
/** Rough luminance check so the indicator stays visible on any body color. */
const isDarkColor = (color: string): boolean => {
const m = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!m) return false;
let h = m[1];
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110;
};
/** Chicken-head pointer knob rotated by the current angle. */
const ChickenHead: React.FC<{ bodyColor: string; indicatorColor: string }> = ({
bodyColor,
indicatorColor,
}) => {
const { size, center: c, angle } = useKnobContext();
const id = React.useId();
const bodyR = size * 0.26;
const noseR = size * 0.42;
// Body circle with a pointed nose reaching up to noseR, authored at 12
// o'clock. Two shoulder points at ±38° blend the nose into the circle.
const left = polarToCartesian(c, c, bodyR, -38);
const right = polarToCartesian(c, c, bodyR, 38);
const tip = { x: c, y: c - noseR };
return (
<g transform={`rotate(${angle} ${c} ${c})`} filter={`url(#${id}-s)`}>
<defs>
<radialGradient id={`${id}-g`} cx="0.4" cy="0.3" r="1">
<stop offset="0" stopColor="#ffffff" stopOpacity="0.32" />
<stop offset="0.5" stopColor="#ffffff" stopOpacity="0.05" />
<stop offset="1" stopColor="#000000" stopOpacity="0.28" />
</radialGradient>
<filter id={`${id}-s`} x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy={size * 0.015} stdDeviation={size * 0.02} floodColor="#000" floodOpacity="0.5" />
</filter>
</defs>
<circle cx={c} cy={c} r={bodyR} fill={bodyColor} />
<path
d={`M ${left.x} ${left.y} Q ${c - size * 0.055} ${c - noseR * 0.72} ${tip.x} ${tip.y} Q ${c + size * 0.055} ${c - noseR * 0.72} ${right.x} ${right.y} Z`}
fill={bodyColor}
/>
<circle cx={c} cy={c} r={bodyR} fill={`url(#${id}-g)`} />
{/* indicator line down the nose */}
<line
x1={c}
y1={c - noseR + size * 0.03}
x2={c}
y2={c - bodyR * 0.2}
stroke={indicatorColor}
strokeWidth={Math.max(1.5, size * 0.018)}
strokeLinecap="round"
/>
{/* center screw */}
<circle cx={c} cy={c} r={size * 0.045} fill="#b9b3a4" stroke="#5f594c" strokeWidth="0.8" />
<line
x1={c - size * 0.03}
y1={c}
x2={c + size * 0.03}
y2={c}
stroke="#5f594c"
strokeWidth="1"
/>
</g>
);
};
const ScaleLabels: React.FC<{ labels: readonly string[]; color: string }> = ({ labels, color }) => {
const { size, center: c, angleOffset, angleRange } = useKnobContext();
return (
<g>
{labels.map((text, i) => {
const t = labels.length === 1 ? 0 : i / (labels.length - 1);
const p = polarToCartesian(c, c, size / 2 - size * 0.02, angleOffset + t * angleRange);
return (
<text
key={i}
x={p.x}
y={p.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={size * 0.085}
fontFamily={UI_FONT}
fill={color}
style={{ pointerEvents: 'none' }}
>
{text}
</text>
);
})}
</g>
);
};
/** Vintage amp-style knob: chicken-head pointer over a printed tick scale. */
export const VintageKnob: React.FC<VintageKnobProps> = ({
size = 96,
bodyColor = '#efe6d0',
scaleColor = 'rgba(255,255,255,0.35)',
color,
labelColor = 'rgba(255,255,255,0.45)',
scaleLabels,
indicatorColor,
label,
className,
style,
...core
}) => (
<Knob size={size} className={className} style={style} {...core}>
<Ticks
count={11}
radius={size / 2 - size * (scaleLabels ? 0.1 : 0.04)}
length={size * 0.055}
width={Math.max(1.2, size * 0.016)}
color={scaleColor}
activeColor={color ?? scaleColor}
/>
{scaleLabels && <ScaleLabels labels={scaleLabels} color={scaleColor} />}
<ChickenHead
bodyColor={bodyColor}
indicatorColor={
indicatorColor ?? (isDarkColor(bodyColor) ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.55)')
}
/>
{label && (
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.46} fontSize={size * 0.095}>
{label}
</KnobLabel>
)}
</Knob>
);

View file

@ -0,0 +1,23 @@
import type * as React from 'react';
import type { KnobCoreProps } from '../core/types';
/** Props common to every prebuilt knob skin. */
export interface SkinProps extends KnobCoreProps {
/** Square canvas size in px. */
size?: number;
/** Caption drawn in the travel gap at the bottom of the knob. */
label?: string;
/** Show the numeric readout. Default varies per skin. */
showValue?: boolean;
/** Unit suffix for the readout, e.g. "dB", "Hz", "%". */
unit?: string;
/** Custom readout formatter (takes precedence over unit/decimals). */
format?: (value: number) => string;
className?: string;
style?: React.CSSProperties;
}
export const MONO_FONT =
"'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
export const UI_FONT =
"'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"noEmit": true
},
"include": ["src"]
}

1953
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- packages/*
- apps/*