Przejdź do treści

Slider

Wybiera wartość lub zakres wartości przez przeciąganie wzdłuż toru — sprawdza się najlepiej, gdy przybliżone położenie liczy się bardziej niż dokładna liczba.

Kod źródłowyBase UIRejestr
import { Volume1Icon, Volume2Icon } from "lucide-react"

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

export default function SliderDemo() {
  return (
    <div className="flex w-72 items-center gap-3 text-muted-foreground">
      <Volume1Icon aria-hidden="true" className="size-4 shrink-0" />
      <Slider defaultValue={64} aria-label="Głośność" />
      <Volume2Icon aria-hidden="true" className="size-4 shrink-0" />
    </div>
  )
}

Anatomia

  1. 1TorPełny zakres wartości w neutralnym odcieniu.
  2. 2ZakresWybrana część zakresu, wypełniona kolorem marki — od początku toru albo między dwoma uchwytami.
  3. 3UchwytElement, który się przeciąga. To on przyjmuje fokus i ma obszar klikalny 32 px; dodaj drugi uchwyt, aby wybierać zakres.

Instalacja

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/slider.json

Użycie

import { Slider } from "@/components/ui/slider"
<Slider defaultValue={50} aria-label="Volume" />

Przekaż liczbę, aby uzyskać jeden uchwyt, albo tablicę, aby uzyskać zakres — po jednym uchwycie na każdą wartość. Wypełniona część toru i obramowanie uchwytu mają kolor marki.

Przykłady

Z etykietą i wartością

Pokazuj bieżącą wartość obok etykiety zawsze, gdy liczba ma znaczenie. Nazwij suwak przez aria-labelledby wskazujące widoczną etykietę; nazwa trafia do uchwytu, bo to na nim faktycznie ustawia się fokus czytnika ekranu.

80%
"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"

export default function SliderLabeled() {
  const [opacity, setOpacity] = React.useState(80)

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <div className="flex items-center justify-between">
        <Label id="opacity-label">Krycie warstwy</Label>
        <output
          htmlFor="opacity"
          aria-live="polite"
          className="font-mono text-sm text-muted-foreground tabular-nums"
        >
          {opacity}%
        </output>
      </div>
      <Slider
        id="opacity"
        aria-labelledby="opacity-label"
        value={opacity}
        onValueChange={(value) => setOpacity(value as number)}
        format={{ style: "unit", unit: "percent" }}
      />
    </div>
  )
}

Zakres

Wartość w postaci tablicy renderuje dwa uchwyty. minStepsBetweenValues nie pozwala im na siebie nachodzić, a getAriaLabel nadaje każdemu uchwytowi własną nazwę — Cena minimalna i Cena maksymalna — żeby nie były odczytywane jako dwa identyczne suwaki.

240 USD – 760 USD
"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"

const currency = new Intl.NumberFormat("pl-PL", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
})

export default function SliderRange() {
  const [range, setRange] = React.useState([240, 760])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <div className="flex items-center justify-between">
        <Label id="price-label">Cena</Label>
        <span className="font-mono text-sm text-muted-foreground tabular-nums">
          {currency.format(range[0])} – {currency.format(range[1])}
        </span>
      </div>
      <Slider
        aria-labelledby="price-label"
        value={range}
        onValueChange={(value) => setRange(value as number[])}
        min={0}
        max={1000}
        step={10}
        minStepsBetweenValues={10}
        getAriaLabel={(index) =>
          index === 0 ? "Cena minimalna" : "Cena maksymalna"
        }
        format={{
          style: "currency",
          currency: "USD",
          maximumFractionDigits: 0,
        }}
      />
    </div>
  )
}

Kroki

step przyciąga wartość do wielokrotności kroku. Przy kilku dyskretnych wartościach narysuj pod torem etykiety podziałki i opisz wartość słowami przez getAriaValueText — 3 repliki mówią więcej niż samo 3.

3 × 1 vCPU
"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
import { cn } from "@/lib/utils"

const marks = [1, 2, 3, 4, 5, 6]

