Bloom Logo

Slider

An interactive range slider component supporting multi-thumb controls, graduated marks, floating hover tooltips, and frequency histogram charts.

importimport { Slider } from "@/components/ui/slider/slider";
$npx @bloomui-react/cli add slider
slider.tsx
"use client";

import * as SliderPrimitive from "@radix-ui/react-slider";
import * as React from "react";
import { cn } from "@/lib/utils";

export interface SliderMark {
  value: number;
  label?: string;
}

export interface SliderProps
  extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
  color?:
    | "default"
    | "primary"
    | "secondary"
    | "accent"
    | "success"
    | "warning"
    | "danger";
  size?: "sm" | "md" | "lg";
  label?: React.ReactNode;
  showValue?: boolean;
  showTooltip?: boolean;
  formatTooltip?: (value: number) => string;
  formatValue?: (val: number[]) => string;
  marks?: SliderMark[];
  histogramData?: number[];
  histogramHeight?: number;
  isRequired?: boolean;
  isInvalid?: boolean;
}

const trackColorMap = {
  default: "bg-zinc-300 dark:bg-zinc-700",
  primary: "bg-sky-500",
  secondary: "bg-purple-500",
  accent: "bg-pink-500",
  success: "bg-emerald-500",
  warning: "bg-amber-500",
  danger: "bg-rose-500",
};

const thumbBorderMap = {
  default: "border-zinc-400 dark:border-zinc-600 focus-visible:ring-sky-500/20",
  primary: "border-sky-500 focus-visible:ring-sky-500/20",
  secondary: "border-purple-500 focus-visible:ring-purple-500/20",
  accent: "border-pink-500 focus-visible:ring-pink-500/20",
  success: "border-emerald-500 focus-visible:ring-emerald-500/20",
  warning: "border-amber-500 focus-visible:ring-amber-500/20",
  danger: "border-rose-500 focus-visible:ring-rose-500/20",
};

const sizeMap = {
  sm: { track: "h-1", thumb: "size-3.5" },
  md: { track: "h-2", thumb: "size-4" },
  lg: { track: "h-3", thumb: "size-5" },
};

const Slider = React.forwardRef<
  React.ComponentRef<typeof SliderPrimitive.Root>,
  SliderProps
