Tablas HTML: estructura, celdas combinadas y estilo con CSS
Tablas HTML: estructura, celdas combinadas y estilo con CSS
En el capitulo 3 trabajamos con texto: parrafos, encabezados, listas, enfasis y entidades HTML como &, < o . Todo ese contenido fluye en una sola direccion, de arriba hacia abajo, y cada elemento ocupa su linea o su trozo de linea. Pero hay informacion que no fluye: informacion que tiene dos dimensiones al mismo tiempo. Un ranking de tiempos por piloto y por pista. Un horario de clases por dia y por bloque. Una comparacion de planes por caracteristica y por precio. Cuando un dato solo tiene sentido si sabes simultaneamente a que fila y a que columna pertenece, estas frente a datos tabulares, y para eso HTML tiene un conjunto de elementos dedicado.
Este capitulo cubre ese conjunto completo. Partimos del esqueleto minimo de una tabla, subimos a los grupos de filas (thead, tbody, tfoot), luego a los atributos que combinan celdas (colspan y rowspan), y de ahi pasamos al lado visual: como CSS trata a las tablas de forma distinta al resto de elementos, por que border-collapse existe, que hace table-layout: fixed, como se logran las filas alternadas y las cabeceras que quedan fijas al hacer scroll. Al final generamos una tabla completa desde JavaScript y le agregamos ordenamiento por columna, porque en la practica casi ninguna tabla real se escribe a mano: se genera desde datos.
Que es una tabla y que no lo es
Una tabla HTML representa una cuadricula de datos donde cada celda esta asociada a una o mas cabeceras. Esa asociacion es el punto central. No es una tabla porque se vea como una cuadricula; es una tabla porque el significado de cada celda depende de su posicion en dos ejes.
Piensa en un lector de pantalla recorriendo la pagina. Cuando entra a una tabla bien construida, puede anunciar “columna Tiempo, fila Toad, valor 1 minuto 51 segundos”. Esa frase existe solo porque el navegador sabe que 1' 51'' 10 esta en la columna cuya cabecera dice “Tiempo” y en la fila cuya cabecera dice “Toad”. Si la misma cuadricula se hubiera armado con div y CSS Grid, visualmente se veria igual, pero esa relacion no existiria en ninguna parte y el lector de pantalla solo anunciaria una lista de textos sueltos.
La regla practica es esta:
| Caso | Elemento correcto | Motivo |
|---|---|---|
| Ranking de resultados por piloto y pista | table | El dato depende de fila y columna |
| Horario semanal por dia y bloque horario | table | Cuadricula real de dos ejes |
| Comparacion de planes por caracteristica | table | Cada celda cruza plan con caracteristica |
| Galeria de tarjetas de producto | ul + CSS Grid | Es una lista, la posicion no aporta significado |
| Layout general de la pagina (menu, contenido, pie) | Elementos semanticos + CSS | La posicion es presentacion, no dato |
| Formulario con etiquetas alineadas | form + CSS Grid | Alineacion visual, no datos tabulares |
Usar tablas para maquetar el layout completo de una pagina fue una practica comun en los anos noventa, cuando CSS todavia no tenia herramientas de posicionamiento decentes. Hoy CSS Grid y Flexbox resuelven eso, y las tablas quedaron para lo que siempre fueron: datos. Veremos posicionamiento con CSS en el proximo capitulo.
El esqueleto minimo
Empecemos con la tabla mas simple posible. Solo necesitas tres elementos: el contenedor table, una fila tr, y celdas dentro de esa fila.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Tabla minima</title>
</head>
<body>
<table>
<tr>
<th>Piloto</th>
<th>Pista</th>
<th>Tiempo</th>
</tr>
<tr>
<td>Toad</td>
<td>Luigi Raceway</td>
<td>1' 51'' 10</td>
</tr>
<tr>
<td>Mario</td>
<td>Luigi Raceway</td>
<td>1' 51'' 11</td>
</tr>
</table>
</body>
</html>
Guarda esto como tabla.html, abrelo en el navegador y veras tres columnas con la primera fila en negrita y centrada. Ese formato lo aplica la hoja de estilos por defecto del navegador al elemento th, no algo que hayas escrito tu.
Los nombres importan y conviene fijarlos bien desde el principio, porque es el punto donde mas gente se enreda:
tablees el contenedor completo. Todo lo demas vive dentro.trviene de table row: es una fila, la linea horizontal.thviene de table header cell: es una celda de cabecera.tdviene de table data cell: es una celda de datos.
No existe un elemento para declarar una columna de contenido. Las columnas se forman implicitamente: la tercera celda de cada fila forma la tercera columna. Si una fila tiene tres celdas y otra tiene cuatro, la tabla queda desalineada porque el navegador debe inventar una celda vacia para completar la cuadricula.
flowchart TD
T["table"] --> C["caption<br/>titulo accesible"]
T --> CG["colgroup<br/>columnas para estilo"]
T --> TH["thead<br/>grupo de cabecera"]
T --> TB["tbody<br/>grupo de cuerpo"]
T --> TF["tfoot<br/>grupo de pie"]
TH --> TRH["tr fila de cabecera"]
TRH --> TH1["th Piloto"]
TRH --> TH2["th Pista"]
TRH --> TH3["th Tiempo"]
TB --> TR1["tr fila de datos"]
TR1 --> TD1["td Toad"]
TR1 --> TD2["td Luigi Raceway"]
TR1 --> TD3["td 1' 51'' 10"]
TF --> TRF["tr fila de resumen"]
TRF --> TDF["td Total: 2 vueltas"]
El diagrama muestra el arbol real que construye el navegador. Fijate en que th y td nunca cuelgan directamente de table: siempre hay un tr en medio, y ese tr casi siempre vive dentro de un grupo de filas.
Los grupos de filas: thead, tbody y tfoot
Una tabla de tres filas no necesita mas estructura. Una tabla de doscientas filas si. Los grupos de filas separan el rol de cada bloque:
theadcontiene la fila (o filas) que nombran las columnas.tbodycontiene los datos.tfootcontiene totales, resumenes o notas de cierre.
Reescribamos el ejemplo con la estructura completa:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Ranking Super Club Net</title>
</head>
<body>
<h1>Records de la pista Luigi Raceway</h1>
<table>
<caption>Mejores tiempos registrados en el torneo de verano</caption>
<thead>
<tr>
<th scope="col">Rango</th>
<th scope="col">Piloto</th>
<th scope="col">Vehiculo</th>
<th scope="col">Tiempo</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">1</th>
<td>Toad</td>
<td>Kart estandar</td>
<td>1' 51'' 10</td>
</tr>
<tr>
<th scope="row">2</th>
<td>Mario</td>
<td>Kart estandar</td>
<td>1' 51'' 11</td>
</tr>
<tr>
<th scope="row">3</th>
<td>Luigi</td>
<td>Kart estandar</td>
<td>1' 51'' 12</td>
</tr>
<tr>
<th scope="row">4</th>
<td>Wario</td>
<td>Kart pesado</td>
<td>1' 51'' 13</td>
</tr>
<tr>
<th scope="row">5</th>
<td>Bowser</td>
<td>Kart pesado</td>
<td>1' 51'' 14</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4">5 participantes registrados. Diferencia entre el primero y el ultimo: 4 centesimas.</td>
</tr>
</tfoot>
</table>
</body>
</html>
Hay cuatro cosas nuevas ahi y vale la pena revisarlas una por una.
caption: el titulo de la tabla
caption debe ser el primer hijo de table. El navegador lo dibuja arriba de la tabla y, mas importante, lo expone como el nombre accesible de la tabla completa. Un usuario que navega saltando entre tablas escucha ese texto para decidir si le interesa entrar.
Un h2 justo antes de la tabla se ve parecido, pero no cumple la misma funcion: queda como un encabezado suelto del documento, no como el nombre de esa tabla en particular. caption es el elemento correcto.
Puedes moverlo abajo con CSS sin cambiar el HTML:
caption {
caption-side: bottom;
padding-top: 0.75rem;
font-size: 0.9rem;
color: #9aa0a6;
text-align: left;
}
th con scope: a que se refiere cada cabecera
th marca una celda como cabecera, pero por si solo no dice si encabeza una columna o una fila. El atributo scope lo declara explicitamente:
| Valor de scope | Significado | Uso tipico |
|---|---|---|
col | Esta celda encabeza la columna en la que esta | Fila de titulos dentro de thead |
row | Esta celda encabeza la fila en la que esta | Primera celda de cada fila de datos |
colgroup | Encabeza un grupo de columnas | Cabecera que abarca varias columnas con colspan |
rowgroup | Encabeza un grupo de filas | Cabecera que abarca varias filas con rowspan |
En el ejemplo, el numero de rango es un th scope="row", no un td. Es una decision de significado: el rango identifica la fila, igual que el nombre de la columna identifica la columna. Al hacerlo asi, un lector de pantalla que llega a la celda del tiempo puede anunciar “fila 3, columna Tiempo, 1 minuto 51 segundos 12”.
tfoot y su posicion
Historicamente tfoot debia escribirse antes de tbody en el codigo fuente, porque los navegadores antiguos necesitaban conocer el pie antes de renderizar filas. En HTML moderno se escribe al final, en su orden natural, y el navegador lo dibuja abajo igual. Si encuentras codigo viejo con tfoot en medio, esa es la razon.
Que hace el navegador si omites tbody
Si escribes filas sueltas directamente dentro de table, sin tbody, el analizador del navegador inserta un tbody implicito. Es decir, este HTML:
<table>
<tr><td>A</td></tr>
</table>
produce en el DOM exactamente esta estructura:
<table>
<tbody>
<tr><td>A</td></tr>
</tbody>
</table>
Eso tiene una consecuencia practica muy concreta: un selector CSS como table > tr nunca coincide con nada, porque en el DOM real tr no es hijo directo de table. Y en JavaScript, document.querySelector('table').appendChild(tr) mete la fila fuera del tbody, en un lugar que el navegador va a normalizar de forma no siempre intuitiva. Escribir tbody de forma explicita evita ambas sorpresas.
colgroup y col: estilo por columna
Ya dijimos que no existe un elemento que contenga una columna de datos. Pero si existe uno que permite referirse a una columna para darle estilo: colgroup con sus hijos col.
<table>
<colgroup>
<col class="col-rango">
<col class="col-piloto">
<col span="2" class="col-numerica">
</colgroup>
<thead>
<tr>
<th scope="col">Rango</th>
<th scope="col">Piloto</th>
<th scope="col">Vueltas</th>
<th scope="col">Tiempo</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">1</th>
<td>Toad</td>
<td>3</td>
<td>1' 51'' 10</td>
</tr>
</tbody>
</table>
El atributo span="2" hace que ese col represente dos columnas consecutivas. En total, el colgroup describe cuatro columnas: rango, piloto, y dos numericas.
Ahora la parte que sorprende: solo un conjunto muy pequeno de propiedades CSS funciona sobre col. La razon es que las celdas no son descendientes del col en el arbol del DOM; el col solo proyecta ciertas propiedades hacia la columna. Las que funcionan de forma confiable son:
Propiedad en col | Funciona | Comentario |
|---|---|---|
width | Si | Define el ancho de la columna |
background / background-color | Si | Se pinta detras de las celdas |
border | Si | Solo con border-collapse: collapse |
visibility: collapse | Si | Oculta la columna completa |
color | No | El color de texto lo define la celda |
text-align | No | Se aplica a cada td o th |
padding | No | El relleno pertenece a la celda |
Entonces col sirve para anchos, fondos y ocultar columnas. Para alinear texto o dar relleno necesitas apuntar a las celdas. Con :nth-child puedes hacerlo sin agregar clases a cada celda:
/* Alinea a la derecha las columnas 3 y 4 de todas las filas */
tbody td:nth-child(3),
tbody td:nth-child(4),
thead th:nth-child(3),
thead th:nth-child(4) {
text-align: right;
font-variant-numeric: tabular-nums;
}
font-variant-numeric: tabular-nums pide a la fuente que use digitos de ancho fijo. En una columna de numeros eso alinea las unidades, decenas y centenas verticalmente, lo que hace las cifras mucho mas faciles de comparar de un vistazo.
colspan: una celda que abarca varias columnas
Llegamos a la parte que hace que las tablas dejen de ser cuadriculas rigidas. colspan indica cuantas columnas ocupa una celda hacia la derecha, contandose a si misma.
<table>
<caption>Ventas por trimestre</caption>
<thead>
<tr>
<th scope="col">Producto</th>
<th scope="colgroup" colspan="3">Ventas 2026</th>
</tr>
<tr>
<th scope="col">Nombre</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Q3</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Kart estandar</th>
<td>120</td>
<td>145</td>
<td>160</td>
</tr>
<tr>
<th scope="row">Kart pesado</th>
<td>80</td>
<td>92</td>
<td>101</td>
</tr>
</tbody>
</table>
La primera fila de la cabecera tiene solo dos celdas escritas, pero ocupa cuatro columnas: la celda “Producto” ocupa una, y la celda “Ventas 2026” ocupa tres gracias a colspan="3". La segunda fila tiene las cuatro celdas normales.
La cuenta que siempre debe cuadrar es esta: la suma de los colspan de todas las celdas de una fila debe ser igual al numero de columnas de la tabla. Una celda sin colspan cuenta como 1. Si en la fila anterior sumas 1 + 3 obtienes 4, que coincide con las 4 celdas de la segunda fila. La tabla esta balanceada.
rowspan: una celda que abarca varias filas
rowspan hace lo mismo hacia abajo: la celda ocupa varias filas. Y aqui aparece el detalle que confunde a casi todo el mundo la primera vez.
<table>
<caption>Horario de talleres</caption>
<thead>
<tr>
<th scope="col">Bloque</th>
<th scope="col">Lunes</th>
<th scope="col">Martes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">09:00</th>
<td rowspan="2">Taller de HTML<br>(dos bloques seguidos)</td>
<td>Introduccion a CSS</td>
</tr>
<tr>
<th scope="row">10:00</th>
<td>Selectores CSS</td>
</tr>
<tr>
<th scope="row">11:00</th>
<td>JavaScript basico</td>
<td>DOM y eventos</td>
</tr>
</tbody>
</table>
Mira la segunda fila del tbody. Tiene una cabecera de fila (09:00… no, 10:00) y una sola celda de datos, no dos. La columna de Lunes en esa fila ya esta ocupada por la celda “Taller de HTML” que baja desde arriba con rowspan="2". Si escribieras dos td en esa fila, el navegador empujaria uno de ellos hacia una quinta columna inexistente y la tabla se rompe.
Esa es la regla clave: una celda con rowspan consume espacio en las filas siguientes, y esas filas deben omitir las celdas correspondientes.
flowchart TB
subgraph FILA1["Fila 09:00"]
A["th 09:00"]
B["td Taller HTML<br/>rowspan igual a 2"]
C["td Introduccion a CSS"]
end
subgraph FILA2["Fila 10:00"]
D["th 10:00"]
E["celda ocupada por B<br/>no se escribe"]
F["td Selectores CSS"]
end
subgraph FILA3["Fila 11:00"]
G["th 11:00"]
H["td JavaScript basico"]
I["td DOM y eventos"]
end
B -. "extiende su ocupacion hacia abajo" .-> E
FILA1 --> FILA2 --> FILA3
Cuando colspan y rowspan se combinan, la celda ocupa un rectangulo de rowspan filas por colspan columnas. Una celda con rowspan="2" colspan="3" ocupa seis posiciones de la cuadricula.
Como piensa el navegador la cuadricula
Internamente el navegador no lee la tabla como filas y columnas independientes, sino que construye una cuadricula vacia y va colocando celdas en ella siguiendo un algoritmo muy simple. Entender ese algoritmo hace que colspan y rowspan dejen de parecer magia.
flowchart TD
START([Inicio de una fila tr]) --> POS["Cursor en la columna 0"]
POS --> CHECK{"Posicion ocupada por<br/>una celda con rowspan<br/>de una fila anterior?"}
CHECK -->|Si| SKIP["Avanzar el cursor<br/>a la siguiente columna libre"]
SKIP --> CHECK
CHECK -->|No| NEXT{"Queda alguna celda<br/>th o td por colocar<br/>en esta fila?"}
NEXT -->|Si| PLACE["Colocar la celda ocupando<br/>colspan columnas y rowspan filas"]
PLACE --> MARK["Marcar como reservadas<br/>las posiciones de las filas siguientes"]
MARK --> ADV["Avanzar el cursor<br/>colspan posiciones"]
ADV --> CHECK
NEXT -->|No| END([Fin de la fila])
END --> WIDTH["El ancho de la tabla es<br/>la fila mas ancha encontrada"]
De ahi salen dos consecuencias practicas. Primero, si una fila tiene celdas de mas, la tabla completa gana una columna y todas las demas filas quedan con un hueco al final. Segundo, si una fila tiene celdas de menos, el navegador deja huecos al final de esa fila, que se dibujan como espacio vacio sin bordes.
Referencia completa de elementos y atributos
Antes de pasar a CSS, esta es la tabla de referencia con todo lo que compone una tabla HTML.
| Elemento | Nombre completo | Padre valido | Notas |
|---|---|---|---|
table | Table | Contenido de flujo | Contenedor raiz |
caption | Caption | table | Debe ser el primer hijo |
colgroup | Column group | table | Va despues de caption |
col | Column | colgroup | Elemento vacio, sin cierre |
thead | Table head | table | Solo uno por tabla |
tbody | Table body | table | Puede haber varios |
tfoot | Table foot | table | Solo uno por tabla |
tr | Table row | thead, tbody, tfoot | Contiene celdas |
th | Table header cell | tr | Celda de cabecera |
td | Table data cell | tr | Celda de datos |
| Atributo | Se aplica a | Valor | Que hace |
|---|---|---|---|
colspan | th, td | Entero positivo | Numero de columnas que ocupa la celda |
rowspan | th, td | Entero positivo, o 0 en tbody | Numero de filas que ocupa la celda |
scope | th | col, row, colgroup, rowgroup | Declara que encabeza la celda |
headers | th, td | Lista de id separada por espacios | Asocia una celda con cabeceras concretas |
abbr | th | Texto | Version corta del titulo para lectores de pantalla |
span | col, colgroup | Entero positivo | Numero de columnas que representa |
El atributo headers es la salida para tablas con cabeceras irregulares, donde scope no alcanza. Se usa asi:
<table>
<caption>Precios por region y modalidad</caption>
<tr>
<td></td>
<th id="norte" scope="col">Norte</th>
<th id="sur" scope="col">Sur</th>
</tr>
<tr>
<th id="basico" scope="row">Basico</th>
<td headers="basico norte">$10.000</td>
<td headers="basico sur">$12.000</td>
</tr>
<tr>
<th id="premium" scope="row">Premium</th>
<td headers="premium norte">$18.000</td>
<td headers="premium sur">$21.000</td>
</tr>
</table>
Cada td declara exactamente que cabeceras lo describen. Es mas verboso, pero cuando la tabla tiene cabeceras a varios niveles es la unica forma de que la relacion quede explicita.
Sobre los atributos que ya no debes usar: border, cellpadding, cellspacing, align, bgcolor, valign y width existieron como atributos HTML en las tablas. Estan obsoletos. Todo eso se hace hoy con CSS, y es exactamente lo que veremos ahora.
CSS para tablas: el modelo de bordes
Escribe esta tabla y aplicale el CSS mas obvio del mundo:
th, td {
border: 1px solid #444;
padding: 8px;
}
El resultado tiene bordes dobles: entre dos celdas contiguas ves dos lineas de 1px con un espacio entre ellas. Eso ocurre porque, por defecto, cada celda dibuja su propio borde completo y ademas hay un espacio de separacion entre celdas. Ese modo se llama modelo de bordes separados y es el valor inicial de border-collapse.
table {
border-collapse: separate; /* valor por defecto */
border-spacing: 2px; /* separacion entre celdas */
}
Con border-collapse: collapse el navegador fusiona los bordes adyacentes en una sola linea:
table {
border-collapse: collapse;
}
th, td {
border: 1px solid #444;
padding: 8px;
}
Ahora si obtienes la cuadricula limpia de una linea por division. La diferencia entre los dos modos es mas profunda que lo visual:
| Aspecto | separate | collapse |
|---|---|---|
| Bordes entre celdas | Dos lineas independientes | Una linea fusionada |
border-spacing | Funciona | Se ignora |
border-radius en celdas | Funciona | Se ignora |
box-shadow en celdas | Funciona | Comportamiento inconsistente |
Borde en tr | Se ignora | Funciona |
Borde en col | Se ignora | Funciona |
| Resolucion de conflictos | No aplica | Gana el borde mas ancho y visible |
position: sticky en th | Funciona limpio | Puede perder bordes al desplazarse |
Ese ultimo punto es el que mas problemas causa en la practica y lo resolvemos mas adelante con la cabecera pegajosa.
Cuando dos celdas vecinas declaran bordes distintos en modo collapse, el navegador debe elegir uno. La regla de desempate va asi: gana el estilo hidden sobre todos; despues gana el borde mas ancho; si empatan en ancho, gana el estilo mas “solido” segun el orden double, solid, dashed, dotted, ridge, outset, groove, inset; y si todo empata, gana el borde de la celda que aparece antes en el documento. Por eso a veces defines un borde de 2px en una celda y no lo ves: el vecino tenia uno mas ancho.
Un ejemplo completo con estilo
Vamos a vestir la tabla de records. Este archivo es autocontenido: copialo, guardalo y abrelo.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Records con estilo</title>
<style>
:root {
--fondo: #0d0f12;
--superficie: #161a1f;
--superficie-alt: #1c2127;
--linea: #2c333c;
--texto: #e6e9ee;
--texto-suave: #9aa4b2;
--acento: #ff5252;
}
body {
margin: 0;
padding: 2rem 1rem;
background: var(--fondo);
color: var(--texto);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
}
h1 {
text-align: center;
color: var(--acento);
letter-spacing: 0.05em;
}
table {
width: 100%;
max-width: 46rem;
margin: 0 auto;
border-collapse: collapse;
background: var(--superficie);
font-size: 0.95rem;
}
caption {
caption-side: bottom;
padding-top: 0.75rem;
color: var(--texto-suave);
font-size: 0.85rem;
text-align: left;
}
th, td {
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--linea);
text-align: left;
vertical-align: middle;
}
thead th {
background: var(--superficie-alt);
border-bottom: 2px solid var(--acento);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--texto-suave);
}
tbody th[scope="row"] {
width: 3.5rem;
text-align: center;
color: var(--acento);
font-variant-numeric: tabular-nums;
}
/* Columna de tiempo: alineada a la derecha y con digitos de ancho fijo */
td:last-child {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Filas cebra */
tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.03);
}
tbody tr:hover {
background: rgba(255, 82, 82, 0.09);
}
tbody tr:last-child td,
tbody tr:last-child th {
border-bottom: none;
}
tfoot td {
padding-top: 0.9rem;
border-top: 2px solid var(--linea);
border-bottom: none;
color: var(--texto-suave);
font-size: 0.85rem;
}
</style>
</head>
<body>
<h1>Records Super Club Net</h1>
<table>
<caption>Tiempos oficiales del torneo de verano, pista Luigi Raceway.</caption>
<thead>
<tr>
<th scope="col">Rango</th>
<th scope="col">Piloto</th>
<th scope="col">Vehiculo</th>
<th scope="col">Tiempo</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">1</th>
<td>Toad</td>
<td>Kart estandar</td>
<td>1' 51'' 10</td>
</tr>
<tr>
<th scope="row">2</th>
<td>Mario</td>
<td>Kart estandar</td>
<td>1' 51'' 11</td>
</tr>
<tr>
<th scope="row">3</th>
<td>Luigi</td>
<td>Kart estandar</td>
<td>1' 51'' 12</td>
</tr>
<tr>
<th scope="row">4</th>
<td>Wario</td>
<td>Kart pesado</td>
<td>1' 51'' 13</td>
</tr>
<tr>
<th scope="row">5</th>
<td>Bowser</td>
<td>Kart pesado</td>
<td>1' 51'' 14</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4">5 participantes. Diferencia entre el primero y el ultimo: 4 centesimas.</td>
</tr>
</tfoot>
</table>
</body>
</html>
Repasemos las decisiones de estilo que importan.
border-collapse: collapse y solo border-bottom. En vez de dibujar una cuadricula completa, usamos solo lineas horizontales. Es una tabla mas liviana de leer: el ojo sigue las filas sin que las lineas verticales lo interrumpan. Cuando las columnas son pocas y estan bien alineadas, no necesitas separadores verticales.
padding en th, td, no en tr. Este es un punto que atrapa a mucha gente: padding no funciona en tr. Las filas son cajas anonimas cuya altura la determinan sus celdas. Lo mismo pasa con margin: ni tr, ni td, ni th aceptan margenes. La separacion entre celdas se controla con padding dentro de la celda o con border-spacing en la tabla.
tbody tr:nth-child(even) para las filas cebra. El selector :nth-child(even) coincide con las filas 2, 4, 6. Su hermano :nth-child(odd) coincide con 1, 3, 5. Como el selector esta anclado a tbody, la fila de cabecera no cuenta en la numeracion. Si escribieras table tr:nth-child(even) sin anclar a tbody, y hubiese un thead, el conteo se reinicia dentro de cada grupo de filas de todos modos, porque :nth-child cuenta entre hermanos y las filas de thead y tbody no son hermanas entre si.
rgba(255, 255, 255, 0.03) en lugar de un color solido. Al usar un fondo semitransparente, la franja cebra se combina con lo que haya debajo. Eso permite que el :hover se vea correctamente encima sin tener que definir dos variantes de hover, una para filas pares y otra para impares.
vertical-align: middle. Por defecto las celdas alinean su contenido al centro vertical, pero conviene declararlo cuando las filas tienen alturas distintas. Los valores utiles en celdas son top, middle, bottom y baseline. En una tabla donde algunas celdas tienen texto largo y otras una sola palabra, top suele leerse mejor.
table-layout: como se calculan los anchos
Por defecto, el navegador usa el algoritmo de ancho automatico: lee toda la tabla, mide el contenido de cada celda, y reparte el ancho de las columnas segun lo que necesite el contenido. Es inteligente, pero tiene dos costos. El primero es de rendimiento: con miles de filas, medir todo antes de pintar nada se nota. El segundo es de control: una celda con una palabra muy larga puede ensanchar su columna y comprimir el resto.
table-layout: fixed cambia el algoritmo:
table {
table-layout: fixed;
width: 100%;
}
Con fixed, el navegador determina los anchos usando solo la primera fila de la tabla (y los col si existen), ignorando el contenido de las filas siguientes. Puede empezar a pintar antes de haber leido toda la tabla, y los anchos son predecibles.
| Aspecto | table-layout: auto | table-layout: fixed |
|---|---|---|
| Que mide | Todo el contenido de la tabla | Solo la primera fila y los col |
| Rendimiento con muchas filas | Se degrada | Constante |
| Predecibilidad de anchos | Baja | Alta |
| Contenido largo | Ensancha la columna | Se desborda o se corta |
Requiere width en la tabla | No | Practicamente si |
Cuando fijas los anchos, necesitas decidir que pasa con el texto que no cabe. Tres opciones:
/* Opcion 1: cortar con puntos suspensivos */
.truncar {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Opcion 2: partir palabras largas */
.partir {
overflow-wrap: break-word;
word-break: break-word;
}
/* Opcion 3: nunca partir, dejar que la columna aguante */
.sin-partir {
white-space: nowrap;
}
Un ejemplo completo con anchos fijos y columnas definidas por col:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Anchos fijos</title>
<style>
body {
background: #0d0f12;
color: #e6e9ee;
font-family: system-ui, sans-serif;
padding: 2rem;
}
table {
table-layout: fixed;
width: 100%;
border-collapse: collapse;
}
.col-id { width: 4rem; }
.col-desc { width: auto; }
.col-num { width: 7rem; }
th, td {
padding: 0.6rem;
border-bottom: 1px solid #2c333c;
text-align: left;
}
td.descripcion {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
td.numero {
text-align: right;
font-variant-numeric: tabular-nums;
}
</style>
</head>
<body>
<table>
<colgroup>
<col class="col-id">
<col class="col-desc">
<col class="col-num">
</colgroup>
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Descripcion</th>
<th scope="col">Monto</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">001</th>
<td class="descripcion">Inscripcion al torneo de verano categoria juvenil, incluye acceso a las tres pistas</td>
<td class="numero">12.500</td>
</tr>
<tr>
<th scope="row">002</th>
<td class="descripcion">Arriendo de kart pesado</td>
<td class="numero">8.000</td>
</tr>
<tr>
<th scope="row">003</th>
<td class="descripcion">Seguro de accidentes personales para participantes menores de edad</td>
<td class="numero">3.200</td>
</tr>
</tbody>
</table>
</body>
</html>
La columna de descripcion corta el texto con puntos suspensivos en lugar de ensanchar la tabla. Las columnas de ID y monto tienen ancho fijo, asi que las cifras siempre quedan alineadas.
Cabecera pegajosa al hacer scroll
Cuando una tabla tiene muchas filas, la cabecera se pierde al desplazarse. position: sticky la mantiene visible:
thead th {
position: sticky;
top: 0;
z-index: 2;
background: #1c2127;
}
Tres detalles que hacen que funcione o no:
El background es obligatorio. Sin un fondo opaco, las filas se ven pasando por detras de la cabecera. Como thead es transparente por defecto, hay que pintarlo.
El fondo va en th, no en thead. Si pintas thead y aplicas sticky a th, el fondo del thead no viaja con las celdas y quedas con cabeceras transparentes.
El borde inferior desaparece con border-collapse: collapse. En modo colapsado, los bordes pertenecen a la tabla, no a las celdas, asi que no se desplazan junto con la cabecera pegajosa. La solucion habitual es reemplazar el borde por una sombra, que si se pinta en la celda:
thead th {
position: sticky;
top: 0;
z-index: 2;
background: #1c2127;
border-bottom: none;
box-shadow: inset 0 -2px 0 #ff5252;
}
inset dibuja la sombra hacia adentro de la celda, formando una linea de 2px en el borde inferior que si acompana al elemento pegajoso.
Para que sticky tenga efecto, el elemento necesita un contenedor con scroll. Si la pagina completa hace scroll, top: 0 se ancla al viewport. Si envuelves la tabla en un contenedor con overflow: auto y altura maxima, se ancla a ese contenedor:
<div class="tabla-scroll">
<table>
<!-- contenido -->
</table>
</div>
.tabla-scroll {
max-height: 24rem;
overflow: auto;
border: 1px solid #2c333c;
border-radius: 8px;
}
Una precaucion: overflow: hidden en cualquier ancestro rompe sticky por completo, y es un error dificil de diagnosticar porque no produce ningun mensaje. Si tu cabecera pegajosa no pega, revisa los ancestros buscando overflow distinto de visible.
Tablas en pantallas pequenas
Una tabla de ocho columnas no cabe en un telefono. Hay dos estrategias razonables y una tercera que conviene conocer.
Estrategia 1: scroll horizontal en un contenedor
Es la mas simple y la que conserva la tabla intacta:
<div class="tabla-scroll" role="region" aria-label="Tabla de records" tabindex="0">
<table><!-- ... --></table>
</div>
.tabla-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tabla-scroll table {
min-width: 40rem;
}
El min-width en la tabla es lo que fuerza el scroll: sin el, la tabla se comprime hasta ser ilegible en lugar de desbordarse. El tabindex="0" junto con role="region" y aria-label hace que el contenedor sea alcanzable con el teclado, para que alguien que navega sin mouse pueda desplazarlo con las flechas.
Estrategia 2: reorganizar en tarjetas
En pantallas angostas, convertimos cada fila en un bloque donde cada dato lleva su etiqueta al lado. Se logra guardando el nombre de la columna en un atributo data-* y sacandolo con content: attr():
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tabla responsiva</title>
<style>
body {
background: #0d0f12;
color: #e6e9ee;
font-family: system-ui, sans-serif;
padding: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.6rem;
border-bottom: 1px solid #2c333c;
text-align: left;
}
@media (max-width: 40rem) {
thead {
/* Ocultamos la cabecera visualmente pero la mantenemos
fuera del flujo sin usar display: none */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
tbody tr {
display: block;
margin-bottom: 1rem;
border: 1px solid #2c333c;
border-radius: 8px;
padding: 0.5rem;
}
tbody td,
tbody th {
display: flex;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px dashed #2c333c;
text-align: right;
}
tbody tr > *:last-child {
border-bottom: none;
}
tbody td::before,
tbody th::before {
content: attr(data-etiqueta);
font-weight: 600;
color: #9aa4b2;
text-align: left;
}
}
</style>
</head>
<body>
<table>
<caption>Records de la pista</caption>
<thead>
<tr>
<th scope="col">Rango</th>
<th scope="col">Piloto</th>
<th scope="col">Vehiculo</th>
<th scope="col">Tiempo</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" data-etiqueta="Rango">1</th>
<td data-etiqueta="Piloto">Toad</td>
<td data-etiqueta="Vehiculo">Kart estandar</td>
<td data-etiqueta="Tiempo">1' 51'' 10</td>
</tr>
<tr>
<th scope="row" data-etiqueta="Rango">2</th>
<td data-etiqueta="Piloto">Mario</td>
<td data-etiqueta="Vehiculo">Kart estandar</td>
<td data-etiqueta="Tiempo">1' 51'' 11</td>
</tr>
</tbody>
</table>
</body>
</html>
Reduce el ancho de la ventana por debajo de 40rem y veras cada fila convertida en una tarjeta con etiquetas.
Hay una advertencia importante con esta tecnica: cambiar display de los elementos de tabla destruye la semantica de tabla. Cuando le pones display: block a un tr, el navegador deja de tratarlo como fila y la relacion entre celdas y cabeceras se pierde para las tecnologias asistivas. Por eso este enfoque conviene solo cuando el data-etiqueta reproduce la informacion de la cabecera en cada celda, como en el ejemplo. Si la tabla es densa y las relaciones importan, la estrategia 1 con scroll horizontal es la opcion segura.
Fijate tambien en como ocultamos el thead: no usamos display: none, porque eso lo elimina tambien para los lectores de pantalla. Usamos la tecnica de ocultamiento visual con clip-path: inset(50%) y tamano de 1px, que lo saca de la vista pero lo deja disponible en el arbol de accesibilidad.
Generar tablas desde JavaScript
Casi ninguna tabla de produccion se escribe a mano. Los datos vienen de una API, de un archivo, de un formulario, y la tabla se construye en el navegador. El DOM tiene una API dedicada a tablas que es mas comoda que armar HTML como texto.
La API de tablas del DOM
| Propiedad o metodo | Pertenece a | Que hace |
|---|---|---|
table.rows | HTMLTableElement | Coleccion viva de todas las filas de la tabla |
table.tHead | HTMLTableElement | Referencia al thead, o null |
table.tBodies | HTMLTableElement | Coleccion de los tbody |
table.tFoot | HTMLTableElement | Referencia al tfoot, o null |
table.caption | HTMLTableElement | Referencia al caption, o null |
table.createTHead() | HTMLTableElement | Crea el thead si no existe y lo devuelve |
table.createTBody() | HTMLTableElement | Crea un nuevo tbody y lo devuelve |
table.createCaption() | HTMLTableElement | Crea el caption si no existe y lo devuelve |
table.insertRow(i) | HTMLTableElement | Inserta un tr en la posicion indicada |
table.deleteRow(i) | HTMLTableElement | Elimina la fila en esa posicion |
tbody.rows | HTMLTableSectionElement | Filas de ese grupo |
tbody.insertRow(i) | HTMLTableSectionElement | Inserta una fila en ese grupo |
tr.cells | HTMLTableRowElement | Coleccion de celdas de la fila |
tr.insertCell(i) | HTMLTableRowElement | Inserta un td en la fila |
tr.rowIndex | HTMLTableRowElement | Indice de la fila en toda la tabla |
tr.sectionRowIndex | HTMLTableRowElement | Indice de la fila dentro de su grupo |
td.cellIndex | HTMLTableCellElement | Indice de la celda en su fila |
td.colSpan | HTMLTableCellElement | Lee o escribe el colspan |
td.rowSpan | HTMLTableCellElement | Lee o escribe el rowspan |
Un detalle sobre insertRow e insertCell: si les pasas -1 o no les pasas nada, insertan al final. Y insertCell siempre crea un td, nunca un th. Si necesitas una celda de cabecera debes crearla con document.createElement('th') e insertarla tu mismo.
Construir la tabla desde un arreglo de objetos
El HTML solo necesita una tabla vacia con un id:
<h1>Records generados desde datos</h1>
<table id="tabla-records"></table>
Y el script la construye completa. Reutiliza el CSS de la seccion anterior:
const columnas = [
{ clave: 'piloto', titulo: 'Piloto', tipo: 'texto' },
{ clave: 'vehiculo', titulo: 'Vehiculo', tipo: 'texto' },
{ clave: 'vueltas', titulo: 'Vueltas', tipo: 'numero' },
{ clave: 'segundos', titulo: 'Tiempo', tipo: 'numero' }
];
const datos = [
{ piloto: 'Toad', vehiculo: 'Kart estandar', vueltas: 3, segundos: 111.10 },
{ piloto: 'Mario', vehiculo: 'Kart estandar', vueltas: 3, segundos: 111.11 },
{ piloto: 'Luigi', vehiculo: 'Kart estandar', vueltas: 3, segundos: 111.12 },
{ piloto: 'Wario', vehiculo: 'Kart pesado', vueltas: 3, segundos: 111.13 },
{ piloto: 'Bowser', vehiculo: 'Kart pesado', vueltas: 3, segundos: 111.14 }
];
function formatearTiempo(segundos) {
const minutos = Math.floor(segundos / 60);
const resto = (segundos % 60).toFixed(2);
return minutos + "' " + resto.padStart(5, '0') + "''";
}
function construirTabla(tabla, columnas, filas) {
tabla.innerHTML = ''; // limpiamos contenido previo
const titulo = tabla.createCaption();
titulo.textContent = 'Tiempos registrados en la pista Luigi Raceway.';
const cabecera = tabla.createTHead();
const filaCabecera = cabecera.insertRow();
for (const columna of columnas) {
const celda = document.createElement('th');
celda.scope = 'col';
celda.textContent = columna.titulo;
filaCabecera.appendChild(celda); // insertCell solo crea td
}
const cuerpo = tabla.createTBody();
for (const fila of filas) {
const tr = cuerpo.insertRow();
for (const columna of columnas) {
const td = tr.insertCell();
const valor = fila[columna.clave];
td.textContent = columna.clave === 'segundos'
? formatearTiempo(valor)
: valor;
if (columna.tipo === 'numero') {
td.classList.add('numero');
}
}
}
}
construirTabla(document.getElementById('tabla-records'), columnas, datos);
Nota el uso de textContent en lugar de innerHTML para poner el contenido de cada celda. Si los datos vienen de fuera (una API, un formulario) y contienen algo como <script>, innerHTML lo interpretaria como HTML y podria ejecutarlo. textContent inserta el valor como texto literal, sin interpretar etiquetas. Es la opcion segura por defecto.
Ordenar por columna al hacer clic
Ahora agregamos ordenamiento. La logica es directa: al hacer clic en una cabecera, tomamos las filas del tbody, las ordenamos comparando el valor de esa columna, y las volvemos a insertar en el nuevo orden.
sequenceDiagram
participant U as Usuario
participant TH as Cabecera th
participant JS as Manejador de clic
participant D as Arreglo de datos
participant TB as tbody del DOM
U->>TH: Clic en la columna Tiempo
TH->>JS: Evento click con la clave de columna
JS->>JS: Es la misma columna del orden anterior?
alt Misma columna
JS->>JS: Invertir la direccion asc / desc
else Columna distinta
JS->>JS: Direccion inicial ascendente
end
JS->>D: Ordenar copia del arreglo por esa clave
D-->>JS: Arreglo ordenado
JS->>TB: Vaciar filas actuales
JS->>TB: Insertar filas en el nuevo orden
JS->>TH: Actualizar aria-sort en todas las cabeceras
TB-->>U: Tabla reordenada visible
El codigo completo:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tabla ordenable</title>
<style>
body {
background: #0d0f12;
color: #e6e9ee;
font-family: system-ui, sans-serif;
padding: 2rem;
}
table {
width: 100%;
max-width: 44rem;
border-collapse: collapse;
background: #161a1f;
}
th, td {
padding: 0.65rem 0.85rem;
border-bottom: 1px solid #2c333c;
text-align: left;
}
thead th {
background: #1c2127;
border-bottom: 2px solid #ff5252;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9aa4b2;
cursor: pointer;
user-select: none;
}
thead th:hover {
color: #e6e9ee;
}
thead th::after {
content: '';
display: inline-block;
margin-left: 0.4rem;
opacity: 0.4;
}
thead th[aria-sort="ascending"]::after {
content: '\2191';
opacity: 1;
color: #ff5252;
}
thead th[aria-sort="descending"]::after {
content: '\2193';
opacity: 1;
color: #ff5252;
}
thead th[aria-sort="none"]::after {
content: '\2195';
}
tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.03);
}
td.numero {
text-align: right;
font-variant-numeric: tabular-nums;
}
</style>
</head>
<body>
<h1>Records ordenables</h1>
<table id="tabla"></table>
<script>
const columnas = [
{ clave: 'piloto', titulo: 'Piloto', tipo: 'texto' },
{ clave: 'vehiculo', titulo: 'Vehiculo', tipo: 'texto' },
{ clave: 'vueltas', titulo: 'Vueltas', tipo: 'numero' },
{ clave: 'segundos', titulo: 'Tiempo', tipo: 'numero' }
];
const datos = [
{ piloto: 'Toad', vehiculo: 'Kart estandar', vueltas: 3, segundos: 111.10 },
{ piloto: 'Mario', vehiculo: 'Kart estandar', vueltas: 3, segundos: 111.11 },
{ piloto: 'Luigi', vehiculo: 'Kart estandar', vueltas: 4, segundos: 148.20 },
{ piloto: 'Wario', vehiculo: 'Kart pesado', vueltas: 3, segundos: 111.13 },
{ piloto: 'Bowser', vehiculo: 'Kart pesado', vueltas: 4, segundos: 149.05 }
];
// Intl.Collator ordena texto respetando acentos y la letra n con tilde
const comparadorTexto = new Intl.Collator('es', { sensitivity: 'base' });
let claveActual = null;
let direccionActual = 'ascending';
function formatearTiempo(segundos) {
const minutos = Math.floor(segundos / 60);
const resto = (segundos % 60).toFixed(2);
return minutos + "' " + resto.padStart(5, '0') + "''";
}
function comparar(a, b, columna) {
const va = a[columna.clave];
const vb = b[columna.clave];
if (columna.tipo === 'numero') {
return va - vb;
}
return comparadorTexto.compare(String(va), String(vb));
}
function ordenar(clave) {
const columna = columnas.find(c => c.clave === clave);
if (claveActual === clave) {
direccionActual = direccionActual === 'ascending' ? 'descending' : 'ascending';
} else {
claveActual = clave;
direccionActual = 'ascending';
}
const signo = direccionActual === 'ascending' ? 1 : -1;
datos.sort((a, b) => signo * comparar(a, b, columna));
pintarCuerpo();
actualizarIndicadores();
}
function actualizarIndicadores() {
const cabeceras = tabla.tHead.rows[0].cells;
for (const th of cabeceras) {
if (th.dataset.clave === claveActual) {
th.setAttribute('aria-sort', direccionActual);
} else {
th.setAttribute('aria-sort', 'none');
}
}
}
function pintarCabecera() {
const cabecera = tabla.createTHead();
const fila = cabecera.insertRow();
for (const columna of columnas) {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = columna.titulo;
th.dataset.clave = columna.clave;
th.setAttribute('aria-sort', 'none');
th.tabIndex = 0;
th.addEventListener('click', () => ordenar(columna.clave));
th.addEventListener('keydown', (evento) => {
if (evento.key === 'Enter' || evento.key === ' ') {
evento.preventDefault();
ordenar(columna.clave);
}
});
fila.appendChild(th);
}
}
function pintarCuerpo() {
// Eliminamos el tbody anterior si existe
if (tabla.tBodies.length > 0) {
tabla.tBodies[0].remove();
}
const cuerpo = tabla.createTBody();
for (const fila of datos) {
const tr = cuerpo.insertRow();
for (const columna of columnas) {
const td = tr.insertCell();
const valor = fila[columna.clave];
td.textContent = columna.clave === 'segundos'
? formatearTiempo(valor)
: valor;
if (columna.tipo === 'numero') {
td.classList.add('numero');
}
}
}
}
const tabla = document.getElementById('tabla');
pintarCabecera();
pintarCuerpo();
actualizarIndicadores();
</script>
</body>
</html>
Tres puntos de este ejemplo merecen atencion.
Se ordena el arreglo de datos, no las filas del DOM. Podrias tomar los tr y reordenarlos, pero entonces tendrias que leer los valores desde el texto de las celdas, y ese texto ya esta formateado (1' 51'' 10 no se puede comparar numericamente). Ordenar la fuente de datos y repintar es mas simple y correcto.
Intl.Collator en lugar de comparar cadenas con <. El operador < compara por punto de codigo Unicode, asi que “Ñ” queda despues de “Z”, y “arbol” queda despues de “Arbol”. Intl.Collator('es') ordena segun las reglas del espanol. La opcion sensitivity: 'base' hace que mayusculas y acentos no alteren el orden.
aria-sort en la cabecera. Este atributo comunica a las tecnologias asistivas por que columna esta ordenada la tabla y en que direccion. Sus valores son ascending, descending, none y other. Solo una cabecera debe tener un valor distinto de none a la vez. El CSS aprovecha ese mismo atributo para dibujar la flecha con ::after, asi que el indicador visual y el accesible se mantienen sincronizados sin codigo extra.
Las secuencias \2191, \2193 y \2195 en la propiedad content son escapes CSS de caracteres Unicode: flecha arriba, flecha abajo y flecha doble vertical. En CSS los caracteres se escapan con barra invertida seguida del codigo hexadecimal, sin el prefijo &#x que usariamos en HTML.
Errores comunes
| Sintoma | Causa | Solucion |
|---|---|---|
| Bordes dobles entre celdas | El modo por defecto es border-collapse: separate y cada celda dibuja su propio borde | Aplica border-collapse: collapse en el table |
| Una fila queda mas corta que las demas | Faltan td en esa fila, o una celda con rowspan de arriba ya ocupa ese lugar y ademas se escribieron las celdas | Cuenta que la suma de colspan mas las posiciones reservadas por rowspan de filas anteriores de igual el numero de columnas |
| La tabla gana una columna vacia al final | Una fila tiene una celda de mas, o un colspan que suma mas de lo debido | Revisa fila por fila la suma de colspan |
padding o margin en tr no hacen nada | tr no acepta relleno ni margenes; su caja la definen las celdas | Aplica padding a th y td, o usa border-spacing en el table |
El selector table > tr no coincide con nada | El navegador inserta un tbody implicito, asi que tr no es hijo directo de table | Escribe tbody de forma explicita y usa table > tbody > tr |
border-radius en las celdas no se aplica | Con border-collapse: collapse los bordes pertenecen a la tabla y se ignoran los radios de celda | Usa border-collapse: separate con border-spacing: 0, o redondea el contenedor |
| La cabecera pegajosa pierde su borde inferior al hacer scroll | Con collapse, el borde no viaja con la celda desplazada | Reemplaza el borde por box-shadow: inset 0 -2px 0 <color> |
position: sticky no funciona en la cabecera | Algun ancestro tiene overflow distinto de visible | Revisa la cadena de ancestros y elimina el overflow: hidden |
| Una palabra larga descuadra todas las columnas | Con table-layout: auto el contenido determina el ancho | Usa table-layout: fixed con anchos en col, mas overflow-wrap o text-overflow |
| Las filas cebra empiezan en la fila equivocada | El selector cuenta la fila de cabecera | Ancla el selector: tbody tr:nth-child(even) |
| Un lector de pantalla lee la tabla como texto suelto | Falta scope en los th, o la tabla se armo con div | Usa th con scope="col" o scope="row", o headers en tablas irregulares |
| La tabla en movil se comprime hasta ser ilegible | El contenedor tiene overflow-x: auto pero la tabla no tiene ancho minimo | Agrega min-width a la tabla para forzar el desbordamiento |
Al aplicar display: block a las filas se pierde la accesibilidad | Cambiar display destruye el rol de tabla | Repite la cabecera en cada celda con data-* y content: attr(), o prefiere el scroll horizontal |
insertCell crea un td cuando queria un th | insertCell solo crea celdas de datos por diseno | Crea el th con document.createElement('th') y usa appendChild |
| Datos externos rompen la tabla o inyectan HTML | Se uso innerHTML para poner el contenido de las celdas | Usa textContent para valores que vienen de fuera |
| El texto se ordena mal con enes y acentos | La comparacion con < usa puntos de codigo Unicode | Usa new Intl.Collator('es').compare |
| Los numeros de una columna no se alinean verticalmente | La fuente usa digitos de ancho proporcional | Agrega font-variant-numeric: tabular-nums y text-align: right |
Las celdas vacias no muestran su borde en modo separate | El valor por defecto de empty-cells puede ocultarlas en ciertos casos | Declara empty-cells: show en el table |
Ejercicios propuestos
1. Horario semanal. Construye una tabla con los cinco dias habiles como columnas y cuatro bloques horarios como filas. Usa th scope="col" para los dias y th scope="row" para los horarios. Al menos una actividad debe ocupar dos bloques seguidos con rowspan, y al menos una debe ocupar dos dias con colspan. Verifica que cada fila sume exactamente seis posiciones contando la columna de horarios.
2. Boleta de compra. Arma una tabla con las columnas Producto, Cantidad, Precio unitario y Subtotal. Agrega un tfoot con tres filas: neto, impuesto y total. En cada una de esas filas usa colspan="3" en la primera celda para que la etiqueta ocupe el ancho de las tres primeras columnas y el monto quede alineado bajo la columna de subtotal.
3. Tabla de estadisticas con cabeceras a dos niveles. Crea una tabla donde la primera fila de la cabecera agrupe columnas (“Local” y “Visita”, cada una con colspan="2" y scope="colgroup") y la segunda fila tenga las columnas concretas (“Goles”, “Tiros”). Confirma que la suma de la primera fila es igual al numero de columnas de la segunda.
4. Estilo desde cero. Toma la tabla del ejercicio 2 y aplicale: border-collapse: collapse, solo lineas horizontales, filas cebra ancladas al tbody, la columna de montos alineada a la derecha con tabular-nums, y un caption posicionado abajo con caption-side.
5. Anchos fijos. Rehaz el ejercicio 4 con table-layout: fixed y un colgroup que asigne 45% a Producto, 15% a Cantidad, 20% a Precio y 20% a Subtotal. Haz que la descripcion del producto se corte con puntos suspensivos cuando no quepa.
6. Cabecera pegajosa. Genera cincuenta filas de datos y envuelve la tabla en un contenedor con max-height: 20rem y overflow: auto. Aplica position: sticky a las celdas de la cabecera con fondo opaco y una linea inferior hecha con box-shadow: inset. Comprueba que la cabecera se mantiene visible mientras te desplazas.
7. Version responsiva. Agrega a la tabla del ejercicio 6 una media query que, bajo 40rem de ancho, convierta cada fila en una tarjeta usando data-etiqueta y content: attr(). Oculta el thead con la tecnica de ocultamiento visual, no con display: none.
8. Tabla generada. Escribe una funcion construirTabla(contenedor, columnas, filas) que reciba la definicion de columnas y un arreglo de objetos, y genere la tabla completa con createTHead, createTBody, insertRow e insertCell. Usa textContent para todos los valores.
9. Ordenamiento en ambas direcciones. Agrega a la funcion anterior la capacidad de ordenar al hacer clic en cualquier cabecera, alternando entre ascendente y descendente. Usa Intl.Collator para las columnas de texto y resta directa para las numericas. Actualiza aria-sort en cada cabecera y dibuja la flecha con ::after a partir de ese mismo atributo.
10. Filtro de filas. Agrega un campo de texto sobre la tabla que filtre las filas mostrando solo las que contienen el texto escrito en alguna de sus celdas. Filtra el arreglo de datos y vuelve a pintar el tbody, en lugar de ocultar filas con display: none, para que el conteo de las filas cebra siga siendo correcto.
Lo que viene
Las tablas nos dieron la primera estructura bidimensional del curso, y con ella aparecieron ideas de CSS que van mucho mas alla de las tablas: cajas con relleno y bordes, seleccion por posicion con :nth-child, atributos usados como selectores, y contenido generado con ::after. Todo eso reaparece constantemente en el resto del curso.
En el capitulo 5 pasamos al contenido visual: la etiqueta img con sus atributos src, alt, title, width y height, los formatos de imagen que conviene usar en cada caso, como controlar el recorte y el ajuste con object-fit, los fondos con background-image y sus propiedades de tamano, repeticion y posicion, y las propiedades de posicionamiento de CSS que permiten mover elementos fuera de su lugar natural en el flujo del documento. Si en algun momento necesitas volver sobre lo anterior, el temario completo esta en el indice del curso.