export default function SliderSteps() {
  const [replicas, setReplicas] = React.useState(3)

  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <div className="flex items-center justify-between">
        <Label id="replicas-label">Repliki</Label>
        <span className="text-sm text-muted-foreground tabular-nums">
          {replicas} × 1 vCPU
        </span>
      </div>
      <Slider
        aria-labelledby="replicas-label"
        value={replicas}
        onValueChange={(value) => setReplicas(value as number)}
        min={1}
        max={6}
        step={1}
        getAriaValueText={(_, value) => `Repliki: ${value}`}
      />
      <div aria-hidden="true" className="flex justify-between px-px">
        {marks.map((mark) => (
          <span
            key={mark}
            className={cn(
              "flex w-4 flex-col items-center gap-1 text-xs text-muted-foreground tabular-nums",
              mark === replicas && "font-medium text-foreground"
            )}
          >
            <span className="h-1.5 w-px bg-gray-7" />
            {mark}
          </span>
        ))}
      </div>
    </div>
  )
}

Pionowy

orientation="vertical" pasuje do kontrolek, które kojarzą się z wysokością — poziomów, korektorów, powiększenia. Wysokość suwaka nadaj przez jego kontener.

60 Hz
230 Hz
910 Hz
3,6 kHz
14 kHz
import { Slider } from "@/components/ui/slider"

const bands = [
  { label: "60 Hz", value: 62 },
  { label: "230 Hz", value: 48 },
  { label: "910 Hz", value: 55 },
  { label: "3,6 kHz", value: 70 },
  { label: "14 kHz", value: 40 },
]

export default function SliderVertical() {
  return (
    <div className="flex h-48 items-stretch gap-6">
      {bands.map((band) => (
        <div key={band.label} className="flex flex-col items-center gap-2">
          <Slider
            orientation="vertical"
            defaultValue={band.value}
            aria-label={`Wzmocnienie ${band.label}`}
            className="flex-1"
          />
          <span className="font-mono text-[0.6875rem] text-muted-foreground">
            {band.label}
          </span>
        </div>
      ))}
    </div>
  )
}

Wyłączony

Wyłączony suwak nadal pokazuje wartość, choć jest półprzezroczysty, i nie reaguje na interakcję. Jeśli powód nie jest oczywisty, wyjaśnij go obok.

import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"

export default function SliderDisabled() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Label id="bitrate-label" className="text-muted-foreground">
        Przepływność — zablokowana w twoim planie
      </Label>
      <Slider aria-labelledby="bitrate-label" defaultValue={40} disabled />
    </div>
  )
}

Wytyczne

Kiedy używać

  • Gdy względne położenie liczy się bardziej niż precyzja: głośność, krycie, powiększenie, przedział cenowy.
  • Gdy ludziom pomaga widok wartości na tle całego zakresu.

Kiedy nie używać

  • Gdy liczy się dokładna liczba — użyj komponentu Input z type="number" albo połącz z nim suwak.
  • Przy bardzo dużych zakresach, w których jeden piksel obejmuje wiele wartości — użyj pola liczbowego.
  • Przy kilku nazwanych opcjach — użyj komponentu Radio Group lub Toggle Group.

Pokazuj wartość

Suwak bez widocznej wartości każe ludziom zgadywać. Pokaż wartość obok etykiety, sformatuj ją ($240, 80%) własną funkcją formatującą i aktualizuj ją na żywo podczas przeciągania. Te same opcje przekaż do format, żeby czytniki ekranu odczytywały wartość tak, jak widzą ją ludzie.

Krycie80%
DobrzeBieżąca wartość jest widoczna i sformatowana.
Krycie
ŹleBez widocznej wartości ludzie mogą tylko szacować, co wybrali.

Zatwierdzaj raz, nie zasypuj żądaniami

onValueChange wywołuje się bez przerwy podczas przeciągania — używaj go do aktualizowania widoku. Do zapisu lub pobierania danych po zakończeniu przeciągania użyj onValueCommitted, żeby jedna zmiana nie wysłała pięćdziesięciu żądań.

Dostępność

