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-ticksOr install the full npm package:
npm install bigbulluiUsage
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.tsxZero 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
| Prop | Type | Description |
|---|---|---|
| min | number | Minimum value |
| max | number | Maximum value |
| step | number | Step interval |
| ticks | number[] | Custom tick positions |
| value | number | undefined | Controlled value |
| defaultValue | number | Default uncontrolled value |
| onValueChange | (value: number) => void | Change handler |