style(range): redesign slider with precision gauge and ruler ticks
Revamp the range input to feature a minimalist "precision gauge" style. Introduce a hairline rail, ruler notches for discrete steps, and a slim needle thumb. Add CSS variables and logic for per-instance tick sizing and coloring. Remove bulky inline class-based styling in favor of centralized CSS for improved maintainability and visual clarity.
This commit is contained in:
parent
690628b318
commit
7ef18d6900
2 changed files with 133 additions and 25 deletions
|
|
@ -424,3 +424,116 @@ h1, h2, h3, h4, h5, h6 {
|
|||
-webkit-mask: linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%);
|
||||
border-radius: 999px; /* subtle rounding to reinforce taper */
|
||||
}
|
||||
|
||||
/* Range slider — "precision gauge": a hairline rail with ruler notches per step
|
||||
and a slim accent needle instead of a ball. Per-instance values
|
||||
(--range-progress, --range-tick-size, --range-tick-color) come from inline styles. */
|
||||
.range-input {
|
||||
--range-track-h: 2px;
|
||||
--range-needle-h: 1rem;
|
||||
--range-needle-w: 3px;
|
||||
--range-tick-size: 25%; /* segment width; only used when ticks are enabled */
|
||||
--range-tick-color: transparent; /* set to a real color inline for discrete sliders */
|
||||
--range-fill: var(--muted);
|
||||
--range-empty: color-mix(in srgb, var(--offbase) 82%, var(--background));
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.range-input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.range-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Ticks ride above the muted fill / empty rail (first listed background = top). */
|
||||
.range-input::-webkit-slider-runnable-track {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
var(--range-tick-color) 0,
|
||||
var(--range-tick-color) 1.5px,
|
||||
transparent 1.5px,
|
||||
transparent var(--range-tick-size)
|
||||
),
|
||||
linear-gradient(
|
||||
to right,
|
||||
var(--range-fill) 0,
|
||||
var(--range-fill) var(--range-progress),
|
||||
var(--range-empty) var(--range-progress),
|
||||
var(--range-empty) 100%
|
||||
);
|
||||
}
|
||||
.range-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: var(--range-needle-w);
|
||||
height: var(--range-needle-h);
|
||||
margin-top: calc((var(--range-track-h) - var(--range-needle-h)) / 2);
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.range-input:hover::-webkit-slider-thumb {
|
||||
transform: scaleY(1.3);
|
||||
}
|
||||
.range-input:active::-webkit-slider-thumb {
|
||||
transform: scaleY(1.55);
|
||||
}
|
||||
.range-input:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Firefox: track + native progress fill + matching needle. */
|
||||
.range-input::-moz-range-track {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
var(--range-tick-color) 0,
|
||||
var(--range-tick-color) 1.5px,
|
||||
transparent 1.5px,
|
||||
transparent var(--range-tick-size)
|
||||
),
|
||||
var(--range-empty);
|
||||
}
|
||||
.range-input::-moz-range-progress {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background: var(--range-fill);
|
||||
}
|
||||
.range-input::-moz-range-thumb {
|
||||
width: var(--range-needle-w);
|
||||
height: var(--range-needle-h);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.range-input:hover::-moz-range-thumb {
|
||||
transform: scaleY(1.3);
|
||||
}
|
||||
.range-input:active::-moz-range-thumb {
|
||||
transform: scaleY(1.55);
|
||||
}
|
||||
.range-input:focus-visible::-moz-range-thumb {
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { cn } from './cn';
|
|||
|
||||
type RangeStyle = CSSProperties & {
|
||||
'--range-progress'?: string;
|
||||
'--range-tick-size'?: string;
|
||||
'--range-tick-color'?: string;
|
||||
};
|
||||
|
||||
function toNumber(value: string | number | readonly string[] | undefined, fallback: number): number {
|
||||
|
|
@ -24,30 +26,16 @@ function resolveRangeProgress(props: InputHTMLAttributes<HTMLInputElement>): num
|
|||
return Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
|
||||
const rangeInputClass = cn(
|
||||
'h-6 w-full cursor-pointer appearance-none bg-transparent [--range-track-h:0.5rem] [--range-thumb-h:1.25rem]',
|
||||
'focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-accent/25',
|
||||
'focus-visible:[&::-moz-range-thumb]:ring-4 focus-visible:[&::-moz-range-thumb]:ring-accent/25',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
'[&::-webkit-slider-runnable-track]:h-[var(--range-track-h)] [&::-webkit-slider-runnable-track]:rounded-full',
|
||||
'[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--secondary-accent)_0%,var(--secondary-accent)_var(--range-progress),color-mix(in_srgb,var(--offbase)_82%,var(--background))_var(--range-progress),color-mix(in_srgb,var(--offbase)_82%,var(--background))_100%)]',
|
||||
'[&::-webkit-slider-runnable-track]:shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_10%,transparent)]',
|
||||
'[&::-webkit-slider-thumb]:mt-[calc((var(--range-track-h)-var(--range-thumb-h))/2)] [&::-webkit-slider-thumb]:h-[var(--range-thumb-h)] [&::-webkit-slider-thumb]:w-[var(--range-thumb-h)] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[color-mix(in_srgb,var(--background)_78%,white)]',
|
||||
'[&::-webkit-slider-thumb]:bg-accent',
|
||||
'[&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:duration-150 [&::-webkit-slider-thumb]:ease-out',
|
||||
'active:[&::-webkit-slider-thumb]:scale-[1.07]',
|
||||
'[&::-moz-range-track]:h-[var(--range-track-h)] [&::-moz-range-track]:rounded-full [&::-moz-range-track]:border-0',
|
||||
'[&::-moz-range-track]:bg-[color-mix(in_srgb,var(--offbase)_82%,var(--background))]',
|
||||
'[&::-moz-range-progress]:h-[var(--range-track-h)] [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:border-0 [&::-moz-range-progress]:bg-secondary-accent',
|
||||
'[&::-moz-range-thumb]:h-[var(--range-thumb-h)] [&::-moz-range-thumb]:w-[var(--range-thumb-h)] [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2',
|
||||
'[&::-moz-range-thumb]:border-[color-mix(in_srgb,var(--background)_78%,white)] [&::-moz-range-thumb]:bg-accent',
|
||||
'[&::-moz-range-thumb]:transition-transform [&::-moz-range-thumb]:duration-150 [&::-moz-range-thumb]:ease-out',
|
||||
'active:[&::-moz-range-thumb]:scale-[1.07]',
|
||||
);
|
||||
|
||||
function rangeInputClassName(className?: string) {
|
||||
return cn(rangeInputClass, className);
|
||||
// Discrete sliders get a ruler: one notch per step. Returns the segment width
|
||||
// (as a %) for the repeating tick gradient, or null for continuous/large ranges.
|
||||
function resolveTickSize(props: InputHTMLAttributes<HTMLInputElement>): string | null {
|
||||
const min = toNumber(props.min, 0);
|
||||
const max = toNumber(props.max, 100);
|
||||
const step = toNumber(props.step, 1);
|
||||
if (step <= 0) return null;
|
||||
const segments = Math.round((max - min) / step);
|
||||
if (!Number.isFinite(segments) || segments < 2 || segments > 24) return null;
|
||||
return `${100 / segments}%`;
|
||||
}
|
||||
|
||||
export function RangeInput({
|
||||
|
|
@ -55,10 +43,17 @@ export function RangeInput({
|
|||
style,
|
||||
...props
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>) {
|
||||
const tickSize = resolveTickSize(props);
|
||||
const rangeStyle: RangeStyle = {
|
||||
...style,
|
||||
'--range-progress': `${resolveRangeProgress(props)}%`,
|
||||
...(tickSize
|
||||
? {
|
||||
'--range-tick-size': tickSize,
|
||||
'--range-tick-color': 'color-mix(in srgb, var(--foreground) 22%, transparent)',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return <input type="range" className={rangeInputClassName(className)} style={rangeStyle} {...props} />;
|
||||
return <input type="range" className={cn('range-input', className)} style={rangeStyle} {...props} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue