feat: text & slider animations
This commit is contained in:
@@ -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'],
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ 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.
|
||||||
|
|
||||||
### Styling system (`src/styles/`)
|
### Styling system (`src/styles/`)
|
||||||
|
|
||||||
@@ -47,7 +49,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,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="modern-sk-field-char"
|
||||||
|
>
|
||||||
|
{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} />
|
||||||
|
|||||||
@@ -224,6 +224,94 @@ 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. */
|
||||||
|
position: relative;
|
||||||
|
white-space: inherit;
|
||||||
|
animation: modern-sk-char-in var(--dur-base) var(--ease-snap) both;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@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);
|
||||||
@@ -538,6 +626,11 @@ textarea.modern-sk-field {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
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. */
|
||||||
|
transition:
|
||||||
|
left 0.12s ease-out,
|
||||||
|
right 0.12s ease-out;
|
||||||
}
|
}
|
||||||
.modern-sk-slider__thumb {
|
.modern-sk-slider__thumb {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -551,6 +644,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 > span:not(.modern-sk-slider__track) {
|
||||||
|
transition: left 0.12s ease-out;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.modern-sk-slider > span:not(.modern-sk-slider__track),
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user