Vela UI Docsvenizia.ai

Slider

An input for selecting a value or range of values along a track.

npx shadcn@latest add @velaui/slider
npx shadcn@latest add @velaui/slider

Examples

  • Default
  • Price range
  • Ruler
  • Full volume
  • Disabled
  • Orientation

View code

Slider · Default

Installation

pnpm dlx velaui add @velaui/slider
pnpm dlx velaui add @velaui/slider

Code

slider-default.tsx
import { Slider } from "@/components/ui/slider";

export default function Example() {
  return <Slider defaultValue={[40]} />;
}

View code

Slider · Price range

Installation

pnpm dlx velaui add @velaui/slider
pnpm dlx velaui add @velaui/slider

Code

slider-price-range.tsx
import { Slider } from "@/components/ui/slider";

export default function Example() {
  return <Slider variant="accent" defaultValue={[25, 75]} showLabel />;
}

View code

Slider · Ruler

Installation

pnpm dlx velaui add @velaui/slider
pnpm dlx velaui add @velaui/slider

Code

slider-ruler.tsx
import { Slider } from "@/components/ui/slider";

export default function Example() {
  return <Slider defaultValue={[60]} step={5} showRuler showLabel />;
}

View code

Slider · Full volume

Installation

pnpm dlx velaui add @velaui/slider
pnpm dlx velaui add @velaui/slider

Code

slider-full-volume.tsx
import { Slider } from "@/components/ui/slider";

export default function Example() {
  return <Slider variant="accent" defaultValue={[0, 100]} showLabel />;
}

View code

Slider · Disabled

Installation

pnpm dlx velaui add @velaui/slider
pnpm dlx velaui add @velaui/slider

Code

slider-disabled.tsx
import { Slider } from "@/components/ui/slider";

export default function Example() {
  return <Slider defaultValue={[50]} disabled />;
}

View code

Slider · Orientation

Installation

pnpm dlx velaui add @velaui/slider
pnpm dlx velaui add @velaui/slider

Code

slider-orientation.tsx
import { Slider } from "@/components/ui/slider";

export default function Example() {
  return <Slider defaultValue={[40]} orientation="vertical" className="h-30" />;
}

Usage

import { Slider } from "@/components/ui/slider";

export default function Example() {
  return <Slider defaultValue={[40]} />;
}

Props

PropTypeDefault
maxnumber100
minnumber0
orientation"horizontal" | "vertical""horizontal"
showLabelbooleanfalse
showRulerbooleanfalse
stepnumber1
variant"accent" | "primary""primary"

Takes these and every prop of SliderProps.

PropTypeDefault
maxnumber100
minnumber0
orientationimport("/app/node_modules/.bun/@base-ui+react@1.6.0+df7f6a806acd27ca/node_modules/@base-ui/react/index").Orientation"horizontal"
showLabelbooleanfalse
showRulerbooleanfalse
stepnumber1
variant"accent" | "primary""primary"

Takes these and every prop of SliderProps.

Dependencies

npm
class-variance-authority@0.7.1radix-ui@1.6.0
Installs with it
Tooltip
npm
@base-ui/react@1.6.0class-variance-authority@0.7.1
Installs with it
Tooltip

Source

components/ui/slider.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

const sliderRangeVariants = cva(
  "absolute rounded-full select-none data-horizontal:h-full data-vertical:w-full",
  {
    variants: {
      variant: {
        primary:
          "bg-primary-default group-data-disabled/slider:bg-primary-disabled",
        accent:
          "bg-accent-default group-data-disabled/slider:bg-accent-disabled",
      },
    },
    defaultVariants: {
      variant: "primary",
    },
  },
);

