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:
Richard R 2026-06-02 22:47:23 -06:00
parent 690628b318
commit 7ef18d6900
2 changed files with 133 additions and 25 deletions

View file

@ -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);
}

View file

@ -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} />;
}