Radio
Picks exactly one option in a group; the previous choice clears. Mutually exclusive settings.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Mutually exclusive plan:
```tsx import { Radio } from '@/components/atoms/Radio';
<fieldset> <legend>Billing period</legend> <Radio name="period" label="Monthly" checked={period === 'm'} onChange={() => setPeriod('m')} /> <Radio name="period" label="Yearly" checked={period === 'y'} onChange={() => setPeriod('y')} /> </fieldset> ```
## Accesibilidad
- Share the same `name` across options in a group; prefer a `fieldset` + `legend` (or `Field`) for the group name. - Built-in `label` associates text with the control; do not rely on placeholder text alone.
### Correcto
- Input nativo `<input type='radio'>` — screen readers lo leen como radio button - name compartido entre opciones del mismo grupo para exclusion mutua nativa - Touch target 40x40px cumple WCAG - Arrow keys navegan entre opciones del mismo name (comportamiento nativo) - Label clickeable — click en el texto selecciona la opcion - focus-visible ring solo por teclado
### Evitar
- No usar Radio sin name — las opciones no seran mutuamente excluyentes - No usar Radio para toggles on/off — usar Checkbox o Toggle en su lugar - No usar un solo Radio — siempre son 2+ opciones en un grupo
## Cuándo no usar
- Multi-select → `Checkbox` / `ToggleGroup type="multiple"`. - On/off for a single preference → `Toggle` / one `Checkbox`.
## Criterio de uso
- Úsalo cuando las opciones son mutuamente excluyentes y el usuario necesita comparar alternativas antes de elegir una. - Muestra todas las opciones relevantes cuando el conjunto es pequeño; para listas largas considera `Select` o `Combobox`. - Mantén una opción seleccionada cuando el formulario exige una elección y valida el grupo como una unidad.
## Gotchas
- Todos los radios del grupo comparten `name` y deben vivir bajo un `fieldset` con `legend` o un label equivalente. - No dependas del placeholder ni de una diferencia de color para comunicar la opción seleccionada. - **Nota**: La unica diferencia visual con Checkbox: border-radius 9999px (circular vs 4px cuadrado) y dot interno con scale animation en vez de check/minus SVG icon. - **Nota**: CSS autocontenido. La animacion del dot (scale 0→1) es puro CSS via :checked selector — no necesita JS para la transicion.
Uso
import { Radio, RadioGroup } from '@/components/atoms/Radio';
<RadioGroup value={v} onValueChange={setV}>
<Radio value="a" label="A" />
<Radio value="b" label="B" />
</RadioGroup>Props
checked: booleandisabled: booleanerror: booleanlabel: stringonChange: (checked: boolean) => void
Gotchas
- a11y
Group radios with the same name and a fieldset/legend or Field group label.
Anatomía CSS
<div class="radio"> <span class="radio__circle"></span> <span class="radio__dot"></span> <span class="radio__input"></span> <span class="radio__label"></span> </div>
| Clase | Propósito |
|---|---|
radio | root |
radio--disabled | modifier |
radio--error | modifier |
radio__circle | element |
radio__dot | element |
radio__input | element |
radio__label | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #0a0a0a | checkbox.bg.checked |
| all | border | #0a0a0a | checkbox.bg.checked |
| all | hover-bg | #262626 | checkbox.bg.checked.hover |
| all | disabled-bg | #525252 | checkbox.fg.disabled |
| all | disabled-border | #f5f5f5 | checkbox.bg.disabled |
| all | error-border | #f84131 | checkbox.border.error |
| all | fg | #0a0a0a | foreground |
| all | disabled-fg | #525252 | muted.foreground |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | var(--duration-150) | var(--easing-out) |
border-color | var(--duration-150) | var(--easing-out) |
box-shadow | var(--duration-150) | var(--easing-out) |
transform | var(--duration-150) | var(--easing-out) |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Radio
Single size (16px circle, 40px touch target).
Native <input type="radio"> hidden, visual replacement via label.
Shares color tokens with Checkbox (same palette).
States: unchecked, checked, disabled, error
------------------------------------------------------------------------- */
.radio {
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
position: relative;
}
.radio--disabled {
cursor: not-allowed;
pointer-events: none;
}
/* Hidden native input with touch target */
.radio__input {
position: absolute;
width: 2.5rem;
height: 2.5rem;
margin: 0;
padding: 0;
opacity: 0;
cursor: inherit;
left: -0.75rem;
top: 50%;
transform: translateY(-50%);
}
/* Visual circle */
.radio__circle {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
flex-shrink: 0;
border-radius: 9999px;
border: 1.5px solid #525252;
background-color: #fafafa;
transition:
background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Inner dot */
.radio__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background-color: #fafafa;
transform: scale(0);
transition: transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* ---- Hover ---- */
.radio__input:hover:not(:disabled) ~ .radio__circle {
background-color: #f5f5f5;
}
.radio__input:checked:hover:not(:disabled) ~ .radio__circle {
background-color: #262626;
}
/* ---- Checked ---- */
.radio__input:checked ~ .radio__circle {
background-color: #0a0a0a;
border-color: #0a0a0a;
}
.radio__input:checked ~ .radio__circle .radio__dot {
transform: scale(1);
}
/* ---- Focus ---- */
.radio__input:focus-visible ~ .radio__circle {
box-shadow: 0 0 0 2px #a1a1a1b3;
}
/* ---- Disabled ---- */
.radio__input:disabled ~ .radio__circle {
background-color: #f5f5f5;
border-color: #f5f5f5;
}
.radio__input:disabled ~ .radio__circle .radio__dot {
background-color: #525252;
}
/* ---- Error ---- */
.radio--error .radio__circle {
border-color: #f84131;
}
/* ---- Label ---- */
.radio__label {
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
font-size: 12.8px;
line-height: 1;
color: #0a0a0a;
}
.radio--disabled .radio__label {
color: #525252;
}
Codigo fuente
import { forwardRef, type InputHTMLAttributes } from 'react'; export type RadioProps = { checked?: boolean; disabled?: boolean; error?: boolean; label?: string; className?: string; onChange?: (checked: boolean) => void; } & Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'checked' | 'onChange'>; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } export const Radio = forwardRef<HTMLInputElement, RadioProps>( ( { checked = false, disabled = false, error = false, label, className, onChange, ...props }, ref, ) => { const classes = cn( 'radio', disabled && 'radio--disabled', error && 'radio--error', className, ); return ( <label className={classes}> <input ref={ref} type="radio" className="radio__input" checked={checked} disabled={disabled} onChange={(e) => onChange?.(e.target.checked)} {...props} /> <span className="radio__circle"> <span className="radio__dot" /> </span> {label && <span className="radio__label">{label}</span>} </label> ); }, ); Radio.displayName = 'Radio';