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
72%
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.

15
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

16 px

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

30
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.

250°
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

PropTypeDefaultWhat it does
labelReactNodenoneVisible label, tied to the input.
valuenumbernoneControlled value. Pair it with onChange.
defaultValuenumbermidpointUncontrolled starting value. Without one the browser starts at the midpoint, snapped to the step.
minnumber0Lowest value.
maxnumber100Highest value.
stepnumber1Granularity of the value.
showValuebooleanfalseShows the current value next to the label in an output element.
formatValue(value: number) => stringnoneTurns the value into text for the readout and for aria-valuetext, e.g. 40%.
...restComponentProps<"input">noneEvery native input attribute, including name, disabled and ref. className and style go to the wrapper.

Anatomy

root.eq-sliderWrapper that carries className, style and the fill position.
header.eq-slider-headerHolds the label and the readout on one line.
label.eq-slider-label
output.eq-slider-outputOnly with showValue.
input.eq-slider-inputThe native range input; track and thumb are its pseudo-elements. Paint them with --eq-slider-track and --eq-slider-fill.

Keyboard

ArrowRightArrowUpIncreases the value by one step.
ArrowLeftArrowDownDecreases the value by one step.
PageUpPageDownChanges the value by a larger amount, as the browser defines it.
HomeEndSets 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index