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

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";