Explorer
KNOW-PAT-113

Web UI/UX - Custom Accessible Form Controls : radio/checkbox sans lib, sr-only input, styled span

Domaine
web-uiux
Type
pattern
Priorité
P1

Parent : [[INDEX-WEB-UIUX]]

Web UI/UX - Custom Accessible Form Controls : radio/checkbox sans lib, sr-only input, styled span

Problème

Les <input type="radio"> et <input type="checkbox"> natifs sont moches et impossibles à styler cohéremment cross-browser. Importer une lib de composants (Radix, MUI) alourdit le bundle pour 2 composants simples.

Solution

Pattern "hidden native input + styled visual span" : l'input natif est caché (sr-only) mais reste fonctionnel (keyboard, screen reader, form submission). Le span visible affiche l'état.

// Radio custom
function Radio({ name, checked, onChange, label }: {
  name: string; checked: boolean; onChange: () => void; label: string
}) {
  return (
    <label className="flex items-center gap-2.5 cursor-pointer group py-1">
      <span
        className="w-3.5 h-3.5 rounded-full border flex items-center justify-center shrink-0 transition-colors"
        style={{ borderColor: checked ? 'var(--accent)' : 'var(--border-hover)', background: checked ? 'var(--accent)' : 'transparent' }}
      >
        {checked && <span className="w-1.5 h-1.5 rounded-full bg-white block" />}
      </span>
      <input type="radio" name={name} checked={checked} onChange={onChange} className="sr-only" />
      <span className="text-xs transition-colors" style={{ color: checked ? 'var(--text)' : 'var(--text-2)' }}>{label}</span>
    </label>
  )
}

// Checkbox custom
function Checkbox({ checked, onChange, label }: {
  checked: boolean; onChange: () => void; label: string
}) {
  return (
    <label className="flex items-center gap-2.5 cursor-pointer py-1">
      <span
        className="w-3.5 h-3.5 rounded-[3px] border flex items-center justify-center shrink-0 transition-colors"
        style={{ borderColor: checked ? 'var(--accent)' : 'var(--border-hover)', background: checked ? 'var(--accent)' : 'transparent' }}
      >
        {checked && <span className="text-white text-[9px] font-bold leading-none">✓</span>}
      </span>
      <input type="checkbox" checked={checked} onChange={onChange} className="sr-only" />
      <span className="text-xs transition-colors" style={{ color: checked ? 'var(--text)' : 'var(--text-2)' }}>{label}</span>
    </label>
  )
}

Règles d'accessibilité

  1. className="sr-only" sur l'input natif : invisible visuellement mais accessible aux screen readers
  2. <label> englobe tout : clic sur le texte OU le span toggle le input
  3. cursor-pointer sur le label : indique la zone cliquable
  4. Transitions sur border et color : feedback visuel immédiat
  5. Couleur checked = accent color : cohérence avec le design system

Pourquoi

  • Zéro dépendance, zéro bundle supplémentaire
  • 100% accessible (keyboard navigation, screen readers)
  • Totalement stylable avec le design system
  • Fonctionne dans n'importe quel formulaire HTML

Quand l'utiliser

  • Filtres, formulaires, préférences, settings
  • Quand une lib de composants est overkill

Quand NE PAS l'utiliser

  • Formulaire complexe avec 50+ champs (utiliser une lib comme Radix)
  • Besoin de composants avancés (switch toggle, slider) → Radix UI

Références

  • ProjectAlpha/GlobalSearch.tsx L53-81