const sliderThumbVariants = cva(
  // Hover and press are drawn as a halo OUTSIDE the 16px handle, so they are a
  // ring, not a border — the handle measures the same in every state. The
  // value tooltip is portalled by Tooltip, so it never reflows the track.
  "relative block size-4 shrink-0 rounded-full ring-0 outline-none transition-[background-color,box-shadow] select-none after:absolute after:-inset-2 focus-visible:ring-3 group-data-disabled/slider:pointer-events-none",
  {
    variants: {
      variant: {
        primary:
          "bg-primary-default hover:ring-2 hover:ring-bg-surface-1 focus-visible:ring-ring-neutral-shadow active:bg-primary-hover active:ring-0 group-data-disabled/slider:bg-primary-disabled",
        accent:
          "bg-accent-default hover:bg-accent-hover hover:ring-2 hover:ring-teal-100 focus-visible:ring-ring-accent-shadow active:bg-accent-default active:ring-0 group-data-disabled/slider:bg-accent-disabled",
      },
    },
    defaultVariants: {
      variant: "primary",
    },
  },
);

export type SliderProps = React.ComponentProps<typeof SliderPrimitive.Root> &
  VariantProps<typeof sliderThumbVariants> & {
    /** Render the selected range's low/high values under the track. */
    showLabel?: boolean;
    /** Render the tick ruler above the track. */
    showRuler?: boolean;
  };

