Segmented Control
ProntoBotão segmentado com pílula deslizante (estilo iOS). Toggle entre 2-5 opções de mesma categoria com mudança imediata.
Usar quando
Toggle entre views (Lista/Quadro/Calendário). Ranges temporais (Mês/Trimestre/Ano). Filtros mutuamente excludentes.
Não usar quando
Mais de 5 opções (use Tabs ou Select). Multi-seleção (use Checkbox). Sem mudança imediata (use Radio + Submit).
Variantes
Microinterações
| Microinteração | Disparada por | Comportamento | Timing |
|---|---|---|---|
| Pílula slide | click outra opção | Background ink desliza para nova posição | 300ms cubic-bezier(0.32,0.72,0,1) |
| Color invert | opção ativa | Texto inkMuted → surface (branco), background ink | 150ms ease |
| Hover (não-ativo) | mouseenter em inactive | Color → ink (mais escuro) | 150ms ease |
| Keyboard nav | ← → | Move seleção para opção adjacente | instant |
Acessibilidade
Acessibilidade — checklist
Teclado
| Tab | Foco no controle |
| ← / → | Move seleção entre opções |
| Home / End | Primeira / última |
ARIA esperado
- role="radiogroup" no container
- role="radio" + aria-checked="true|false" em cada
- aria-label="Período" no group
Notas
- Use radiogroup, NÃO tablist (semântica diferente).
- Mudança aplica imediato — sem botão Salvar.
- Em mobile, toque-friendly (mínimo 32px de altura).
- Pílula deslizante é parte da experiência — não pule animação.
Código
'use client';
import { useState } from 'react';
import { T } from '@/lib/tokens';
export function SegmentedControl({ options, value, onChange }) {
const idx = options.findIndex(o => o.id === value);
return (
<div role="radiogroup" style={{
display: 'inline-flex', position: 'relative',
background: T.surface3, padding: 4, borderRadius: 980,
}}>
<span style={{
position: 'absolute', top: 4, bottom: 4,
left: `calc(${(idx * 100) / options.length}% + 4px)`,
width: `calc(${100 / options.length}% - 8px)`,
background: T.ink, borderRadius: 980,
transition: 'left 300ms cubic-bezier(0.32,0.72,0,1)',
}} />
{options.map(o => (
<button key={o.id} role="radio" aria-checked={value === o.id}
onClick={() => onChange(o.id)}
style={{ position: 'relative', zIndex: 1, padding: '6px 14px',
color: value === o.id ? T.surface : T.inkMuted }}>
{o.label}
</button>
))}
</div>
);
}Regras
Faça
- ✓Pílula deslizante (essência do componente).
- ✓Background surface3, pílula ink.
- ✓Texto inkMuted (off) / surface (on).
- ✓Aplica imediato.
- ✓Tamanho sm 24px / md 32px / lg 40px de altura.
Não faça
- ✗Não use para >5 opções (caos visual).
- ✗Não force submit após selecionar.
- ✗Não esconda animação (pílula precisa deslizar).
- ✗Não use cor decorativa nas opções.
- ✗Não tenha labels muito longos (caps em ~12 caracteres).
Tokens usados
| Token | Valor | Papel |
|---|---|---|
T.ink | #0A0A0A | pílula ativa |
T.surface3 | #F3F0E6 | background do track |
T.surface | #FFFFFF | texto da opção ativa |
T.inkMuted | #5A5A5E | opções inativas |
RADIUS.pill | 980 | forma |
MOTION.base | 250ms | pílula slide |