React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

sliderrangeinputnumericformcomponenttailwind

Slider

A range input for selecting numeric values along a track, providing a visual and interactive way to choose a value within a defined min/max boundary.

Use Cases

  • Adjust volume, brightness, or opacity in media and design tools
  • Set price range filters on e-commerce product listings
  • Control zoom level on maps or image viewers
  • Select a duration or timeout value in settings panels
  • Choose font size or spacing in a theme customizer
  • Filter search results by distance or rating
  • Set progress thresholds for dashboards and reports

Simplest Implementation

"use client";
 
interface SliderProps {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
}
 
export function Slider({ value, onChange, min = 0, max = 100 }: SliderProps) {
  return (
    <input
      type="range"
      min={min}
      max={max}
      value={value}
      onChange={(e) => onChange(Number(e.target.value))}
      className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
    />
  );
}

A minimal range slider using the native <input type="range">. The appearance-none class removes the default browser styling, and accent-blue-600 colors the thumb and filled portion of the track in supported browsers. The onChange callback converts the string value to a number before passing it to the parent.

Variations

With Min/Max Labels

"use client";
 
interface SliderProps {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  label?: string;
}
 
export function Slider({ value, onChange, min = 0, max = 100, label }: SliderProps) {
  return (
    <div className="w-full">
      {label && (
        <span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
      )}
      <input
        type="range"
        min={min}
        max={max}
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
        className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
      />
      <div className="mt-1 flex justify-between text-xs text-gray-500">
        <span>{min}</span>
        <span>{max}</span>
      </div>
    </div>
  );
}

The min and max values are displayed below the slider track using flex justify-between. This gives the user clear context about the range boundaries without needing to guess.

With Step Marks

"use client";
 
interface SliderProps {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
}
 
export function Slider({ value, onChange, min = 0, max = 100, step = 25 }: SliderProps) {
  const marks = [];
  for (let i = min; i <= max; i += step) {
    marks.push(i);
  }
 
  return (
    <div className="w-full">
      <input
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
        className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
      />
      <div className="relative mt-2 h-4">
        {marks.map((mark) => {
          const percent = ((mark - min) / (max - min)) * 100;
          return (
            <span
              key={mark}
              className="absolute -translate-x-1/2 text-xs text-gray-500"
              style={{ left: `${percent}%` }}
            >
              {mark}
            </span>
          );
        })}
      </div>
    </div>
  );
}

Step marks are generated from the step prop and positioned absolutely using a percentage offset. Each mark is centered over its position with -translate-x-1/2. The step attribute on the input ensures the thumb snaps to these discrete values.

Dual-Thumb Range Slider

"use client";
 
import { useState, useCallback } from "react";
 
interface RangeSliderProps {
  min?: number;
  max?: number;
  values: [number, number];
  onChange: (values: [number, number]) => void;
}
 
export function RangeSlider({ min = 0, max = 100, values, onChange }: RangeSliderProps) {
  const [dragging, setDragging] = useState<"min" | "max" | null>(null);
 
  const getPercent = useCallback(
    (val: number) => ((val - min) / (max - min)) * 100,
    [min, max]
  );
 
  const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newMin = Math.min(Number(e.target.value), values[1] - 1);
    onChange([newMin, values[1]]);
  };
 
  const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newMax = Math.max(Number(e.target.value), values[0] + 1);
    onChange([values[0], newMax]);
  };
 
  return (
    <div className="relative w-full">
      {/* Track background */}
      <div className="h-2 w-full rounded-full bg-gray-200" />
 
      {/* Active range */}
      <div
        className="absolute top-0 h-2 rounded-full bg-blue-600"
        style={{
          left: `${getPercent(values[0])}%`,
          width: `${getPercent(values[1]) - getPercent(values[0])}%`,
        }}
      />
 
      {/* Min thumb */}
      <input
        type="range"
        min={min}
        max={max}
        value={values[0]}
        onChange={handleMinChange}
        onMouseDown={() => setDragging("min")}
        onMouseUp={() => setDragging(null)}
        className="pointer-events-none absolute top-0 h-2 w-full appearance-none bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600"
        style={{ zIndex: dragging === "min" ? 30 : 20 }}
      />
 
      {/* Max thumb */}
      <input
        type="range"
        min={min}
        max={max}
        value={values[1]}
        onChange={handleMaxChange}
        onMouseDown={() => setDragging("max")}
        onMouseUp={() => setDragging(null)}
        className="pointer-events-none absolute top-0 h-2 w-full appearance-none bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600"
        style={{ zIndex: dragging === "max" ? 30 : 20 }}
      />
 
      {/* Value labels */}
      <div className="mt-4 flex justify-between text-sm text-gray-600">
        <span>{values[0]}</span>
        <span>{values[1]}</span>
      </div>
    </div>
  );
}

