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.
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
- 1TorPełny zakres wartości w neutralnym odcieniu.
- 2ZakresWybrana część zakresu, wypełniona kolorem marki — od początku toru albo między dwoma uchwytami.
- 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.jsonUż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.
"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.
"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.
"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.
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.
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.
| Klawisz | Dział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-labelledbywskazującego widoczną etykietę alboaria-label. prfct przekazuje nazwę do uchwytów; w zakresie nazwij każdy uchwyt przezgetAriaLabel. - Opisz wartość.
formatkształtuje tylko wartość odczytywaną. Suwak sam nie renderuje tekstu wartości, więc tę, którą pokazujesz, sformatuj samodzielnie.getAriaValueTextnadpisuje 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.