Skip to content
Interface

Slider

A real input[type=range] underneath, kept transparent and fully interactive with the visible track drawn behind it — the opposite of the usual div-with-pointer-handlers approach, and why it has arrow keys, Page Up/Down, Home/End, screen-reader announcements and form participation without any of it being reimplemented. Two details do the visual work: the value bubble travels with the thumb, and the thumb is inset by half its own width as the value moves so it sits flush inside the track at both ends. A plain left:percent overhangs by half a thumb at 0 and 100 — small, and exactly where people look when checking a slider is at its limit.

Loading preview…
Loading preview…
Loading preview…

The first and last sit at 0 and 100 — the thumb stays inside the track at both ends rather than overhanging it.

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Slider } from "@/components/slider"; <Slider />

Customise

1

Notches only render when the steps would be wider than a couple of pixels.

Otherwise it appears on hover, drag or focus.

The component

components/craft/interface/Slider.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useCallback, useId, useState } from "react"; /** * A range slider with a value that travels with the thumb. * * Still a real input[type=range] underneath, kept transparent and fully * interactive with the visible track drawn behind it — the opposite of the * usual div-with-pointer-handlers approach. That is why it has arrow keys, * Page Up/Down, Home/End, screen-reader announcements and form participation * without any of it being reimplemented. Hand-rolled sliders re-derive that * behaviour and most stop at drag. * * Two details do the visual work: * * The thumb is inset by half its own width as the value moves, so at 0% and * 100% it sits flush inside the track instead of hanging over the ends. A * plain `left: percent` overhangs by half a thumb at both extremes — small, * and precisely where people look when checking a slider is at its limit. * * The fill is a gradient colour stop rather than a scaled child, so the * coloured portion always ends exactly at the thumb centre. A scaled element * drifts against the thumb for the same reason. */ export interface SliderProps {  label: string;  min?: number;  max?: number;  step?: number;  value?: number;  defaultValue?: number;  onChange?: (value: number) => void;  /** Formats the displayed value — units, percentages, currency. */  format?: (value: number) => string;  accent?: string;  size?: "sm" | "md" | "lg";  /** Show notches at each step. Ignored when the steps would be denser than ~2px. */  ticks?: boolean;  /** Keep the value bubble visible rather than only while active. */  alwaysShowValue?: boolean;  still?: boolean;  className?: string;} const SIZES = {  sm: { track: 6, thumb: 16, row: 28 },  md: { track: 10, thumb: 22, row: 36 },  lg: { track: 16, thumb: 30, row: 46 },} as const; export function Slider({  label,  min = 0,  max = 100,  step = 1,  value,  defaultValue = 50,  onChange,  format,  accent = "#c3f53c",  size = "md",  ticks = false,  alwaysShowValue = false,  still = false,  className = "",}: SliderProps) {  const id = useId();  const [internal, setInternal] = useState(defaultValue);  const current = value ?? internal;  const [active, setActive] = useState(false);   const dims = SIZES[size];   const set = useCallback(    (next: number) => {      if (value === undefined) setInternal(next);      onChange?.(next);    },    [value, onChange],  );   const span = max - min || 1;  const fraction = Math.max(0, Math.min(1, (current - min) / span));  const percent = fraction * 100;   /* Inset by half a thumb as the value travels, so the thumb sits flush inside     the track at both ends rather than overhanging it. */  const thumbLeft = `calc(${percent}% + ${(0.5 - fraction) * dims.thumb}px)`;   const stepCount = Math.round(span / step);  const showTicks = ticks && stepCount > 1 && stepCount <= 40;   const display = format ? format(current) : String(current);  const revealed = alwaysShowValue || active;   return (    <div className={`w-full ${className}`}>      <div className="mb-3 flex items-baseline justify-between gap-4">        <label          htmlFor={id}          className="font-mono text-micro tracking-[0.14em] text-bone-400 uppercase"        >          {label}        </label>        {/* The static readout stays for screen readers and for when the bubble            is hidden; it is the one that is always correct. */}        <output          htmlFor={id}          className="font-mono text-micro tabular-nums transition-opacity"          style={{ color: accent, opacity: revealed ? 0.35 : 1 }}        >          {display}        </output>      </div>       <div className="relative" style={{ height: dims.row }}>        {/* Value bubble, travelling with the thumb. */}        <div          aria-hidden          className="pointer-events-none absolute -translate-x-1/2 rounded-md px-2 py-1 font-mono text-[0.7rem] tabular-nums whitespace-nowrap"          style={{            left: thumbLeft,            bottom: `calc(50% + ${dims.thumb / 2 + 8}px)`,            background: accent,            color: "#0a0b0e",            opacity: revealed ? 1 : 0,            transform: `translate(-50%, ${revealed ? 0 : 4}px)`,            transition: still ? "none" : "opacity 160ms linear, transform 160ms cubic-bezier(0.16,1,0.3,1)",          }}        >          {display}        </div>         {/* Track and fill in one gradient, so the boundary lands exactly at the            thumb centre at every value. */}        <div          aria-hidden          className="absolute top-1/2 left-0 w-full -translate-y-1/2 overflow-hidden rounded-full"          style={{            height: dims.track,            background: `linear-gradient(to right, ${accent} 0%, ${accent} ${percent}%, var(--color-ink-600) ${percent}%, var(--color-ink-600) 100%)`,          }}        >          {showTicks ? (            <div className="flex h-full w-full items-center justify-between px-[6px]">              {Array.from({ length: stepCount + 1 }, (_, i) => (                <span                  key={i}                  className="w-px"                  style={{                    height: dims.track * 0.45,                    // Notches invert over the fill so they stay legible on                    // both sides of the boundary.                    background:                      i / stepCount <= fraction                        ? "rgba(10,11,14,0.45)"                        : "rgba(255,255,255,0.16)",                  }}                />              ))}            </div>          ) : null}        </div>         <div          aria-hidden          className="pointer-events-none absolute top-1/2 rounded-full"          style={{            left: thumbLeft,            width: dims.thumb,            height: dims.thumb,            background: "var(--color-ink-900)",            border: `2px solid ${accent}`,            boxShadow: active ? `0 0 0 6px ${accent}22` : "0 1px 6px rgba(0,0,0,0.5)",            transform: `translate(-50%, -50%) scale(${active ? 1.12 : 1})`,            transition: still              ? "none"              : "transform 180ms cubic-bezier(0.16,1,0.3,1), box-shadow 180ms linear",          }}        >          {/* A small core, so the thumb reads as an object rather than a ring. */}          <span            className="absolute top-1/2 left-1/2 rounded-full"            style={{              width: dims.thumb * 0.28,              height: dims.thumb * 0.28,              background: accent,              transform: "translate(-50%, -50%)",            }}          />        </div>         {/*          The real control. Transparent rather than display:none — hiding it          would take the keyboard behaviour and the accessibility tree with it.        */}        <input          id={id}          type="range"          min={min}          max={max}          step={step}          value={current}          onChange={(e) => set(Number(e.target.value))}          onPointerDown={() => setActive(true)}          onPointerUp={() => setActive(false)}          onPointerCancel={() => setActive(false)}          // Focus counts as active too, so a keyboard user gets the same          // readout a dragging user does.          onFocus={() => setActive(true)}          onBlur={() => setActive(false)}          onPointerEnter={() => setActive(true)}          onPointerLeave={(e) => {            if (e.buttons === 0 && document.activeElement !== e.currentTarget) {              setActive(false);            }          }}          className="absolute inset-0 h-full w-full cursor-pointer opacity-0"        />      </div>    </div>  );} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+2.4KB gzipped · no new dependencies

Runs on

Compositor

Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.

Reduced motion

The thumb resizes and the bubble appears instantly rather than easing. Position and value are unaffected.

Accessibility
  • A native range input, so arrow keys, Page Up/Down and Home/End all work without being reimplemented.
  • Announced correctly by screen readers, including min, max and current value.
  • Participates in forms and validation like any other input.
  • The readout is an output element associated with the input, and stays present even when the bubble is hidden.
  • Focus counts as active, so a keyboard user gets the same readout a dragging user does.
Watch out
  • The native input is transparent rather than display:none — hiding it would take the keyboard behaviour and the accessibility tree with it. Do not 'clean that up'.
  • Hit area is the full row height, which is deliberate for touch but larger than the visible thumb.
  • Notches are suppressed above 40 steps: past that they are closer together than the line width and read as a grey band.