Two overlapping range inputs create a dual-thumb slider. The inputs are set to pointer-events-none with only the thumbs receiving pointer-events-auto, so the user can drag each independently. The active track segment between the thumbs is an absolutely positioned div with dynamic left and width values. The z-index is adjusted based on which thumb is being dragged to prevent the wrong thumb from capturing the event when they overlap.

With Live Value Display

"use client";
 
interface SliderProps {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  label: string;
  unit?: string;
}
 
export function Slider({ value, onChange, min = 0, max = 100, label, unit = "" }: SliderProps) {
  return (
    <div className="w-full">
      <div className="mb-2 flex items-center justify-between">
        <span className="text-sm font-medium text-gray-700">{label}</span>
        <span className="rounded bg-blue-50 px-2 py-0.5 text-sm font-semibold text-blue-700">
          {value}{unit}
        </span>
      </div>
      <input
        type="range"
        min={min}
        max={max}
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
        className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
      />
    </div>
  );
}

The current value is displayed as a badge in the top-right corner, updating in real-time as the user drags the thumb. The unit prop (e.g., "px", "%", "ms") appends a unit suffix to the displayed value for clarity.

Vertical Slider

"use client";
 
interface SliderProps {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  height?: string;
}
 
export function VerticalSlider({ value, onChange, min = 0, max = 100, height = "12rem" }: SliderProps) {
  return (
    <div className="flex flex-col items-center gap-2" style={{ height }}>
      <span className="text-xs text-gray-500">{max}</span>
      <input
        type="range"
        min={min}
        max={max}
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
        className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
        style={{
          writingMode: "vertical-lr",
          direction: "rtl",
          width: height,
        }}
      />
      <span className="text-xs text-gray-500">{min}</span>
    </div>
  );
}

The writing-mode: vertical-lr and direction: rtl CSS properties rotate the native range input to a vertical orientation. The direction: rtl ensures the minimum value is at the bottom and maximum at the top. This approach avoids CSS transforms which can break click coordinates.

Complex Implementation

"use client";
 
import { forwardRef, useId, useCallback, useRef, useEffect, useState } from "react";
 
type SliderSize = "sm" | "md" | "lg";
 
interface SliderProps {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  size?: SliderSize;
  label?: string;
  showValue?: boolean;
  showTooltip?: boolean;
  unit?: string;
  disabled?: boolean;
  marks?: { value: number; label?: string }[];
  name?: string;
  id?: string;
  className?: string;
}
 
const sizeClasses: Record<SliderSize, { track: string; thumb: string }> = {
  sm: {
    track: "h-1",
    thumb: "[&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5",
  },
  md: {
    track: "h-2",
    thumb: "[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5",
  },
  lg: {
    track: "h-3",
    thumb: "[&::-webkit-slider-thumb]:h-6 [&::-webkit-slider-thumb]:w-6",
  },
};
 
