Select
Pozwala wybrać jedną opcję — albo kilka — z listy, która pozostaje schowana, dopóki nie jest potrzebna.
import { Field, FieldLabel } from "@/components/ui/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const regions = [
{ label: "Wybierz region", value: null },
{ label: "Frankfurt (eu-central-1)", value: "fra1" },
{ label: "Londyn (eu-west-2)", value: "lhr1" },
{ label: "Waszyngton (us-east-1)", value: "iad1" },
{ label: "San Francisco (us-west-1)", value: "sfo1" },
{ label: "Tokio (ap-northeast-1)", value: "hnd1" },
]
export default function SelectDemo() {
return (
<Field className="w-72">
<FieldLabel htmlFor="region">Region wdrożenia</FieldLabel>
<Select items={regions} defaultValue="fra1">
<SelectTrigger id="region" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{regions.slice(1).map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
)
}Anatomia
- 1WyzwalaczWygląda jak pole tekstowe i ma te same wysokości, więc w formularzu równa się z pozostałymi polami.
- 2WartośćEtykieta wybranej opcji — albo placeholder w przygaszonym kolorze, gdy nic nie jest wybrane.
- 3IkonaStrzałki w górę i w dół: lista otwiera się, przykrywając wyzwalacz, a wybrana opcja wypada dokładnie w jego miejscu.
Instalacja
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/select.jsonUżycie
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"const regions = [
{ label: "Select a region", value: null },
{ label: "Frankfurt", value: "fra1" },
{ label: "Tokyo", value: "hnd1" },
]
<Select items={regions}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="fra1">Frankfurt</SelectItem>
<SelectItem value="hnd1">Tokyo</SelectItem>
</SelectGroup>
</SelectContent>
</Select>Przekaż items do komponentu głównego. Dzięki temu SelectValue wyświetla etykietę wybranej opcji zamiast jej surowej wartości, a element z value: null staje się placeholderem widocznym, dopóki nic nie jest wybrane. Umieszczaj ten element na liście tylko wtedy, gdy wyczyszczenie wyboru to pełnoprawna opcja — i wtedy nazwij go jak opcję, na przykład Brak roli.
Przykłady
Grupy
Długie listy podziel na grupy z etykietami i oddziel je separatorami. Etykiety niech będą krótkimi rzeczownikami — to nagłówki, a nie opcje, więc nie da się ich wybrać.
import * as React from "react"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const timezones = [
{
region: "Ameryka",
zones: [
{ label: "Nowy Jork (GMT−5)", value: "america/new_york" },
{ label: "Chicago (GMT−6)", value: "america/chicago" },
{ label: "Los Angeles (GMT−8)", value: "america/los_angeles" },
{ label: "São Paulo (GMT−3)", value: "america/sao_paulo" },
],
},
{
region: "Europa",
zones: [
{ label: "Londyn (GMT+0)", value: "europe/london" },
{ label: "Berlin (GMT+1)", value: "europe/berlin" },
{ label: "Warszawa (GMT+1)", value: "europe/warsaw" },
],
},
{
region: "Azja i Pacyfik",
zones: [
{ label: "Singapur (GMT+8)", value: "asia/singapore" },
{ label: "Tokio (GMT+9)", value: "asia/tokyo" },
{ label: "Sydney (GMT+11)", value: "australia/sydney" },
],
},
]
const items = [
{ label: "Wybierz strefę czasową", value: null },
...timezones.flatMap((group) => group.zones),
]
export default function SelectGroups() {
return (
<Select items={items}>
<SelectTrigger className="w-64" aria-label="Strefa czasowa">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timezones.map((group, index) => (
<React.Fragment key={group.region}>
{index > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>{group.region}</SelectLabel>
{group.zones.map((zone) => (
<SelectItem key={zone.value} value={zone.value}>
{zone.label}
</SelectItem>
))}
</SelectGroup>
</React.Fragment>
))}
</SelectContent>
</Select>
)
}Rozmiary
Wyzwalacz ma rozmiary sm, default i lg — te same wysokości 32, 36 i 40 px co Input i Button, więc pasek filtrów układa się w równej linii bez ani jednej własnej klasy.
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const periods = [
{ label: "Ostatnie 7 dni", value: "7d" },
{ label: "Ostatnie 30 dni", value: "30d" },
{ label: "Ostatnie 90 dni", value: "90d" },
]
const sizes = ["sm", "default", "lg"] as const
export default function SelectSizes() {
return (
<div className="flex flex-col items-center gap-4">
{sizes.map((size) => (
<div key={size} className="flex items-center gap-2">
<Select items={periods} defaultValue="30d">
<SelectTrigger size={size} className="w-40" aria-label="Okres">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{periods.map((period) => (
<SelectItem key={period.value} value={period.value}>
{period.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input size={size} placeholder="Filtruj zdarzenia" className="w-40" />
<Button size={size} variant="outline">
Eksportuj
</Button>
</div>
))}
</div>
)
}Wyrównanie do opcji i tryb popper
Domyślnie lista otwiera się wyrównana do wyzwalacza: wybrana opcja leży dokładnie na wyzwalaczu, jak w natywnym menu macOS, więc wzrok nie musi nigdzie wędrować. Ustaw alignItemWithTrigger={false} na SelectContent, aby uzyskać klasyczną listę rozwijaną, która otwiera się pod spodem — to lepszy wybór przy dolnej krawędzi okna, w gęstych paskach narzędzi i wszędzie tam, gdzie zasłonięcie wyzwalacza ukryłoby kontekst.
Zaznaczona opcja nakrywa wyzwalacz — jak w natywnym menu.
Lista rozwija się pod wyzwalaczem i nigdy go nie zasłania.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const fonts = [
{ label: "Inter", value: "inter" },
{ label: "Geist Mono", value: "geist-mono" },
{ label: "System UI", value: "system" },
{ label: "Georgia", value: "georgia" },
]
function FontSelect({ popper }: { popper?: boolean }) {
return (
<Select items={fonts} defaultValue="geist-mono">
<SelectTrigger
id={popper ? "font-popper" : "font-aligned"}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={!popper}>
<SelectGroup>
{fonts.map((font) => (
<SelectItem key={font.value} value={font.value}>
{font.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export default function SelectPositioning() {
return (
<div className="grid w-full max-w-lg gap-8 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="font-aligned">Wyrównanie do opcji</FieldLabel>
<FontSelect />
<FieldDescription>
Zaznaczona opcja nakrywa wyzwalacz — jak w natywnym menu.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="font-popper">Popper</FieldLabel>
<FontSelect popper />
<FieldDescription>
Lista rozwija się pod wyzwalaczem i nigdy go nie zasłania.
</FieldDescription>
</Field>
</div>
)
}Wielokrotny wybór
Ustaw multiple, a do defaultValue przekaż tablicę. Funkcja przekazana do SelectValue pozwala zdecydować, ile wybranych opcji wymienić z nazwy, zanim zastąpi je podsumowanie — długie listy po przecinku zostają ucięte i przestają być czytelne.
"use client"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const channels = [
{ label: "E-mail", value: "email" },
{ label: "Powiadomienia push", value: "push" },
{ label: "SMS", value: "sms" },
{ label: "Slack", value: "slack" },
{ label: "Microsoft Teams", value: "teams" },
]
export default function SelectMultiple() {
return (
<Select items={channels} multiple defaultValue={["email", "slack"]}>
<SelectTrigger className="w-64" aria-label="Kanały powiadomień">
<SelectValue>
{(value: string[]) => {
if (value.length === 0) return "Wybierz kanały"
if (value.length === 1) {
return channels.find((channel) => channel.value === value[0])
?.label
}
return `Kanały: ${value.length}`
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{channels.map((channel) => (
<SelectItem key={channel.value} value={channel.value}>
{channel.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}<Select items={channels} multiple defaultValue={["email", "slack"]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) =>
value.length === 1 ? labelFor(value[0]) : `${value.length} channels`
}
</SelectValue>
</SelectTrigger>
…
</Select>Rozbudowane opcje i obiekty jako wartości
Opcja może zawierać więcej niż samą etykietę. Gdy wartości są obiektami, za pomocą itemToStringValue powiedz liście wyboru, jak serializować je na potrzeby formularzy, a wybraną opcję wyrenderuj sam przez funkcję renderującą SelectValue. Klasa h-auto pozwoli wyzwalaczowi urosnąć: rozmiar to zwykła klasa wysokości, więc wysokość podana w className ją zastępuje.
"use client"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const plans = [
{
name: "Hobby",
price: "0 $",
description: "Do projektów osobistych i eksperymentów.",
},
{
name: "Pro",
price: "20 $",
description: "Dla profesjonalistów wdrażających na produkcję.",
},
{
name: "Team",
price: "60 $",
description: "Wspólne przestrzenie robocze, role i dzienniki audytu.",
},
]
type Plan = (typeof plans)[number]
function PlanOption({ plan }: { plan: Plan }) {
return (
<span className="flex w-full flex-col items-start gap-0.5 text-left">
<span className="flex w-full items-baseline justify-between gap-4 text-sm font-medium text-foreground">
{plan.name}
<span className="font-normal text-muted-foreground tabular-nums">
{plan.price}/mies.
</span>
</span>
<span className="text-xs whitespace-normal text-muted-foreground">
{plan.description}
</span>
</span>
)
}
export default function SelectRichItems() {
return (
<Select
defaultValue={plans[1]}
itemToStringValue={(plan: Plan) => plan.name}
>
<SelectTrigger
className="h-auto w-full max-w-80 py-2.5"
aria-label="Plan"
>
<SelectValue>{(plan: Plan) => <PlanOption plan={plan} />}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{plans.map((plan) => (
<SelectItem key={plan.name} value={plan} className="py-2">
<PlanOption plan={plan} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}Z ikonami i wyłączonymi opcjami
Elementy wizualne na początku opcji — kropki statusu, flagi, awatary — ułatwiają szybkie przejrzenie listy. Umieść je zarówno w opcji, jak i w funkcji renderującej wartość, żeby wyzwalacz odzwierciedlał wybór. Wyłączone opcje pozostają widoczne, więc wiadomo, że istnieją, ale nie da się ich podświetlić ani wybrać.
"use client"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
const statuses = [
{
label: "Backlog",
value: "backlog",
dot: "border border-gray-9 bg-transparent",
},
{ label: "Do zrobienia", value: "todo", dot: "bg-gray-9" },
{ label: "W toku", value: "in-progress", dot: "bg-warning-9" },
{ label: "W przeglądzie", value: "in-review", dot: "bg-brand-9" },
{ label: "Gotowe", value: "done", dot: "bg-success-9" },
{ label: "Anulowane", value: "canceled", dot: "bg-danger-9", disabled: true },
]
function StatusDot({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={cn("size-2 shrink-0 rounded-full", className)}
/>
)
}
export default function SelectStatus() {
return (
<Select items={statuses} defaultValue="in-progress">
<SelectTrigger className="w-48" aria-label="Status">
<SelectValue>
{(value: string) => {
const status = statuses.find((item) => item.value === value)
return (
<>
<StatusDot className={status?.dot} />
{status?.label}
</>
)
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{statuses.map((status) => (
<SelectItem
key={status.value}
value={status.value}
disabled={status.disabled}
>
<StatusDot className={status.dot} />
{status.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}W formularzu
Połącz z Field, aby dodać etykietę, opis i komunikat błędu. htmlFor w FieldLabel wskazuje id wyzwalacza; name i required wysyłają i walidują wartość jak natywna kontrolka. Aby pokazać błąd, oznacz wyzwalacz atrybutem aria-invalid, a pole — atrybutem data-invalid.
Używany w e-mailach i w panelu.
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const roles = [
{ label: "Wybierz rolę", value: null },
{ label: "Przeglądający", value: "viewer" },
{ label: "Edytujący", value: "editor" },
{ label: "Administrator", value: "admin" },
{ label: "Właściciel", value: "owner" },
]
const languages = [
{ label: "English", value: "en" },
{ label: "Polski", value: "pl" },
{ label: "Deutsch", value: "de" },
]
export default function SelectForm() {
return (
<FieldGroup className="w-full max-w-sm">
<Field>
<FieldLabel htmlFor="language">Język</FieldLabel>
<Select items={languages} name="language" defaultValue="en">
<SelectTrigger id="language" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{languages.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>Używany w e-mailach i w panelu.</FieldDescription>
</Field>
<Field data-invalid>
<FieldLabel htmlFor="role">Rola</FieldLabel>
<Select items={roles} name="role" required>
<SelectTrigger id="role" className="w-full" aria-invalid>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{roles.slice(1).map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError>Wybierz rolę przed wysłaniem zaproszenia.</FieldError>
</Field>
<Field data-disabled>
<FieldLabel htmlFor="workspace">Przestrzeń robocza</FieldLabel>
<Select
items={[{ label: "Acme Inc.", value: "acme" }]}
defaultValue="acme"
disabled
>
<SelectTrigger id="workspace" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="acme">Acme Inc.</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
)
}Wytyczne
Kiedy używać
- Do wyboru spośród pięciu do piętnastu wzajemnie wykluczających się opcji, których nie trzeba porównywać obok siebie.
- Gdy brakuje miejsca, a bieżący wybór jest ważniejszy niż pozostałe możliwości.
Kiedy nie używać
- Przy dwóch do pięciu opcjach, które mieszczą się na ekranie — pokaż je wszystkie za pomocą Radio Group lub Toggle Group; chowanie ich za kliknięciem spowalnia pracę.
- Przy długich listach, które trzeba przeszukiwać — użyj komponentu Combobox.
- Do wywoływania akcji, takich jak Duplikuj czy Usuń — użyj komponentu Dropdown Menu. Lista wyboru przechowuje wartość, a menu wykonuje polecenie.
- Gdy lepiej sprawdzi się natywny selektor platformy, na przykład w formularzach wypełnianych głównie na telefonach — użyj komponentu Native Select.
Kolejność i sformułowania
Układaj opcje tak, jak myślą o nich ludzie: według częstości, według wielkości (Mały, Średni, Duży) albo alfabetycznie — nigdy w kolejności, w jakiej trafiły do bazy danych. Pisz krótkie etykiety o jednakowej budowie, a placeholder niech opisuje czynność (Wybierz region), a nie pole (Region).
Wartości domyślne
Wybierz wartość z góry, gdy istnieje bezpieczna, typowa odpowiedź — większości osób oszczędzisz w ten sposób jednej decyzji. Zostaw listę pustą, gdy zły wybór ma konsekwencje (kraj rozliczeniowy, poziom uprawnień) — wtedy wybór jest świadomy, a walidacja może wychwycić pominięcie.
Dostępność
Wyzwalacz to button z role="combobox", który steruje rozwijaną listą listbox. Base UI zarządza fokusem, aria-expanded, podświetlaniem z klawiatury i wyszukiwaniem przez wpisywanie, a wartość wysyła przez ukryte pole formularza.
| Klawisz | Działanie |
|---|---|
EnterSpacja↓↑ | Otwiera listę z podświetloną wybraną opcją. Gdy lista jest otwarta, Enter lub Spacja wybiera podświetloną opcję. |
↓↑ | Przesuwa podświetlenie między opcjami, pomijając wyłączone. |
HomeEnd | Podświetla pierwszą lub ostatnią opcję. |
A–Z | Wyszukiwanie przez wpisywanie: przechodzi do następnej opcji, która zaczyna się od wpisanych znaków. |
Esc | Zamyka listę bez zmiany wartości i przywraca fokus na wyzwalacz. |
Tab | Zamyka listę i przenosi fokus do następnej kontrolki. |
- Nadaj etykietę każdej liście wyboru. Powiąż widoczny
FieldLabelz wyzwalaczem przezhtmlForalbo nadaj wyzwalaczowiaria-label, gdy kontekst wizualnie jasno pokazuje jego przeznaczenie (na przykład w pasku narzędzi). - Krawędzie. Obramowanie wyzwalacza używa tokenu
input, który ma kontrast co najmniej 3:1 względem strony, zgodnie z WCAG 1.4.11. Stany zaznaczenia i podświetlenia nigdy nie opierają się wyłącznie na kolorze: wybraną opcję oznacza znacznik wyboru. - Rozmiar celu. Opcje mają co najmniej 32 px wysokości, wyzwalacz również — więcej niż minimum 24 × 24 px z WCAG 2.2.
- Modalność. Otwarta lista jest domyślnie modalna: przewijanie strony jest zablokowane, a kliknięcie poza listą najpierw ją zamyka. Ustaw
modal={false}naSelect, jeśli strona musi pozostać interaktywna.
Dokumentacja API
Select
Komponent główny. Nie renderuje żadnego elementu. Przyjmuje wszystkie propsy Select.Root z Base UI.
SelectTrigger
Przycisk, który pokazuje wartość i otwiera listę. Renderuje <button>.
SelectValue
Wyświetla wybór wewnątrz wyzwalacza. Renderuje <span>.
SelectContent
Pozycjonowany popup z przewijaną listą opcji. Sam renderuje SelectScrollUpButton i SelectScrollDownButton na krawędziach listy; oba są eksportowane na potrzeby własnego popupu.
SelectItem
Opcja. Renderuje <div> z role="option".
SelectGroup, SelectLabel, SelectSeparator
SelectGroup grupuje opcje, SelectLabel nadaje grupie tytuł i musi znajdować się wewnątrz grupy, a SelectSeparator rysuje linię podziału między grupami. Przyjmują propsy swoich odpowiedników z Base UI.