>(
  (
    {
      className,
      color = "primary",
      size = "md",
      label,
      showValue = false,
      showTooltip = false,
      formatTooltip,
      formatValue,
      marks,
      histogramData,
      histogramHeight = 40,
      value,
      defaultValue,
      min = 0,
      max = 100,
      onValueChange,
      isRequired = false,
      isInvalid: _isInvalid = false,
      ...props
    },
    ref,
  ) => {
    const initialVal = React.useMemo(() => {
      if (Array.isArray(value)) return value;
      if (Array.isArray(defaultValue)) return defaultValue;
      return [min];
    }, [value, defaultValue, min]);

    const [currentVal, setCurrentVal] = React.useState<number[]>(initialVal);
    const [hoveredThumbIndex, setHoveredThumbIndex] = React.useState<
      number | null
    >(null);

    React.useEffect(() => {
      if (Array.isArray(value)) {
        setCurrentVal(value);
      }
    }, [value]);

    const handleValueChange = (vals: number[]) => {
      if (value === undefined) {
        setCurrentVal(vals);
      }
      onValueChange?.(vals);
    };

    const formattedDisplay = React.useMemo(() => {
      if (formatValue) return formatValue(currentVal);
      return currentVal.join(" - ");
    }, [currentVal, formatValue]);

    const maxHistogramVal = React.useMemo(() => {
      if (!histogramData || histogramData.length === 0) return 1;
      return Math.max(...histogramData);
    }, [histogramData]);

    const activeRange = React.useMemo(() => {
      if (currentVal.length === 0) return [min, max];
      if (currentVal.length === 1) return [min, currentVal[0]];
      const sorted = [...currentVal].sort((a, b) => a - b);
      return [sorted[0], sorted[sorted.length - 1]];
    }, [currentVal, min, max]);

    return (
      <div className="w-full flex flex-col gap-2">
        {(label || showValue) && (
          <div className="flex items-center justify-between text-xs font-semibold text-zinc-900 dark:text-zinc-100">
            {label && (
              <span>
                {label}
                {isRequired && <span className="text-rose-500 ml-0.5">*</span>}
              </span>
            )}
            {showValue && (
              <span className="text-zinc-500 font-mono text-xs">
                {formattedDisplay}
              </span>
            )}
          </div>
        )}

        <div className="relative w-full flex flex-col items-center">
          {histogramData && histogramData.length > 0 && (
            <div
              style={{ height: `${histogramHeight}px` }}
              className="w-full flex items-end gap-1 px-1 mb-1 select-none pointer-events-none"
            >
              {histogramData.map((val, idx) => {
                const stepSize = (max - min) / histogramData.length;
                const barMin = min + idx * stepSize;
                const barMax = barMin + stepSize;
                const heightPercent = Math.max(
                  8,
                  (val / maxHistogramVal) * 100,
                );

                const isActive =
                  barMax >= activeRange[0] && barMin <= activeRange[1];

                return (
                  <div
                    key={idx}
                    style={{ height: `${heightPercent}%` }}
                    className={cn(
                      "flex-1 rounded-t transition-colors duration-200",
                      isActive
                        ? "bg-sky-500/80 dark:bg-sky-400/80"
                        : "bg-zinc-200/80 dark:bg-zinc-800/80",
                    )}
                  />
                );
              })}
            </div>
          )}

          <div className="relative w-full">
            <SliderPrimitive.Root
              ref={ref}
              value={value}
              defaultValue={defaultValue}
              min={min}
              max={max}
              onValueChange={handleValueChange}
              className={cn(
                "relative flex w-full touch-none select-none items-center cursor-pointer z-10",
                className,
              )}
              {...props}
            >
              <SliderPrimitive.Track
                className={cn(
                  "relative w-full grow overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800",
                  sizeMap[size].track,
                )}
              >
                <SliderPrimitive.Range
                  className={cn("absolute h-full", trackColorMap[color])}
                />
              </SliderPrimitive.Track>

              {currentVal.map((val, i) => {
                const _percent = Math.min(
                  100,
                  Math.max(0, ((val - min) / (max - min)) * 100),
                );
                const isHovered = hoveredThumbIndex === i;

                return (
                  <React.Fragment key={i}>
                    <SliderPrimitive.Thumb
                      onMouseEnter={() => setHoveredThumbIndex(i)}
                      onMouseLeave={() => setHoveredThumbIndex(null)}
                      className={cn(
                        "block rounded-full border-2 bg-white dark:bg-zinc-900 shadow-xs transition-transform focus-visible:outline-none focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50 hover:scale-110 cursor-pointer relative",
                        sizeMap[size].thumb,
                        thumbBorderMap[color],
                      )}
                    >
                      {showTooltip && (
                        <div
                          className={cn(
                            "absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 rounded-lg bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900 text-[10px] font-bold font-mono shadow-md whitespace-nowrap pointer-events-none transition-all duration-150 origin-bottom",
                            isHovered
                              ? "opacity-100 scale-100 translate-y-0"
                              : "opacity-0 scale-95 translate-y-1",
                          )}
                        >
                          {formatTooltip ? formatTooltip(val) : val}
                          <div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900 dark:border-t-zinc-100" />
                        </div>
                      )}
                    </SliderPrimitive.Thumb>
                  </React.Fragment>
                );
              })}
            </SliderPrimitive.Root>

            {marks && marks.length > 0 && (
              <div className="relative w-full mt-2 h-4 select-none">
                {marks.map((mark, i) => {
                  const percent = Math.min(
                    100,
                    Math.max(0, ((mark.value - min) / (max - min)) * 100),
                  );
                  return (
                    <div
                      key={i}
                      style={{ left: `${percent}%` }}
                      className="absolute -translate-x-1/2 flex flex-col items-center"
                    >
                      <div className="h-1.5 w-0.5 bg-zinc-300 dark:bg-zinc-700 mb-0.5" />
                      {mark.label && (
                        <span className="text-[10px] text-zinc-400 dark:text-zinc-500 font-medium whitespace-nowrap">
                          {mark.label}
                        </span>
                      )}
                    </div>
                  );
                })}
              </div>
            )}
          </div>
        </div>
      </div>
    );
  },
);
Slider.displayName = SliderPrimitive.Root.displayName;

export { Slider };

Default

Standard single value slider track.

Sensitivity Level45

Colors

Style active range fill with theme alert colors: primary, success, warning, danger, and default.

color: primary | success | warning | danger | default
Primary
Success
Warning
Danger
Default

Single Thumb with Tooltip

Standard single value slider with floating hover tooltip.

showTooltip: booleanformatTooltip: (val: number) => string
Volume Level60%

Multi-Thumb Range

Pass an array of multiple values to render multi-thumb controls.

value: number[]
Multi-Zone Bandwidth Control15 | 50 | 85

Histogram Chart Mode

Render a background frequency distribution chart above the slider track with live range highlight.

histogramData: number[]histogramHeight?: number
Nightly Price Filter$30 — $70

Graduated Marks & Ticks

Add step markers along the track with label text.

marks: SliderMark[]
Temperature Range
0°C
25°C
50°C
75°C
100°C

Required State

Displays an asterisk next to the label indicating that configuring the slider is mandatory.

isRequired: boolean
Max Temperature threshold*

Props — Slider

Supported properties for the Slider component.

PropTypeDefaultDescription
color'default' | 'primary' | 'success' | 'warning' | 'danger''primary'Background fill track and thumb border color theme.
size'sm' | 'md' | 'lg''md'Dimension height for track and width for thumb.