Skip to contentVibraUI
Inputs & filters

Slider input

A slider and a number field bound to one value, for a range you also want to type into.

Controlled only. One name covers the group and both controls: the number field carries the real label, and the slider takes the same label as its own name, so its thumb — where the focus lands — is read out as "Sample rate, slider" and not as a bare slider. Without a visible label, give SliderInput an aria-label (or aria-labelledby) and it names both controls the same way. A label that is an element rather than words — or an aria-labelledby — reaches the thumb by id, through the slider's aria-labelledby, which Base UI also puts on the slider's own group; that group then carries the name, and the pair is not wrapped in a second group of the same name. Dragging reports on every move; typing reports as the number changes and clamps on blur.

Install

npx shadcn@latest add @vibra/slider-input

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuenumber—The current value, shared by the slider and the field.
onValueChange(value: number) => void—Called with the new value from either control.
minnumber—Lower end of the range.
maxnumber—Upper end of the range.
stepnumber1Granularity of the slider and the number field.
format(value: number) => stringgrouped digitsHow the number reads in the field at rest and on the scale.
labelReact.ReactNode—Sits above the slider and names the group.
showTicksbooleanfalseDraws an evenly spaced scale with both ends labelled.
unitstring—Sits inside the number field after the value, e.g. a percent sign.

Dependencies

Source

components/ui/slider-input.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Label } from "@/components/ui/label"
import { NumberInput } from "@/components/ui/number-input"
import { Slider } from "@/components/ui/slider"

const TICK_COUNT = 5

export type SliderInputProps = React.ComponentProps<"div"> & {
  value: number
  onValueChange: (value: number) => void
  min: number
  max: number
  step?: number
  /** How the number reads in the field and on the scale; grouped digits by default. */
  format?: (value: number) => string
  label?: React.ReactNode
  /** Draws an evenly spaced scale with the ends labelled. */
  showTicks?: boolean
  /** Sits inside the number field after the value, e.g. "%" or "ms". */
  unit?: string
}

/** A slider and a number field bound to the same value, for a range you also want to type into. */
function SliderInput({
  className,
  value,
  onValueChange,
  min,
  max,
  step = 1,
  format,
  label,
  showTicks = false,
  unit,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  ...props
}: SliderInputProps) {
  const labelId = React.useId()
  const inputId = React.useId()
  const readValue = format ?? ((n: number) => formatNumber(n))
  // One name for both controls: the visible label, or the group's own
  // aria-label or aria-labelledby when there is none.
  // - The number field takes the label through its <label for>.
  // - The slider's focus lands on the range input in its thumb. A name with
  //   words of its own (a text label, an aria-label) goes on the thumb as its
  //   aria-label, and the pair sits in a group of that name.
  // - A name that is an element (a label node, an aria-labelledby) can only
  //   reach the thumb by id, through the slider's aria-labelledby, which Base
  //   UI also puts on the slider's own group. That group then carries the
  //   name, and the pair's container is not made a second group of it.
  const labelledBy = label ? labelId : ariaLabelledBy
  const labelText = label
    ? typeof label === "string" || typeof label === "number"
      ? String(label)
      : undefined
    : ariaLabel
  const sliderNamesGroup = !labelText && Boolean(labelledBy)

  return (
    <div
      data-slot="slider-input"
      role={sliderNamesGroup ? undefined : "group"}
      aria-labelledby={sliderNamesGroup ? undefined : labelledBy}
      aria-label={labelledBy ? undefined : labelText}
      className={cn("flex w-full flex-col gap-2", className)}
      {...props}
    >
      <div className="flex items-center justify-between gap-3">
        {label ? (
          <Label id={labelId} htmlFor={inputId}>
            {label}
          </Label>
        ) : (
          <span />
        )}
        <NumberInput
          id={inputId}
          aria-labelledby={label ? undefined : ariaLabelledBy}
          aria-label={label || ariaLabelledBy ? undefined : labelText}
          value={value}
          min={min}
          max={max}
          step={step}
          format={format}
          suffix={unit}
          onValueChange={(next) => onValueChange(next ?? min)}
          className="w-24 shrink-0"
        />
      </div>

      <Slider
        aria-label={labelText}
        aria-labelledby={labelText ? undefined : labelledBy}
        value={value}
        min={min}
        max={max}
        step={step}
        onValueChange={(next) => onValueChange(typeof next === "number" ? next : next[0])}
      />

      {showTicks ? (
        <div data-slot="slider-input-scale" className="flex flex-col gap-1">
          <div aria-hidden="true" className="flex items-center justify-between">
            {Array.from({ length: TICK_COUNT }, (_, index) => (
              <span key={index} className="h-1 w-px bg-border" />
            ))}
          </div>
          <div className="flex items-center justify-between text-xs text-muted-foreground tabular-nums">
            <span>{readValue(min)}</span>
            <span>{readValue(max)}</span>
          </div>
        </div>
      ) : null}
    </div>
  )
}

export { SliderInput }