11 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
olly 4919bc26e5 visuals, fixes & storybook upd 2026-06-01 01:09:55 +03:00
olly a5d2742c7c fixes 2026-05-31 18:09:55 +03:00
olly 22afa7e1a5 feat: structure 2026-05-31 17:49:29 +03:00
51 changed files with 3600 additions and 961 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 }}
+26 -2
View File
@@ -2,8 +2,29 @@ import type { StorybookConfig } from 'storybook-react-rsbuild';
/* Dev-only playground. Never shipped — package `files` is ["dist"]. */
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)'],
addons: ['@storybook/addon-docs'],
staticDirs: ['../src/assets'],
framework: {
name: 'storybook-react-rsbuild',
options: {},
@@ -13,9 +34,12 @@ const config: StorybookConfig = {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
// Keep our own props; drop the noise inherited from node_modules.
// Keep our own props + Radix primitives; drop other node_modules noise.
propFilter: (prop) =>
prop.parent ? !/node_modules/.test(prop.parent.fileName) : true,
prop.parent
? !/node_modules/.test(prop.parent.fileName) ||
/node_modules\/radix-ui/.test(prop.parent.fileName)
: true,
},
},
};
+12
View File
@@ -0,0 +1,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
@font-face {
font-family: 'Anta';
src: url('/Anta-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
</style>
+3 -2
View File
@@ -2,8 +2,9 @@ import { useEffect, type ReactNode } from 'react';
import type { Preview, Decorator } from 'storybook-react-rsbuild';
import { Tooltip } from 'radix-ui';
/* The shipped library surface, exactly as a consumer would load it. */
import '../src/styles/fonts.css';
/* The shipped library surface, exactly as a consumer would load it.
Fonts are loaded via preview-head.html (Google Fonts link + Anta @font-face)
to avoid bundler inlining the @import url() mid-stylesheet. */
import '../src/styles/index.css';
/* Storybook-only canvas styling (background, docs blocks). */
import './preview.css';
+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.
### 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/`)
@@ -47,7 +51,7 @@ All components live in one file. Pattern: Radix provides logic/accessibility, ev
- `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`).
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
+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:
```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.
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "modern-sk",
"version": "0.1.0",
"name": "@olly/modern-sk",
"version": "0.1.2",
"description": "ModernSK — tactile, dark-first React component library built on Radix primitives.",
"license": "MIT",
"type": "module",
@@ -22,6 +22,9 @@
"files": [
"dist"
],
"publishConfig": {
"registry": "https://git.ollyhearn.ru/api/packages/olly/npm/"
},
"scripts": {
"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",
+10
View File
@@ -30,6 +30,7 @@ import {
Dialog,
DialogClose,
IconButton,
Knob,
List,
MenuRow,
MenuSeparator,
@@ -238,6 +239,7 @@ const App = () => {
<div className="cap">Slider &amp; stepper</div>
<div className="cluster">
<Slider defaultValue={[62]} max={100} step={1} />
<Slider defaultValue={[40]} max={100} step={20} marks />
<Stepper
onDecrement={() => setCount((n) => Math.max(0, n - 1))}
onIncrement={() => setCount((n) => n + 1)}
@@ -247,6 +249,14 @@ const App = () => {
</span>
</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 className="stack">
<div>
+57
View File
@@ -0,0 +1,57 @@
import { type ReactNode } from 'react';
import { AlertDialog as RAlertDialog } from 'radix-ui';
import { Button } from '../button';
export const AlertDialog = ({
trigger,
title,
description,
cancelLabel = 'Cancel',
actionLabel = 'Confirm',
destructive,
onAction,
open,
defaultOpen,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
cancelLabel?: string;
actionLabel?: string;
destructive?: boolean;
onAction?: () => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RAlertDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
{trigger && <RAlertDialog.Trigger asChild>{trigger}</RAlertDialog.Trigger>}
<RAlertDialog.Portal>
<RAlertDialog.Overlay className="modern-sk-overlay" />
<RAlertDialog.Content className="modern-sk-dialog">
<RAlertDialog.Title className="modern-sk-dialog__title">
{title}
</RAlertDialog.Title>
{description && (
<RAlertDialog.Description className="modern-sk-dialog__desc">
{description}
</RAlertDialog.Description>
)}
<div className="modern-sk-dialog__footer">
<RAlertDialog.Cancel asChild>
<Button variant="ghost">{cancelLabel}</Button>
</RAlertDialog.Cancel>
<RAlertDialog.Action asChild>
<Button
variant={destructive ? 'ember' : 'primary'}
onClick={onAction}
>
{actionLabel}
</Button>
</RAlertDialog.Action>
</div>
</RAlertDialog.Content>
</RAlertDialog.Portal>
</RAlertDialog.Root>
);
+44
View File
@@ -0,0 +1,44 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { cx } from '../utils';
type BadgeVariant = 'lime' | 'ember' | 'neutral' | 'outline';
export const Badge = ({
variant = 'neutral',
dot,
className,
children,
...props
}: ComponentPropsWithoutRef<'span'> & {
variant?: BadgeVariant;
dot?: boolean;
}) => (
<span
className={cx(
'modern-sk-badge',
`modern-sk-badge--${variant}`,
dot && 'modern-sk-badge--dot',
className,
)}
{...props}
>
{children}
</span>
);
export const Chip = ({
children,
onRemove,
}: {
children: ReactNode;
onRemove?: () => void;
}) => (
<span className="modern-sk-chip">
{children}
{onRemove && (
<button type="button" className="x" onClick={onRemove} aria-label="Remove">
×
</button>
)}
</span>
);
+27
View File
@@ -0,0 +1,27 @@
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export type BtnVariant = 'key' | 'primary' | 'ember' | 'ghost';
type ButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm';
iconOnly?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'key', size, iconOnly, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'modern-sk-btn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size === 'sm' && 'modern-sk-btn--sm',
iconOnly && 'modern-sk-btn--icon',
className,
)}
{...props}
/>
),
);
Button.displayName = 'Button';
+19
View File
@@ -0,0 +1,19 @@
import { type ReactNode } from 'react';
import { cx } from '../utils';
type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
export const Callout = ({
variant = 'info',
icon,
children,
}: {
variant?: CalloutVariant;
icon?: ReactNode;
children: ReactNode;
}) => (
<div className={cx('modern-sk-callout', variant !== 'info' && `modern-sk-callout--${variant}`)}>
{icon && <span className="modern-sk-callout__icon">{icon}</span>}
<div className="modern-sk-callout__body">{children}</div>
</div>
);
+6
View File
@@ -0,0 +1,6 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Card = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-card', className)} {...props} />
);
+55
View File
@@ -0,0 +1,55 @@
import { type ReactNode } from 'react';
import { Dialog as RDialog } from 'radix-ui';
import { X } from '@phosphor-icons/react';
import { IconButton } from '../icon-button';
export const Dialog = ({
trigger,
title,
description,
children,
footer,
open,
defaultOpen,
onOpenChange,
modal,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
modal?: boolean;
}) => (
<RDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} modal={modal}>
{trigger && <RDialog.Trigger asChild>{trigger}</RDialog.Trigger>}
<RDialog.Portal>
<RDialog.Overlay className="modern-sk-overlay" />
<RDialog.Content className="modern-sk-dialog">
<RDialog.Title className="modern-sk-dialog__title">{title}</RDialog.Title>
{description && (
<RDialog.Description className="modern-sk-dialog__desc">
{description}
</RDialog.Description>
)}
{children && <div className="modern-sk-dialog__body">{children}</div>}
{footer && <div className="modern-sk-dialog__footer">{footer}</div>}
<RDialog.Close asChild>
<IconButton
variant="ghost"
size="sm"
className="modern-sk-dialog__close"
aria-label="Close"
>
<X size={14} weight="bold" />
</IconButton>
</RDialog.Close>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
);
export const DialogClose = RDialog.Close;
+25
View File
@@ -0,0 +1,25 @@
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
import type { BtnVariant } from '../button';
type IconButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm' | 'lg';
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ variant = 'key', size, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'modern-sk-btn',
'modern-sk-iconbtn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size && `modern-sk-iconbtn--${size}`,
className,
)}
{...props}
/>
),
);
IconButton.displayName = 'IconButton';
+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>
);
};
+17
View File
@@ -0,0 +1,17 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const List = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-list', className)} {...props} />
);
export const Row = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
<div
className={cx('modern-sk-row', selected && 'is-selected', className)}
{...props}
/>
);
+56
View File
@@ -0,0 +1,56 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { DropdownMenu as RMenu } from 'radix-ui';
export const Menu = RMenu.Root;
export const MenuTrigger = RMenu.Trigger;
export const MenuContent = ({
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Content>) => (
<RMenu.Portal>
<RMenu.Content className="modern-sk-menu" sideOffset={6} {...props}>
{children}
</RMenu.Content>
</RMenu.Portal>
);
export const MenuItem = ({
icon,
shortcut,
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Item> & {
icon?: ReactNode;
shortcut?: string;
}) => (
<RMenu.Item className="modern-sk-menu-item" {...props}>
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</RMenu.Item>
);
export const MenuSeparator = () => (
<RMenu.Separator className="modern-sk-menu-sep" />
);
export const MenuSurface = ({ children }: { children: ReactNode }) => (
<div className="modern-sk-menu">{children}</div>
);
export const MenuRow = ({
icon,
shortcut,
children,
}: {
icon?: ReactNode;
shortcut?: string;
children: ReactNode;
}) => (
<div className="modern-sk-menu-item">
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</div>
);
+16
View File
@@ -0,0 +1,16 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Progress as RProgress } from 'radix-ui';
import { cx } from '../utils';
export const Progress = ({
value = 0,
className,
...props
}: ComponentPropsWithoutRef<typeof RProgress.Root>) => (
<RProgress.Root className={cx('modern-sk-progress', className)} value={value} {...props}>
<RProgress.Indicator
className="modern-sk-progress__indicator"
style={{ width: `${value}%` }}
/>
</RProgress.Root>
);
+22
View File
@@ -0,0 +1,22 @@
import { type ComponentPropsWithoutRef } from 'react';
import { ScrollArea as RScrollArea } from 'radix-ui';
import { cx } from '../utils';
export const ScrollArea = ({
children,
className,
...props
}: ComponentPropsWithoutRef<typeof RScrollArea.Root>) => (
<RScrollArea.Root className={cx('modern-sk-scroll', className)} {...props}>
<RScrollArea.Viewport className="modern-sk-scroll__viewport">
{children}
</RScrollArea.Viewport>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="vertical">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="horizontal">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Corner />
</RScrollArea.Root>
);
@@ -0,0 +1,66 @@
import { type ComponentPropsWithoutRef, useEffect, useRef } from 'react';
import { ToggleGroup as RToggleGroup } from 'radix-ui';
import { cx } from '../utils';
type SegProps = Omit<
ComponentPropsWithoutRef<typeof RToggleGroup.Root>,
'type' | 'onValueChange' | 'defaultValue' | 'value'
> & {
value: string;
defaultValue?: string;
onValueChange: (v: string) => void;
items: Array<{ value: string; label: string }>;
};
export const SegmentedControl = ({
value,
onValueChange,
items,
className,
...props
}: SegProps) => {
const rootRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLSpanElement>(null);
const initialized = useRef(false);
useEffect(() => {
const root = rootRef.current;
const thumb = thumbRef.current;
if (!root || !thumb) return;
const selected = root.querySelector<HTMLElement>('[data-state="on"]');
if (!selected) return;
if (!initialized.current) {
thumb.style.transition = 'none';
}
thumb.style.transform = `translateX(${selected.offsetLeft}px)`;
thumb.style.width = `${selected.offsetWidth}px`;
if (!initialized.current) {
thumb.getBoundingClientRect();
thumb.style.transition = '';
initialized.current = true;
}
}, [value]);
return (
<RToggleGroup.Root
ref={rootRef}
type="single"
className={cx('modern-sk-seg', className)}
value={value}
onValueChange={(v) => v && onValueChange(v)}
{...props}
>
<span ref={thumbRef} className="modern-sk-seg__thumb" aria-hidden />
{items.map((it) => (
<RToggleGroup.Item
key={it.value}
value={it.value}
className="modern-sk-seg__item"
>
{it.label}
</RToggleGroup.Item>
))}
</RToggleGroup.Root>
);
};
+42
View File
@@ -0,0 +1,42 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Select as RSelect } from 'radix-ui';
import { Check, CaretDown } from '@phosphor-icons/react';
type SelectProps = ComponentPropsWithoutRef<typeof RSelect.Root> & {
placeholder?: string;
items: Array<{ value: string; label: string }>;
'aria-label'?: string;
};
export const Select = ({ placeholder, items, ...rest }: SelectProps) => (
<RSelect.Root {...rest}>
<RSelect.Trigger className="modern-sk-select" aria-label={rest['aria-label']}>
<RSelect.Value placeholder={placeholder} />
<RSelect.Icon className="modern-sk-select__icon">
<CaretDown size={12} weight="bold" />
</RSelect.Icon>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
className="modern-sk-select__content"
position="popper"
sideOffset={6}
>
<RSelect.Viewport>
{items.map((it) => (
<RSelect.Item
key={it.value}
value={it.value}
className="modern-sk-select__item"
>
<RSelect.ItemText>{it.label}</RSelect.ItemText>
<RSelect.ItemIndicator className="modern-sk-select__item-indicator">
<Check size={14} weight="bold" />
</RSelect.ItemIndicator>
</RSelect.Item>
))}
</RSelect.Viewport>
</RSelect.Content>
</RSelect.Portal>
</RSelect.Root>
);
+53
View File
@@ -0,0 +1,53 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import {
Switch as RSwitch,
Checkbox as RCheckbox,
RadioGroup as RRadioGroup,
} from 'radix-ui';
export const Switch = (props: ComponentPropsWithoutRef<typeof RSwitch.Root>) => (
<RSwitch.Root className="modern-sk-switch" {...props}>
<RSwitch.Thumb className="modern-sk-switch__thumb" />
</RSwitch.Root>
);
export const Checkbox = (props: ComponentPropsWithoutRef<typeof RCheckbox.Root>) => (
<RCheckbox.Root className="modern-sk-check" {...props}>
<RCheckbox.Indicator className="modern-sk-check__indicator">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--lime-ink)"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12l5 5L20 6" />
</svg>
</RCheckbox.Indicator>
</RCheckbox.Root>
);
export const RadioGroup = RRadioGroup.Root;
export const RadioItem = ({
value,
...props
}: ComponentPropsWithoutRef<typeof RRadioGroup.Item>) => (
<RRadioGroup.Item className="modern-sk-radio" value={value} {...props}>
<RRadioGroup.Indicator className="modern-sk-radio__indicator" />
</RRadioGroup.Item>
);
export const Control = ({
children,
control,
}: {
children: ReactNode;
control: ReactNode;
}) => (
<label className="modern-sk-control">
{control}
{children}
</label>
);
+150
View File
@@ -0,0 +1,150 @@
import { type ComponentPropsWithoutRef, type CSSProperties } from 'react';
import { Slider as RSlider } from 'radix-ui';
type Mark = { value: number; label?: string };
type MarksProp = boolean | Array<number | Mark>;
type NotchPlacement = 'top' | 'bottom' | 'both' | 'none';
type KnobStyle = 'square' | 'round';
type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & {
/**
* Step marks.
* - `true` — auto-generate one mark per `step` between `min` and `max`.
* - array — explicit marks; numbers or `{ value, label }` for tick labels.
*/
marks?: MarksProp;
/**
* Where to draw the notch ticks relative to the track.
* `'bottom'` (default), `'top'`, `'both'`, or `'none'` to hide ticks
* (labels still render when provided). No effect without `marks`.
*/
notches?: NotchPlacement;
/** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */
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;
};
function resolveMarks(
marks: MarksProp,
min: number,
max: number,
step: number,
): Mark[] {
if (Array.isArray(marks)) {
return marks
.map((m) => (typeof m === 'number' ? { value: m } : m))
.filter((m) => m.value >= min && m.value <= max);
}
if (marks !== true) return [];
if (!(step > 0) || max <= min) return [];
const count = Math.floor((max - min) / step);
// Guard against absurd notch counts (e.g. step=1 over a 01000 range).
if (count < 1 || count > 100) return [];
return Array.from({ length: count + 1 }, (_, i) => ({ value: min + i * step }));
}
const percent = (value: number, min: number, max: number) =>
max === min ? 0 : (value - min) / (max - min);
const NotchLayer = ({
marks,
min,
max,
side,
}: {
marks: Mark[];
min: number;
max: number;
side: 'top' | 'bottom';
}) => (
<div className={`modern-sk-slider__notches modern-sk-slider__notches--${side}`} aria-hidden>
{marks.map((mark) => (
<span
key={mark.value}
className="modern-sk-slider__notch"
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
/>
))}
</div>
);
export const Slider = ({
marks,
notches = 'bottom',
knobStyle = 'square',
animated,
min = 0,
max = 100,
step = 1,
className,
...props
}: SliderProps) => {
const resolved = marks != null ? resolveMarks(marks, min, max, step) : [];
const hasMarks = resolved.length > 0;
const hasLabels = resolved.some((m) => m.label != null);
const showTop = hasMarks && (notches === 'top' || notches === 'both');
const showBottom = hasMarks && (notches === 'bottom' || notches === 'both');
const isAnimated = animated !== undefined ? animated : hasMarks;
const cls = [
'modern-sk-slider',
`modern-sk-slider--knob-${knobStyle}`,
isAnimated && 'modern-sk-slider--animated',
hasMarks && 'modern-sk-slider--has-marks',
hasLabels && 'modern-sk-slider--has-labels',
showTop && 'modern-sk-slider--notch-top',
showBottom && 'modern-sk-slider--notch-bottom',
className,
]
.filter(Boolean)
.join(' ');
return (
<RSlider.Root className={cls} min={min} max={max} step={step} {...props}>
<RSlider.Track className="modern-sk-slider__track">
<RSlider.Range className="modern-sk-slider__range" />
{showTop && <NotchLayer marks={resolved} min={min} max={max} side="top" />}
{showBottom && <NotchLayer marks={resolved} min={min} max={max} side="bottom" />}
{hasLabels && (
<div className="modern-sk-slider__labels" aria-hidden>
{resolved.map((mark) =>
mark.label != null ? (
<span
key={mark.value}
className="modern-sk-slider__label"
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
>
{mark.label}
</span>
) : null,
)}
</div>
)}
</RSlider.Track>
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
</RSlider.Root>
);
};
export const Stepper = ({
onDecrement,
onIncrement,
}: {
onDecrement: () => void;
onIncrement: () => void;
}) => (
<div className="modern-sk-stepper">
<button type="button" onClick={onDecrement} aria-label="Decrease">
</button>
<button type="button" onClick={onIncrement} aria-label="Increase">
+
</button>
</div>
);
+74
View File
@@ -0,0 +1,74 @@
import { useId, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Spinner = ({
size,
className,
...props
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
const uid = useId();
const grooveId = `modern-sk-groove-${uid}`;
const glowId = `modern-sk-glow-${uid}`;
return (
<span
role="status"
aria-label="Loading"
className={cx('modern-sk-spinner', size && `modern-sk-spinner--${size}`, className)}
{...props}
>
<svg viewBox="0 0 36 36" fill="none">
<defs>
{/* Carved channel: flat ring sunk by a top inner shadow — like the switch well. */}
<filter id={grooveId} x="-30%" y="-30%" width="160%" height="160%">
<feComponentTransfer in="SourceAlpha" result="inv">
<feFuncA type="table" tableValues="1 0" />
</feComponentTransfer>
<feGaussianBlur in="inv" stdDeviation="1" result="blur" />
<feOffset in="blur" dy="1" result="off" />
<feFlood floodColor="#000" floodOpacity="0.7" />
<feComposite in2="off" operator="in" />
<feComposite in2="SourceAlpha" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="SourceGraphic" />
<feMergeNode in="shadow" />
</feMerge>
</filter>
{/* Soft round glow — generous region so it never clips to a square. */}
<filter id={glowId} x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="1.6" />
</filter>
</defs>
<circle
cx="18"
cy="18"
r="14"
stroke="var(--spin-track)"
strokeWidth="5"
filter={`url(#${grooveId})`}
/>
<g className="modern-sk-spinner__arc">
<circle
cx="18"
cy="18"
r="14"
stroke="var(--lime)"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="22 88"
opacity="0.8"
filter={`url(#${glowId})`}
/>
<circle
cx="18"
cy="18"
r="14"
stroke="var(--lime)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="22 88"
/>
</g>
</svg>
</span>
);
};
+24
View File
@@ -0,0 +1,24 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Table = ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => (
<div className="modern-sk-table-wrap">
<table className="modern-sk-table" {...props}>
{children}
</table>
</div>
);
export const THead = (p: ComponentPropsWithoutRef<'thead'>) => <thead {...p} />;
export const TBody = (p: ComponentPropsWithoutRef<'tbody'>) => <tbody {...p} />;
export const Tr = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'tr'> & { selected?: boolean }) => (
<tr className={cx(selected && 'is-selected', className)} {...props} />
);
export const Th = (p: ComponentPropsWithoutRef<'th'>) => <th {...p} />;
export const Td = (p: ComponentPropsWithoutRef<'td'>) => <td {...p} />;
+28
View File
@@ -0,0 +1,28 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Tabs as RTabs } from 'radix-ui';
import { cx } from '../utils';
export const Tabs = RTabs.Root;
export const TabsList = ({
items,
className,
...props
}: { items: Array<{ value: string; label: string }> } & Omit<
ComponentPropsWithoutRef<typeof RTabs.List>,
'children'
>) => (
<RTabs.List className={cx('modern-sk-tabs', className)} {...props}>
{items.map((it) => (
<RTabs.Trigger
key={it.value}
value={it.value}
className="modern-sk-tabs__trigger"
>
{it.label}
</RTabs.Trigger>
))}
</RTabs.List>
);
export const TabsContent = RTabs.Content;
+278
View File
@@ -0,0 +1,278 @@
import {
forwardRef,
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
type Ref,
} from 'react';
import { cx } from '../utils';
/* ------------------------------------------------------------------ *
* Typing animation (osu!-lazer style)
*
* 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';
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
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';
export const SearchField = ({
icon,
...props
}: TextFieldProps & { icon: ReactNode }) => (
<div className="modern-sk-search">
<span className="ph">{icon}</span>
<TextField {...props} />
</div>
);
+42
View File
@@ -0,0 +1,42 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { Tooltip as RTooltip } from 'radix-ui';
import { cx } from '../utils';
type TooltipProps = {
content: ReactNode;
children: ReactNode;
delayDuration?: number;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
} & Omit<ComponentPropsWithoutRef<typeof RTooltip.Content>, 'children' | 'content'>;
export const Tooltip = ({
content,
children,
delayDuration,
open,
defaultOpen,
onOpenChange,
sideOffset = 6,
className,
...contentProps
}: TooltipProps) => (
<RTooltip.Root
delayDuration={delayDuration}
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<RTooltip.Trigger asChild>{children}</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content
className={cx('modern-sk-tooltip', className)}
sideOffset={sideOffset}
{...contentProps}
>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
);
+22 -679
View File
@@ -1,679 +1,22 @@
/* ============================================================
ModernSK UI — Radix Primitives wrapped in the ModernSK look.
Logic/accessibility from Radix; every pixel from the tokens in
styles/tokens.css + styles/components.css.
============================================================ */
import {
forwardRef,
useId,
type ComponentPropsWithoutRef,
type CSSProperties,
type ReactNode,
} from 'react';
import {
Switch as RSwitch,
Checkbox as RCheckbox,
RadioGroup as RRadioGroup,
Tabs as RTabs,
Slider as RSlider,
DropdownMenu as RMenu,
Tooltip as RTooltip,
Progress as RProgress,
Select as RSelect,
ToggleGroup as RToggleGroup,
Dialog as RDialog,
AlertDialog as RAlertDialog,
ScrollArea as RScrollArea,
} from 'radix-ui';
import { Check, CaretDown, X } from '@phosphor-icons/react';
const cx = (...c: Array<string | false | undefined>) =>
c.filter(Boolean).join(' ');
/* ---------- BUTTON ---------- */
type BtnVariant = 'key' | 'primary' | 'ember' | 'ghost';
type ButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm';
iconOnly?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'key', size, iconOnly, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'modern-sk-btn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size === 'sm' && 'modern-sk-btn--sm',
iconOnly && 'modern-sk-btn--icon',
className,
)}
{...props}
/>
),
);
Button.displayName = 'Button';
/* ---------- TEXT FIELD ---------- */
export const TextField = forwardRef<
HTMLInputElement,
ComponentPropsWithoutRef<'input'>
>(({ className, ...props }, ref) => (
<input ref={ref} className={cx('modern-sk-field', className)} {...props} />
));
TextField.displayName = 'TextField';
export const TextArea = forwardRef<
HTMLTextAreaElement,
ComponentPropsWithoutRef<'textarea'>
>(({ className, ...props }, ref) => (
<textarea ref={ref} className={cx('modern-sk-field', className)} {...props} />
));
TextArea.displayName = 'TextArea';
export const SearchField = ({
icon,
...props
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => (
<div className="modern-sk-search">
<span className="ph">{icon}</span>
<TextField {...props} />
</div>
);
/* ---------- SELECT (Radix) ---------- */
type SelectProps = {
value?: string;
defaultValue?: string;
onValueChange?: (v: string) => void;
placeholder?: string;
items: Array<{ value: string; label: string }>;
'aria-label'?: string;
};
export const Select = ({
value,
defaultValue,
onValueChange,
placeholder,
items,
...rest
}: SelectProps) => (
<RSelect.Root
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
>
<RSelect.Trigger className="modern-sk-select" aria-label={rest['aria-label']}>
<RSelect.Value placeholder={placeholder} />
<RSelect.Icon className="modern-sk-select__icon">
<CaretDown size={12} weight="bold" />
</RSelect.Icon>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
className="modern-sk-select__content"
position="popper"
sideOffset={6}
>
<RSelect.Viewport>
{items.map((it) => (
<RSelect.Item
key={it.value}
value={it.value}
className="modern-sk-select__item"
>
<RSelect.ItemText>{it.label}</RSelect.ItemText>
<RSelect.ItemIndicator className="modern-sk-select__item-indicator">
<Check size={14} weight="bold" />
</RSelect.ItemIndicator>
</RSelect.Item>
))}
</RSelect.Viewport>
</RSelect.Content>
</RSelect.Portal>
</RSelect.Root>
);
/* ---------- SWITCH ---------- */
export const Switch = (
props: ComponentPropsWithoutRef<typeof RSwitch.Root>,
) => (
<RSwitch.Root className="modern-sk-switch" {...props}>
<RSwitch.Thumb className="modern-sk-switch__thumb" />
</RSwitch.Root>
);
/* ---------- CHECKBOX ---------- */
export const Checkbox = (
props: ComponentPropsWithoutRef<typeof RCheckbox.Root>,
) => (
<RCheckbox.Root className="modern-sk-check" {...props}>
<RCheckbox.Indicator className="modern-sk-check__indicator">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--lime-ink)"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12l5 5L20 6" />
</svg>
</RCheckbox.Indicator>
</RCheckbox.Root>
);
/* ---------- RADIO GROUP ---------- */
export const RadioGroup = RRadioGroup.Root;
export const RadioItem = ({
value,
...props
}: ComponentPropsWithoutRef<typeof RRadioGroup.Item>) => (
<RRadioGroup.Item className="modern-sk-radio" value={value} {...props}>
<RRadioGroup.Indicator className="modern-sk-radio__indicator" />
</RRadioGroup.Item>
);
/* control + label row helper */
export const Control = ({
children,
control,
}: {
children: ReactNode;
control: ReactNode;
}) => (
<label className="modern-sk-control">
{control}
{children}
</label>
);
/* ---------- SEGMENTED CONTROL (ToggleGroup single) ---------- */
type SegProps = {
value: string;
onValueChange: (v: string) => void;
items: Array<{ value: string; label: string }>;
};
export const SegmentedControl = ({
value,
onValueChange,
items,
}: SegProps) => (
<RToggleGroup.Root
type="single"
className="modern-sk-seg"
value={value}
onValueChange={(v) => v && onValueChange(v)}
>
{items.map((it) => (
<RToggleGroup.Item
key={it.value}
value={it.value}
className="modern-sk-seg__item"
>
{it.label}
</RToggleGroup.Item>
))}
</RToggleGroup.Root>
);
/* ---------- SLIDER ---------- */
export const Slider = (
props: ComponentPropsWithoutRef<typeof RSlider.Root>,
) => (
<RSlider.Root className="modern-sk-slider" {...props}>
<RSlider.Track className="modern-sk-slider__track">
<RSlider.Range className="modern-sk-slider__range" />
</RSlider.Track>
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
</RSlider.Root>
);
/* ---------- STEPPER ---------- */
export const Stepper = ({
onDecrement,
onIncrement,
}: {
onDecrement: () => void;
onIncrement: () => void;
}) => (
<div className="modern-sk-stepper">
<button type="button" onClick={onDecrement} aria-label="Decrease">
</button>
<button type="button" onClick={onIncrement} aria-label="Increase">
+
</button>
</div>
);
/* ---------- TABS ---------- */
export const Tabs = RTabs.Root;
export const TabsList = ({
items,
}: {
items: Array<{ value: string; label: string }>;
}) => (
<RTabs.List className="modern-sk-tabs">
{items.map((it) => (
<RTabs.Trigger
key={it.value}
value={it.value}
className="modern-sk-tabs__trigger"
>
{it.label}
</RTabs.Trigger>
))}
</RTabs.List>
);
export const TabsContent = RTabs.Content;
/* ---------- PROGRESS ---------- */
export const Progress = ({ value = 0 }: { value?: number }) => (
<RProgress.Root className="modern-sk-progress" value={value}>
<RProgress.Indicator
className="modern-sk-progress__indicator"
style={{ width: `${value}%` }}
/>
</RProgress.Root>
);
/* ---------- BADGE ---------- */
type BadgeVariant = 'lime' | 'ember' | 'neutral' | 'outline';
export const Badge = ({
variant = 'neutral',
dot,
className,
children,
...props
}: ComponentPropsWithoutRef<'span'> & {
variant?: BadgeVariant;
dot?: boolean;
}) => (
<span
className={cx(
'modern-sk-badge',
`modern-sk-badge--${variant}`,
dot && 'modern-sk-badge--dot',
className,
)}
{...props}
>
{children}
</span>
);
/* ---------- CHIP ---------- */
export const Chip = ({
children,
onRemove,
}: {
children: ReactNode;
onRemove?: () => void;
}) => (
<span className="modern-sk-chip">
{children}
{onRemove && (
<button type="button" className="x" onClick={onRemove} aria-label="Remove">
×
</button>
)}
</span>
);
/* ---------- CARD ---------- */
export const Card = ({
className,
...props
}: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-card', className)} {...props} />
);
/* ---------- LIST ---------- */
export const List = ({
className,
...props
}: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-list', className)} {...props} />
);
export const Row = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
<div
className={cx('modern-sk-row', selected && 'is-selected', className)}
{...props}
/>
);
/* ---------- DROPDOWN MENU (Radix) ---------- */
export const Menu = RMenu.Root;
export const MenuTrigger = RMenu.Trigger;
export const MenuContent = ({
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Content>) => (
<RMenu.Portal>
<RMenu.Content className="modern-sk-menu" sideOffset={6} {...props}>
{children}
</RMenu.Content>
</RMenu.Portal>
);
export const MenuItem = ({
icon,
shortcut,
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Item> & {
icon?: ReactNode;
shortcut?: string;
}) => (
<RMenu.Item className="modern-sk-menu-item" {...props}>
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</RMenu.Item>
);
export const MenuSeparator = () => (
<RMenu.Separator className="modern-sk-menu-sep" />
);
/* Static menu surface — for showcasing the menu without a trigger. */
export const MenuSurface = ({ children }: { children: ReactNode }) => (
<div className="modern-sk-menu">{children}</div>
);
export const MenuRow = ({
icon,
shortcut,
children,
}: {
icon?: ReactNode;
shortcut?: string;
children: ReactNode;
}) => (
<div className="modern-sk-menu-item">
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</div>
);
/* ---------- TOOLTIP (Radix) ---------- */
export const Tooltip = ({
content,
children,
}: {
content: ReactNode;
children: ReactNode;
}) => (
<RTooltip.Root>
<RTooltip.Trigger asChild>{children}</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content className="modern-sk-tooltip" sideOffset={6}>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
);
/* ---------- ICON BUTTON ---------- */
type IconButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm' | 'lg';
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ variant = 'key', size, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'modern-sk-btn',
'modern-sk-iconbtn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size && `modern-sk-iconbtn--${size}`,
className,
)}
{...props}
/>
),
);
IconButton.displayName = 'IconButton';
/* ---------- SPINNER ----------
Carved donut groove (sunk like the switch well, dark rim at top →
light catch at the bottom) with a glossy lime arc spinning inside it. */
export const Spinner = ({
size,
className,
...props
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
const gid = `modern-sk-groove-${useId()}`;
return (
<span
role="status"
aria-label="Loading"
className={cx('modern-sk-spinner', size && `modern-sk-spinner--${size}`, className)}
{...props}
>
<svg viewBox="0 0 36 36" fill="none">
<defs>
<linearGradient
id={gid}
x1="18"
y1="4"
x2="18"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="var(--spin-groove-1)" />
<stop offset="1" stopColor="var(--spin-groove-2)" />
</linearGradient>
</defs>
{/* carved channel */}
<circle cx="18" cy="18" r="14" stroke={`url(#${gid})`} strokeWidth="5" />
{/* glossy lime arc, nested inside the groove with rounded ends */}
<circle
className="modern-sk-spinner__arc"
cx="18"
cy="18"
r="14"
stroke="var(--lime)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="22 88"
/>
</svg>
</span>
);
};
/* ---------- CALLOUT ---------- */
type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
export const Callout = ({
variant = 'info',
icon,
children,
}: {
variant?: CalloutVariant;
icon?: ReactNode;
children: ReactNode;
}) => (
<div className={cx('modern-sk-callout', variant !== 'info' && `modern-sk-callout--${variant}`)}>
{icon && <span className="modern-sk-callout__icon">{icon}</span>}
<div className="modern-sk-callout__body">{children}</div>
</div>
);
/* ---------- TABLE ---------- */
export const Table = ({
children,
...props
}: ComponentPropsWithoutRef<'table'>) => (
<div className="modern-sk-table-wrap">
<table className="modern-sk-table" {...props}>
{children}
</table>
</div>
);
export const THead = (p: ComponentPropsWithoutRef<'thead'>) => <thead {...p} />;
export const TBody = (p: ComponentPropsWithoutRef<'tbody'>) => <tbody {...p} />;
export const Tr = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'tr'> & { selected?: boolean }) => (
<tr className={cx(selected && 'is-selected', className)} {...props} />
);
export const Th = (p: ComponentPropsWithoutRef<'th'>) => <th {...p} />;
export const Td = (p: ComponentPropsWithoutRef<'td'>) => <td {...p} />;
/* ---------- SCROLL AREA (Radix) ---------- */
export const ScrollArea = ({
children,
className,
style,
}: {
children: ReactNode;
className?: string;
style?: CSSProperties;
}) => (
<RScrollArea.Root className={cx('modern-sk-scroll', className)} style={style}>
<RScrollArea.Viewport className="modern-sk-scroll__viewport">
{children}
</RScrollArea.Viewport>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="vertical">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="horizontal">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Corner />
</RScrollArea.Root>
);
/* ---------- DIALOG / MODAL (Radix Dialog) ---------- */
export const Dialog = ({
trigger,
title,
description,
children,
footer,
open,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
open?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RDialog.Root open={open} onOpenChange={onOpenChange}>
{trigger && <RDialog.Trigger asChild>{trigger}</RDialog.Trigger>}
<RDialog.Portal>
<RDialog.Overlay className="modern-sk-overlay" />
<RDialog.Content className="modern-sk-dialog">
<RDialog.Title className="modern-sk-dialog__title">{title}</RDialog.Title>
{description && (
<RDialog.Description className="modern-sk-dialog__desc">
{description}
</RDialog.Description>
)}
{children && <div className="modern-sk-dialog__body">{children}</div>}
{footer && <div className="modern-sk-dialog__footer">{footer}</div>}
<RDialog.Close asChild>
<IconButton
variant="ghost"
size="sm"
className="modern-sk-dialog__close"
aria-label="Close"
>
<X size={14} weight="bold" />
</IconButton>
</RDialog.Close>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
);
/* Low-level Dialog parts (for custom compositions). */
export const DialogClose = RDialog.Close;
/* ---------- ALERT DIALOG (Radix AlertDialog) ---------- */
export const AlertDialog = ({
trigger,
title,
description,
cancelLabel = 'Cancel',
actionLabel = 'Confirm',
destructive,
onAction,
open,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
cancelLabel?: string;
actionLabel?: string;
destructive?: boolean;
onAction?: () => void;
open?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RAlertDialog.Root open={open} onOpenChange={onOpenChange}>
{trigger && <RAlertDialog.Trigger asChild>{trigger}</RAlertDialog.Trigger>}
<RAlertDialog.Portal>
<RAlertDialog.Overlay className="modern-sk-overlay" />
<RAlertDialog.Content className="modern-sk-dialog">
<RAlertDialog.Title className="modern-sk-dialog__title">
{title}
</RAlertDialog.Title>
{description && (
<RAlertDialog.Description className="modern-sk-dialog__desc">
{description}
</RAlertDialog.Description>
)}
<div className="modern-sk-dialog__footer">
<RAlertDialog.Cancel asChild>
<Button variant="ghost">{cancelLabel}</Button>
</RAlertDialog.Cancel>
<RAlertDialog.Action asChild>
<Button
variant={destructive ? 'ember' : 'primary'}
onClick={onAction}
>
{actionLabel}
</Button>
</RAlertDialog.Action>
</div>
</RAlertDialog.Content>
</RAlertDialog.Portal>
</RAlertDialog.Root>
);
/* ---------- WINDOW CHROME ---------- */
export const Window = ({
title,
badge,
children,
...props
}: ComponentPropsWithoutRef<'div'> & {
title: string;
badge?: ReactNode;
}) => (
<div className="modern-sk-window" {...props}>
<div className="modern-sk-titlebar">
<span className="modern-sk-traffic r" />
<span className="modern-sk-traffic y" />
<span className="modern-sk-traffic g" />
<span className="ttl">{title}</span>
{badge && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{badge}
</div>
)}
</div>
{children}
</div>
);
export * from './button';
export * from './icon-button';
export * from './text-field';
export * from './select';
export * from './selection';
export * from './segmented-control';
export * from './slider';
export * from './knob';
export * from './tabs';
export * from './progress';
export * from './badge';
export * from './card';
export * from './list';
export * from './menu';
export * from './tooltip';
export * from './spinner';
export * from './callout';
export * from './table';
export * from './scroll-area';
export * from './dialog';
export * from './alert-dialog';
export * from './window';
+2
View File
@@ -0,0 +1,2 @@
export const cx = (...c: Array<string | false | undefined>) =>
c.filter(Boolean).join(' ');
+26
View File
@@ -0,0 +1,26 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
export const Window = ({
title,
badge,
children,
...props
}: ComponentPropsWithoutRef<'div'> & {
title: string;
badge?: ReactNode;
}) => (
<div className="modern-sk-window" {...props}>
<div className="modern-sk-titlebar">
<span className="modern-sk-traffic r" />
<span className="modern-sk-traffic y" />
<span className="modern-sk-traffic g" />
<span className="ttl">{title}</span>
{badge && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{badge}
</div>
)}
</div>
{children}
</div>
);
+64
View File
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { AlertDialog, Button } from '../components/ui';
const meta = {
title: 'Overlays/AlertDialog',
component: AlertDialog,
parameters: {
docs: {
description: {
component:
'Confirmation dialog built on Radix AlertDialog. Use for destructive or irreversible actions — focus is trapped and the cancel button is always reachable. Set `destructive` to switch the action button to the ember (red) variant.',
},
},
},
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
cancelLabel: { control: 'text' },
actionLabel: { control: 'text' },
destructive: { control: 'boolean' },
trigger: { control: false },
},
args: {
title: 'Delete this project?',
description: 'This action cannot be undone.',
actionLabel: 'Delete',
cancelLabel: 'Cancel',
destructive: true,
trigger: <Button variant="ember">Delete</Button>,
onAction: () => {},
},
} satisfies Meta<typeof AlertDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Destructive: Story = {
render: () => (
<AlertDialog
trigger={<Button variant="ember">Delete account</Button>}
title="Delete your account?"
description="All data will be permanently removed. This cannot be undone."
actionLabel="Delete account"
cancelLabel="Keep account"
destructive
onAction={() => {}}
/>
),
};
export const Confirm: Story = {
render: () => (
<AlertDialog
trigger={<Button variant="primary">Publish</Button>}
title="Publish changes?"
description="This will make your changes visible to all users."
actionLabel="Publish"
cancelLabel="Cancel"
onAction={() => {}}
/>
),
};
+1
View File
@@ -26,6 +26,7 @@ const meta = {
iconOnly: { control: 'boolean', description: 'Square padding for a single glyph.' },
disabled: { control: 'boolean' },
children: { control: 'text' },
className: { control: 'text' },
},
args: { children: 'Button', variant: 'key' },
} satisfies Meta<typeof Button>;
+31 -4
View File
@@ -10,6 +10,7 @@ import {
Th,
Td,
Badge,
Chip,
} from '../components/ui';
const meta = {
@@ -18,18 +19,23 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Cards, selectable lists/rows, and the bordered table.',
component: 'Cards, selectable lists/rows, badges, chips, and the bordered table.',
},
},
},
argTypes: {
className: { control: 'text' },
},
} satisfies Meta<typeof Card>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CardSurface: Story = {
render: () => (
<Card style={{ maxWidth: 320, padding: 20 }}>
export const CardPlayground: Story = {
name: 'Card',
args: { children: 'Card content' },
render: (args) => (
<Card {...args} style={{ maxWidth: 320, padding: 20 }}>
<h3 className="modern-sk-h3">Storage</h3>
<p className="modern-sk-body">128 GB of 256 GB used.</p>
</Card>
@@ -47,6 +53,27 @@ export const ListRows: Story = {
),
};
export const BadgeShowcase: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<Badge variant="lime">Lime</Badge>
<Badge variant="ember">Ember</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="lime" dot>Online</Badge>
</div>
),
};
export const Chips: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Chip>Design</Chip>
<Chip onRemove={() => {}}>Removable</Chip>
</div>
),
};
export const DataTable: Story = {
render: () => (
<Table>
+54
View File
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Dialog, Button, TextField } from '../components/ui';
const meta = {
title: 'Overlays/Dialog',
component: Dialog,
parameters: {
docs: {
description: {
component:
'Modal dialog built on Radix Dialog. Pass a `trigger` to wire open/close automatically, or control it with `open` / `onOpenChange`. Title is required; description, body, and footer are optional slots.',
},
},
},
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
trigger: { control: false },
children: { control: false },
footer: { control: false },
},
args: {
title: 'Rename project',
description: 'Choose a new name for this project.',
trigger: <Button variant="primary">Open dialog</Button>,
},
} satisfies Meta<typeof Dialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const WithBody: Story = {
name: 'With body',
render: () => (
<Dialog
trigger={<Button variant="primary">Open dialog</Button>}
title="Rename project"
description="Choose a new name for this project."
footer={<Button variant="primary">Save</Button>}
>
<TextField placeholder="Project name" defaultValue="My project" />
</Dialog>
),
};
export const NoDescription: Story = {
name: 'No description',
args: {
description: undefined,
title: 'Confirm action',
},
};
+13
View File
@@ -14,11 +14,24 @@ const meta = {
},
},
},
argTypes: {
value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
className: { control: 'text' },
},
args: { value: 40 },
} satisfies Meta<typeof Progress>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div style={{ width: 320 }}>
<Progress {...args} />
</div>
),
};
function ProgressDemo() {
const [v, setV] = useState(40);
return (
+11 -2
View File
@@ -14,8 +14,17 @@ const meta = {
},
},
argTypes: {
variant: { control: 'inline-radio', options: ['key', 'primary', 'ember', 'ghost'] },
size: { control: 'inline-radio', options: ['sm', undefined, 'lg'] },
variant: {
control: 'inline-radio',
options: ['key', 'primary', 'ember', 'ghost'],
description: 'Visual emphasis. `key` is the default neutral button.',
},
size: {
control: 'inline-radio',
options: ['sm', undefined, 'lg'],
description: 'Button size: `sm` compact, default regular, `lg` large.',
},
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof IconButton>;
+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 },
};
+23
View File
@@ -23,11 +23,34 @@ const meta = {
},
},
},
argTypes: {
children: { control: false },
content: { control: 'text' },
delayDuration: { control: 'number' },
open: { control: 'boolean' },
defaultOpen: { control: 'boolean' },
onOpenChange: { action: 'open changed' },
sideOffset: { control: 'number' },
},
args: {
content: 'Tooltip text',
children: null,
delayDuration: 0,
},
} satisfies Meta<typeof Tooltip>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
name: 'Tooltip Playground',
render: (args) => (
<Tooltip {...args}>
<Button variant="ghost">Hover me</Button>
</Tooltip>
),
};
export const TooltipStory: Story = {
name: 'Tooltip',
render: () => (
+73
View File
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { ScrollArea } from '../components/ui';
const meta = {
title: 'Layout/ScrollArea',
component: ScrollArea,
parameters: {
docs: {
description: {
component:
'Radix ScrollArea with custom styled scrollbars. Wraps content in a viewport with vertical and horizontal scrollbars.',
},
},
},
argTypes: {
children: { control: false },
className: { control: 'text' },
},
} satisfies Meta<typeof ScrollArea>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<ScrollArea style={{ width: 300, height: 200, border: '1px solid var(--neutral-border)' }}>
<div style={{ padding: 16 }}>
<h4 className="modern-sk-h4" style={{ marginBottom: 8 }}>Scrollable content</h4>
{Array.from({ length: 20 }).map((_, i) => (
<p key={i} className="modern-sk-body" style={{ marginBottom: 12 }}>
Item {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
))}
</div>
</ScrollArea>
),
};
export const Vertical: Story = {
render: () => (
<ScrollArea style={{ width: 280, height: 150, border: '1px solid var(--neutral-border)' }}>
<div style={{ padding: 12 }}>
{Array.from({ length: 30 }).map((_, i) => (
<div key={i} style={{ padding: 8, borderBottom: '1px solid var(--neutral-border)' }}>
Row {i + 1}
</div>
))}
</div>
</ScrollArea>
),
};
export const Horizontal: Story = {
render: () => (
<ScrollArea style={{ width: 320, height: 80, border: '1px solid var(--neutral-border)' }}>
<div style={{ display: 'flex', gap: 8, padding: 12, width: 'fit-content' }}>
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
style={{
padding: 8,
minWidth: 100,
background: 'var(--neutral-surface)',
borderRadius: 4,
}}
>
Item {i + 1}
</div>
))}
</div>
</ScrollArea>
),
};
+68
View File
@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { SegmentedControl } from '../components/ui';
const meta = {
title: 'Selection/SegmentedControl',
component: SegmentedControl,
parameters: {
docs: {
description: {
component:
'Single-select button group with animated thumb. Pass `items` array of `{ value, label }` objects, plus `value` and `onValueChange` for control.',
},
},
},
argTypes: {
value: { control: 'text' },
onValueChange: { action: 'value changed' },
items: { control: false },
className: { control: 'text' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof SegmentedControl>;
export default meta;
type Story = StoryObj<typeof meta>;
function SegmentedDemo() {
const [v, setV] = useState('day');
return (
<SegmentedControl
value={v}
onValueChange={setV}
items={[
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]}
/>
);
}
export const Playground: Story = {
render: () => <SegmentedDemo />,
};
export const TimeRange: Story = {
render: () => <SegmentedDemo />,
};
function OptionsDemo() {
const [v, setV] = useState('draft');
return (
<SegmentedControl
value={v}
onValueChange={setV}
items={[
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'archived', label: 'Archived' },
]}
/>
);
}
export const Options: Story = {
render: () => <OptionsDemo />,
};
+9
View File
@@ -19,6 +19,15 @@ const meta = {
},
},
},
argTypes: {
items: { control: false },
placeholder: { control: 'text' },
disabled: { control: 'boolean' },
defaultValue: { control: 'text' },
value: { control: 'text' },
onValueChange: { action: 'value changed' },
'aria-label': { control: 'text' },
},
args: { items, placeholder: 'Pick a release…', 'aria-label': 'macOS release' },
} satisfies Meta<typeof Select>;
+10
View File
@@ -22,11 +22,21 @@ const meta = {
},
},
},
argTypes: {
defaultChecked: { control: 'boolean' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
onCheckedChange: { action: 'checked changed' },
},
} satisfies Meta<typeof Switch>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
args: { defaultChecked: false },
};
export const Switches: Story = {
render: () => (
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
+54 -3
View File
@@ -8,16 +8,67 @@ const meta = {
docs: {
description: {
component:
'Radix Slider in the carved-track skin. All Radix Slider props pass through (`defaultValue`, `min`, `max`, `step`, `onValueChange`).',
'Radix Slider in the carved-track skin. All Radix Slider props pass through (`defaultValue`, `min`, `max`, `step`, `onValueChange`). Set `marks` to carve step notches into the track — `marks` snaps to the Radix `step`.',
},
},
},
args: { defaultValue: [60], min: 0, max: 100, step: 1 },
argTypes: {
defaultValue: { control: 'object', description: 'Uncontrolled starting value(s).' },
min: { control: 'number' },
max: { control: 'number' },
step: { control: 'number', description: 'Snap increment (also drives auto `marks`).' },
disabled: { control: 'boolean' },
marks: {
control: 'boolean',
description: 'Step marks. `true` derives one per `step`; or pass an array for custom/labelled marks.',
},
notches: {
control: 'inline-radio',
options: ['top', 'bottom', 'both', 'none'],
description: 'Notch tick placement relative to the track (labels still render when `none`).',
},
knobStyle: {
control: 'inline-radio',
options: ['square', 'round'],
description: 'Knob shape: `square` (default) or `round`.',
},
className: { control: 'text' },
},
decorators: [(Story) => <div style={{ width: 280 }}><Story /></div>],
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = { args: { defaultValue: [60], max: 100, step: 1 } };
export const Playground: Story = {};
export const Stepped: Story = { args: { defaultValue: [40], max: 100, step: 10 } };
/** `marks` auto-derives one notch per `step`. */
export const Stepped: Story = {
args: { defaultValue: [40], step: 10, marks: true },
};
/** `notches='both'` carves ticks above and below the bar. */
export const NotchesBoth: Story = {
args: { defaultValue: [60], step: 20, marks: true, notches: 'both' },
};
/** Pass an array of `{ value, label }` for labelled ticks. */
export const LabelledMarks: Story = {
args: {
defaultValue: [50],
step: 25,
notches: 'bottom',
marks: [
{ value: 0, label: 'Off' },
{ value: 25, label: 'Low' },
{ value: 50, label: 'Mid' },
{ value: 75, label: 'High' },
{ value: 100, label: 'Max' },
],
},
};
export const Disabled: Story = {
args: { defaultValue: [30], step: 10, marks: true, disabled: true },
};
+6
View File
@@ -12,6 +12,12 @@ const meta = {
},
},
},
argTypes: {
defaultValue: { control: 'text' },
value: { control: 'text' },
onValueChange: { action: 'value changed' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Tabs>;
export default meta;
+10
View File
@@ -13,6 +13,16 @@ const meta = {
},
},
},
argTypes: {
placeholder: { control: 'text' },
value: { control: 'text' },
defaultValue: { control: 'text' },
disabled: { control: 'boolean' },
readOnly: { control: 'boolean' },
required: { control: 'boolean' },
type: { control: 'text' },
onChange: { action: 'changed' },
},
args: { placeholder: 'Type here…' },
} satisfies Meta<typeof TextField>;
+5
View File
@@ -12,6 +12,11 @@ const meta = {
},
},
},
argTypes: {
title: { control: 'text' },
badge: { control: false },
children: { control: false },
},
args: { title: 'Finder' },
} satisfies Meta<typeof Window>;
+1552 -252
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -7,7 +7,13 @@
@import './tokens.css';
@import './components.css';
/* Non-invasive box-sizing for our own components only (zero specificity). */
/* Non-invasive box-sizing + branded font for our own components only
(zero specificity, so consumer elements are never touched and the
--font-mono / --font-display classes still win by class specificity).
This is what carries the typeface onto portalled content (tooltips,
menus, dialogs) and bare text nodes (control labels) that never set
their own font-family. */
:where([class^='modern-sk-'], [class*=' modern-sk-']) {
box-sizing: border-box;
font-family: var(--font-sans);
}
+5 -7
View File
@@ -178,10 +178,9 @@
--grain-opacity: 0.45;
/* ---------- SPINNER GROOVE ----------
Carved donut channel — dark at the top rim, catching light at the
bottom, exactly like the sunk wells (switch / field). */
--spin-groove-1: #090a07; /* top — in shadow */
--spin-groove-2: #34352b; /* bottom — catches light */
Solid carved channel — flat base felt, sunk by an SVG inner shadow,
exactly like the switch well (no gradient). */
--spin-track: #23241c;
}
/* ============================================================
@@ -264,9 +263,8 @@
radial-gradient(90% 70% at 85% 110%, rgba(233,87,43,0.08), transparent 60%),
radial-gradient(100% 100% at 50% 50%, #f2f2ea 0%, #ecece3 60%, #e2e2d6 100%);
/* carved groove on warm paper — top grey shadow, bottom near-white */
--spin-groove-1: #c2c3b6;
--spin-groove-2: #ffffff;
/* carved groove on warm paper — flat base, sunk by inner shadow */
--spin-track: #dcdcd2;
}
/* ============================================================