function Slider({
  className,
  defaultValue,
  value,
  min = 0,
  max = 100,
  step = 1,
  orientation = "horizontal",
  variant = "primary",
  showLabel = false,
  showRuler = false,
  onValueChange,
  ...props
}: SliderProps) {
  // The thumb tooltips read the live value, so an uncontrolled slider has to
  // mirror it in state — defaultValue alone never updates as the user drags.
  const [uncontrolledValues, setUncontrolledValues] = React.useState<number[]>(
    () => (Array.isArray(defaultValue) ? defaultValue : [min, max]),
  );
  const _values = Array.isArray(value) ? value : uncontrolledValues;

  const handleValueChange = React.useCallback(
    (next: number[]) => {
      if (!Array.isArray(value)) setUncontrolledValues(next);
      onValueChange?.(next);
    },
    [value, onValueChange],
  );

  // A thumb's tooltip is open while it is hovered, keyboard-focused, or
  // dragged. Radix closes an uncontrolled tooltip on pointer-down, which is
  // exactly when the value starts changing — hence the controlled state.
  const [hoveredThumb, setHoveredThumb] = React.useState<number | null>(null);
  const [draggedThumb, setDraggedThumb] = React.useState<number | null>(null);
  const [focusedThumb, setFocusedThumb] = React.useState<number | null>(null);

  React.useEffect(() => {
    if (draggedThumb === null) return;
    const stopDragging = () => setDraggedThumb(null);
    window.addEventListener("pointerup", stopDragging);
    window.addEventListener("pointercancel", stopDragging);
    return () => {
      window.removeEventListener("pointerup", stopDragging);
      window.removeEventListener("pointercancel", stopDragging);
    };
  }, [draggedThumb]);

  // One ruler mark per step, alternating a 4px major and a 1px minor — so the
  // ticks a user can actually land on are the ticks that get drawn.
  const rulerTicks = React.useMemo(
    () =>
      Array.from(
        { length: Math.floor((max - min) / step) + 1 },
        (_, index) => index,
      ),
    [min, max, step],
  );

  const low = _values.length > 1 ? _values[0] : min;
  const high = _values[_values.length - 1];

  return (
    <SliderPrimitive.Root
      data-slot="slider"
      data-variant={variant}
      defaultValue={defaultValue}
      value={value}
      min={min}
      max={max}
      step={step}
      orientation={orientation}
      onValueChange={handleValueChange}
      className={cn(
        "group/slider relative flex w-full cursor-pointer touch-none flex-col items-start select-none data-disabled:cursor-not-allowed data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-row data-vertical:gap-2",
        className,
      )}
      {...props}
    >
      <div
        data-slot="slider-container"
        // Radix only stamps data-orientation on its own parts, so every plain
        // element here keys off the root's group instead of its own attribute.
        className="relative flex h-6 w-full items-center group-data-vertical/slider:h-full group-data-vertical/slider:w-6 group-data-vertical/slider:flex-col"
      >
        {showRuler && (
          <div
            aria-hidden
            data-slot="slider-ruler"
            className="pointer-events-none absolute inset-x-0 top-0 flex items-end justify-between px-2 group-data-vertical/slider:inset-x-auto group-data-vertical/slider:inset-y-0 group-data-vertical/slider:left-0 group-data-vertical/slider:flex-col group-data-vertical/slider:px-0 group-data-vertical/slider:py-2"
          >
            {rulerTicks.map((tick) => (
              <span
                key={tick}
                className={cn(
                  "w-px bg-border-default",
                  tick % 2 === 0
                    ? "h-1 group-data-vertical/slider:h-px group-data-vertical/slider:w-1"
                    : "h-px",
                )}
              />
            ))}
          </div>
        )}
        <SliderPrimitive.Track
          data-slot="slider-track"
          className="relative grow overflow-hidden rounded-full bg-bg-surface-1 data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
        >
          <SliderPrimitive.Range
            data-slot="slider-range"
            className={cn(sliderRangeVariants({ variant }))}
          />
        </SliderPrimitive.Track>
        {Array.from({ length: _values.length }, (_, index) => (
          <TooltipProvider key={index} delayDuration={0}>
            <Tooltip
              open={
                hoveredThumb === index ||
                focusedThumb === index ||
                draggedThumb === index
              }
            >
              <TooltipTrigger asChild>
                <SliderPrimitive.Thumb
                  data-slot="slider-thumb"
                  className={cn(sliderThumbVariants({ variant }))}
                  onPointerEnter={() => setHoveredThumb(index)}
                  onPointerLeave={() => setHoveredThumb(null)}
                  onPointerDown={() => setDraggedThumb(index)}
                  // Pointer-down focuses the thumb too, so match :focus-visible
                  // to keep the tooltip from sticking around after a drag.
                  onFocus={(event) => {
                    if (event.currentTarget.matches(":focus-visible"))
                      setFocusedThumb(index);
                  }}
                  onBlur={() => setFocusedThumb(null)}
                />
              </TooltipTrigger>
              <TooltipContent
                data-slot="slider-thumb-tooltip"
                side={orientation === "vertical" ? "right" : "top"}
                // The thumb owns the pointer during a drag; the tooltip must
                // never intercept it or swallow the slider's hit area.
                onPointerDownOutside={(event) => event.preventDefault()}
                className="pointer-events-none items-center justify-center"
              >
                {_values[index]}
              </TooltipContent>
            </Tooltip>
          </TooltipProvider>
        ))}
      </div>
      {showLabel && (
        <div
          data-slot="slider-label-range"
          className="pointer-events-none flex w-full items-center justify-between text-xs leading-[1.3] font-normal whitespace-nowrap text-text-strong group-data-vertical/slider:h-full group-data-vertical/slider:w-auto group-data-vertical/slider:flex-col-reverse group-data-vertical/slider:items-start"
        >
          <span>{low}</span>
          <span className="text-right">{high}</span>
        </div>
      )}
    </SliderPrimitive.Root>
  );
}

export { Slider, sliderRangeVariants, sliderThumbVariants };
components/ui/slider.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

const sliderRangeVariants = cva(
  "absolute rounded-full select-none data-horizontal:h-full data-vertical:w-full",
  {
    variants: {
      variant: {
        primary:
          "bg-primary-default group-data-disabled/slider:bg-primary-disabled",
        accent:
          "bg-accent-default group-data-disabled/slider:bg-accent-disabled",
      },
    },
    defaultVariants: {
      variant: "primary",
    },
  },
);

