From 7ef18d6900034eea9889aaa327032095fd8ad154 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 2 Jun 2026 22:47:23 -0600 Subject: [PATCH] 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. --- src/app/globals.css | 113 ++++++++++++++++++++++++++++++++++++ src/components/ui/range.tsx | 45 +++++++------- 2 files changed, 133 insertions(+), 25 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 284d34c..b168ce2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); +} diff --git a/src/components/ui/range.tsx b/src/components/ui/range.tsx index 033fadb..3597943 100644 --- a/src/components/ui/range.tsx +++ b/src/components/ui/range.tsx @@ -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): 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): 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, '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 ; + return ; }