# Slider

> Pointer and keyboard slider with a glowing track and optional range.

Category: Forms. Docs: https://get-neonkit.vercel.app/components/slider

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/slider.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/slider`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Slider } from "@/components/ui/slider"

<Slider defaultValue={[64]} aria-label="Volume" />
```

## Examples

### Volume

showValue="hover" reveals the value while hovering, dragging or focused.

```tsx
"use client";

import { Volume, Volume2 } from "lucide-react";
import { Slider } from "@/components/ui/slider";

export default function SliderDemo() {
  return (
    <div className="flex w-full max-w-xs items-center gap-3">
      <Volume className="size-4 shrink-0 text-white/40" />
      <Slider
        defaultValue={[64]}
        aria-label="Volume"
        showValue="hover"
        formatValue={(value) => `${value}%`}
      />
      <Volume2 className="size-4 shrink-0 text-white/40" />
    </div>
  );
}
```

### Range

Pass two values for two thumbs. They stop at each other instead of crossing.

```tsx
"use client";

import * as React from "react";
import { Slider } from "@/components/ui/slider";

const price = (value: number) => `$${value.toLocaleString("en-US")}`;

export default function SliderPriceRange() {
  const [range, setRange] = React.useState([120, 480]);

  return (
    <div className="w-full max-w-sm">
      <div className="mb-4 flex items-baseline justify-between">
        <span id="price-range" className="text-sm font-medium text-white/90">
          Price
        </span>
        <span className="text-sm tabular-nums text-white/55">
          {price(range[0])} – {price(range[1])}
        </span>
      </div>
      <Slider
        value={range}
        onValueChange={setRange}
        min={0}
        max={1000}
        step={10}
        formatValue={price}
        showValue="hover"
        aria-labelledby="price-range"
        thumbLabels={["Minimum price", "Maximum price"]}
      />
    </div>
  );
}
```

### Named steps

Map a small integer range to labels with formatValue, which is also what screen readers hear.

```tsx
"use client";

import * as React from "react";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";

const stops = ["1 min", "5 min", "15 min", "30 min", "1 hour", "Never"];

export default function SliderSteps() {
  const [index, setIndex] = React.useState(2);

  return (
    <div className="w-full max-w-sm">
      <div className="mb-4 flex items-baseline justify-between">
        <span id="auto-lock" className="text-sm font-medium text-white/90">
          Auto-lock
        </span>
        <span className="text-sm text-white/55">{stops[index]}</span>
      </div>
      <Slider
        value={[index]}
        onValueChange={([next]) => setIndex(next)}
        max={stops.length - 1}
        formatValue={(i) => stops[i]}
        aria-labelledby="auto-lock"
      />
      {/* Inset by half the 18px thumb so each label sits under its stop. */}
      <div aria-hidden className="relative mx-[9px] mt-2.5 h-6">
        {stops.map((stop, i) => (
          <span
            key={stop}
            className="absolute top-0 flex -translate-x-1/2 flex-col items-center gap-1.5"
            style={{ left: `${(i / (stops.length - 1)) * 100}%` }}
          >
            <span className="h-1 w-px bg-white/20" />
            <span
              className={cn(
                "whitespace-nowrap text-[11px] leading-none transition-colors duration-150",
                i === index ? "text-white/90" : "text-white/35",
              )}
            >
              {stop}
            </span>
          </span>
        ))}
      </div>
    </div>
  );
}
```

### Colors

Pass a palette name or any hex value to color.