const sliderThumbVariants = cva(
  // Hover and press are drawn as a halo OUTSIDE the 16px handle, so they are a
  // ring, not a border — the handle measures the same in every state. The
  // value tooltip is portalled by Tooltip, so it never reflows the track.
  // Base UI routes focus (and :focus-visible) to a hidden range input inside
  // the thumb, so the focus rings have a has-… twin that matches the input.
  "relative block size-4 shrink-0 rounded-full ring-0 outline-none transition-[background-color,box-shadow] select-none after:absolute after:-inset-2 focus-visible:ring-3 has-[[type=range]:focus-visible]:ring-3 group-data-disabled/slider:pointer-events-none",
  {
    variants: {
      variant: {
        primary:
          "bg-primary-default hover:ring-2 hover:ring-bg-surface-1 focus-visible:ring-ring-neutral-shadow has-[[type=range]:focus-visible]:ring-ring-neutral-shadow active:bg-primary-hover active:ring-0 group-data-disabled/slider:bg-primary-disabled",
        accent:
          "bg-accent-default hover:bg-accent-hover hover:ring-2 hover:ring-teal-100 focus-visible:ring-ring-accent-shadow has-[[type=range]:focus-visible]:ring-ring-accent-shadow active:bg-accent-default active:ring-0 group-data-disabled/slider:bg-accent-disabled",
      },
    },
    defaultVariants: {
      variant: "primary",
    },
  },
);

export type SliderProps = SliderPrimitive.Root.Props &
  VariantProps<typeof sliderThumbVariants> & {
    /** Render the selected range's low/high values under the track. */
    showLabel?: boolean;
    /** Render the tick ruler above the track. */
    showRuler?: boolean;
  };

