Tablas HTML: estructura, celdas combinadas y estilo con CSS

Por: Artiko
htmlcssjavascriptdomfrontendtablascolspanrowspanborder-collapseaccesibilidaddatos-tabulares

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:

CasoElemento correctoMotivo
Ranking de resultados por piloto y pistatableEl dato depende de fila y columna
Horario semanal por dia y bloque horariotableCuadricula real de dos ejes
Comparacion de planes por caracteristicatableCada celda cruza plan con caracteristica
Galeria de tarjetas de productoul + CSS GridEs una lista, la posicion no aporta significado
Layout general de la pagina (menu, contenido, pie)Elementos semanticos + CSSLa posicion es presentacion, no dato
Formulario con etiquetas alineadasform + CSS GridAlineacion 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:

  • table es el contenedor completo. Todo lo demas vive dentro.
  • tr viene de table row: es una fila, la linea horizontal.
  • th viene de table header cell: es una celda de cabecera.
  • td viene 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:

  • thead contiene la fila (o filas) que nombran las columnas.
  • tbody contiene los datos.
  • tfoot contiene 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 scopeSignificadoUso tipico
colEsta celda encabeza la columna en la que estaFila de titulos dentro de thead
rowEsta celda encabeza la fila en la que estaPrimera celda de cada fila de datos
colgroupEncabeza un grupo de columnasCabecera que abarca varias columnas con colspan
rowgroupEncabeza un grupo de filasCabecera 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 colFuncionaComentario
widthSiDefine el ancho de la columna
background / background-colorSiSe pinta detras de las celdas
borderSiSolo con border-collapse: collapse
visibility: collapseSiOculta la columna completa
colorNoEl color de texto lo define la celda
text-alignNoSe aplica a cada td o th
paddingNoEl 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.

ElementoNombre completoPadre validoNotas
tableTableContenido de flujoContenedor raiz
captionCaptiontableDebe ser el primer hijo
colgroupColumn grouptableVa despues de caption
colColumncolgroupElemento vacio, sin cierre
theadTable headtableSolo uno por tabla
tbodyTable bodytablePuede haber varios
tfootTable foottableSolo uno por tabla
trTable rowthead, tbody, tfootContiene celdas
thTable header celltrCelda de cabecera
tdTable data celltrCelda de datos
AtributoSe aplica aValorQue hace
colspanth, tdEntero positivoNumero de columnas que ocupa la celda
rowspanth, tdEntero positivo, o 0 en tbodyNumero de filas que ocupa la celda
scopethcol, row, colgroup, rowgroupDeclara que encabeza la celda
headersth, tdLista de id separada por espaciosAsocia una celda con cabeceras concretas
abbrthTextoVersion corta del titulo para lectores de pantalla
spancol, colgroupEntero positivoNumero 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:

Aspectoseparatecollapse
Bordes entre celdasDos lineas independientesUna linea fusionada
border-spacingFuncionaSe ignora
border-radius en celdasFuncionaSe ignora
box-shadow en celdasFuncionaComportamiento inconsistente
Borde en trSe ignoraFunciona
Borde en colSe ignoraFunciona
Resolucion de conflictosNo aplicaGana el borde mas ancho y visible
position: sticky en thFunciona limpioPuede 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.

