Checkbox e Radio Button Personalizzati: Solo CSS Moderno
Perché personalizzare checkbox e radio button nel 2026
Per anni il problema era semplice e frustrante: i checkbox e i radio button nativi del browser erano impossibili da stilizzare. La soluzione standard era nasconderli con display:none, aggiungere un elemento <span> finto e gestire tutto con JavaScript. Funzionava, ma al costo di rompere l’accessibilità, duplicare la logica di stato e aggiungere weight inutile al bundle.
Oggi non ha più senso. Il CSS moderno — con appearance:none, i selettori :checked, :focus-visible, :disabled e i pseudo-elementi — permette di personalizzare completamente questi controlli mantenendo il DOM nativo. Niente JavaScript, niente wrapper, niente perdita di accessibilità. Il form rimane un vero form HTML, gli screen reader lo leggono correttamente, la navigazione da tastiera funziona senza interventi aggiuntivi.
In questo articolo vediamo come costruire checkbox e radio button completamente custom con solo CSS, coprendo tutti gli stati interattivi necessari per un’UI professionale: default, hover, checked, focus, disabled e indeterminate. Ogni snippet è pronto per la produzione e rispetta le linee guida WCAG 2.2. Se stai costruendo un design system o vuoi migliorare la qualità dei tuoi form, questo è il punto di partenza.
La base: appearance:none e il reset del controllo nativo
Il punto di partenza di ogni personalizzazione è appearance: none. Questa proprietà rimuove lo stile predefinito del sistema operativo dall’elemento, trasformandolo in una superficie bianca su cui disegnare. Senza di essa, i browser applicano lo stile del SO (diverso su Windows, macOS, Android) e ignorano molte proprietà CSS.
Insieme ad appearance vanno sempre impostati width, height, border e background: il controllo non ha dimensioni proprie una volta resettato. La proprietà cursor: pointer è fondamentale per l’UX — l’utente deve capire che l’elemento è cliccabile.
/* Reset base per checkbox e radio */
input[type="checkbox"],
input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border: 2px solid #64748b;
background: transparent;
cursor: pointer;
vertical-align: middle;
position: relative;
transition: border-color 0.2s ease, background 0.2s ease;
flex-shrink: 0;
}
/* Il checkbox ha angoli arrotondati, il radio è circolare */
input[type="checkbox"] { border-radius: 4px; }
input[type="radio"] { border-radius: 50%; }
/* Hover state */
input[type="checkbox"]:hover,
input[type="radio"]:hover {
border-color: #06b6d4;
}
Questo reset funziona su tutti i browser moderni. Per IE11 e browser molto vecchi servirebbe un fallback, ma nel 2026 non ha senso supportarli. Nota: su Safari mobile -webkit-appearance: none è ancora necessario.
Stato checked: il segno di spunta con pseudo-elementi
Una volta rimosso lo stile nativo, il browser non mostrerà più nulla quando l’utente seleziona il controllo. Dobbiamo ridisegnare lo stato :checked da zero. L’approccio più solido usa ::before o ::after per disegnare il segno di spunta o il cerchietto interno.
Per il checkbox la tecnica classica usa un border-bottom e border-right ruotati con transform: rotate(45deg) per formare una “L” rovesciata che simula il classico tick. Per il radio button invece basta un cerchio interno più piccolo. Entrambe le tecniche non richiedono immagini o SVG.
/* Checkbox: background e bordo al checked */
input[type="checkbox"]:checked {
background: #06b6d4;
border-color: #06b6d4;
}
/* Tick mark via pseudo-elemento */
input[type="checkbox"]::after {
content: "";
display: block;
position: absolute;
inset: 0;
/* Segno di spunta geometrico */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8l4 4 6-6' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: 12px 12px;
opacity: 0;
transition: opacity 0.15s ease;
}
input[type="checkbox"]:checked::after {
opacity: 1;
}
/* Radio button: cerchio interno */
input[type="radio"]::after {
content: "";
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #06b6d4;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.15s ease;
}
input[type="radio"]:checked {
border-color: #06b6d4;
}
input[type="radio"]:checked::after {
transform: translate(-50%, -50%) scale(1);
}
La scelta tra SVG data URI e CSS geometrico puro per il tick dipende dal livello di controllo. L’SVG inline è più flessibile e preciso. Il CSS geometrico (bordi ruotati) è un approccio valido ma con qualche pixel di differenza su display ad alta densità. Per un design system professionale preferisco sempre l’SVG inline. Per approfondire la gestione dell’accessibilità nei form, leggi la guida su best practice per form di contatto accessibili.
Focus accessibile: :focus-visible al posto di :focus
Questo è il punto dove la maggior parte degli sviluppatori sbaglia. Molti rimuovono outline completamente per ragioni estetiche (outline: none) e dimenticano di sostituirlo con qualcosa di equivalente. Il risultato è un form inaccessibile per chi naviga da tastiera o usa tecnologie assistive — un problema serio sia per UX che per conformità WCAG 2.1.
La soluzione moderna è :focus-visible: mostra l’indicatore di focus solo quando l’utente naviga con tastiera, non quando clicca con il mouse. Questo risolve il problema estetico mantenendo l’accessibilità. È supportato da tutti i browser moderni. Se ti interessa il tema, ho scritto una guida completa su come gestire il focus via JavaScript e tabindex.
/* Rimuovi outline di default ma NON nascondere il focus */
input[type="checkbox"],
input[type="radio"] {
outline: none; /* rimosso il default brutto */
}
/* Mostra focus ring SOLO con navigazione da tastiera */
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
outline: 3px solid #06b6d4;
outline-offset: 2px;
border-radius: 4px; /* segue la forma del checkbox */
}
input[type="radio"]:focus-visible {
border-radius: 50%; /* per il radio è circolare */
}
/* Stato disabled */
input[type="checkbox"]:disabled,
input[type="radio"]:disabled {
opacity: 0.4;
cursor: not-allowed;
border-color: #94a3b8;
}
input[type="checkbox"]:disabled:checked,
input[type="radio"]:disabled:checked {
background: #94a3b8;
border-color: #94a3b8;
}
Un errore comune è non gestire lo stato :disabled visivamente. Il browser non lo stila più con appearance: none, quindi tocca a noi. L’opacità ridotta e il cursore not-allowed comunicano chiaramente che il controllo non è interattivo.
Il componente completo con label: HTML semantico
Un checkbox o radio button da solo non ha molto senso: serve sempre una <label> associata. La struttura HTML corretta è fondamentale: o si usa <label for="id"> con l’id sul controllo, oppure si avvolge il controllo dentro la label. Entrambe le soluzioni sono corrette. Preferisco la seconda perché riduce il rischio di disallineamento tra id e for e permette uno styling più semplice.
<!-- Struttura con label avvolgente -->
<label class="checkbox-label">
<input type="checkbox" name="terms" value="accept">
<span class="checkbox-text">Accetto i termini e condizioni</span>
</label>
<!-- Gruppo radio button -->
<fieldset class="radio-group">
<legend>Piano tariffario</legend>
<label class="radio-label">
<input type="radio" name="plan" value="free">
<span class="radio-text">Free</span>
</label>
<label class="radio-label">
<input type="radio" name="plan" value="pro">
<span class="radio-text">Pro — 9€/mese</span>
</label>
<label class="radio-label">
<input type="radio" name="plan" value="enterprise">
<span class="radio-text">Enterprise</span>
</label>
</fieldset>/* Layout della label */
.checkbox-label,
.radio-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 0.95rem;
color: #e2e8f0;
user-select: none;
padding: 6px 0;
}
/* Il testo è cliccabile ma non si seleziona accidentalmente */
.checkbox-text,
.radio-text {
line-height: 1.4;
}
/* Fieldset reset */
.radio-group {
border: none;
padding: 0;
margin: 0;
}
.radio-group legend {
font-weight: 600;
margin-bottom: 12px;
color: #94a3b8;
font-size: 0.875rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* Evidenzia la label al hover del controllo */
.checkbox-label:hover input[type="checkbox"],
.radio-label:hover input[type="radio"] {
border-color: #06b6d4;
}
/* Quando checked, la label cambia colore */
.checkbox-label:has(input:checked) .checkbox-text,
.radio-label:has(input:checked) .radio-text {
color: #ffffff;
}
L’uso di :has() nell’ultimo snippet è CSS moderno puro: permette di stilizzare la label in base allo stato del suo input, senza JavaScript. È supportato da Chrome 105+, Firefox 121+ e Safari 15.4+. Per una panoramica aggiornata del CSS avanzato, consulta la guida CSS avanzato layout e animazioni.
Stato indeterminate: il caso speciale dei checkbox
Esiste uno stato che molti dimenticano: indeterminate. È utile nei checkbox “seleziona tutto” dove alcuni figli sono selezionati e altri no. Non esiste in HTML puro — va impostato via JavaScript — ma il CSS lo gestisce con il selettore :indeterminate.
Visivamente si rappresenta tradizionalmente con una linea orizzontale al centro del checkbox, a metà tra vuoto e spuntato. È un dettaglio importante per interfacce con selezione multipla come tabelle dati o file manager. Se stai costruendo componenti simili, dai un’occhiata a come creare tabelle interattive con HTML, CSS e JavaScript.
/* Stato indeterminate del checkbox */
input[type="checkbox"]:indeterminate {
background: #06b6d4;
border-color: #06b6d4;
}
input[type="checkbox"]:indeterminate::after {
opacity: 1;
/* Sovrascriviamo il tick con una linea */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect x='3' y='7' width='10' height='2' rx='1' fill='%23fff'/%3E%3C/svg%3E");
}
/* Impostare lo stato indeterminate via JS */
/* document.querySelector('#selectAll').indeterminate = true; */
/* Focus visible anche su indeterminate */
input[type="checkbox"]:indeterminate:focus-visible {
outline: 3px solid #06b6d4;
outline-offset: 2px;
}
Lo switch toggle è un’altra variante comune: tecnicamente è un checkbox con aspetto di interruttore on/off. Si costruisce con le stesse tecniche — appearance: none su un input[type="checkbox"] e uno stile completamente custom che simula il movimento della linguetta via transform: translateX. Non lo approfondiamo qui per non allungare troppo l’articolo, ma il principio è identico.
Animazioni e microinterazioni CSS
Un form ben progettato non si limita a funzionare: deve anche comunicare. Le microinterazioni — piccole animazioni che confermano l’azione dell’utente — aumentano la perceived quality dell’interfaccia. Per checkbox e radio button le animazioni più utili sono: la transizione del segno di spunta, il ripple al click e il cambio colore del bordo.
Tutte queste animazioni devono rispettare prefers-reduced-motion: chi ha impostato la preferenza per il ridotto movimento nel sistema operativo non deve vederle. È accessibilità di base — non un optional. Per approfondire il tema delle animazioni accessibili, leggi la guida alle strategie di accessibilità per animazioni web.
/* Sistema completo con animazioni */
@layer components {
.form-control {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.form-control input[type="checkbox"],
.form-control input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 22px;
height: 22px;
border: 2px solid #475569;
background: #0f172a;
cursor: pointer;
position: relative;
transition:
border-color 250ms ease,
background-color 250ms ease,
box-shadow 250ms ease;
flex-shrink: 0;
}
.form-control input[type="checkbox"] { border-radius: 5px; }
.form-control input[type="radio"] { border-radius: 50%; }
/* Hover */
.form-control:hover input {
border-color: #06b6d4;
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.12);
}
/* Checked */
.form-control input[type="checkbox"]:checked,
.form-control input[type="radio"]:checked {
border-color: #06b6d4;
background-color: #06b6d4;
}
/* Tick animato con clip-path */
.form-control input[type="checkbox"]::after {
content: "";
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8l4 4 6-6' stroke='%23fff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") center/12px no-repeat;
opacity: 0;
transform: scale(0.5);
transition: opacity 200ms ease, transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.form-control input[type="checkbox"]:checked::after {
opacity: 1;
transform: scale(1);
}
/* Dot animato per radio */
.form-control input[type="radio"]::after {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.form-control input[type="radio"]:checked::after {
transform: translate(-50%, -50%) scale(1);
}
/* Focus visibile da tastiera */
.form-control input:focus-visible {
outline: 3px solid #06b6d4;
outline-offset: 3px;
}
/* Disabled */
.form-control input:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.form-control:has(input:disabled) {
cursor: not-allowed;
color: #475569;
}
/* Rispetta prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.form-control input,
.form-control input::after {
transition: none;
}
}
}
Il cubic-bezier(0.34, 1.56, 0.64, 1) è una curva di tipo spring: supera leggermente il valore target e poi torna. Crea un effetto “pop” molto più vivace di un classico ease-out. Questo tipo di microinterazione è identico a quello usato da Gmail, Linear e Notion — piccoli dettagli che costruiscono la perceived quality del prodotto.
FAQ e Domande Frequenti
Funzionano gli screen reader con appearance:none?
Sì, e questo è il grande vantaggio rispetto alla soluzione con display:none e span finti. appearance: none modifica solo il rendering visivo, non la semantica HTML. Il browser continua a esporre il controllo all’accessibility tree con il ruolo corretto (checkbox o radio), lo stato (checked/unchecked) e il nome (dalla label associata). NVDA, JAWS e VoiceOver annunciano tutto correttamente. La regola d’oro: mai nascondere l’elemento nativo con display:none o visibility:hidden — usa appearance:none e stila sopra.
Serve un polyfill per :focus-visible?
Nel 2026 no. :focus-visible ha supporto globale superiore al 95% — Chrome, Firefox, Safari, Edge lo supportano tutti da anni. Esiste il pacchetto npm focus-visible per chi deve supportare browser molto vecchi, ma per la stragrande maggioranza dei progetti moderni è superfluo. Puoi verificare il supporto attuale su caniuse.com. L’unico edge case reale è Safari iOS 15 e precedenti, ma rappresentano una quota di traffico trascurabile per la maggior parte dei prodotti web.
Come gestire il checkbox dentro React o Vue?
Il CSS funziona esattamente allo stesso modo: applichi le classi al normale input JSX/template. In React, lo stato è gestito da useState e il binding con checked + onChange. Il CSS stilizza i selettori :checked basandosi sull’attributo DOM aggiornato da React. Per lo stato indeterminate, che non è un attributo HTML standard, serve un ref e un useEffect per impostarlo: ref.current.indeterminate = true. Nessun’altra differenza rispetto all’HTML vanilla. Se vuoi approfondire le performance con i componenti React, leggi come ottimizzare il DOM in React.
Posso usare CSS per creare uno switch toggle?
Assolutamente sì, con le stesse identiche tecniche. Un toggle switch è un input[type="checkbox"] con appearance: none, stilizzato come una pillola allungata. Il cerchio interno si sposta via transform: translateX quando il checkbox è :checked. Non servono librerie, non serve JavaScript. La semantica è corretta perché il controllo è ancora un checkbox nativo. L’unica accortezza: aggiungi role="switch" e aria-checked se vuoi comunicare più esplicitamente agli screen reader che si tratta di un toggle on/off anziché di una casella di spunta tradizionale.
Conclusione
I checkbox e i radio button personalizzati con solo CSS sono oggi una realtà solida e praticabile. Con appearance: none, i selettori di stato (:checked, :focus-visible, :disabled, :indeterminate), i pseudo-elementi e :has() hai tutto il necessario per costruire controlli che competono visivamente con qualsiasi libreria di componenti, mantenendo l’accessibilità nativa del browser.
Il risultato è codice più leggero, più manutenibile e intrinsecamente accessibile. Niente JavaScript, niente wrapper extra, niente ARIA aggiuntiva. Il browser fa il lavoro pesante, tu controlli l’estetica. Se stai costruendo un design system o un’applicazione web nel 2026, questi snippet sono il punto di partenza giusto per ogni form professionale.
Suggerimenti e Risorse
🔧 Strumento: Usa il browser DevTools di Chrome (Elements > Accessibility panel) per verificare in tempo reale che il tuo checkbox custom sia correttamente esposto all’accessibility tree con ruolo e stato giusti.
💡 Pro tip: Organizza i tuoi stili per checkbox e radio dentro un
@layer componentsper evitare conflitti di specificità con il resto del design system. I Cascade Layers rendono la gestione degli stili molto più prevedibile — approfondisci nella guida su Cascade Layers in CSS.
🎯 Pattern da copiare: Crea una classe utility
.form-controlche gestisce il layout flex con la label, e separatamente gli stili dell’input. Questo permette di riutilizzare il layout con qualsiasi tipo di controllo (select, textarea, input text) senza duplicare codice.

