Toggle Group
Um controle segmentado onde uma ou mais opções podem ser selecionadas de uma linha de botões, comumente usado para alternadores de visualização, filtros e seletores de opções compactos.
Busque em todas as páginas da documentação
Um controle segmentado onde uma ou mais opções podem ser selecionadas de uma linha de botões, comumente usado para alternadores de visualização, filtros e seletores de opções compactos.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}Um grupo de alternância mínimo de seleção única usando role="radiogroup" e role="radio" para acessibilidade. O botão selecionado obtém um fundo azul enquanto os botões não selecionados permanecem brancos. Os utilitários first:rounded-l-lg e last:rounded-r-lg arredondam apenas os cantos externos, criando uma forma de pílula conectada.
"use client";
interface ToggleOption {
value: string;
label: string;
}
interface ToggleGroupProps {
options: ToggleOption[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option.value}
role="radio"
aria-checked={value === option.value}
onClick={() => onChange(option.value)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option.value
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option.label}
</button>
))}
</div>
);
}Separar value de label permite que o texto de exibição seja diferente do valor programático. Isso é comum quando os valores são strings semelhantes a enum ("grid", "list") mas os rótulos precisam ser mais descritivos ou localizados.
"use client";
interface ToggleGroupProps {
options: string[];
values: string[];
onChange: (values: string[]) => void;
}
export function ToggleGroup({ options, values, onChange }: ToggleGroupProps) {
function toggle(option: string) {
if (values.includes(option)) {
onChange(values.filter((v) => v !== option));
} else {
onChange([...values, option]);
}
}
return (
<div className="inline-flex rounded-lg border border-gray-300" role="group" aria-label="Toggle options">
{options.map((option) => (
<button
key={option}
role="checkbox"
aria-checked={values.includes(option)}
onClick={() => toggle(option)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
values.includes(option)
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}A seleção múltipla usa role="checkbox" em vez de role="radio" porque várias opções podem estar ativas simultaneamente. A função toggle adiciona ou remove a opção clicada do array de valores. Este padrão é comum para barras de ferramentas de formatação de texto (negrito, itálico, sublinhado).
"use client";
interface ToggleOption {
value: string;
label: string;
icon: React.ReactNode;
}
interface ToggleGroupProps {
options: ToggleOption[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option.value}
role="radio"
aria-checked={value === option.value}
aria-label={option.label}
onClick={() => onChange(option.value)}
className={`inline-flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option.value
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option.icon}
<span className="hidden sm:inline">{option.label}</span>
</button>
))}
</div>
);
}
// Uso:
// <ToggleGroup
// value={view}
// onChange={setView}
// options={[
// {
// value: "grid",
// label: "Grid",
// icon: <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" /></svg>,
// },
// {
// value: "list",
// label: "List",
// icon: <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>,
// },
// ]}
// />Ícones são exibidos ao lado dos rótulos, com os rótulos ocultos em telas pequenas via hidden sm:inline para economizar espaço. O aria-label garante acessibilidade quando o texto do rótulo não está visível. Este é o padrão padrão para controles de alternância de visualização.
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex gap-1 rounded-full bg-gray-100 p-1" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-all ${
value === option
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
{option}
</button>
))}
</div>
);
}Um controle segmentado em forma de pílula com uma bandeja de fundo. A opção selecionada obtém um fundo branco com uma sombra sutil, criando um efeito de "aba" elevada. O gap-1 e p-1 no contêiner fornecem espaçamento interno uniforme. Este estilo é popular em interfaces inspiradas em iOS e painéis modernos.
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex gap-2" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`rounded-lg border-2 px-4 py-2 text-sm font-medium transition-colors ${
value === option
? "border-blue-600 bg-blue-50 text-blue-700"
: "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}Cada opção é um botão com borda independente com espaçamento entre eles, em vez de uma faixa conectada. O botão selecionado obtém uma borda azul e um fundo tingido. Este estilo funciona bem quando as opções são visualmente pesadas (por exemplo, cartões com descrições) e precisam de mais separação.
"use client";
type GroupSize = "sm" | "md" | "lg";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
size?: GroupSize;
}
const sizeClasses: Record<GroupSize, string> = {
sm: "px-2.5 py-1 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
export function ToggleGroup({ options, value, onChange, size = "md" }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${sizeClasses[size]} ${
value === option
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}Um mapa de tamanho controla o preenchimento e o tamanho da fonte juntos para manter as proporções consistentes em cada tamanho. Pequeno funciona bem em barras de ferramentas densas, médio é o padrão para formulários e grande se adequa a seções principais ou filtros proeminentes.
"use client";
import { forwardRef, useId, useCallback, useRef } from "react";
type SelectionMode = "single" | "multiple";
type GroupSize = "sm" | "md" | "lg";
type GroupVariant = "default" | "pill" | "outline";
interface ToggleOption {
value: string;
label: string;
icon?: React.ReactNode;
disabled?: boolean;
}
interface ToggleGroupBaseProps {
options: ToggleOption[];
size?: GroupSize;
variant?: GroupVariant;
disabled?: boolean;
label?: string;
className?: string;
}
interface SingleToggleGroupProps extends ToggleGroupBaseProps {
mode?: "single";
value: string;
onChange: (value: string) => void;
}
interface MultipleToggleGroupProps extends ToggleGroupBaseProps {
mode: "multiple";
value: string[];
onChange: (value: string[]) => void;
}
type ToggleGroupProps = SingleToggleGroupProps | MultipleToggleGroupProps;
const sizeClasses: Record<GroupSize, string> = {
sm: "px-2.5 py-1 text-xs gap-1.5",
md: "px-4 py-2 text-sm gap-2",
lg: "px-6 py-3 text-base gap-2.5",
};
const variantClasses: Record<GroupVariant, { container: string; active: string; inactive: string; rounded: string }> = {
default: {
container: "inline-flex rounded-lg border border-gray-300",
active: "bg-blue-600 text-white",
inactive: "bg-white text-gray-700 hover:bg-gray-50",
rounded: "first:rounded-l-lg last:rounded-r-lg",
},
pill: {
container: "inline-flex gap-1 rounded-full bg-gray-100 p-1",
active: "bg-white text-gray-900 shadow-sm",
inactive: "text-gray-600 hover:text-gray-900",
rounded: "rounded-full",
},
outline: {
container: "inline-flex gap-2",
active: "border-blue-600 bg-blue-50 text-blue-700",
inactive: "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50",
rounded: "rounded-lg border-2",
},
};
export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(function ToggleGroup(
props,
ref
) {
const {
options,
size = "md",
variant = "default",
disabled = false,
label,
className,
mode = "single",
value,
onChange,
} = props;
const groupId = useId();
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const v = variantClasses[variant];
const isSelected = useCallback(
(optionValue: string) => {
if (mode === "multiple") {
return (value as string[]).includes(optionValue);
}
return value === optionValue;
},
[mode, value]
);
const handleClick = useCallback(
(optionValue: string) => {
if (disabled) return;
if (mode === "multiple") {
const currentValues = value as string[];
const multiOnChange = onChange as (v: string[]) => void;
if (currentValues.includes(optionValue)) {
multiOnChange(currentValues.filter((v) => v !== optionValue));
} else {
multiOnChange([...currentValues, optionValue]);
}
} else {
(onChange as (v: string) => void)(optionValue);
}
},
[disabled, mode, value, onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, index: number) => {
const enabledIndices = options
.map((opt, i) => (!opt.disabled ? i : -1))
.filter((i) => i !== -1);
const currentEnabledIndex = enabledIndices.indexOf(index);
let nextIndex: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
nextIndex = enabledIndices[(currentEnabledIndex + 1) % enabledIndices.length];
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
nextIndex = enabledIndices[(currentEnabledIndex - 1 + enabledIndices.length) % enabledIndices.length];
break;
case "Home":
e.preventDefault();
nextIndex = enabledIndices[0];
break;
case "End":
e.preventDefault();
nextIndex = enabledIndices[enabledIndices.length - 1];
break;
}
if (nextIndex !== null) {
buttonRefs.current[nextIndex]?.focus();
if (mode === "single") {
handleClick(options[nextIndex].value);
}
}
},
[options, mode, handleClick]
);
const role = mode === "single" ? "radiogroup" : "group";
const itemRole = mode === "single" ? "radio" : "checkbox";
return (
<div className={className}>
{label && (
<span id={`${groupId}-label`} className="mb-2 block text-sm font-medium text-gray-700">
{label}
</span>
)}
<div
ref={ref}
role={role}
aria-labelledby={label ? `${groupId}-label` : undefined}
aria-label={label ? undefined : "Toggle group"}
className={`${v.container} ${disabled ? "opacity-50" : ""}`}
>
{options.map((option, index) => {
const selected = isSelected(option.value);
const isDisabled = disabled || !!option.disabled;
return (
<button
key={option.value}
ref={(el) => { buttonRefs.current[index] = el; }}
role={itemRole}
type="button"
aria-checked={selected}
aria-label={option.icon && !option.label ? option.value : undefined}
disabled={isDisabled}
tabIndex={
mode === "single"
? selected || (value === "" && index === 0) ? 0 : -1
: 0
}
onClick={() => handleClick(option.value)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={[
"inline-flex items-center font-medium transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1",
sizeClasses[size],
v.rounded,
isDisabled ? "cursor-not-allowed" : "cursor-pointer",
selected ? v.active : v.inactive,
].join(" ")}
>
{option.icon && <span className="shrink-0">{option.icon}</span>}
{option.label && <span>{option.label}</span>}
</button>
);
})}
</div>
</div>
);
});Principais aspectos:
mode alterna entre SingleToggleGroupProps e MultipleToggleGroupProps, fornecendo tipos TypeScript corretos para value e onChange no call site. O modo único usa uma string, o modo múltiplo usa um array de strings.tabIndex={0}. As teclas de seta movem o foco e a seleção juntas, correspondendo ao padrão de grupo de rádio WAI-ARIA. No modo de seleção múltipla, todos os botões são navegáveis por tabulação.cursor-not-allowed.label visível é fornecida, o grupo a referencia via aria-labelledby para que os leitores de tela anunciem o propósito do grupo. Sem um rótulo, um aria-label genérico é usado como fallback.Usando role="radiogroup" para seleção múltipla -- grupos de rádio permitem apenas uma seleção. Se várias opções puderem estar ativas, use role="group" com role="checkbox" em cada botão em vez de role="radio".
Faltando type="button" dentro de formulários -- botões dentro de um <form> são type="submit" por padrão. Cada botão de alternância precisa de type="button" para evitar o envio do formulário ao clicar.
first: e last: do Tailwind não aplicados -- se os botões estiverem envolvidos em elementos extras (como um <div> para tooltips), as pseudo-classes first: e last: visam os wrappers, não os botões. Aplique o raio da borda ao wrapper em vez disso.
Navegação por teclado sem roving tabindex -- se cada botão tiver tabIndex={0}, o usuário deve pressionar Tab em todas as opções para sair do grupo. Use roving tabindex (tabIndex={-1} em itens não selecionados) para que Tab passe por todo o grupo em uma única pressionada.
Igualdade de referência de array em seleção múltipla -- passar uma nova referência de array a cada renderização (por exemplo, values={[...selected]}) pode causar re-renderizações desnecessárias em componentes filhos. Memorize o array de valores ou use uma referência estável.
Estado selecionado perdido ao remontar -- se o grupo de alternância for renderizado condicionalmente (por exemplo, dentro de um painel de abas), a seleção é redefinida, a menos que o estado seja elevado para um pai ou persistido. Sempre armazene o valor no ancestral estável mais próximo.
Gap inconsistente entre botões conectados -- bordas em botões adjacentes se duplicam, criando uma linha de 2px entre eles. Use border-l-0 em todos os botões, exceto o primeiro, ou use uma única borda no contêiner com o utilitário divide-x.
Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