```tsx
"use client";

import * as React from "react";
import { Slider } from "@/components/ui/slider";

const channels = [
  { key: "r", label: "Red", color: "red" },
  { key: "g", label: "Green", color: "green" },
  { key: "b", label: "Blue", color: "blue" },
] as const;

type Rgb = Record<(typeof channels)[number]["key"], number>;

export default function SliderColors() {
  const [rgb, setRgb] = React.useState<Rgb>({ r: 34, g: 211, b: 238 });
  const hex = `#${[rgb.r, rgb.g, rgb.b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;

  return (
    <div className="flex w-full max-w-sm items-center gap-6">
      <div className="flex shrink-0 flex-col items-center gap-2">
        <div
          className="size-16 rounded-xl border border-white/10"
          style={{ backgroundColor: hex, boxShadow: `0 0 28px ${hex}55` }}
        />
        <span className="font-mono text-xs uppercase text-white/55">{hex}</span>
      </div>
      <div className="grid flex-1 gap-4">
        {channels.map((channel) => (
          <div key={channel.key} className="grid grid-cols-[2.75rem_1fr_1.75rem] items-center gap-3">
            <span className="text-xs text-white/55">{channel.label}</span>
            <Slider
              color={channel.color}
              size="sm"
              max={255}
              value={[rgb[channel.key]]}
              onValueChange={([value]) => setRgb((current) => ({ ...current, [channel.key]: value }))}
              aria-label={channel.label}
            />
            <span className="text-right text-xs tabular-nums text-white/90">{rgb[channel.key]}</span>
          </div>
        ))}
      </div>
    </div>
  );
}
```

## API reference

### Slider

Accepts every div attribute; the ref points at the root.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number[]` | — | Controlled values. One number for a single thumb, two for a range. |
| `defaultValue` | `number[]` | `[min]` | Initial values when uncontrolled. |
| `onValueChange` | `(value: number[]) => void` | — | Called on every change while dragging or pressing keys. |
| `onValueCommit` | `(value: number[]) => void` | — | Called once when the pointer or key is released. |
| `min` | `number` | `0` | Lowest value. |
| `max` | `number` | `100` | Highest value. |
| `step` | `number` | `1` | Values snap to multiples of step from min. |
| `showValue` | `boolean \| "hover"` | `false` | Value bubble above each thumb, always or only on hover, drag and focus. |
| `formatValue` | `(value: number) => string` | — | Formats the bubble and aria-valuetext. |
| `thumbLabels` | `string[]` | — | Accessible name per thumb. Ranges default to "Minimum" and "Maximum". |
| `name` | `string` | — | Submits the values with a form, as name[] for ranges. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Track of 4, 6 or 8px high. |
| `disabled` | `boolean` | `false` | Blocks interaction and removes thumbs from the tab order. |

## Accessibility

- Each thumb has role="slider" with aria-valuemin, aria-valuemax and aria-valuenow.
- Arrow keys move by one step, Shift+Arrow and Page Up/Down by ten, Home and End jump to the ends.
- A single slider takes its name from aria-label or aria-labelledby; a range uses them to label the group and thumbLabels to name each thumb.
- Pressing the track jumps the nearest thumb there and keeps dragging; grabbing a thumb never makes it jump.

## Source

