Skip to content
All Taste components

Inputs

Slider

Pick one value or a range on a track, with the current values shown as you drag.

single · range · value bubbles · scale labels

Delivery radius8 km
0 km25 km
Price per plate₹250 – ₹1,200
₹250₹1,200
₹0₹2,000
"use client";
import { useId, useState } from "react";
import { cn } from "@/lib/utils";

/*
 * The visible track and thumbs are plain elements; a native <input type="range">
 * sits on top, invisible, so pointer, touch, arrow keys, Home/End and screen
 * readers all work without custom handling.
 */
const native =
  "absolute -left-[9px] inset-y-0 m-0 w-[calc(100%+18px)] cursor-pointer appearance-none bg-transparent opacity-0";
const thumb = "absolute top-1/2 size-[18px] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-bg shadow transition-shadow";

type Base = { label: string; min: number; max: number; step?: number; format?: (v: number) => string };

export function Slider({ label, min, max, step = 1, value, onChange, format = String }:
  Base & { value: number; onChange: (v: number) => void }) {
  const id = useId();
  const [focused, setFocused] = useState(false);
  const pct = ((value - min) / (max - min)) * 100;
  return (
    <div className="grid gap-2.5">
      <div className="flex items-baseline justify-between gap-3">
        <label htmlFor={id} className="text-[13px] font-medium text-fg">{label}</label>
        <span className="text-[13px] tabular-nums text-fg-muted">{format(value)}</span>
      </div>
      <div className="relative mx-[9px] h-5">
        <span className="absolute -inset-x-[9px] top-2 h-1 rounded-full bg-surface-2" />
        <span className="absolute -left-[9px] top-2 h-1 rounded-full bg-primary" style={{ width: "calc(" + pct + "% + 9px)" }} />
        <span className={cn(thumb, focused && "ring-4 ring-primary/25")} style={{ left: pct + "%" }} />
        <input
          id={id} type="range" min={min} max={max} step={step} value={value}
          aria-valuetext={format(value)}
          onChange={(e) => onChange(Number(e.target.value))}
          onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
          className={native}
        />
      </div>
      <div className="flex justify-between text-xs tabular-nums text-fg-subtle"><span>{format(min)}</span><span>{format(max)}</span></div>
    </div>
  );
}

export function RangeSlider({ label, min, max, step = 1, value, onChange, format = String }:
  Base & { value: [number, number]; onChange: (v: [number, number]) => void }) {
  const id = useId();
  const [focused, setFocused] = useState<0 | 1 | null>(null);
  const [lo, hi] = value;
  const p = (v: number) => ((v - min) / (max - min)) * 100;
  // Both inputs span the track; only their thumbs take the pointer, so either can be dragged.
  const dual = cn(native, "pointer-events-none [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-5 [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none");

  return (
    <div role="group" aria-labelledby={id} className="grid gap-1">
      <div className="flex items-baseline justify-between gap-3">
        <span id={id} className="text-[13px] font-medium text-fg">{label}</span>
        <span className="text-[13px] tabular-nums text-fg-muted">{format(lo)} – {format(hi)}</span>
      </div>
      <div className="relative mx-[9px] mt-8 h-5">
        <span className="absolute -inset-x-[9px] top-2 h-1 rounded-full bg-surface-2" />
        <span className="absolute top-2 h-1 rounded-full bg-primary" style={{ left: p(lo) + "%", width: p(hi) - p(lo) + "%" }} />
        {[lo, hi].map((v, i) => (
          <span key={i} className={cn(thumb, focused === i && "ring-4 ring-primary/25")} style={{ left: p(v) + "%" }}>
            <span className="absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-sm bg-fg px-1.5 py-0.5 text-xs font-medium tabular-nums text-bg">
              {format(v)}
            </span>
          </span>
        ))}
        <input
          type="range" min={min} max={max} step={step} value={lo}
          aria-label={"Minimum " + label.toLowerCase()} aria-valuetext={format(lo)}
          onChange={(e) => onChange([Math.min(Number(e.target.value), hi - step), hi])}
          onFocus={() => setFocused(0)} onBlur={() => setFocused(null)}
          className={dual}
        />
        <input
          type="range" min={min} max={max} step={step} value={hi}
          aria-label={"Maximum " + label.toLowerCase()} aria-valuetext={format(hi)}
          onChange={(e) => onChange([lo, Math.max(Number(e.target.value), lo + step)])}
          onFocus={() => setFocused(1)} onBlur={() => setFocused(null)}
          className={dual}
        />
      </div>
      <div className="mt-1.5 flex justify-between text-xs tabular-nums text-fg-subtle"><span>{format(min)}</span><span>{format(max)}</span></div>
    </div>
  );
}

// <Slider label="Delivery radius" min={0} max={25} value={km} onChange={setKm} format={(v) => v + " km"} />
// <RangeSlider label="Price per plate" min={0} max={2000} step={50} value={price} onChange={setPrice}
//   format={(v) => "₹" + v.toLocaleString("en-IN")} />

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.