Każdy uchwyt zawiera natywny <input type="range">, więc suwak od razu działa z czytnikami ekranu, sterowaniem głosowym i funkcjami formularzy w przeglądarce.

KlawiszDziałanie
Tab
Przenosi fokus na następny uchwyt. Każdy uchwyt zakresu ma własne miejsce w kolejności tabulacji.
→↑
Zwiększa wartość o jeden krok.
←↓
Zmniejsza wartość o jeden krok.
PageUpShift↑
Zwiększa wartość o duży krok (domyślnie 10).
PageDownShift↓
Zmniejsza wartość o duży krok.
HomeEnd
Ustawia wartość minimalną lub maksymalną.
  • Nadaj nazwę. Użyj aria-labelledby wskazującego widoczną etykietę albo aria-label. prfct przekazuje nazwę do uchwytów; w zakresie nazwij każdy uchwyt przez getAriaLabel.
  • Opisz wartość. format kształtuje tylko wartość odczytywaną. Suwak sam nie renderuje tekstu wartości, więc tę, którą pokazujesz, sformatuj samodzielnie. getAriaValueText nadpisuje to, co jest odczytywane, gdy słowa wyrażają wartość lepiej niż liczby.
  • Krawędzie. Uchwyt ma obramowanie 2 px w kolorze marki, które zapewnia mu kontrast co najmniej 3:1 na każdej powierzchni (WCAG 1.4.11). Tor nie jest potrzebny do rozpoznania kontrolki, więc może pozostać stonowany.
  • Rozmiar celu. Uchwyty mają 16 px, a ich przezroczysty obszar klikalny — 32 × 32 px; po najechaniu i podczas przeciągania lekko się powiększają.

Dokumentacja API

Slider

Renderuje <div role="group"> z torem i jednym uchwytem na każdą wartość. Przyjmuje wszystkie propsy Slider.Root z Base UI.

PropTypDomyślnie
value

Wartość albo wartości zakresu. W trybie kontrolowanym używaj razem z onValueChange.

number | number[]Brak wartości domyślnej
defaultValue

Wartość początkowa w trybie niekontrolowanym. Tablica tworzy zakres.

number | number[]Brak wartości domyślnej
onValueChange

Wywoływana bez przerwy, gdy wartość się zmienia.

(value, details) => voidBrak wartości domyślnej
onValueCommitted

Wywoływana raz, gdy kończy się przeciąganie lub naciśnięcie klawisza — używaj jej do zapisu.

(value, details) => voidBrak wartości domyślnej
min

Najmniejsza wartość.

number0
max

Największa wartość.

number100
step

Krok — wartości są przyciągane do jego wielokrotności.

number1
largeStep

Duży krok — dla klawiszy Page Up/Down i Shift+strzałka.

number10
minStepsBetweenValues

Minimalna odległość między uchwytami zakresu, w krokach.

number0
orientation

Kierunek toru.

"horizontal" | "vertical""horizontal"
format

Formatuje wartość odczytywaną przez technologie wspomagające (aria-valuetext).

Intl.NumberFormatOptionsBrak wartości domyślnej
aria-label

Dostępna nazwa przekazywana do uchwytów.

stringBrak wartości domyślnej
aria-labelledby

Id widocznej etykiety, przekazywany do uchwytów.

stringBrak wartości domyślnej
getAriaLabel

Nadaje osobną nazwę każdemu uchwytowi zakresu.

(index: number) => stringBrak wartości domyślnej
getAriaValueText

Własny tekst, który czytnik ekranu odczytuje jako wartość.

(formatted, value, index) => stringBrak wartości domyślnej
thumbCollisionBehavior

Co się dzieje, gdy uchwyty zakresu się spotkają.

"push" | "swap" | "none""push"
thumbAlignment

Położenie uchwytu przy wartości minimalnej i maksymalnej. prfct domyślnie używa edge, przy którym uchwyt nie wychodzi poza tor; domyślna wartość w Base UI to center.

"center" | "edge" | "edge-client-only""edge"
name

Wysyła wartość z formularzem.

stringBrak wartości domyślnej
disabled

Ignoruje interakcję i przygasza suwak.

booleanfalse