Aspectotable-layout: autotable-layout: fixed
Que mideTodo el contenido de la tablaSolo la primera fila y los col
Rendimiento con muchas filasSe degradaConstante
Predecibilidad de anchosBajaAlta
Contenido largoEnsancha la columnaSe desborda o se corta
Requiere width en la tablaNoPracticamente 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 metodoPertenece aQue hace
table.rowsHTMLTableElementColeccion viva de todas las filas de la tabla
table.tHeadHTMLTableElementReferencia al thead, o null
table.tBodiesHTMLTableElementColeccion de los tbody
table.tFootHTMLTableElementReferencia al tfoot, o null
table.captionHTMLTableElementReferencia al caption, o null
table.createTHead()HTMLTableElementCrea el thead si no existe y lo devuelve
table.createTBody()HTMLTableElementCrea un nuevo tbody y lo devuelve
table.createCaption()HTMLTableElementCrea el caption si no existe y lo devuelve
table.insertRow(i)HTMLTableElementInserta un tr en la posicion indicada
table.deleteRow(i)HTMLTableElementElimina la fila en esa posicion
tbody.rowsHTMLTableSectionElementFilas de ese grupo
tbody.insertRow(i)HTMLTableSectionElementInserta una fila en ese grupo
tr.cellsHTMLTableRowElementColeccion de celdas de la fila
tr.insertCell(i)HTMLTableRowElementInserta un td en la fila
tr.rowIndexHTMLTableRowElementIndice de la fila en toda la tabla
tr.sectionRowIndexHTMLTableRowElementIndice de la fila dentro de su grupo
td.cellIndexHTMLTableCellElementIndice de la celda en su fila
td.colSpanHTMLTableCellElementLee o escribe el colspan
td.rowSpanHTMLTableCellElementLee 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

SintomaCausaSolucion
Bordes dobles entre celdasEl modo por defecto es border-collapse: separate y cada celda dibuja su propio bordeAplica border-collapse: collapse en el table
Una fila queda mas corta que las demasFaltan td en esa fila, o una celda con rowspan de arriba ya ocupa ese lugar y ademas se escribieron las celdasCuenta 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 finalUna fila tiene una celda de mas, o un colspan que suma mas de lo debidoRevisa fila por fila la suma de colspan
padding o margin en tr no hacen nadatr no acepta relleno ni margenes; su caja la definen las celdasAplica padding a th y td, o usa border-spacing en el table
El selector table > tr no coincide con nadaEl navegador inserta un tbody implicito, asi que tr no es hijo directo de tableEscribe tbody de forma explicita y usa table > tbody > tr
border-radius en las celdas no se aplicaCon border-collapse: collapse los bordes pertenecen a la tabla y se ignoran los radios de celdaUsa border-collapse: separate con border-spacing: 0, o redondea el contenedor
La cabecera pegajosa pierde su borde inferior al hacer scrollCon collapse, el borde no viaja con la celda desplazadaReemplaza el borde por box-shadow: inset 0 -2px 0 <color>
position: sticky no funciona en la cabeceraAlgun ancestro tiene overflow distinto de visibleRevisa la cadena de ancestros y elimina el overflow: hidden
Una palabra larga descuadra todas las columnasCon table-layout: auto el contenido determina el anchoUsa table-layout: fixed con anchos en col, mas overflow-wrap o text-overflow
Las filas cebra empiezan en la fila equivocadaEl selector cuenta la fila de cabeceraAncla el selector: tbody tr:nth-child(even)
Un lector de pantalla lee la tabla como texto sueltoFalta scope en los th, o la tabla se armo con divUsa th con scope="col" o scope="row", o headers en tablas irregulares
La tabla en movil se comprime hasta ser ilegibleEl contenedor tiene overflow-x: auto pero la tabla no tiene ancho minimoAgrega min-width a la tabla para forzar el desbordamiento
Al aplicar display: block a las filas se pierde la accesibilidadCambiar display destruye el rol de tablaRepite la cabecera en cada celda con data-* y content: attr(), o prefiere el scroll horizontal
insertCell crea un td cuando queria un thinsertCell solo crea celdas de datos por disenoCrea el th con document.createElement('th') y usa appendChild
Datos externos rompen la tabla o inyectan HTMLSe uso innerHTML para poner el contenido de las celdasUsa textContent para valores que vienen de fuera
El texto se ordena mal con enes y acentosLa comparacion con < usa puntos de codigo UnicodeUsa new Intl.Collator('es').compare
Los numeros de una columna no se alinean verticalmenteLa fuente usa digitos de ancho proporcionalAgrega font-variant-numeric: tabular-nums y text-align: right
Las celdas vacias no muestran su borde en modo separateEl valor por defecto de empty-cells puede ocultarlas en ciertos casosDeclara 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.