function Slider({
  className,
  defaultValue,
  value,
  min = 0,
  max = 100,
  step = 1,
  orientation = "horizontal",
  variant = "primary",
  showLabel = false,
  showRuler = false,
  onValueChange,
  ...props
}: SliderProps) {
  // The thumb tooltips read the live value, so an uncontrolled slider has to
  // mirror it in state — defaultValue alone never updates as the user drags.
  const [uncontrolledValues, setUncontrolledValues] = React.useState<number[]>(
    () =>
      Array.isArray(defaultValue)
        ? [...defaultValue]
        : defaultValue !== undefined
          ? [defaultValue]
          : [min, max],
  );
  const _values = Array.isArray(value) ? value : uncontrolledValues;

  const handleValueChange = React.useCallback(
    (
      next: number | readonly number[],
      details: SliderPrimitive.Root.ChangeEventDetails,
    ) => {
      if (!Array.isArray(value)) {
        setUncontrolledValues(Array.isArray(next) ? [...next] : [next]);
      }
      onValueChange?.(next, details);
    },
    [value, onValueChange],
  );

  // A thumb's tooltip is open while it is hovered, keyboard-focused, or
  // dragged. Leaving the tooltip fully controlled is what keeps it open
  // through the pointer-down that starts a drag — exactly when the value
  // begins to change.
  const [hoveredThumb, setHoveredThumb] = React.useState<number | null>(null);
  const [draggedThumb, setDraggedThumb] = React.useState<number | null>(null);
  const [focusedThumb, setFocusedThumb] = React.useState<number | null>(null);

  React.useEffect(() => {
    if (draggedThumb === null) return;
    const stopDragging = () => setDraggedThumb(null);
    window.addEventListener("pointerup", stopDragging);
    window.addEventListener("pointercancel", stopDragging);
    return () => {
      window.removeEventListener("pointerup", stopDragging);
      window.removeEventListener("pointercancel", stopDragging);
    };
  }, [draggedThumb]);

  // One ruler mark per step, alternating a 4px major and a 1px minor — so the
  // ticks a user can actually land on are the ticks that get drawn.
  const rulerTicks = React.useMemo(
    () =>
      Array.from(
        { length: Math.floor((max - min) / step) + 1 },
        (_, index) => index,
      ),
    [min, max, step],
  );

  const low = _values.length > 1 ? _values[0] : min;
  const high = _values[_values.length - 1];

  return (
    <SliderPrimitive.Root
      data-slot="slider"
      data-variant={variant}
      defaultValue={defaultValue}
      value={value}
      min={min}
      max={max}
      step={step}
      orientation={orientation}
      // Radix keeps the thumbs inside the track at min and max; Base UI centres them on the ends.
      thumbAlignment="edge"
      onValueChange={handleValueChange}
      className={cn(
        "group/slider relative flex w-full cursor-pointer touch-none flex-col items-start select-none data-disabled:cursor-not-allowed data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-row data-vertical:gap-2",
        className,
      )}
      {...props}
    >
      <SliderPrimitive.Control
        data-slot="slider-container"
        // Base UI stamps data-orientation on every part, but the container and
        // its plain children keep keying off the root's group — the same
        // group-data-vertical pairing used by the ruler and range labels.
        className="relative flex h-6 w-full items-center group-data-vertical/slider:h-full group-data-vertical/slider:w-6 group-data-vertical/slider:flex-col"
      >
        {showRuler && (
          <div
            aria-hidden
            data-slot="slider-ruler"
            className="pointer-events-none absolute inset-x-0 top-0 flex items-end justify-between px-2 group-data-vertical/slider:inset-x-auto group-data-vertical/slider:inset-y-0 group-data-vertical/slider:left-0 group-data-vertical/slider:flex-col group-data-vertical/slider:px-0 group-data-vertical/slider:py-2"
          >
            {rulerTicks.map((tick) => (
              <span
                key={tick}
                className={cn(
                  "w-px bg-border-default",
                  tick % 2 === 0
                    ? "h-1 group-data-vertical/slider:h-px group-data-vertical/slider:w-1"
                    : "h-px",
                )}
              />
            ))}
          </div>
        )}
        <SliderPrimitive.Track
          data-slot="slider-track"
          className="relative grow overflow-hidden rounded-full bg-bg-surface-1 data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
        >
          <SliderPrimitive.Indicator
            data-slot="slider-range"
            className={cn(sliderRangeVariants({ variant }))}
          />
        </SliderPrimitive.Track>
        {Array.from({ length: _values.length }, (_, index) => (
          <TooltipProvider key={index} delay={0}>
            <Tooltip
              open={
                hoveredThumb === index ||
                focusedThumb === index ||
                draggedThumb === index
              }
            >
              <TooltipTrigger
                render={
                  <SliderPrimitive.Thumb
                    data-slot="slider-thumb"
                    className={cn(sliderThumbVariants({ variant }))}
                    onPointerEnter={() => setHoveredThumb(index)}
                    onPointerLeave={() => setHoveredThumb(null)}
                    onPointerDown={() => setDraggedThumb(index)}
                    // Pointer-down focuses the thumb's hidden range input, so
                    // match :focus-visible on either the thumb or the input —
                    // the tooltip must not stick around after a drag.
                    onFocus={(event) => {
                      const target = event.target;
                      if (
                        event.currentTarget.matches(":focus-visible") ||
                        (target instanceof Element &&
                          target.matches(":focus-visible"))
                      ) {
                        setFocusedThumb(index);
                      }
                    }}
                    onBlur={() => setFocusedThumb(null)}
                  />
                }
              />
              <TooltipContent
                data-slot="slider-thumb-tooltip"
                side={orientation === "vertical" ? "right" : "top"}
                // The thumb owns the pointer during a drag; the content is
                // pointer-events-none, so it can never intercept it or
                // swallow the slider's hit area.
                className="pointer-events-none items-center justify-center"
              >
                {_values[index]}
              </TooltipContent>
            </Tooltip>
          </TooltipProvider>
        ))}
      </SliderPrimitive.Control>
      {showLabel && (
        <div
          data-slot="slider-label-range"
          className="pointer-events-none flex w-full items-center justify-between text-xs leading-[1.3] font-normal whitespace-nowrap text-text-strong group-data-vertical/slider:h-full group-data-vertical/slider:w-auto group-data-vertical/slider:flex-col-reverse group-data-vertical/slider:items-start"
        >
          <span>{low}</span>
          <span className="text-right">{high}</span>
        </div>
      )}
    </SliderPrimitive.Root>
  );
}

export { Slider, sliderRangeVariants, sliderThumbVariants };