`components/ui/slider.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// Thumbs travel inside a rail inset by half their width, so they never
// hang past the ends of the track.
const sizes = {
  sm: { root: "h-4", track: "h-1", thumb: "size-3.5", rail: "inset-x-[7px]", radius: 7 },
  md: { root: "h-5", track: "h-1.5", thumb: "size-[18px]", rail: "inset-x-[9px]", radius: 9 },
  lg: { root: "h-6", track: "h-2", thumb: "size-[22px]", rail: "inset-x-[11px]", radius: 11 },
} as const;

const thumbGlow = "shadow-[0_0_18px_rgb(var(--neon)/0.8),0_1px_3px_rgb(0_0_0/0.5)]";

function clamp(value: number, lower: number, upper: number) {
  return Math.min(upper, Math.max(lower, value));
}

function decimalsOf(value: number) {
  return (String(value).split(".")[1] ?? "").length;
}

export interface SliderProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "defaultValue" | "onChange"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  /** One number for a single thumb, two for a range. */
  value?: number[];
  defaultValue?: number[];
  onValueChange?: (value: number[]) => void;
  /** Fires once the pointer or key is released. */
  onValueCommit?: (value: number[]) => void;
  min?: number;
  max?: number;
  step?: number;
  disabled?: boolean;
  /** Accessible name per thumb. Ranges default to "Minimum" and "Maximum". */
  thumbLabels?: string[];
  /** Formats the value bubble and the value read to screen readers. */
  formatValue?: (value: number) => string;
  /** Shows a value bubble above each thumb, always or on hover, drag and focus. */
  showValue?: boolean | "hover";
  /** Submits each value under this name (`name[]` for ranges). */
  name?: string;
}

const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
  (
    {
      className,
      style,
      color,
      size = "md",
      value: valueProp,
      defaultValue,
      onValueChange,
      onValueCommit,
      min = 0,
      max = 100,
      step = 1,
      disabled = false,
      thumbLabels,
      formatValue,
      showValue = false,
      name,
      "aria-label": ariaLabel,
      "aria-labelledby": ariaLabelledBy,
      onPointerDown,
      onPointerMove,
      onPointerUp,
      onPointerCancel,
      ...props
    },
    ref,
  ) => {
    const [uncontrolled, setUncontrolled] = React.useState(() => defaultValue ?? [min]);
    const isControlled = valueProp !== undefined;
    const values = isControlled ? valueProp : uncontrolled;
    const isRange = values.length > 1;

    const railRef = React.useRef<HTMLDivElement>(null);
    const thumbRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
    const drag = React.useRef<{ index: number; offset: number } | null>(null);
    const keyChanged = React.useRef(false);
    const [active, setActive] = React.useState<number | null>(null);

    const s = sizes[size];
    const decimals = Math.max(decimalsOf(step), decimalsOf(min));
    const percent = (value: number) =>
      max === min ? 0 : ((clamp(value, min, max) - min) / (max - min)) * 100;

    /** Snaps, keeps the thumb between its neighbours, and reports changes. */
    function setValueAt(index: number, raw: number) {
      const snapped = Number((Math.round((raw - min) / step) * step + min).toFixed(decimals));
      const lower = index > 0 ? values[index - 1] : min;
      const upper = index < values.length - 1 ? values[index + 1] : max;
      const next = clamp(snapped, lower, upper);
      if (next === values[index]) return false;

      const nextValues = values.map((value, i) => (i === index ? next : value));
      if (!isControlled) setUncontrolled(nextValues);
      onValueChange?.(nextValues);
      return true;
    }

    function valueFromPointer(clientX: number) {
      const rect = railRef.current?.getBoundingClientRect();
      if (!rect || rect.width === 0) return min;
      return min + clamp((clientX - rect.left) / rect.width, 0, 1) * (max - min);
    }

    function closestThumb(raw: number) {
      let index = 0;
      let best = Infinity;
      values.forEach((value, i) => {
        const distance = Math.abs(value - raw);
        // On a tie (stacked thumbs), move the one on the side of the pointer.
        if (distance < best || (distance === best && raw > value)) {
          best = distance;
          index = i;
        }
      });
      return index;
    }

    function handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {
      onPointerDown?.(event);
      if (disabled || event.defaultPrevented || event.button !== 0) return;
      // We move focus to the thumb ourselves.
      event.preventDefault();

      let index = thumbRefs.current.findIndex((thumb) => thumb?.contains(event.target as Node));
      let offset = 0;
      const grabbed = thumbRefs.current[index];
      if (grabbed) {
        // Grabbing a thumb off-center should not make it jump.
        const rect = grabbed.getBoundingClientRect();
        offset = event.clientX - (rect.left + rect.width / 2);
      } else {
        const raw = valueFromPointer(event.clientX);
        index = closestThumb(raw);
        setValueAt(index, raw);
      }

      event.currentTarget.setPointerCapture(event.pointerId);
      drag.current = { index, offset };
      setActive(index);
      thumbRefs.current[index]?.focus({ preventScroll: true });
    }

    function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {
      onPointerMove?.(event);
      if (!drag.current) return;
      setValueAt(drag.current.index, valueFromPointer(event.clientX - drag.current.offset));
    }

    function endDrag() {
      if (!drag.current) return;
      drag.current = null;
      setActive(null);
      onValueCommit?.(values);
    }

    function handleKeyDown(event: React.KeyboardEvent<HTMLSpanElement>, index: number) {
      if (disabled) return;
      const current = values[index];
      const page = step * 10;
      const delta: Record<string, number> = {
        ArrowRight: event.shiftKey ? page : step,
        ArrowUp: event.shiftKey ? page : step,
        ArrowLeft: event.shiftKey ? -page : -step,
        ArrowDown: event.shiftKey ? -page : -step,
        PageUp: page,
        PageDown: -page,
        Home: -Infinity,
        End: Infinity,
      };
      if (!(event.key in delta)) return;
      event.preventDefault();
      const target = clamp(current + delta[event.key], min, max);
      if (setValueAt(index, target)) keyChanged.current = true;
    }

    function handleKeyUp() {
      if (!keyChanged.current) return;
      keyChanged.current = false;
      onValueCommit?.(values);
    }

    const first = percent(values[0] ?? min);
    const last = percent(values[values.length - 1] ?? min);

    return (
      <div
        ref={ref}
        role={isRange ? "group" : undefined}
        aria-label={isRange ? ariaLabel : undefined}
        aria-labelledby={isRange ? ariaLabelledBy : undefined}
        aria-disabled={disabled || undefined}
        data-orientation="horizontal"
        data-disabled={disabled ? "" : undefined}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "group/slider relative flex w-full touch-none select-none items-center",
          // Taller invisible hit area for touch.
          "before:absolute before:inset-x-0 before:-inset-y-2 before:content-['']",
          disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
          s.root,
          className,
        )}
        {...props}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={(event) => {
          onPointerUp?.(event);
          endDrag();
        }}
        onPointerCancel={(event) => {
          onPointerCancel?.(event);
          endDrag();
        }}
      >
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 rounded-full bg-white/10",
            s.track,
          )}
        />

        <div ref={railRef} className={cn("absolute inset-y-0", s.rail)}>
          <span
            aria-hidden
            style={{
              left: isRange ? `${first}%` : -s.radius,
              right: `${100 - last}%`,
            }}
            className={cn(
              "pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_10px_rgb(var(--neon)/0.55)]",
              s.track,
            )}
          />

          {values.map((value, i) => {
            const text = formatValue ? formatValue(value) : String(value);
            const dragging = active === i;
            const label =
              thumbLabels?.[i] ??
              (isRange ? (i === 0 ? "Minimum" : i === values.length - 1 ? "Maximum" : undefined) : ariaLabel);

            return (
              <span key={i} className="absolute inset-y-0 w-0" style={{ left: `${percent(value)}%` }}>
                <span
                  ref={(node) => {
                    thumbRefs.current[i] = node;
                  }}
                  role="slider"
                  tabIndex={disabled ? -1 : 0}
                  aria-label={label}
                  aria-labelledby={!isRange && !thumbLabels?.[i] ? ariaLabelledBy : undefined}
                  aria-valuemin={i > 0 ? values[i - 1] : min}
                  aria-valuemax={i < values.length - 1 ? values[i + 1] : max}
                  aria-valuenow={value}
                  aria-valuetext={formatValue ? text : undefined}
                  aria-orientation="horizontal"
                  aria-disabled={disabled || undefined}
                  data-active={dragging ? "" : undefined}
                  onKeyDown={(event) => handleKeyDown(event, i)}
                  onKeyUp={handleKeyUp}
                  className={cn(
                    "peer absolute left-0 top-1/2 block -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[rgb(var(--neon))] bg-[#eef3fa] outline-none",
                    "shadow-[0_0_8px_rgb(var(--neon)/0.4),0_1px_3px_rgb(0_0_0/0.5)]",
                    "transition-[transform,scale,box-shadow] duration-150 ease-out motion-reduce:transition-none",
                    "focus-visible:scale-110 focus-visible:shadow-[0_0_18px_rgb(var(--neon)/0.8),0_1px_3px_rgb(0_0_0/0.5)]",
                    "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
                    dragging
                      ? cn("scale-110", thumbGlow)
                      : !disabled &&
                          "hover:shadow-[0_0_12px_rgb(var(--neon)/0.6),0_1px_3px_rgb(0_0_0/0.5)]",
                    s.thumb,
                  )}
                />
                {showValue && (
                  <span
                    aria-hidden
                    className={cn(
                      "pointer-events-none absolute bottom-full left-0 mb-1.5 -translate-x-1/2 origin-bottom whitespace-nowrap rounded-md border border-white/10 bg-[#0a0b11] px-1.5 py-0.5 text-xs font-medium leading-4 tabular-nums text-white/90 shadow-[0_4px_12px_rgb(0_0_0/0.5)]",
                      "transition-[opacity,transform,scale] duration-150 ease-out motion-reduce:transition-none",
                      showValue === true || dragging
                        ? "scale-100 opacity-100"
                        : cn(
                            "scale-[0.96] opacity-0 peer-focus-visible:scale-100 peer-focus-visible:opacity-100",
                            !disabled && "group-hover/slider:scale-100 group-hover/slider:opacity-100",
                          ),
                    )}
                  >
                    {text}
                  </span>
                )}
              </span>
            );
          })}
        </div>

        {name &&
          values.map((value, i) => (
            <input key={i} type="hidden" name={isRange ? `${name}[]` : name} value={value} />
          ))}
      </div>
    );
  },
);
Slider.displayName = "Slider";

export { Slider };
```
