bigbullui logobigbullui
Componentform

SliderTicks

Slider with tick marks, labels, and snap-to-ticks functionality

Preview

Live Preview
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add slider-ticks

Or install the full npm package:

npm install bigbullui

Usage

import { SliderTicks } from "@/components/ui/slider-ticks";

<SliderTicks min={0} max={100} step={1} ticks={[0, 25, 50, 75, 100]} defaultValue={50} onValueChange={(v) => {}} />

Source code

slider-ticks.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface SliderTicksProps {
  min?: number;
  max?: number;
  step?: number;
  ticks?: number[];
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  className?: string;
}

export function SliderTicks({
  min = 0,
  max = 100,
  step = 1,
  ticks: providedTicks,
  value: controlledValue,
  defaultValue: defaultVal,
  onValueChange,
  className,
}: SliderTicksProps) {
  const ticks = providedTicks ?? [];
  const [internalValue, setInternalValue] = React.useState<number>(defaultVal ?? min);
  const isControlled = controlledValue !== undefined;
  const currentValue = isControlled ? controlledValue : internalValue;

  React.useEffect(() => {
    if (isControlled) {
      setInternalValue(controlledValue);
    }
  }, [isControlled, controlledValue]);

  const handleChange = (val: number) => {
    setInternalValue(val);
    onValueChange?.(val);
  };

  // Calculate tick positions
  const tickPositions: number[] = [];
  const tickLabels: (string | null)[] = [];

  // Generate ticks based on step
  for (let i = min; i <= max; i += step) {
    tickPositions.push(i);
    tickLabels.push(String(i));
  }

  // Add provided ticks if not already included
  providedTicks?.forEach(t => {
    if (!tickPositions.includes(t)) {
      tickPositions.push(t);
      tickLabels.push(String(t));
    }
  });

  const percent = ((currentValue - min) / (max - min)) * 100;

  const trackRef = React.useRef<HTMLDivElement>(null);
  const handleDrag = (clientX: number) => {
    const track = trackRef.current;
    if (!track) return;
    const rect = track.getBoundingClientRect();
    const x = clientX - rect.left;
    const clamped = Math.max(0, Math.min(100, (x / rect.width) * 100));
    const newVal = Math.round(min + (max - min) * (clamped / 100));
    handleChange(newVal);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    const stepVal = step;
    if (e.key === "ArrowRight" || e.key === "ArrowUp") {
      handleChange(currentValue + stepVal);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
      handleChange(currentValue - stepVal);
    }
  };

  const trackClasses = cn(
    "relative h-1.5 rounded-full bg-border/30 overflow-hidden",
    "motion-reduce:transition-none",
  );

  const thumbClasses = cn(
    "absolute -translate-x-1/2 bottom-full -bottom-1 rounded-full bg-ring w-4 h-4",
    "motion-reduce:transition-none",
  );

  const labelClasses = cn(
    "font-[10px] uppercase tracking-[0.15em] text-[10px] text-muted-foreground",
    "absolute bottom-full left-[calc(-50%_-_4px)] mb-1 text-center",
  );

  return (
    <div
      className={cn(
        "relative w-full min-h-6",
        className,
      )}
    >
      <div
        ref={trackRef}
        className={trackClasses}
        onMouseDown={(e) => {
          handleDrag(e.clientX);
          const handleMove = (moveEvent: MouseEvent) => handleDrag(moveEvent.clientX);
          const handleUp = () => {
            document.removeEventListener("mousemove", handleMove);
            document.removeEventListener("mouseup", handleUp);
          };
          document.addEventListener("mousemove", handleMove);
          document.addEventListener("mouseup", handleUp);
        }}
        onKeyDown={handleKeyDown}
        role="slider"
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={currentValue}
      >
        {/* Tick marks row */}
        <div className="flex text-[10px] uppercase tracking-[0.15em]">
          {tickPositions.map((pos, idx) => {
            const posPercent = ((pos - min) / (max - min)) * 100;
            const label = tickLabels[idx] ?? "";
            const isMajor = ticks.includes(pos);
            return (
              <div
                key={pos}
                className={cn(
                  "flex-1 px-0.5 text-center",
                  isMajor && "font-medium text-accent-foreground",
                  !isMajor && "text-muted-foreground",
                  "motion-reduce:transition-none",
                )}
              >
                <span className={labelClasses} style={{ left: `${posPercent}%` }}>
                  {label}
                </span>
              </div>
            );
          })}
        </div>

        {/* Track background */}
        <div
          className="absolute inset-0 bg-gradient-to-r from-ring/20 via-transparent to-ring/20"
        />

        {/* Thumb */}
        <div
          className={cn(
            thumbClasses,
            `transform translateX(${percent}%)`,
          )}
        />
      </div>
    </div>
  );
}

SliderTicks.displayName = "SliderTicks";

Props

PropTypeDescription
minnumberMinimum value
maxnumberMaximum value
stepnumberStep interval
ticksnumber[]Custom tick positions
valuenumber | undefinedControlled value
defaultValuenumberDefault uncontrolled value
onValueChange(value: number) => voidChange handler