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,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>;
};