feat(ui): redesign range input with dynamic progress styling and improved accessibility

Revamp the range input component to support dynamic progress indication using CSS custom properties and linear gradients. Add logic to compute and set the progress percentage based on current value, min, and max. Refine focus and disabled states for better accessibility and usability. Update styling for both WebKit and Mozilla engines to ensure consistent appearance. This change enhances visual feedback and modernizes the range slider UI.
This commit is contained in:
Richard R 2026-06-01 14:08:58 -06:00
parent 4086f80066
commit 7c8577ba32

View file

@ -1,12 +1,50 @@
import type { InputHTMLAttributes } from 'react';
import type { CSSProperties, InputHTMLAttributes } from 'react';
import { cn } from './cn';
type RangeStyle = CSSProperties & {
'--range-progress'?: string;
};
function toNumber(value: string | number | readonly string[] | undefined, fallback: number): number {
if (typeof value === 'number') return Number.isFinite(value) ? value : fallback;
if (typeof value === 'string') {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function resolveRangeProgress(props: InputHTMLAttributes<HTMLInputElement>): number {
const min = toNumber(props.min, 0);
const max = toNumber(props.max, 100);
const value = toNumber(props.value, min);
const span = max - min;
if (span <= 0) return 0;
const progress = ((value - min) / span) * 100;
return Math.min(100, Math.max(0, progress));
}
const rangeInputClass = cn(
'w-full cursor-pointer appearance-none rounded-lg bg-surface-sunken accent-accent',
'[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-runnable-track]:bg-surface-sunken',
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent',
'[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-lg [&::-moz-range-track]:bg-surface-sunken',
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-accent',
'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(--accent)_0%,var(--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]:shadow-[0_2px_10px_color-mix(in_srgb,var(--accent)_45%,transparent)]',
'[&::-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-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]:shadow-[0_2px_10px_color-mix(in_srgb,var(--accent)_45%,transparent)]',
'[&::-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) {
@ -15,7 +53,13 @@ function rangeInputClassName(className?: string) {
export function RangeInput({
className,
style,
...props
}: InputHTMLAttributes<HTMLInputElement>) {
return <input type="range" className={rangeInputClassName(className)} {...props} />;
const rangeStyle: RangeStyle = {
...style,
'--range-progress': `${resolveRangeProgress(props)}%`,
};
return <input type="range" className={rangeInputClassName(className)} style={rangeStyle} {...props} />;
}