export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
  {
    value,
    onChange,
    min = 0,
    max = 100,
    step = 1,
    size = "md",
    label,
    showValue = false,
    showTooltip = false,
    unit = "",
    disabled = false,
    marks,
    name,
    id: externalId,
    className,
  },
  ref
) {
  const generatedId = useId();
  const sliderId = externalId ?? generatedId;
  const s = sizeClasses[size];
  const [isDragging, setIsDragging] = useState(false);
  const trackRef = useRef<HTMLDivElement>(null);
 
  const percent = ((value - min) / (max - min)) * 100;
 
  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      if (disabled) return;
      onChange(Number(e.target.value));
    },
    [disabled, onChange]
  );
 
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (disabled) return;
      let newValue = value;
 
      switch (e.key) {
        case "Home":
          newValue = min;
          break;
        case "End":
          newValue = max;
          break;
        default:
          return;
      }
 
      e.preventDefault();
      onChange(newValue);
    },
    [disabled, value, min, max, onChange]
  );
 
  return (
    <div className={`w-full ${disabled ? "opacity-50" : ""} ${className ?? ""}`}>
      {(label || showValue) && (
        <div className="mb-2 flex items-center justify-between">
          {label && (
            <label htmlFor={sliderId} className="text-sm font-medium text-gray-700">
              {label}
            </label>
          )}
          {showValue && (
            <output
              htmlFor={sliderId}
              className="rounded bg-blue-50 px-2 py-0.5 text-sm font-semibold text-blue-700"
            >
              {value}{unit}
            </output>
          )}
        </div>
      )}
 
      <div ref={trackRef} className="relative">
        {/* Custom track fill */}
        <div
          className={`pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 rounded-full bg-blue-600 ${s.track}`}
          style={{ width: `${percent}%` }}
        />
 
        {/* Tooltip */}
        {showTooltip && isDragging && (
          <div
            className="absolute -top-8 z-10 -translate-x-1/2 rounded bg-gray-900 px-2 py-0.5 text-xs text-white"
            style={{ left: `${percent}%` }}
          >
            {value}{unit}
          </div>
        )}
 
        <input
          ref={ref}
          id={sliderId}
          type="range"
          name={name}
          min={min}
          max={max}
          step={step}
          value={value}
          disabled={disabled}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          onMouseDown={() => setIsDragging(true)}
          onMouseUp={() => setIsDragging(false)}
          onTouchStart={() => setIsDragging(true)}
          onTouchEnd={() => setIsDragging(false)}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={value}
          aria-valuetext={`${value}${unit}`}
          className={[
            "relative w-full cursor-pointer appearance-none rounded-full bg-gray-200",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
            "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600 [&::-webkit-slider-thumb]:transition-shadow",
            "[&::-webkit-slider-thumb]:hover:shadow-lg [&::-webkit-slider-thumb]:active:ring-blue-700",
            disabled ? "cursor-not-allowed" : "",
            s.track,
            s.thumb,
          ].join(" ")}
        />
      </div>
 
      {marks && marks.length > 0 && (
        <div className="relative mt-2 h-5">
          {marks.map((mark) => {
            const markPercent = ((mark.value - min) / (max - min)) * 100;
            return (
              <button
                key={mark.value}
                type="button"
                onClick={() => !disabled && onChange(mark.value)}
                className="absolute -translate-x-1/2 text-xs text-gray-500 hover:text-gray-700"
                style={{ left: `${markPercent}%` }}
              >
                {mark.label ?? mark.value}
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
});

Key aspects:

  • forwardRef -- allows parent components to access the native input for focus management or integration with form libraries.
  • Custom track fill -- an absolutely positioned div covers the left portion of the track proportional to the current value, providing a filled-track visual that works across browsers without vendor-specific pseudo-element hacks.
  • Drag-only tooltip -- the tooltip appears only while the user is actively dragging (isDragging state), preventing clutter while providing precise value feedback during interaction.
  • ARIA value attributes -- aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext give screen readers full context about the slider's current state. The aria-valuetext includes the unit for human-readable announcements.
  • Home/End key support -- the keyboard handler adds Home (jump to min) and End (jump to max) on top of the browser's native arrow key handling, matching the WAI-ARIA slider pattern.
  • Clickable marks -- mark labels are rendered as buttons that snap the slider to their value on click, providing quick access to common presets without precise dragging.
  • <output> element -- the live value display uses the semantic <output> element with htmlFor linking it to the slider, which screen readers treat as a live region.
  • Touch support -- onTouchStart and onTouchEnd listeners track dragging state on mobile in addition to mouse events.

Gotchas

  • onChange returns a string -- the native range input's onChange event gives e.target.value as a string. Always convert with Number() or parseFloat() before using the value in calculations.

  • Cross-browser thumb styling -- WebKit, Firefox, and Edge all use different pseudo-elements for the slider thumb (::-webkit-slider-thumb, ::-moz-range-thumb). Tailwind's arbitrary selectors only target WebKit by default. Add Firefox-specific styles if needed.

  • Step floating-point precision -- a step of 0.1 can produce values like 0.30000000000000004 due to floating-point math. Round displayed values with toFixed() and clamp stored values to the desired precision.

  • Vertical orientation is non-standard -- the writing-mode CSS trick for vertical sliders does not work consistently across all browsers. Consider a fully custom implementation with mouse/touch event handlers for reliable vertical behavior.

  • Dual-thumb z-index conflicts -- when two range inputs overlap for a range slider, the top input captures all events. Use pointer-events-none on the input track and pointer-events-auto on the thumb only, plus dynamic z-index on the active thumb.

  • No onChange on programmatic value change -- setting the value via state does not trigger the native change event. If you need to sync with external listeners, dispatch a synthetic event after updating.

  • Disabled styling on native input -- the native range input's disabled appearance varies wildly across browsers. Apply your own opacity and cursor-not-allowed classes on the wrapper for a consistent look.

  • Input -- Text inputs for direct numeric entry alongside sliders
  • Label -- Accessible labels for slider controls
  • Forms -- Form patterns and controlled component strategies
  • Events -- Event handling for onChange and keyboard interactions