Slider
A pencilled line with the chosen range inked over it, and a drawn ring to drag.
Built on Base UI Sliderimport { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
export default function SliderDemo() {
return (
<div className="flex w-full max-w-md items-stretch gap-10">
<div className="flex flex-1 flex-col gap-8">
<div className="flex flex-col gap-3">
<Label>Roughness</Label>
<Slider defaultValue={40} aria-label="Roughness" seed="slider-one" />
</div>
<div className="flex flex-col gap-3">
<Label>Price range</Label>
<Slider defaultValue={[20, 70]} aria-label="Price range" seed="slider-range" />
</div>
<div className="flex flex-col gap-3">
<Label>In steps of 10</Label>
<Slider defaultValue={50} step={10} aria-label="Steps" seed="slider-steps" />
</div>
<div className="flex flex-col gap-3">
<Label>Disabled</Label>
<Slider defaultValue={30} disabled aria-label="Disabled" seed="slider-disabled" />
</div>
</div>
<Slider defaultValue={60} orientation="vertical" aria-label="Vertical" seed="slider-vertical" className="h-56 w-auto" />
</div>
);
}Installation
pnpm dlx shadcn@latest add @ballpoint/ sliderOr copy the source
"use client";
import { useMemo } from "react";
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { cn } from "@/lib/utils";
import { penStyle, useInkBox, useInkSeed, usePen, type Pen } from "@/hooks/use-ink-box";
import { InkSvg, Stroke } from "@/lib/ink";
import { ringStroke, ruleStroke, verticalStroke } from "@/lib/ink-sketch";
// Track lines are drawn long and stretched along their length only.
const LENGTH = 700;
const THUMB = 20;
/**
* A pencilled line with the chosen range inked over it, and a drawn ring to
* drag. Ranges get one ring per value.
*/
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
seed,
draw,
weight,
speed,
"aria-label": label,
"aria-labelledby": labelledBy,
...props
}: SliderPrimitive.Root.Props & Pick<Pen, "draw" | "weight" | "speed"> & { seed?: string | number }) {
const pen = usePen({ draw, weight, speed });
// One thumb per value: a range slider takes an array.
const values = Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [value ?? defaultValue ?? min];
// The inputs live in the thumbs, so that's where the name goes; the two
// ends of a range are told apart.
const thumbLabel = (index: number) => (label && values.length > 1 ? `${label}, ${index === 0 ? "from" : "to"}` : label);
return (
<SliderPrimitive.Root
className={cn("data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="group/slider relative flex w-full touch-none items-center py-2 select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[orientation=vertical]:px-2 data-[orientation=vertical]:py-0">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow text-ink-line select-none data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
>
<SliderLine seed={seed} pen={pen} weight={1.3} />
<SliderPrimitive.Indicator data-slot="slider-range" className="text-ink select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full">
<SliderLine seed={seed === undefined ? undefined : `${seed}-range`} pen={pen} weight={3} />
</SliderPrimitive.Indicator>
</SliderPrimitive.Track>
{values.map((_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
aria-label={thumbLabel(index)}
aria-labelledby={labelledBy}
className={cn(
"relative block size-5 shrink-0 cursor-grab text-ink select-none outline-none active:cursor-grabbing",
"after:absolute after:-inset-2",
"has-focus-visible:outline-solid has-focus-visible:outline-[1.5px] has-focus-visible:outline-offset-4 has-focus-visible:outline-ring has-focus-visible:rounded-full",
"transition-transform duration-(--dur-press) group-data-dragging/slider:scale-110 motion-reduce:transition-none",
"data-disabled:pointer-events-none",
)}
>
<ThumbRing seed={seed === undefined ? undefined : `${seed}-thumb-${index}`} pen={pen} />
</SliderPrimitive.Thumb>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
);
}
/** A line along the track (or the inked range), following its orientation. */
function SliderLine({ seed, pen, weight }: { seed?: string | number; pen: Pen; weight: number }) {
const s = useInkSeed(seed);
const [ref] = useInkBox([LENGTH, 6]);
const mode = pen.draw ?? "auto";
const paths = useMemo(() => ({ h: ruleStroke(s, LENGTH), v: verticalStroke(s, LENGTH) }), [s]);
return (
<>
<InkSvg
ref={ref}
pending={mode === "auto"}
box={[0, -1, LENGTH, 5]}
stretch
className="inset-x-0 top-1/2 h-[5px] w-full -translate-y-1/2 group-data-[orientation=vertical]/slider:hidden"
style={penStyle(pen)}
>
<Stroke d={paths.h} draw={mode} duration={600} width={weight} />
</InkSvg>
<InkSvg
box={[0, -1, 3, LENGTH + 2]}
stretch
className="inset-y-0 left-1/2 hidden h-full w-[3px] -translate-x-1/2 group-data-[orientation=vertical]/slider:block"
style={penStyle(pen)}
>
<Stroke d={paths.v} width={weight} />
</InkSvg>
</>
);
}
/** A paper disc with a drawn ring, to drag. */
function ThumbRing({ seed, pen }: { seed?: string | number; pen: Pen }) {
const s = useInkSeed(seed);
const ring = useMemo(() => ringStroke(s + 60, THUMB, { turns: 1.1 }), [s]);
return (
<InkSvg box={[-2, -2, THUMB + 4, THUMB + 4]} className="inset-[-2px]" style={{ width: THUMB + 4, height: THUMB + 4, ...penStyle(pen) }}>
<circle cx={THUMB / 2} cy={THUMB / 2} r={THUMB / 2} style={{ fill: "var(--paper)" }} />
<Stroke d={ring} width={1.6} />
</InkSvg>
);
}
export { Slider };Usage
import { Slider } from "@/components/ui/slider"
<Slider defaultValue={40} aria-label="Roughness" />
<Slider defaultValue={[20, 70]} aria-label="Range" />API reference
- seed
string | number - Pins the drawing.
Every other prop goes to Base UI's Slider.
Pen settings
The drawing also takes draw, weight and speed, as props or from the nearest InkProvider. What each one does.