Radio Group
Um conjunto de botões de rádio mutuamente exclusivos para selecionar uma opção de um grupo. Usa inputs de rádio nativos com atributos name compartilhados para navegação nativa por teclado e acessibilidade.
Casos de Uso
- Seleção de método de pagamento (cartão de crédito, PayPal, transferência bancária)
- Opções de velocidade de envio no checkout
- Seleção de plano ou nível em páginas de preços
- Seleção de gênero ou demografia em formulários de perfil
- Seleção de ordem de classificação (mais recentes, mais antigos, populares)
- Preferência de tema (claro, escuro, sistema)
- Perguntas de enquete ou pesquisa de escolha única
Implementação Mais Simples
"use client";
interface RadioGroupProps {
label: string;
name: string;
options: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroup({ label, name, options, value, onChange }: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-2">
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}Um grupo de rádio mínimo usando <fieldset> e <legend> para agrupamento semântico. Todos os rádios compartilham o mesmo atributo name, que permite ao navegador impor a exclusividade mútua e habilita a navegação por setas entre as opções nativamente.
Variações
Vertical Básico
"use client";
interface Option {
value: string;
label: string;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
error?: string;
}
export function RadioGroup({ label, name, options, value, onChange, error }: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-2">
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={!!error}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</fieldset>
);
}Uma pilha vertical de opções de rádio com estado de erro opcional. O atributo aria-invalid em cada input de rádio comunica problemas de validação para leitores de tela. A mensagem de erro aparece abaixo do grupo inteiro.
Layout Horizontal
"use client";
interface RadioGroupProps {
label: string;
name: string;
options: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroupHorizontal({ label, name, options, value, onChange }: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 flex flex-wrap gap-4">
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}As opções são organizadas em uma linha horizontal usando flex e gap-4. A classe flex-wrap garante que as opções quebrem para a próxima linha em telas estreitas em vez de transbordar. Mais adequado para grupos com 2-4 opções com rótulos curtos.
Com Descrições
"use client";
interface Option {
value: string;
label: string;
description: string;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroupWithDescriptions({
label,
name,
options,
value,
onChange,
}: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-3">
{options.map((opt) => (
<label
key={opt.value}
className="flex cursor-pointer items-start gap-3"
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<div>
<span className="text-sm font-medium text-gray-900">{opt.label}</span>
<p className="text-sm text-gray-500">{opt.description}</p>
</div>
</label>
))}
</div>
</fieldset>
);
}Cada opção tem uma descrição secundária abaixo do rótulo. O input de rádio é alinhado ao topo do bloco de texto com items-start e mt-0.5. Este padrão funciona bem para seleção de planos ou opções de envio onde cada escolha precisa de explicação.
Rádio Estilo Cartão
"use client";
interface Option {
value: string;
label: string;
description: string;
price?: string;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroupCards({
label,
name,
options,
value,
onChange,
}: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-3">
{options.map((opt) => (
<label
key={opt.value}
className={`flex cursor-pointer items-start gap-3 rounded-lg border-2 p-4 transition-colors ${
value === opt.value
? "border-blue-600 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{opt.label}</span>
{opt.price && (
<span className="text-sm font-semibold text-gray-900">{opt.price}</span>
)}
</div>
<p className="mt-1 text-sm text-gray-500">{opt.description}</p>
</div>
</label>
))}
</div>
</fieldset>
);
}Envolve cada opção de rádio em um cartão com uma borda visível que destaca quando selecionado. O cartão selecionado recebe uma borda azul e um fundo azul claro. Um campo price opcional é alinhado à direita na linha do cabeçalho. Este padrão é comum para níveis de preço ou seleção de método de envio.
Com Ícones
"use client";
interface Option {
value: string;
label: string;
icon: React.ReactNode;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroupWithIcons({
label,
name,
options,
value,
onChange,
}: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 grid grid-cols-3 gap-3">
{options.map((opt) => (
<label
key={opt.value}
className={`flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors ${
value === opt.value
? "border-blue-600 bg-blue-50 text-blue-600"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="sr-only"
/>
<span className="h-6 w-6">{opt.icon}</span>
<span className="text-sm font-medium">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}Uma grade de cartões de rádio centrados em ícones com o input de rádio nativo visualmente oculto usando sr-only. O cartão inteiro atua como o alvo do clique. O cartão selecionado é destacado com uma borda azul e cor do ícone. Isso funciona bem para alternância de modo de exibição (grade/lista), seleção de tema ou seletores de categoria.
Opções Desabilitadas
"use client";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroup({ label, name, options, value, onChange }: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-2">
{options.map((opt) => (
<label
key={opt.value}
className={`flex items-center gap-2 ${
opt.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
disabled={opt.disabled}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}Opções individuais podem ser desabilitadas enquanto outras permanecem interativas. A classe opacity-50 e cursor-not-allowed no rótulo fornecem feedback visual claro. Botões de rádio desabilitados são automaticamente ignorados na navegação por teclado pelo navegador.
Implementação Complexa
"use client";
import { forwardRef, useId, createContext, useContext, useCallback } from "react";
type RadioSize = "sm" | "md" | "lg";
type RadioVariant = "default" | "card" | "icon";
interface RadioGroupContextValue {
name: string;
value: string;
onChange: (value: string) => void;
size: RadioSize;
variant: RadioVariant;
disabled: boolean;
}
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
function useRadioGroup() {
const ctx = useContext(RadioGroupContext);
if (!ctx) throw new Error("RadioGroupItem must be used within RadioGroup");
return ctx;
}
interface RadioGroupProps {
label?: string;
helperText?: string;
error?: string;
name: string;
value: string;
onChange: (value: string) => void;
size?: RadioSize;
variant?: RadioVariant;
disabled?: boolean;
orientation?: "vertical" | "horizontal";
children: React.ReactNode;
id?: string;
}
const sizeClasses: Record<RadioSize, { radio: string; label: string; desc: string }> = {
sm: { radio: "h-3.5 w-3.5", label: "text-xs", desc: "text-xs" },
md: { radio: "h-4 w-4", label: "text-sm", desc: "text-sm" },
lg: { radio: "h-5 w-5", label: "text-base", desc: "text-sm" },
};
export function RadioGroup({
label,
helperText,
error,
name,
value,
onChange,
size = "md",
variant = "default",
disabled = false,
orientation = "vertical",
children,
id: externalId,
}: RadioGroupProps) {
const generatedId = useId();
const groupId = externalId ?? generatedId;
const errorId = `${groupId}-error`;
const helperId = `${groupId}-helper`;
const sizes = sizeClasses[size];
return (
<RadioGroupContext.Provider value={{ name, value, onChange, size, variant, disabled }}>
<fieldset
aria-invalid={!!error}
aria-describedby={
[error ? errorId : null, helperText ? helperId : null]
.filter(Boolean)
.join(" ") || undefined
}
disabled={disabled}
>
{label && (
<legend className={`font-medium text-gray-700 ${sizes.label}`}>
{label}
</legend>
)}
<div
role="radiogroup"
className={[
"mt-2",
orientation === "horizontal" ? "flex flex-wrap gap-4" : "space-y-3",
].join(" ")}
>
{children}
</div>
{error && (
<p id={errorId} className={`mt-2 text-red-600 ${sizes.desc}`} role="alert">
{error}
</p>
)}
{!error && helperText && (
<p id={helperId} className={`mt-2 text-gray-500 ${sizes.desc}`}>
{helperText}
</p>
)}
</fieldset>
</RadioGroupContext.Provider>
);
}
interface RadioGroupItemProps {
value: string;
label: string;
description?: string;
icon?: React.ReactNode;
disabled?: boolean;
}
export const RadioGroupItem = forwardRef<HTMLInputElement, RadioGroupItemProps>(
function RadioGroupItem({ value: itemValue, label, description, icon, disabled: itemDisabled }, ref) {
const { name, value, onChange, size, variant, disabled: groupDisabled } = useRadioGroup();
const id = useId();
const descriptionId = `${id}-desc`;
const isDisabled = groupDisabled || itemDisabled;
const isSelected = value === itemValue;
const sizes = sizeClasses[size];
const handleChange = useCallback(() => {
if (!isDisabled) onChange(itemValue);
}, [isDisabled, onChange, itemValue]);
if (variant === "card") {
return (
<label
className={[
"flex items-start gap-3 rounded-lg border-2 p-4 transition-colors",
isDisabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer",
isSelected
? "border-blue-600 bg-blue-50"
: isDisabled
? "border-gray-200"
: "border-gray-200 hover:border-gray-300",
]
.filter(Boolean)
.join(" ")}
>
<input
ref={ref}
id={id}
type="radio"
name={name}
value={itemValue}
checked={isSelected}
onChange={handleChange}
disabled={isDisabled}
aria-describedby={description ? descriptionId : undefined}
className={`mt-0.5 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed ${sizes.radio}`}
/>
<div>
<span className={`font-medium text-gray-900 ${sizes.label}`}>{label}</span>
{description && (
<p id={descriptionId} className={`mt-0.5 text-gray-500 ${sizes.desc}`}>
{description}
</p>
)}
</div>
</label>
);
}
if (variant === "icon" && icon) {
return (
<label
className={[
"flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors",
isDisabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer",
isSelected
? "border-blue-600 bg-blue-50 text-blue-600"
: isDisabled
? "border-gray-200 text-gray-400"
: "border-gray-200 text-gray-500 hover:border-gray-300",
]
.filter(Boolean)
.join(" ")}
>
<input
ref={ref}
id={id}
type="radio"
name={name}
value={itemValue}
checked={isSelected}
onChange={handleChange}
disabled={isDisabled}
className="sr-only"
/>
<span className="h-6 w-6">{icon}</span>
<span className={`font-medium ${sizes.label}`}>{label}</span>
</label>
);
}
return (
<label
className={`flex items-start gap-3 ${
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<input
ref={ref}
id={id}
type="radio"
name={name}
value={itemValue}
checked={isSelected}
onChange={handleChange}
disabled={isDisabled}
aria-describedby={description ? descriptionId : undefined}
className={`mt-0.5 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed ${sizes.radio}`}
/>
<div>
<span className={`font-medium text-gray-700 ${sizes.label}`}>{label}</span>
{description && (
<p id={descriptionId} className={`text-gray-500 ${sizes.desc}`}>
{description}
</p>
)}
</div>
</label>
);
}
);Aspectos Chave:
- Padrão de componente composto --
RadioGroupeRadioGroupItemcompartilham estado via contexto do React. Isso mantém a API limpa: o pai detémvalueeonChange, enquanto cada item declara apenas seu própriovalueelabel. - forwardRef em itens -- permite que componentes pais anexem refs a inputs de rádio individuais para gerenciamento de foco ou controle programático.
- Três variantes visuais --
defaultrenderiza rádios padrão com rótulos,cardenvolve cada opção em um cartão com borda, eiconcentraliza um ícone com um input de rádio oculto. A variante é definida uma vez no grupo e se aplica a todos os itens. - Desabilitado em cascata -- desabilitar o grupo desabilita todos os itens via atributo
<fieldset disabled>e contexto. Itens individuais também podem ser desabilitados independentemente. - Suporte à orientação --
verticalusaspace-y-3para layout empilhado,horizontalusaflexcom quebra de linha. A orientação é definida no contêiner do grupo. - useId para acessibilidade -- cada item gera seu próprio ID estável para vincular rótulos e descrições. O
aria-describedbyno nível do grupo conecta mensagens de erro e de ajuda ao fieldset. - aria-describedby em itens -- itens individuais se vinculam ao seu texto de descrição para que os leitores de tela anunciem as informações secundárias quando o rádio recebe foco.
Armadilhas
-
Todos os rádios em um grupo devem compartilhar o mesmo nome -- Se mesmo um rádio tiver um
namediferente, o navegador o tratará como um grupo separado e não imporá a exclusividade mútua. Este é o bug mais comum com grupos de rádio. -
Rádios não controlados não podem ser desmarcados -- Ao contrário das caixas de seleção, um botão de rádio não pode ser desmarcado clicando nele novamente. Uma vez que uma seleção é feita em um grupo de rádio, o usuário só pode alterá-la, não limpá-la. Adicione uma opção "Nenhum" se a limpeza for necessária.
-
Faltando fieldset/legend prejudica a acessibilidade -- Leitores de tela usam
<fieldset>e<legend>para anunciar o rótulo do grupo antes da primeira opção. Sem eles, os usuários ouvem rótulos de rádio individuais sem contexto sobre o que o grupo representa. -
Inputs de rádio ocultos precisam de estilos de foco no cartão -- Ao usar
sr-onlypara ocultar rádios em variantes de cartão ou ícone, os indicadores de foco do teclado desaparecem. Adicionehas-[:focus-visible]:ring-2no rótulo do cartão (Tailwind v3.4+) ou gerencie um estado focado manualmente. -
Setas vs Tab para navegação -- Dentro de um grupo de rádio, as setas movem entre as opções e o Tab move para o próximo elemento focável fora do grupo. Se apenas um rádio for focável (o selecionado), o Tab pula todo o grupo. Este é o comportamento correto do navegador, não um bug.
-
defaultValue com grupos de rádio controlados -- Usar
defaultCheckedem rádios individuais enquanto também passa umvaluecontrolado causa conflito de estado. Sempre use controlado (checked) ou não controlado (defaultChecked) consistentemente em todos os rádios do grupo.
Relacionados
- Checkbox -- Alternador booleano para cenários de multi-seleção
- Select -- Alternativa de dropdown para listas longas de opções
- Card -- Componente de cartão usado como base para opções de rádio estilo cartão
- Forms -- Padrões de formulário e estratégias de validação
- Event Handling -- onChange e tipagem de eventos