Inputs
Slider
Picks a number from a range by dragging, for values where roughly right is enough, like volume or opacity.
- Built on
<input type="range"><output>::-webkit-slider-thumb::-moz-range-progress- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.1 kB
- Dependencies
- React
Try its options
Loading its options.
Code
"use client";
import { Slider } from "@/components/equip/slider";
export default function Demo() {
return (
<div style={{ width: 200 }}>
<Slider label="Layer opacity" defaultValue={72} showValue formatValue={(v) => `${v}%`} />
</div>
);
}Your versionchange its options in Look, This part
Examples
Range and step
Keys and dragging both snap to the step, so this can only land on multiples of 5.
Code
import { Slider } from "@/components/equip/slider";
export default function Example() {
return (
<div style={{ width: 240 }}>
<Slider label="Team seats" min={5} max={50} step={5} defaultValue={15} showValue name="seats" />
</div>
);
}Controlled
const region = "eu-west-1";
Code
"use client";
import { useState } from "react";
import { Slider } from "@/components/equip/slider";
export default function Example() {
const [size, setSize] = useState(16);
return (
<div style={{ display: "grid", gap: 12, width: 240 }}>
<Slider
label="Editor font size"
min={11}
max={24}
value={size}
onChange={(event) => setSize(event.currentTarget.valueAsNumber)}
showValue
formatValue={(v) => `${v} px`}
/>
<p style={{ margin: 0, fontSize: size, lineHeight: 1.3 }}>const region = "eu-west-1";</p>
</div>
);
}Disabled
Code
import { Slider } from "@/components/equip/slider";
export default function Example() {
return (
<div style={{ width: 240 }}>
<Slider label="Retention (days)" min={1} max={90} defaultValue={30} showValue disabled />
</div>
);
}Painted track
--eq-slider-track paints the groove and --eq-slider-fill: transparent drops the fill, so a hue slider shows the wheel.
Code
"use client";
import { useState } from "react";
import type { CSSProperties } from "react";
import { Slider } from "@/components/equip/slider";
const wheel = {
"--eq-slider-track": "linear-gradient(to right in oklch longer hue, oklch(0.72 0.19 0), oklch(0.72 0.19 360))",
"--eq-slider-fill": "transparent",
} as CSSProperties;
export default function Example() {
const [hue, setHue] = useState(250);
return (
<div style={{ display: "grid", gap: 12, width: 240 }}>
<Slider
label="Accent hue"
min={0}
max={360}
value={hue}
onChange={(event) => setHue(event.currentTarget.valueAsNumber)}
showValue
formatValue={(v) => `${v}°`}
style={wheel}
/>
<div style={{ height: 32, borderRadius: 8, background: `oklch(0.72 0.19 ${hue})` }} />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label, tied to the input. |
value | number | none | Controlled value. Pair it with onChange. |
defaultValue | number | midpoint | Uncontrolled starting value. Without one the browser starts at the midpoint, snapped to the step. |
min | number | 0 | Lowest value. |
max | number | 100 | Highest value. |
step | number | 1 | Granularity of the value. |
showValue | boolean | false | Shows the current value next to the label in an output element. |
formatValue | (value: number) => string | none | Turns the value into text for the readout and for aria-valuetext, e.g. 40%. |
...rest | ComponentProps<"input"> | none | Every native input attribute, including name, disabled and ref. className and style go to the wrapper. |
Anatomy
| root | .eq-slider | Wrapper that carries className, style and the fill position. |
|---|---|---|
| header | .eq-slider-header | Holds the label and the readout on one line. |
| label | .eq-slider-label | |
| output | .eq-slider-output | Only with showValue. |
| input | .eq-slider-input | The native range input; track and thumb are its pseudo-elements. Paint them with --eq-slider-track and --eq-slider-fill. |
Keyboard
| ArrowRightArrowUp | Increases the value by one step. |
|---|---|
| ArrowLeftArrowDown | Decreases the value by one step. |
| PageUpPageDown | Changes the value by a larger amount, as the browser defines it. |
| HomeEnd | Sets the minimum or the maximum. |
Notes
- It is a client part: a small change handler keeps the filled track and the readout in step with the thumb.
- Every key and the slider role come from the native input; nothing is reimplemented.
- The readout sets aria-live off, because the slider already announces its value as it moves.
- Use formatValue when the bare number is not the meaning, so screen readers hear 40% rather than 40.
- For an exact number, use a number field instead; dragging to a precise value is hard for many people.
- Firefox fills the track with ::-moz-range-progress; WebKit and Blink use a gradient placed from the value.
- To paint the track, set --eq-slider-track on the slider (its style or className) to any background, such as a gradient; it replaces the groove colour in every engine.
- Set --eq-slider-fill to recolour the part up to the thumb, or to transparent to hide it, as a hue or colour slider wants.
- After a native form reset, an uncontrolled slider's fill and readout keep the old value until it moves again.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/slider.tsx85 lines
"use client";
import { useId, useState } from "react";
import type { ChangeEvent, ComponentProps, CSSProperties, ReactNode } from "react";
import "./slider.css";
export type SliderProps = Omit<ComponentProps<"input">, "type" | "value" | "defaultValue" | "min" | "max" | "step"> & {
/** Visible label. */
label: ReactNode;
value?: number;
defaultValue?: number;
min?: number;
max?: number;
step?: number;
/** Shows the current value next to the label in an <output>. */
showValue?: boolean;
/** Turns the value into text for the readout and for assistive tech, e.g. "40%". */
formatValue?: (value: number) => string;
};
/** What the browser picks when no value is given: the midpoint, snapped to the step. */
function nativeDefault(min: number, max: number, step: number) {
if (max < min) return min;
const mid = min + (max - min) / 2;
return Math.min(max, min + Math.round((mid - min) / step) * step);
}
export function Slider({
label,
value,
defaultValue,
min = 0,
max = 100,
step = 1,
showValue = false,
formatValue,
className,
style,
id,
onChange,
...rest
}: SliderProps) {
const autoId = useId();
const inputId = id ?? autoId;
const [inner, setInner] = useState(() => defaultValue ?? nativeDefault(min, max, step));
const current = value ?? inner;
const fraction = max > min ? Math.min(1, Math.max(0, (current - min) / (max - min))) : 0;
const text = formatValue ? formatValue(current) : String(current);
function handleChange(event: ChangeEvent<HTMLInputElement>) {
onChange?.(event);
if (value === undefined) setInner(event.currentTarget.valueAsNumber);
}
return (
<div
className={["eq-slider", className].filter(Boolean).join(" ")}
style={{ ...style, "--_fill": fraction } as CSSProperties}
>
<div className="eq-slider-header">
<label className="eq-slider-label" htmlFor={inputId}>
{label}
</label>
{showValue && (
// The slider announces its own value, so the readout stays quiet.
<output className="eq-slider-output" htmlFor={inputId} aria-live="off">
{text}
</output>
)}
</div>
<input
aria-valuetext={formatValue ? text : undefined}
{...rest}
id={inputId}
className="eq-slider-input"
type="range"
min={min}
max={max}
step={step}
{...(value === undefined ? { defaultValue: defaultValue ?? nativeDefault(min, max, step) } : { value })}
onChange={handleChange}
/>
</div>
);
}components/equip/slider.css126 lines
@layer equip {
.eq-slider {
--_thumb: 16px;
--_track: 6px;
/* Where the fill ends: the thumb's centre, which travels the track minus one thumb. */
--_stop: calc(var(--_thumb) / 2 + (100% - var(--_thumb)) * var(--_fill, 0));
display: grid;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-slider-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--eq-space-2);
}
.eq-slider-label {
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-slider-output {
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-slider-input {
appearance: none;
display: block;
width: 100%;
height: 24px;
margin: 0;
background: transparent;
cursor: pointer;
}
.eq-slider-input:focus-visible {
outline: none;
}
/* The fill is painted from the value itself, so it stays glued to the thumb; easing it
would only make it lag behind the native thumb, which jumps.
WebKit and Blink. Each engine's pseudo-elements live in their own rules:
a selector list naming an unknown pseudo-element is dropped whole. */
.eq-slider-input::-webkit-slider-runnable-track {
height: var(--_track);
border-radius: var(--eq-radius-full);
/* The fill is a layer over the track, so either can be repainted on its own:
--eq-slider-track for the whole groove, --eq-slider-fill for the part up to the thumb. */
background:
linear-gradient(
to right,
var(--eq-slider-fill, var(--eq-solid)) var(--_stop),
transparent var(--_stop)
),
var(--eq-slider-track, var(--eq-tint-strong));
}
.eq-slider-input::-webkit-slider-thumb {
appearance: none;
box-sizing: border-box;
width: var(--_thumb);
height: var(--_thumb);
margin-top: calc((var(--_track) - var(--_thumb)) / 2);
border: 2px solid var(--eq-solid);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
/* Grows under the finger and settles back on the spring when let go. */
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-slider-input:focus-visible::-webkit-slider-thumb {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-slider-input:active:not(:disabled)::-webkit-slider-thumb {
scale: 1.15;
}
/* Firefox draws the filled part itself, so it follows the value with no script. */
.eq-slider-input::-moz-range-track {
height: var(--_track);
border-radius: var(--eq-radius-full);
background: var(--eq-slider-track, var(--eq-tint-strong));
}
.eq-slider-input::-moz-range-progress {
height: var(--_track);
border-radius: var(--eq-radius-full);
background: var(--eq-slider-fill, var(--eq-solid));
}
.eq-slider-input::-moz-range-thumb {
box-sizing: border-box;
width: var(--_thumb);
height: var(--_thumb);
border: 2px solid var(--eq-solid);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
/* Grows under the finger and settles back on the spring when let go. */
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-slider-input:focus-visible::-moz-range-thumb {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-slider-input:active:not(:disabled)::-moz-range-thumb {
scale: 1.15;
}
.eq-slider:has(.eq-slider-input:disabled) {
opacity: 0.45;
}
.eq-slider-input:disabled {
cursor: not-allowed;
}
}