8 Commits

Author SHA1 Message Date
Senko-san ee0dce9d75 feat: build
Publish npm package / publish (push) Failing after 14s
2026-06-06 13:27:33 +03:00
olly 5cd33a2b9d fix: knob & text 2026-06-05 15:11:02 +03:00
olly ef69f7bb65 feat: knobs 2026-06-03 10:42:33 +03:00
olly b6017d60c8 chore: knob upd & memories 2026-06-02 18:10:19 +03:00
olly 3da99a7214 Merge branch 'master' of ssh://git.ollyhearn.ru:49239/olly/modern-sk 2026-06-02 15:27:01 +03:00
olly 3b9d3f908d feat: animated slider 2026-06-02 15:26:32 +03:00
olly 49d6ac8a4e feat: text & slider animations 2026-06-02 12:18:26 +03:00
olly 37f0592464 chore: update readme 2026-06-01 18:17:14 +03:00
12 changed files with 1028 additions and 17 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Publish npm package
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://git.ollyhearn.ru/api/packages/olly/npm/"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+20
View File
@@ -2,6 +2,26 @@ import type { StorybookConfig } from 'storybook-react-rsbuild';
/* Dev-only playground. Never shipped — package `files` is ["dist"]. */ /* Dev-only playground. Never shipped — package `files` is ["dist"]. */
const config: StorybookConfig = { const config: StorybookConfig = {
rsbuildFinal: (config) => {
config.tools ??= {};
// Append our rule without clobbering storybook-react-rsbuild's own
// tools.rspack hook (it injects the storybook-config-entry virtual module
// in build mode). Mutate in place and return nothing so its config stays.
const prev = config.tools.rspack;
config.tools.rspack = [
...(Array.isArray(prev) ? prev : prev ? [prev] : []),
(rspackConfig) => {
rspackConfig.module ??= {};
rspackConfig.module.rules ??= [];
(rspackConfig.module.rules as unknown[]).push({
test: /\.mjs$/,
type: 'javascript/auto',
resolve: { fullySpecified: false },
});
},
];
return config;
},
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'], stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs'], addons: ['@storybook/addon-docs'],
staticDirs: ['../src/assets'], staticDirs: ['../src/assets'],
+7 -3
View File
@@ -37,9 +37,13 @@ Only `dist/` ships (`files: ["dist"]`), so the playground, stories, and `.storyb
Fonts are NOT in the core stylesheet. `tokens.css` defines `--font-display/sans/mono` chains that degrade to `system-ui`; the actual faces (Anta `@font-face` + Onest/Geist Mono Google Fonts `@import`) live in `src/styles/fonts.css`, shipped as the optional `modern-sk/fonts.css` export. Consumers either import it, or override the `--font-*` tokens to remap typefaces. Storybook's `preview.tsx` and the dev `global.css` both import `fonts.css` so the playgrounds stay branded. Fonts are NOT in the core stylesheet. `tokens.css` defines `--font-display/sans/mono` chains that degrade to `system-ui`; the actual faces (Anta `@font-face` + Onest/Geist Mono Google Fonts `@import`) live in `src/styles/fonts.css`, shipped as the optional `modern-sk/fonts.css` export. Consumers either import it, or override the `--font-*` tokens to remap typefaces. Storybook's `preview.tsx` and the dev `global.css` both import `fonts.css` so the playgrounds stay branded.
### Components (`src/components/ui.tsx`) ### Components (`src/components/`)
All components live in one file. Pattern: Radix provides logic/accessibility, every visual comes from CSS. Components are thin wrappers that attach `modern-sk-*` classes and spread props. The `cx()` helper joins class names (no classnames dependency). Components forward refs where they wrap a DOM element. There is no inline styling and no CSS-in-JS — **all appearance is driven by `modern-sk-*` classes resolving against CSS custom properties.** `src/components/ui.tsx` is a barrel that re-exports one folder per component (`button/`, `text-field/`, …); `cx`/shared helpers live in `src/components/utils.ts`. Pattern: Radix provides logic/accessibility, every visual comes from CSS. Components are thin wrappers that attach `modern-sk-*` classes and spread props. The `cx()` helper joins class names (no classnames dependency). Components forward refs where they wrap a DOM element. There is no inline styling and no CSS-in-JS — **all appearance is driven by `modern-sk-*` classes resolving against CSS custom properties.**
Exception — text inputs (`text-field/`): `TextField`/`TextArea`/`SearchField` animate letters in/out (osu!-lazer style). Native fields can't animate per-glyph, so the real element renders with transparent text (`modern-sk-field--animated`, caret stays visible) over a mirrored per-character `<span>` overlay that plays the `modern-sk-char-in/out` keyframes; an LCS diff preserves letter identity so only inserted/removed glyphs animate. The overlay still derives all appearance from `modern-sk-*` classes/tokens — the only JS-set styles are the per-letter pin offset and scroll-sync transform. Pass `animated={false}` to opt out and render the plain native field.
Exception — knob (`knob/`): a rotary circular slider (`Knob`), ported from the design handoff. Two visuals move at different speeds — the **dial** is bound 1:1 to the pointer while dragging (instant, continuous angle via inline `transform: rotate`), while the **gauge fill + value** snap to detents and *glide* between them (like the stepped Slider). It tracks two values: a continuous `visual` (drives the dial mid-drag) and the snapped `committed` (drives fill/ticks/aria); `dialValue = dragging ? visual : committed`, so on drag-release the dial settles to the detent. Three non-obvious constraints, all already tried-and-rejected — don't regress them: (1) the gauge **fill must be the full arc revealed via `strokeDashoffset`** set as a *plain number*`pathLength=1` + CSS `var()`/`calc` silently collapsed to a full ring, and `transition: d` on a swept arc interpolates the endpoint as a straight chord and distorts the arc mid-glide; dashoffset eases along the true circle. (2) the dial transition is disabled via `.is-dragging` so the mouse-bound dial never lags. (3) wheel-to-change uses a **native non-passive `wheel` listener** (via `useEffect` + a ref-held handler) because React's synthetic `onWheel` is passive and `preventDefault` can't block page scroll. `animated` defaults `true` when `step > 0`.
### Styling system (`src/styles/`) ### Styling system (`src/styles/`)
@@ -47,7 +51,7 @@ All components live in one file. Pattern: Radix provides logic/accessibility, ev
- `components.css` — the `modern-sk-*` class definitions. - `components.css` — the `modern-sk-*` class definitions.
- Dark/light is driven by `data-theme` on `<html>`, set by `ThemeProvider` (persisted to `localStorage` under key `modern-sk-theme`, default `dark`). - Dark/light is driven by `data-theme` on `<html>`, set by `ThemeProvider` (persisted to `localStorage` under key `modern-sk-theme`, default `dark`).
When adding or changing a component: add the wrapper in `ui.tsx`, define its `modern-sk-*` styles in `components.css`, and pull any new color/spacing value from a token in `tokens.css` rather than hardcoding. When adding or changing a component: add the wrapper folder under `src/components/` and re-export it from `ui.tsx`, define its `modern-sk-*` styles in `components.css`, and pull any new color/spacing value from a token in `tokens.css` rather than hardcoding.
## Consumer contract ## Consumer contract
+1 -1
View File
@@ -15,7 +15,7 @@ Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
Distributed via self-hosted git — install straight from the repo: Distributed via self-hosted git — install straight from the repo:
```bash ```bash
npm i git+ssh://git@git.ollyhearn.ru:49239/olly/modern-sk.git npm i git+https://git.ollyhearn.ru/olly/modern-sk.git
``` ```
`react` and `react-dom` (>=18) are peer dependencies — your app provides them. `react` and `react-dom` (>=18) are peer dependencies — your app provides them.
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "modern-sk", "name": "@olly/modern-sk",
"version": "0.1.0", "version": "0.1.2",
"description": "ModernSK — tactile, dark-first React component library built on Radix primitives.", "description": "ModernSK — tactile, dark-first React component library built on Radix primitives.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -22,6 +22,9 @@
"files": [ "files": [
"dist" "dist"
], ],
"publishConfig": {
"registry": "https://git.ollyhearn.ru/api/packages/olly/npm/"
},
"scripts": { "scripts": {
"build": "tsup && npm run build:css", "build": "tsup && npm run build:css",
"build:css": "esbuild src/styles/index.css --bundle --outfile=dist/styles.css && esbuild src/styles/fonts.css --bundle --loader:.ttf=dataurl --outfile=dist/fonts.css", "build:css": "esbuild src/styles/index.css --bundle --outfile=dist/styles.css && esbuild src/styles/fonts.css --bundle --loader:.ttf=dataurl --outfile=dist/fonts.css",
+9
View File
@@ -30,6 +30,7 @@ import {
Dialog, Dialog,
DialogClose, DialogClose,
IconButton, IconButton,
Knob,
List, List,
MenuRow, MenuRow,
MenuSeparator, MenuSeparator,
@@ -248,6 +249,14 @@ const App = () => {
</span> </span>
</div> </div>
</div> </div>
<div>
<div className="cap">Knobs circular sliders</div>
<div className="cluster" style={{ gap: 40, alignItems: 'flex-start' }}>
<Knob defaultValue={62} aria-label="Volume" />
<Knob defaultValue={3} min={1} max={5} step={1} aria-label="Quality" />
<Knob defaultValue={40} accent="ember" aria-label="Warmth" />
</div>
</div>
</div> </div>
<div className="stack"> <div className="stack">
<div> <div>
+261
View File
@@ -0,0 +1,261 @@
import {
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
} from 'react';
import { cx } from '../utils';
type KnobAccent = 'lime' | 'ember';
type KnobProps = {
/** Controlled value. */
value?: number;
/** Uncontrolled starting value. Defaults to `min`. */
defaultValue?: number;
/** Fires with the snapped value on every change. */
onValueChange?: (value: number) => void;
min?: number;
max?: number;
/** Snap increment. `0`/omitted = continuous (no detents, no ticks). */
step?: number;
/** Accent colour for the gauge fill + pointer. `'lime'` (default) or `'ember'`. */
accent?: KnobAccent;
/** Diameter in px. Drives every internal measurement via `--knob-size`. */
size?: number;
/**
* Glide the gauge fill + pointer between detents. The dial always tracks the
* pointer 1:1 while dragging; this only eases the *value* settle (keyboard,
* wheel, drag-release) — like the stepped Slider. Defaults to `true` when
* `step > 0`, `false` otherwise.
*/
animated?: boolean;
disabled?: boolean;
className?: string;
id?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
};
// Gauge geometry: 270° sweep starting at the 7-o'clock position. R is the arc
// radius in the 100×100 SVG viewBox.
const START = -135;
const SWEEP = 270;
const R = 43;
const polar = (cx: number, cy: number, r: number, aDeg: number): [number, number] => {
const t = (aDeg * Math.PI) / 180;
return [cx + r * Math.sin(t), cy - r * Math.cos(t)];
};
const arcPath = (a0: number, a1: number) => {
if (a1 - a0 < 0.01) a1 = a0 + 0.01;
const [x0, y0] = polar(50, 50, R, a0);
const [x1, y1] = polar(50, 50, R, a1);
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
};
const TRACK_PATH = arcPath(START, START + SWEEP);
// Arc length of the gauge. The fill is the full arc revealed via dashoffset, so
// the glide eases ALONG the circle (transitioning `d` interpolates endpoints in
// a straight chord and distorts the arc mid-animation).
const ARC_LEN = (R * SWEEP * Math.PI) / 180;
export const Knob = ({
value: valueProp,
defaultValue,
onValueChange,
min = 0,
max = 100,
step = 0,
accent = 'lime',
size,
animated,
disabled = false,
className,
id,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
}: KnobProps) => {
const span = max - min || 1;
const stepped = step > 0;
const isAnimated = animated !== undefined ? animated : stepped;
const clamp = (v: number) => Math.min(max, Math.max(min, v));
const snap = (v: number) =>
stepped ? clamp(Math.round((v - min) / step) * step + min) : clamp(v);
const isControlled = valueProp !== undefined;
const [internal, setInternal] = useState(() => snap(defaultValue ?? min));
const committed = isControlled ? snap(valueProp) : internal;
// Continuous, pointer-bound position for the dial; only meaningful mid-drag.
const [visual, setVisual] = useState(committed);
const [dragging, setDragging] = useState(false);
const elRef = useRef<HTMLDivElement>(null);
// Drag tracking: previous pointer angle + a continuous value accumulator.
// Accumulating per-move deltas (each < 180°) instead of measuring from the
// grab angle lets the dial wrap past ±180° without the value snapping.
const lastAngle = useRef(0);
const acc = useRef(0);
const reactId = useId();
const knobId = id ?? reactId;
// `continuous` keeps the dial on the raw pointer angle; otherwise the dial
// settles (and glides) to the snapped detent.
const commit = (raw: number, continuous: boolean) => {
const c = clamp(raw);
const snapped = snap(c);
setVisual(continuous ? c : snapped);
if (!isControlled) setInternal(snapped);
if (snapped !== committed) onValueChange?.(snapped);
};
const angleAt = (clientX: number, clientY: number) => {
const el = elRef.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return (
(Math.atan2(clientX - (r.left + r.width / 2), -(clientY - (r.top + r.height / 2))) * 180) /
Math.PI
);
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
e.preventDefault();
elRef.current?.focus();
elRef.current?.setPointerCapture(e.pointerId);
setDragging(true);
lastAngle.current = angleAt(e.clientX, e.clientY);
acc.current = clamp(committed);
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
const a = angleAt(e.clientX, e.clientY);
let d = a - lastAngle.current;
if (d > 180) d -= 360;
if (d < -180) d += 360;
lastAngle.current = a;
// Clamp the accumulator (not just the committed value) so overshoot past
// an end doesn't build a dead zone you must unwind before reversing.
acc.current = clamp(acc.current + (d / SWEEP) * span);
commit(acc.current, true);
};
const endDrag = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
elRef.current?.releasePointerCapture(e.pointerId);
setDragging(false);
};
// React's synthetic onWheel is passive, so preventDefault can't block page
// scroll — attach a native non-passive listener. A ref keeps it current
// without re-binding every render.
const wheelRef = useRef<(e: WheelEvent) => void>(() => {});
wheelRef.current = (e) => {
if (disabled) return;
e.preventDefault();
commit(committed - (step || span / 100) * Math.sign(e.deltaY), false);
};
useEffect(() => {
const el = elRef.current;
if (!el) return;
const handler = (e: WheelEvent) => wheelRef.current(e);
el.addEventListener('wheel', handler, { passive: false });
return () => el.removeEventListener('wheel', handler);
}, []);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const big = step || span / 10;
const sm = step || span / 100;
const k = e.key;
if (k === 'ArrowUp' || k === 'ArrowRight') commit(committed + sm, false);
else if (k === 'ArrowDown' || k === 'ArrowLeft') commit(committed - sm, false);
else if (k === 'PageUp') commit(committed + big, false);
else if (k === 'PageDown') commit(committed - big, false);
else if (k === 'Home') commit(min, false);
else if (k === 'End') commit(max, false);
else return;
e.preventDefault();
};
const dialValue = dragging ? visual : committed;
const dialAngle = START + ((clamp(dialValue) - min) / span) * SWEEP;
const fillOffset = ARC_LEN * (1 - (committed - min) / span);
const valueNow = stepped ? committed : Math.round(committed);
const ticks = stepped
? Array.from({ length: Math.round(span / step) + 1 }, (_, i) => {
const a = START + (i / Math.round(span / step)) * SWEEP;
const [x1, y1] = polar(50, 50, R + 6, a);
const [x2, y2] = polar(50, 50, R + 10.5, a);
const val = min + (i / Math.round(span / step)) * span;
return { x1, y1, x2, y2, on: val <= committed + 1e-6, key: i };
})
: [];
return (
<div
ref={elRef}
id={knobId}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={valueNow}
aria-disabled={disabled || undefined}
className={cx(
'modern-sk-knob',
`modern-sk-knob--${accent}`,
isAnimated && 'modern-sk-knob--animated',
dragging && 'is-dragging',
disabled && 'modern-sk-knob--disabled',
className,
)}
style={size != null ? ({ '--knob-size': `${size}px` } as CSSProperties) : undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
>
<svg className="modern-sk-knob__gauge" viewBox="0 0 100 100" aria-hidden>
<path className="modern-sk-knob__track" d={TRACK_PATH} />
<path
className="modern-sk-knob__fill"
d={TRACK_PATH}
strokeDasharray={ARC_LEN}
strokeDashoffset={fillOffset}
/>
<g className="modern-sk-knob__ticks">
{ticks.map((t) => (
<line
key={t.key}
className={cx('modern-sk-knob__tick', t.on && 'is-on')}
x1={t.x1.toFixed(2)}
y1={t.y1.toFixed(2)}
x2={t.x2.toFixed(2)}
y2={t.y2.toFixed(2)}
/>
))}
</g>
</svg>
<div className="modern-sk-knob__cap">
<div className="modern-sk-knob__dial" style={{ transform: `rotate(${dialAngle}deg)` }}>
<span className="modern-sk-knob__pointer" />
</div>
<div className="modern-sk-knob__hub" />
</div>
</div>
);
};
+8
View File
@@ -22,6 +22,11 @@ type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'classNam
notches?: NotchPlacement; notches?: NotchPlacement;
/** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */ /** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */
knobStyle?: KnobStyle; knobStyle?: KnobStyle;
/**
* Enable step-glide animation. Defaults to `true` when `marks` is set, `false` otherwise.
* Explicitly setting this always overrides the default.
*/
animated?: boolean;
className?: string; className?: string;
}; };
@@ -73,6 +78,7 @@ export const Slider = ({
marks, marks,
notches = 'bottom', notches = 'bottom',
knobStyle = 'square', knobStyle = 'square',
animated,
min = 0, min = 0,
max = 100, max = 100,
step = 1, step = 1,
@@ -84,10 +90,12 @@ export const Slider = ({
const hasLabels = resolved.some((m) => m.label != null); const hasLabels = resolved.some((m) => m.label != null);
const showTop = hasMarks && (notches === 'top' || notches === 'both'); const showTop = hasMarks && (notches === 'top' || notches === 'both');
const showBottom = hasMarks && (notches === 'bottom' || notches === 'both'); const showBottom = hasMarks && (notches === 'bottom' || notches === 'both');
const isAnimated = animated !== undefined ? animated : hasMarks;
const cls = [ const cls = [
'modern-sk-slider', 'modern-sk-slider',
`modern-sk-slider--knob-${knobStyle}`, `modern-sk-slider--knob-${knobStyle}`,
isAnimated && 'modern-sk-slider--animated',
hasMarks && 'modern-sk-slider--has-marks', hasMarks && 'modern-sk-slider--has-marks',
hasLabels && 'modern-sk-slider--has-labels', hasLabels && 'modern-sk-slider--has-labels',
showTop && 'modern-sk-slider--notch-top', showTop && 'modern-sk-slider--notch-top',
+262 -10
View File
@@ -1,24 +1,276 @@
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import {
forwardRef,
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
type Ref,
} from 'react';
import { cx } from '../utils'; import { cx } from '../utils';
export const TextField = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'>>( /* ------------------------------------------------------------------ *
({ className, ...props }, ref) => ( * Typing animation (osu!-lazer style)
<input ref={ref} className={cx('modern-sk-field', className)} {...props} /> *
), * Native inputs draw their own text, so individual letters can't be
* animated. Instead the real field renders transparent (caret stays
* visible) and a mirrored per-character <span> overlay sits behind it:
* newly typed letters rise + fade in, erased letters fall + fade out.
* ------------------------------------------------------------------ */
type FieldElement = HTMLInputElement | HTMLTextAreaElement;
interface CharEntry {
id: number;
char: string;
leaving: boolean;
x?: number;
y?: number;
}
/** Longest-common-subsequence match so unchanged letters keep their id
* (and thus don't replay the appear animation on every keystroke). */
function diffChars(prev: ReadonlyArray<{ id: number; char: string }>, next: string) {
const n = prev.length;
const m = next.length;
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] =
prev[i].char === next[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const reusedId: Array<number | null> = new Array(m).fill(null);
const keptPrev = new Array(n).fill(false);
let i = 0;
let j = 0;
while (i < n && j < m) {
if (prev[i].char === next[j]) {
reusedId[j] = prev[i].id;
keptPrev[i] = true;
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
i++;
} else {
j++;
}
}
return { reusedId, keptPrev };
}
function useFieldAnimation(
multiline: boolean,
externalRef: Ref<FieldElement>,
controlledValue: ComponentPropsWithoutRef<'input'>['value'],
initial: ComponentPropsWithoutRef<'input'>['defaultValue'],
) {
const innerRef = useRef<FieldElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const spanRefs = useRef<Map<number, HTMLSpanElement>>(new Map());
const present = useRef<Array<{ id: number; char: string }>>([]);
const nextId = useRef(0);
const idPrefix = useId();
const [text, setText] = useState(() => String(controlledValue ?? initial ?? ''));
const [entries, setEntries] = useState<CharEntry[]>([]);
const setRef = useCallback(
(node: FieldElement | null) => {
innerRef.current = node;
if (typeof externalRef === 'function') externalRef(node);
else if (externalRef) (externalRef as { current: FieldElement | null }).current = node;
},
[externalRef],
);
const syncScroll = useCallback(() => {
const el = innerRef.current;
const ov = overlayRef.current;
if (el && ov) ov.style.transform = `translate(${-el.scrollLeft}px, ${-el.scrollTop}px)`;
}, []);
// Reconcile the overlay whenever the text changes.
useLayoutEffect(() => {
const prev = present.current;
const { reusedId, keptPrev } = diffChars(prev, text);
const nextPresent: Array<{ id: number; char: string }> = [];
for (let k = 0; k < text.length; k++) {
const id = reusedId[k] ?? nextId.current++;
nextPresent.push({ id, char: text[k] });
}
// Letters that were removed fall away — pin them where they last sat.
const leaving: CharEntry[] = [];
for (let k = 0; k < prev.length; k++) {
if (keptPrev[k]) continue;
const el = spanRefs.current.get(prev[k].id);
if (el) {
leaving.push({
id: prev[k].id,
char: prev[k].char,
leaving: true,
x: el.offsetLeft,
y: el.offsetTop,
});
}
}
present.current = nextPresent;
setEntries((current) => [
...nextPresent.map((e) => ({ ...e, leaving: false })),
...current.filter((e) => e.leaving),
...leaving,
]);
syncScroll();
}, [text, syncScroll]);
// Stay in sync when used as a controlled component.
useLayoutEffect(() => {
if (controlledValue !== undefined) setText(String(controlledValue));
}, [controlledValue]);
const handleChange = useCallback((value: string) => setText(value), []);
const onLeaveEnd = useCallback((id: number) => {
spanRefs.current.delete(id);
setEntries((current) => current.filter((e) => e.id !== id));
}, []);
const overlay = (
<div
ref={overlayRef}
aria-hidden="true"
className={cx('modern-sk-field-overlay', multiline && 'modern-sk-field-overlay--multiline')}
>
{entries.map((e) =>
e.leaving ? (
<span
key={`${idPrefix}-${e.id}`}
className="modern-sk-field-char modern-sk-field-char--leaving"
style={{ left: e.x, top: e.y }}
onAnimationEnd={() => onLeaveEnd(e.id)}
>
{e.char}
</span>
) : (
<span
key={`${idPrefix}-${e.id}`}
ref={(node) => {
if (node) spanRefs.current.set(e.id, node);
else spanRefs.current.delete(e.id);
}}
className={cx('modern-sk-field-char', !multiline && 'modern-sk-field-char--composited')}
>
{e.char}
</span>
),
)}
</div>
);
return { setRef, overlay, handleChange, syncScroll };
}
type TextFieldProps = ComponentPropsWithoutRef<'input'> & { animated?: boolean };
type TextAreaProps = ComponentPropsWithoutRef<'textarea'> & { animated?: boolean };
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
false,
ref,
props.value,
props.defaultValue,
);
if (!animated) {
return (
<input
ref={ref}
className={cx('modern-sk-field', className)}
style={style}
onChange={onChange}
onScroll={onScroll}
{...props}
/>
);
}
return (
<div className={cx('modern-sk-field-wrap', className)} style={style}>
<input
ref={setRef}
className="modern-sk-field modern-sk-field--animated"
onChange={(e) => {
handleChange(e.currentTarget.value);
onChange?.(e);
}}
onScroll={(e) => {
syncScroll();
onScroll?.(e);
}}
{...props}
/>
{overlay}
</div>
);
},
); );
TextField.displayName = 'TextField'; TextField.displayName = 'TextField';
export const TextArea = forwardRef<HTMLTextAreaElement, ComponentPropsWithoutRef<'textarea'>>( export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, ...props }, ref) => ( ({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
<textarea ref={ref} className={cx('modern-sk-field', className)} {...props} /> const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
), true,
ref,
props.value,
props.defaultValue,
);
if (!animated) {
return (
<textarea
ref={ref}
className={cx('modern-sk-field', className)}
style={style}
onChange={onChange}
onScroll={onScroll}
{...props}
/>
);
}
return (
<div
className={cx('modern-sk-field-wrap', 'modern-sk-field-wrap--multiline', className)}
style={style}
>
<textarea
ref={setRef}
className="modern-sk-field modern-sk-field--animated"
onChange={(e) => {
handleChange(e.currentTarget.value);
onChange?.(e);
}}
onScroll={(e) => {
syncScroll();
onScroll?.(e);
}}
{...props}
/>
{overlay}
</div>
);
},
); );
TextArea.displayName = 'TextArea'; TextArea.displayName = 'TextArea';
export const SearchField = ({ export const SearchField = ({
icon, icon,
...props ...props
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => ( }: TextFieldProps & { icon: ReactNode }) => (
<div className="modern-sk-search"> <div className="modern-sk-search">
<span className="ph">{icon}</span> <span className="ph">{icon}</span>
<TextField {...props} /> <TextField {...props} />
+1
View File
@@ -5,6 +5,7 @@ export * from './select';
export * from './selection'; export * from './selection';
export * from './segmented-control'; export * from './segmented-control';
export * from './slider'; export * from './slider';
export * from './knob';
export * from './tabs'; export * from './tabs';
export * from './progress'; export * from './progress';
export * from './badge'; export * from './badge';
+97
View File
@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { useState } from 'react';
import { Knob } from '../components/ui';
const meta = {
title: 'Inputs/Knob',
component: Knob,
parameters: {
docs: {
description: {
component:
'Skeuomorphic rotary control — a circular slider. Drag anywhere on the cap (relative angular drag, no jump-to-pointer), scroll to nudge, or focus and use the arrow keys. The dial tracks the pointer 1:1 while dragging; the gauge fill + value snap to detents and *glide* between them when `animated` (on by default for stepped knobs), exactly like the stepped Slider.',
},
},
},
args: { defaultValue: 62, min: 0, max: 100, step: 0, accent: 'lime' },
argTypes: {
defaultValue: {
control: 'number',
description: 'Uncontrolled starting value.',
},
min: { control: 'number' },
max: { control: 'number' },
step: {
control: 'number',
description: '`0` = continuous; `> 0` adds detents + ticks.',
},
accent: { control: 'inline-radio', options: ['lime', 'ember'] },
size: { control: 'number', description: 'Diameter in px (default 108).' },
animated: {
control: 'boolean',
description:
'Glide the fill/value between detents. Defaults to `true` when `step > 0`.',
},
disabled: { control: 'boolean' },
className: { control: 'text' },
},
} satisfies Meta<typeof Knob>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
/** Continuous — the dial and gauge follow the pointer with no snapping. */
export const Continuous: Story = {
args: { defaultValue: 62, step: 0 },
};
/** Stepped — the dial stays bound to the mouse while the fill/value snap and glide into detents. */
export const Stepped: Story = {
args: { defaultValue: 3, min: 1, max: 5, step: 1 },
};
/** Ember accent variant. */
export const Ember: Story = {
args: { defaultValue: 40, accent: 'ember' },
};
export const Disabled: Story = {
args: { defaultValue: 50, disabled: true },
};
const Readout = (args: React.ComponentProps<typeof Knob>) => {
const [v, setV] = useState(62);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 14,
}}
>
<Knob {...args} value={v} onValueChange={setV} aria-label="Volume" />
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: 22,
color: 'var(--fg-1)',
fontVariantNumeric: 'tabular-nums',
}}
>
{v.toFixed(2)}
<span style={{ fontSize: 12, color: 'var(--fg-3)', marginLeft: 2 }}>
%
</span>
</span>
</div>
);
};
/** Controlled, with a live readout below the knob. */
export const WithReadout: Story = {
render: (args) => <Readout {...args} />,
args: { defaultValue: 62 },
};
+329 -1
View File
@@ -224,6 +224,117 @@ textarea.modern-sk-field {
padding-left: 34px; padding-left: 34px;
} }
/* ---------- TYPING ANIMATION (osu!-lazer style char in/out) ---------- */
/* The real field renders transparent over a mirrored per-letter overlay. */
.modern-sk-field-wrap {
position: relative;
width: 100%;
}
.modern-sk-search .modern-sk-field-wrap {
width: 100%;
}
.modern-sk-field--animated {
color: transparent;
caret-color: var(--fg-1);
}
.modern-sk-field--animated::placeholder {
color: var(--fg-3);
}
.modern-sk-field-overlay {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
/* transparent border mirrors the field's 1px border so content aligns */
border: 1px solid transparent;
padding: var(--field-pad-y) var(--field-pad-x);
font-family: var(--font-sans);
font-size: 14px;
line-height: normal;
color: var(--fg-1);
white-space: pre;
text-align: left;
will-change: transform;
}
.modern-sk-field-overlay--multiline {
white-space: pre-wrap;
overflow-wrap: break-word;
line-height: 1.5;
}
.modern-sk-search .modern-sk-field-overlay {
padding-left: 34px;
}
.modern-sk-field-char {
/* inline (not inline-block) so the overlay wraps exactly like the field;
inherit white-space so multiline (pre-wrap) wraps while input (pre) won't.
'pre' on the span itself would suppress wrapping at its boundaries.
Multiline keeps this inline path: inline boxes ignore transform, so the
rise eases `top` (a layout property — acceptable here, textarea wraps
instead of scrolling so there's no per-keystroke scroll-sync thrash). */
position: relative;
white-space: inherit;
animation: modern-sk-char-in var(--dur-base) var(--ease-snap) both;
}
/* Single-line never wraps, so chars can be inline-block and animate
`transform` (compositor-only) instead of `top` (relayout every frame).
The single-line field scrolls horizontally and syncs the overlay each
keystroke, so the layout-triggering `top` path janked there — this doesn't. */
.modern-sk-field-char--composited {
display: inline-block;
will-change: transform, opacity;
animation-name: modern-sk-char-in-rise;
}
.modern-sk-field-char--leaving {
display: inline-block;
position: absolute;
white-space: pre;
animation: modern-sk-char-out var(--dur-base) var(--ease-out) forwards;
}
/* inline elements ignore transform, so the rise uses top instead */
@keyframes modern-sk-char-in {
from {
opacity: 0;
top: -0.32em;
}
to {
opacity: 1;
top: 0;
}
}
/* inline-block (single-line) variant — composited transform, no relayout */
@keyframes modern-sk-char-in-rise {
from {
opacity: 0;
transform: translateY(-0.32em);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes modern-sk-char-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(0.7em) scale(0.9);
}
}
@media (prefers-reduced-motion: reduce) {
.modern-sk-field-char {
animation: none;
}
.modern-sk-field-char--leaving {
animation: modern-sk-char-out 1ms linear forwards;
}
}
/* ---------- SELECT (Radix Select, styled as the glossy key) ---------- */ /* ---------- SELECT (Radix Select, styled as the glossy key) ---------- */
.modern-sk-select { .modern-sk-select {
font-family: var(--font-sans); font-family: var(--font-sans);
@@ -519,7 +630,7 @@ textarea.modern-sk-field {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
width: 200px; width: 100%;
height: var(--ms-thumb); height: var(--ms-thumb);
user-select: none; user-select: none;
touch-action: none; touch-action: none;
@@ -539,6 +650,13 @@ textarea.modern-sk-field {
background: linear-gradient(90deg, var(--lime-deep), var(--lime)); background: linear-gradient(90deg, var(--lime-deep), var(--lime));
box-shadow: 0 0 10px rgba(190, 242, 100, 0.4); box-shadow: 0 0 10px rgba(190, 242, 100, 0.4);
} }
/* Radix positions the range via left/right offsets (not width); ease those
so the fill glides between discrete steps while dragging. */
.modern-sk-slider--animated .modern-sk-slider__range {
transition:
left 0.12s ease-out,
right 0.12s ease-out;
}
.modern-sk-slider__thumb { .modern-sk-slider__thumb {
display: block; display: block;
width: var(--ms-thumb-w); width: var(--ms-thumb-w);
@@ -551,6 +669,18 @@ textarea.modern-sk-field {
cursor: pointer; cursor: pointer;
outline: none; outline: none;
} }
/* Radix sets the step position (left) on a WRAPPER span around the thumb, not on
the thumb element itself — so the transition must live on that wrapper. The
wrapper is the slider's direct child span that isn't the track. */
.modern-sk-slider--animated > span:not(.modern-sk-slider__track) {
transition: left 0.12s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.modern-sk-slider--animated > span:not(.modern-sk-slider__track),
.modern-sk-slider--animated .modern-sk-slider__range {
transition: none;
}
}
.modern-sk-slider--knob-square .modern-sk-slider__thumb { .modern-sk-slider--knob-square .modern-sk-slider__thumb {
--ms-thumb-w: 14px; --ms-thumb-w: 14px;
} }
@@ -631,6 +761,204 @@ textarea.modern-sk-field {
white-space: nowrap; white-space: nowrap;
} }
/* ---------- KNOB (circular slider) ----------
Skeuomorphic rotary control. The dial follows the pointer 1:1 while dragging
(bound to the mouse); the gauge fill + value snap to detents and *glide*
between them (like the stepped Slider) when `--animated`. Anatomy:
recessed SVG gauge ring → glossy cap → knurled dial (rotates, carries the
accent pointer) → turned-metal hub. Sizing flows from --knob-size. */
.modern-sk-knob {
--knob-size: 108px;
--knob-accent: var(--lime);
--knob-accent-deep: var(--lime-deep);
--knob-glow: rgba(190, 242, 100, 0.5);
position: relative;
flex-shrink: 0;
width: var(--knob-size);
height: var(--knob-size);
cursor: grab;
outline: none;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.modern-sk-knob.is-dragging {
cursor: grabbing;
}
.modern-sk-knob--disabled {
cursor: default;
opacity: 0.55;
}
/* recessed gauge ring */
.modern-sk-knob__gauge {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.modern-sk-knob__track {
fill: none;
stroke: var(--steel-800);
stroke-width: 5;
stroke-linecap: round;
}
/* The fill is the full arc, revealed up to the value via stroke-dashoffset (set
inline as a plain number). When `--animated`, transition the offset so the
accent eases along the circle between detents. */
.modern-sk-knob__fill {
fill: none;
stroke: var(--knob-accent);
stroke-width: 5;
stroke-linecap: round;
filter: drop-shadow(0 0 5px var(--knob-glow));
transition: stroke var(--dur-quick) var(--ease-out);
}
.modern-sk-knob--animated .modern-sk-knob__fill {
transition:
stroke-dashoffset 0.12s ease-out,
stroke var(--dur-quick) var(--ease-out);
}
.modern-sk-knob__tick {
stroke: var(--fg-3);
stroke-width: 1.6;
stroke-linecap: round;
opacity: 0.45;
transition:
stroke var(--dur-quick),
opacity var(--dur-quick);
}
.modern-sk-knob__tick.is-on {
stroke: var(--knob-accent);
opacity: 0.95;
}
/* glossy cap — static top-down gloss (light source stays put) */
.modern-sk-knob__cap {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(var(--knob-size) - 34px);
height: calc(var(--knob-size) - 34px);
border-radius: 50%;
border: 1px solid var(--hair-strong);
background:
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.16), transparent 58%),
var(--grad-key);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.08) inset,
0 4px 9px rgba(0, 0, 0, 0.5),
0 11px 22px rgba(0, 0, 0, 0.42);
overflow: hidden;
transition: box-shadow var(--dur-quick) var(--ease-out);
}
/* knurled dial — rotates with the pointer, carries the glowing pointer dot.
The ribbed grip lives on ::before (masked to a ring) so the pointer dot can
sit unmasked on top and keep its full halo. While dragging it tracks the
mouse with no transition; otherwise it glides to the settled value. */
.modern-sk-knob__dial {
position: absolute;
inset: 0;
border-radius: 50%;
will-change: transform;
}
.modern-sk-knob--animated .modern-sk-knob__dial {
transition: transform 0.12s ease-out;
}
.modern-sk-knob.is-dragging .modern-sk-knob__dial {
transition: none;
}
.modern-sk-knob__dial::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: repeating-conic-gradient(
from 0deg,
rgba(255, 255, 255, 0.09) 0deg 3deg,
rgba(0, 0, 0, 0.28) 3deg 6deg
);
-webkit-mask: radial-gradient(circle, transparent 0 58%, #000 61% 100%);
mask: radial-gradient(circle, transparent 0 58%, #000 61% 100%);
}
.modern-sk-knob__pointer {
position: absolute;
top: 5%;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--knob-accent);
box-shadow: 0 0 6px var(--knob-glow);
}
/* turned-metal hub covers the inner ends; pointer reads as an outer notch */
.modern-sk-knob__hub {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48%;
height: 48%;
border-radius: 50%;
background: radial-gradient(circle at 50% 34%, #36372c, #1d1e17);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.07) inset,
0 1px 3px rgba(0, 0, 0, 0.55);
}
.modern-sk-knob:focus-visible .modern-sk-knob__cap {
box-shadow:
var(--focus-ring),
0 1px 0 rgba(255, 255, 255, 0.08) inset,
0 4px 9px rgba(0, 0, 0, 0.5),
0 11px 22px rgba(0, 0, 0, 0.42);
}
/* ember accent variant */
.modern-sk-knob--ember {
--knob-accent: var(--ember);
--knob-accent-deep: var(--ember-deep);
--knob-glow: rgba(233, 87, 43, 0.5);
}
@media (prefers-reduced-motion: reduce) {
.modern-sk-knob--animated .modern-sk-knob__fill,
.modern-sk-knob--animated .modern-sk-knob__dial {
transition: none;
}
}
/* light theme: brighter cap gloss, darker knurl ridges so the grip reads, a
pale turned hub, and a lighter recessed gauge channel. */
[data-theme='light'] .modern-sk-knob__cap {
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.9) inset,
0 2px 5px rgba(0, 0, 0, 0.16),
0 7px 16px rgba(0, 0, 0, 0.12);
}
[data-theme='light'] .modern-sk-knob__dial::before {
background: repeating-conic-gradient(
from 0deg,
rgba(0, 0, 0, 0.09) 0deg 3deg,
rgba(255, 255, 255, 0.6) 3deg 6deg
);
}
[data-theme='light'] .modern-sk-knob__hub {
background: radial-gradient(circle at 50% 34%, #ffffff, #e2e2d6);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.9) inset,
0 1px 3px rgba(0, 0, 0, 0.16);
}
[data-theme='light'] .modern-sk-knob__track {
stroke: var(--steel-500);
}
[data-theme='light'] .modern-sk-knob__tick {
stroke: var(--steel-400);
}
/* ---------- STEPPER ---------- */ /* ---------- STEPPER ---------- */
.modern-sk-stepper { .modern-sk-stepper {
display: inline-flex; display: inline-flex;