Record grid con CSS Grid: perché NON è una tabella
Una tabella HTML (<table>) è la scelta corretta quando i dati sono intrinsecamente tabellari: relazioni riga/colonna, intestazioni che governano celle, lettura matriciale. Ma molti layout “a tabella” sul web non sono tabelle: sono elenchi di record (utenti, ordini, ticket, prodotti) presentati a colonne per comodità visiva.
In questi casi, forzare <table> è spesso più fragile (soprattutto su mobile) e meno modulare. La soluzione ingegneristicamente pulita è un record grid: una lista di record resa “tabellare” dal CSS, senza fingere semantica tabellare.
Regola operativa (decisione rapida)
- Usa
<table>se le intestazioni descrivono colonne/celle e la lettura corretta è riga/colonna. - Usa record grid se ogni riga è un oggetto (record) e le “colonne” sono solo attributi mostrati in modo comodo.
- Non usare ARIA grid solo per “fare tabella”: un grid ARIA è un widget interattivo, non un layout statico.
Obiettivo tecnico
Costruire un componente riusabile che:
- mostra un elenco di record in colonne allineate su contenitori larghi;
- diventa cards su contenitori stretti, senza perdere leggibilità;
- usa CSS Grid come motore principale (allineamento bidimensionale);
- usa container queries per rispondere al contenitore (perfetto per CMS e layout a colonne);
- isola completamente lo stile dentro un
id(zero side-effect sul sito).
1) Markup: record grid senza classi
Il markup sotto è intenzionalmente “semplice”: un wrapper con id, un header visivo (non semantica tabellare), e un body con righe. Ogni “cella” ha un data-label usato per mostrare l’etichetta in vista mobile.
<h3>Demo: elenco utenti</h3>
<p>Layout “tipo tabella” per record (non tabella dati).</p>
<div id="ap-rg" aria-label="Elenco record">
<div id="ap-rg-head" aria-hidden="true">
<div>Nome</div>
<div>Ruolo</div>
<div>Stato</div>
<div>Ultimo accesso</div>
</div>
<div id="ap-rg-body">
<div data-rg-row>
<div data-label="Nome"><strong>Mario Rossi</strong></div>
<div data-label="Ruolo">DevOps</div>
<div data-label="Stato"><span data-status="ok">Attivo</span></div>
<div data-label="Ultimo accesso">2026-02-10</div>
</div>
<div data-rg-row>
<div data-label="Nome"><strong>Amin Haddad</strong></div>
<div data-label="Ruolo">Designer</div>
<div data-label="Stato"><span data-status="warn">In revisione</span></div>
<div data-label="Ultimo accesso">2026-02-14</div>
</div>
<div data-rg-row>
<div data-label="Nome"><strong>Laura Bianchi</strong></div>
<div data-label="Ruolo">Support</div>
<div data-label="Stato"><span data-status="off">Sospeso</span></div>
<div data-label="Ultimo accesso">2026-01-29</div>
</div>
</div>
</div>
2) CSS: Grid “vero” + isolamento totale
Qui avviene il salto di livello: niente CSS globale, niente classi, nessun impatto su body o tipografia del sito. Tutto vive dentro #ap-rg. La griglia è definita con grid-template-columns + minmax() per evitare colonne ingestibili.
/* ===== Record grid: stile isolato + contrasto alto ===== */
#ap-rg{
/* Palette (light default) */
--rg-bg: #ffffff;
--rg-text: #111827; /* quasi-nero leggibile */
--rg-muted: #4b5563; /* etichette / meta */
--rg-border: rgba(17,24,39,.18);
--rg-head-bg: #0f172a; /* header “deciso” */
--rg-head-text: #ffffff;
--rg-row-even: rgba(15,23,42,.04);
--rg-row-hover: rgba(15,23,42,.08);
--rg-radius: 14px;
--rg-shadow: 0 10px 28px rgba(0,0,0,.10);
background: var(--rg-bg);
color: var(--rg-text);
border: 1px solid var(--rg-border);
border-radius: var(--rg-radius);
box-shadow: var(--rg-shadow);
overflow: hidden;
/* Container queries */
container-type: inline-size;
container-name: ap-rg;
/* Aiuta UA per form controls se presenti */
color-scheme: light dark;
}
/* Dark mode (solo dentro il componente) */
@media (prefers-color-scheme: dark){
#ap-rg{
--rg-bg: #0b0f14;
--rg-text: #e5e7eb;
--rg-muted: #9ca3af;
--rg-border: rgba(229,231,235,.16);
--rg-head-bg: #111827;
--rg-head-text: #f9fafb;
--rg-row-even: rgba(255,255,255,.03);
--rg-row-hover: rgba(255,255,255,.06);
--rg-shadow: 0 14px 34px rgba(0,0,0,.45);
}
}
/* Header visivo */
#ap-rg-head{
display: grid;
grid-template-columns:
minmax(14ch, 1.4fr)
minmax(10ch, 1fr)
minmax(10ch, .9fr)
minmax(12ch, .9fr);
gap: 10px;
padding: 12px 14px;
font-weight: 800;
letter-spacing: .2px;
background: var(--rg-head-bg);
color: var(--rg-head-text);
border-bottom: 1px solid rgba(0,0,0,.15);
}
/* Body */
#ap-rg-body{
display: block;
}
/* Riga record */
#ap-rg-body > [data-rg-row]{
display: grid;
grid-template-columns:
minmax(14ch, 1.4fr)
minmax(10ch, 1fr)
minmax(10ch, .9fr)
minmax(12ch, .9fr);
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid rgba(0,0,0,.08);
}
/* Zebra + hover più “visibili” */
#ap-rg-body > [data-rg-row]:nth-child(even){
background: var(--rg-row-even);
}
#ap-rg-body > [data-rg-row]:hover{
background: var(--rg-row-hover);
}
/* Celle: robustezza su testi lunghi */
#ap-rg [data-rg-row] > div{
min-width: 0;
overflow-wrap: anywhere;
}
/* Badge stato: colori leggibili (con testo coerente) */
#ap-rg [data-status]{
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-weight: 800;
font-size: 0.95em;
border: 1px solid rgba(0,0,0,.18);
background: rgba(0,0,0,.06);
color: var(--rg-text);
}
#ap-rg [data-status="ok"]{
background: #dcfce7;
border-color: #22c55e;
color: #14532d;
}
#ap-rg [data-status="warn"]{
background: #fef3c7;
border-color: #f59e0b;
color: #78350f;
}
#ap-rg [data-status="off"]{
background: #fee2e2;
border-color: #ef4444;
color: #7f1d1d;
}
/* Badge in dark mode: stessi significati, luminanza compatibile */
@media (prefers-color-scheme: dark){
#ap-rg [data-status]{
border-color: rgba(255,255,255,.18);
background: rgba(255,255,255,.06);
color: var(--rg-text);
}
#ap-rg [data-status="ok"]{
background: rgba(34,197,94,.18);
border-color: rgba(34,197,94,.55);
color: #bbf7d0;
}
#ap-rg [data-status="warn"]{
background: rgba(245,158,11,.18);
border-color: rgba(245,158,11,.55);
color: #fde68a;
}
#ap-rg [data-status="off"]{
background: rgba(239,68,68,.18);
border-color: rgba(239,68,68,.55);
color: #fecaca;
}
}
/* ===== Container queries: vista stretta = cards ===== */
@container ap-rg (max-width: 520px){
#ap-rg-head{ display: none; }
#ap-rg-body > [data-rg-row]{
grid-template-columns: 1fr;
gap: 8px;
}
/* Etichetta in card-view: più chiara e “separata” */
#ap-rg [data-rg-row] > div::before{
content: attr(data-label) ": ";
font-weight: 800;
color: var(--rg-muted);
}
}
/* ===== Fallback senza container queries ===== */
@supports not (container-type: inline-size){
@media (max-width: 520px){
#ap-rg-head{ display: none; }
#ap-rg-body > [data-rg-row]{
grid-template-columns: 1fr;
gap: 8px;
}
#ap-rg [data-rg-row] > div::before{
content: attr(data-label) ": ";
font-weight: 800;
color: var(--rg-muted);
}
}
}
3) Perché questa soluzione è “superiore” (ingegneria, non estetica)
- Allineamento reale: CSS Grid gestisce righe e colonne come struttura bidimensionale, senza hack di Flexbox.
- Componente riusabile: con container queries non dipendi dal viewport; il componente si adatta a dove lo incolli (colonne, box, sidebar).
- CSS isolato: nessuna guerra col tema del sito, nessun leak su body o elementi esterni.
- Mobile leggibile: da colonne a cards con etichette automatiche (
data-label).
4) Errori tipici da evitare
- Chiamarla “tabella” e poi usarla per dati tabellari veri: se i dati sono matriciali, usa
<table>. - Usare
role="grid"per “dare semantica”: un grid ARIA implica navigazione e focus da widget. Se non implementi quel comportamento, peggiori l’accessibilità invece di migliorarla. - CSS globale nel tutorial (es.
body { ... }): in un CMS significa contaminazione e bug laterali. - Colonne senza
minmax(): con testi reali, prima o poi esplode un layout (overflow, salti, wrapping illogico).
5) Opzionale: sorting leggero (solo se serve davvero)
Se vuoi trasformare questo record grid in un widget “quasi-tabellare” (ordinamento), fallo senza ARIA grid: aggiungi due bottoni esterni (o link) che ordinano il DOM delle righe. Esempio minimale: ordina per “Nome” o “Ultimo accesso”.
<p>
<button type="button" id="ap-rg-sort-name">Ordina per Nome</button>
<button type="button" id="ap-rg-sort-date">Ordina per Ultimo accesso</button>
</p>
(function(){
var body = document.getElementById("ap-rg-body");
if(!body) return;
function getRows(){
return Array.prototype.slice.call(body.querySelectorAll("[data-rg-row]"));
}
function sortBy(colIndex, isDate){
var rows = getRows();
rows.sort(function(a,b){
var av = a.children[colIndex] ? a.children[colIndex].textContent.trim() : "";
var bv = b.children[colIndex] ? b.children[colIndex].textContent.trim() : "";
if(isDate){
return new Date(bv) - new Date(av); // discendente
}
return av.localeCompare(bv, "it", { sensitivity: "base" });
});
rows.forEach(function(r){ body.appendChild(r); });
}
var btnName = document.getElementById("ap-rg-sort-name");
var btnDate = document.getElementById("ap-rg-sort-date");
if(btnName) btnName.addEventListener("click", function(){ sortBy(0, false); });
if(btnDate) btnDate.addEventListener("click", function(){ sortBy(3, true); });
})();Demo: elenco utenti
Layout “tipo tabella” per record (non tabella dati).