Select
Um seletor de dropdown para escolher entre uma lista de opções. Suporta o comportamento nativo do navegador para acessibilidade e compatibilidade com dispositivos móveis, com variantes personalizadas para casos de uso avançados.
Busque em todas as páginas da documentação
Um seletor de dropdown para escolher entre uma lista de opções. Suporta o comportamento nativo do navegador para acessibilidade e compatibilidade com dispositivos móveis, com variantes personalizadas para casos de uso avançados.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function Select({ label, value, onChange, options }: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}Um select rotulado mínimo. O callback onChange retorna o valor da string diretamente para que o componente pai não precise desempacotar e.target.value. O elemento <select> nativo fornece navegação por teclado integrada e seletores amigáveis para dispositivos móveis gratuitamente.
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
required?: boolean;
}
export function Select({ label, value, onChange, options, required }: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
className="mt-1 block w-full appearance-none rounded-lg border border-gray-300 bg-white bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%236b7280%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.23%207.21a.75.75%200%20011.06.02L10%2011.168l3.71-3.938a.75.75%200%20111.08%201.04l-4.25%204.5a.75.75%200%2001-1.08%200l-4.25-4.5a.75.75%200%2001.02-1.06z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem_1.25rem] bg-[right_0.5rem_center] bg-no-repeat px-3 py-2 pr-10 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}Usa appearance-none para remover a seta padrão do navegador e a substitui por um chevron SVG inline via background-image. Isso oferece controle total sobre a estilização do indicador de dropdown, mantendo o comportamento nativo do select.
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}
export function Select({
label,
value,
onChange,
options,
placeholder = "Select an option",
}: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 ${
value === "" ? "text-gray-400" : "text-gray-900"
}`}
>
<option value="" disabled>
{placeholder}
</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}Uma primeira opção desabilitada atua como placeholder. A cor do texto é cinza quando nada é selecionado e muda para a cor normal assim que uma opção é escolhida. Usar disabled na opção placeholder impede que ela seja re-selecionada após o usuário escolher um valor.
"use client";
interface OptionGroup {
label: string;
options: { value: string; label: string }[];
}
interface GroupedSelectProps {
label: string;
value: string;
onChange: (value: string) => void;
groups: OptionGroup[];
}
export function GroupedSelect({ label, value, onChange, groups }: GroupedSelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="" disabled>
Select an option
</option>
{groups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
</label>
);
}Usa o elemento <optgroup> nativo para agrupar visualmente opções relacionadas sob cabeçalhos em negrito. Isso é ideal para listas longas de opções, como países agrupados por continente ou categorias agrupadas por departamento.
"use client";
import { useState, useRef, useEffect } from "react";
interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
label: string;
options: Option[];
selected: string[];
onChange: (selected: string[]) => void;
}
export function MultiSelect({ label, options, selected, onChange }: MultiSelectProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function toggleOption(value: string) {
onChange(
selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value]
);
}
return (
<div ref={containerRef} className="relative">
<span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<span className={selected.length === 0 ? "text-gray-400" : "text-gray-900"}>
{selected.length === 0
? "Select options"
: `${selected.length} selected`}
</span>
<svg
className={`h-4 w-4 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<ul className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
{options.map((opt) => (
<li key={opt.value}>
<button
type="button"
onClick={() => toggleOption(opt.value)}
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-gray-50"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
selected.includes(opt.value)
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-300"
}`}
>
{selected.includes(opt.value) && (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</span>
{opt.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}Um dropdown multi-select personalizado construído com botões e uma lista pop-up. Clicar fora fecha o dropdown através de um listener mousedown. Cada opção exibe um indicador visual de checkbox. O botão de acionamento mostra a contagem de itens selecionados.
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
interface Option {
value: string;
label: string;
}
interface SearchableSelectProps {
label: string;
options: Option[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function SearchableSelect({
label,
options,
value,
onChange,
placeholder = "Search...",
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(
() => options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())),
[options, query]
);
const selectedLabel = options.find((o) => o.value === value)?.label ?? "";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function handleSelect(optionValue: string) {
onChange(optionValue);
setOpen(false);
setQuery("");
}
return (
<div ref={containerRef} className="relative">
<span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
<button
type="button"
onClick={() => {
setOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<span className={value ? "text-gray-900" : "text-gray-400"}>
{selectedLabel || "Select an option"}
</span>
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<ul className="max-h-60 overflow-auto py-1">
{filtered.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-500">No results found</li>
) : (
filtered.map((opt) => (
<li key={opt.value}>
<button
type="button"
onClick={() => handleSelect(opt.value)}
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 ${
opt.value === value ? "bg-blue-50 font-medium text-blue-600" : "text-gray-900"
}`}
>
{opt.label}
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}Um dropdown com um campo de busca que filtra opções conforme o usuário digita. A opção selecionada é destacada na lista com um fundo azul. O hook useMemo evita a re-filtragem a cada renderização. O foco é movido para o campo de busca ao abrir usando setTimeout para aguardar a atualização do DOM.
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
error?: string;
disabled?: boolean;
}
export function Select({ label, value, onChange, options, error, disabled }: SelectProps) {
return (
<div className="block">
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-invalid={!!error}
className={`mt-1 block w-full rounded-lg border bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
>
<option value="" disabled>
Select an option
</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}Combina valor controlado, exibição de erro e estado desabilitado. O atributo aria-invalid informa leitores de tela sobre o erro de validação. A estilização desabilitada impede a interação, mantendo o valor selecionado visível.
"use client";
import { forwardRef, useId, useState, useRef, useEffect, useCallback, useMemo } from "react";
type SelectSize = "sm" | "md" | "lg";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface OptionGroup {
label: string;
options: Option[];
}
interface SelectProps {
label?: string;
helperText?: string;
error?: string;
size?: SelectSize;
options?: Option[];
groups?: OptionGroup[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
searchable?: boolean;
disabled?: boolean;
fullWidth?: boolean;
id?: string;
}
const sizeClasses: Record<SelectSize, { trigger: string; text: string; option: string }> = {
sm: { trigger: "h-8 px-2.5 text-xs", text: "text-xs", option: "px-2.5 py-1.5 text-xs" },
md: { trigger: "h-10 px-3 text-sm", text: "text-sm", option: "px-3 py-2 text-sm" },
lg: { trigger: "h-12 px-4 text-base", text: "text-base", option: "px-4 py-2.5 text-base" },
};
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
{
label,
helperText,
error,
size = "md",
options = [],
groups,
value,
onChange,
placeholder = "Select an option",
searchable = false,
disabled = false,
fullWidth = true,
id: externalId,
},
ref
) {
const generatedId = useId();
const selectId = externalId ?? generatedId;
const errorId = `${selectId}-error`;
const helperId = `${selectId}-helper`;
const listboxId = `${selectId}-listbox`;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const allOptions = useMemo(() => {
if (groups) {
return groups.flatMap((g) => g.options);
}
return options;
}, [options, groups]);
const filtered = useMemo(() => {
if (!searchable || !query) return allOptions;
return allOptions.filter((o) =>
o.label.toLowerCase().includes(query.toLowerCase())
);
}, [allOptions, query, searchable]);
const selectedLabel = allOptions.find((o) => o.value === value)?.label ?? "";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = useCallback(
(optionValue: string) => {
onChange(optionValue);
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
return;
}
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((i) =>
i < filtered.length - 1 ? i + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((i) =>
i > 0 ? i - 1 : filtered.length - 1
);
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && filtered[highlightedIndex]) {
const opt = filtered[highlightedIndex];
if (!opt.disabled) handleSelect(opt.value);
}
break;
case "Escape":
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
break;
}
},
[open, filtered, highlightedIndex, handleSelect]
);
const hasError = !!error;
const sizes = sizeClasses[size];
return (
<div
ref={containerRef}
className={fullWidth ? "w-full" : "inline-flex flex-col"}
onKeyDown={handleKeyDown}
>
{label && (
<label
htmlFor={selectId}
className={`mb-1 block font-medium text-gray-700 ${sizes.text}`}
>
{label}
</label>
)}
<button
ref={ref}
id={selectId}
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-invalid={hasError}
aria-describedby={
[hasError ? errorId : null, helperText ? helperId : null]
.filter(Boolean)
.join(" ") || undefined
}
disabled={disabled}
onClick={() => {
if (!disabled) {
setOpen((o) => !o);
if (searchable) setTimeout(() => inputRef.current?.focus(), 0);
}
}}
className={[
"flex items-center justify-between rounded-lg border shadow-sm transition-colors",
"focus:outline-none focus:ring-1",
"disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500",
sizes.trigger,
fullWidth ? "w-full" : "",
hasError
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500",
]
.filter(Boolean)
.join(" ")}
>
<span className={value ? "text-gray-900 truncate" : "text-gray-400 truncate"}>
{selectedLabel || placeholder}
</span>
<svg
className={`ml-2 h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="relative">
<div className="absolute z-20 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
{searchable && (
<div className="border-b border-gray-100 p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHighlightedIndex(0);
}}
placeholder="Search..."
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
)}
<ul id={listboxId} role="listbox" className="max-h-60 overflow-auto py-1">
{filtered.length === 0 ? (
<li className={`${sizes.option} text-gray-500`}>No results found</li>
) : (
filtered.map((opt, idx) => (
<li
key={opt.value}
role="option"
aria-selected={opt.value === value}
aria-disabled={opt.disabled}
>
<button
type="button"
onClick={() => !opt.disabled && handleSelect(opt.value)}
className={[
"w-full text-left",
sizes.option,
opt.disabled
? "cursor-not-allowed text-gray-400"
: "hover:bg-gray-50",
opt.value === value ? "bg-blue-50 font-medium text-blue-600" : "",
idx === highlightedIndex ? "bg-gray-100" : "",
]
.filter(Boolean)
.join(" ")}
>
{opt.label}
</button>
</li>
))
)}
</ul>
</div>
</div>
)}
<div className="mt-1">
{hasError && (
<p id={errorId} className={`text-red-600 ${sizes.text}`} role="alert">
{error}
</p>
)}
{!hasError && helperText && (
<p id={helperId} className={`text-gray-500 ${sizes.text}`}>
{helperText}
</p>
)}
</div>
</div>
);
});Aspectos chave:
<select>.role="combobox", aria-expanded, aria-haspopup="listbox" e aria-controls para comunicar o estado do dropdown à tecnologia assistiva.searchable é true, um campo de texto aparece no topo do dropdown e filtra as opções com useMemo para desempenho. O foco é movido automaticamente para o campo ao abrir.options planos ou groups estruturados, achatados internamente com useMemo para que a filtragem e a navegação por teclado funcionem de forma uniforme.aria-disabled e um estilo em tons de cinza, impedindo a seleção enquanto permanecem visíveis na lista.mousedown fecha o dropdown ao clicar fora do contêiner, limpo na desmontagem para evitar vazamentos de memória.Limitações de estilo do select nativo -- O elemento <option> não pode ser estilizado com CSS na maioria dos navegadores. Se você precisar de renderização de opções personalizadas (ícones, cores, descrições), você deve construir um dropdown personalizado em vez de usar o <select> nativo.
Opção placeholder re-selecionável -- Um placeholder <option value="" disabled> não pode ser re-selecionado após o usuário escolher um valor, a menos que você adicione um mecanismo de "limpeza" separado. Considere se sua UX precisa de um botão de reset.
Contexto de empilhamento z-index -- Menus de dropdown personalizados precisam de um z-index alto o suficiente para aparecer acima de outros elementos posicionados. Se o dropdown aparecer atrás de um irmão, verifique overflow: hidden em contêineres ancestrais ou crie um portal.
Experiência móvel com selects personalizados -- O <select> nativo fornece o seletor nativo da plataforma em dispositivos móveis (spinner no Android, roda no iOS). Dropdowns personalizados perdem esse benefício. Considere usar um select nativo no celular e um personalizado no desktop.
Dados do formulário com selects personalizados -- Selects personalizados construídos com botões não enviam valores com a submissão nativa do formulário. Adicione um <input type="hidden" name="..." value={value} /> dentro do formulário para incluir o valor em FormData.
onChange dispara com o mesmo valor -- O <select> nativo não dispara onChange quando o usuário re-seleciona a opção já selecionada. Se você precisar detectar a re-seleção, use um manipulador onClick nas opções em vez disso.
Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