Posición CSS Sticky: Fijar cabecera de una tabla

Nuevo posicionamiento CSS Sticky

Figura
Figura. Position sticky

Los posicionamientos CSS que conocemos hace tiempo son static, relative, absolute y fixed. Ahora se introduce el nuevo posicionamiento sticky. El documento oficial W3C Sticky positioning está en fase de borrador (Working Draft) con fecha 01/09/2022. A la fecha en la que edito esta página (diciembre 2022) ya es soportado por todos los navegadores.

Un elemento con posición sticky es inicialmente tratado como static y posicionado en el flujo normal del documento, tras lo cual se aplica un desplazamiento a sus posiciones top, right, bottom y left de forma absoluta respecto al contenedor que supone un bloque de contención para ese elemento. Es una combinación de posicionamiento relativo y absoluto respecto a su bloque de contención. Las barras de desplazamiento (scroll) del contenedor no afecta al elemento, fijándose en esa posición independientemente de ese scroll.

Recordemos que el bloque de contención a efectos de posicionamientos static, relative o sticky es el ascendiente más cercano que es un elemento de bloque, con display con valor block o inline-block por ejemplo. Para el posicionamiento absolute es el ascendiente más cercano que tiene posición distinta de static. Por último para posicionamiento fixed el bloque de contención es el viewport, que usualmente llamamos ventana del navegador.

En este tema recordaremos todos los posicionamientos con un ejemplo interactivo a continuación. En otro apartado veremos una aplicación práctica para sticky. Se tratará de fijar las filas y columnas cabeceras de una tabla.

Ejemplo: Posiciones CSS

Container position
En un lugar de la Mancha, de cuyo nombre no quiero acordarme, no ha mucho tiempo que vivía un hidalgo de los de lanza en astillero, adarga antigua, rocín flaco y galgo corredor. Una olla de algo más vaca que carnero, salpicón las más noches, duelos y quebrantos los sábados, lantejas los viernes, algún palomino de añadidura los domingos, consumían las tres partes de su hacienda. El resto della concluían sayo de velarte, calzas de velludo para las fiestas, con sus pantuflos de lo mesmo, y los días de entresemana se honraba con su vellorí de lo más fino. Tenía en su casa una ama que pasaba de los cuarenta y una sobrina que no llegaba a los veinte, y un mozo de campo y plaza que así ensillaba el rocín como tomaba la podadera. Frisaba la edad de nuestro hidalgo con los cincuenta años. Era de complexión recia, seco de carnes, enjuto de rostro, gran madrugador y amigo de la caza. Quieren decir que tenía el sobrenombre de «Quijada», o «Quesada», que en esto hay alguna diferencia en los autores que deste caso escriben, aunque por conjeturas verisímiles se deja entender que se llamaba «Quijana». Pero esto importa poco a nuestro cuento: basta que en la narración dél no se salga un punto de la verdad.
Box display computed: inline
Box top left initial: 0px 0px
Box top left current: 0px 0px
Figura
Figura. BOX inline: Position static

El ejemplo interactivo anterior tiene un bloque DIV identificado con id="container", con un texto en su interior. Resaltamos las palabras "rocín" y "flaco" en elementos STRONG, siendo el segundo identificado con id="box" con un fondo de color. En lo que sigue nos referiremos a estos elementos como CONTAINER y BOX. En la Figura se observan estos elementos con posicionamiento static.

El flujo de texto posiciona el elemento BOX en la posición arriba e izquierda 38px 123px, datos que presentamos en el ejemplo para observar como se desplaza realmente el elemento con cada posicionamiento. A BOX le damos estilos top, left, z-index, width y height. Todos esos valores serán ignorados para BOX, pues inicialmente es un elemento en línea (inline) con posición estática (static).

Figura
Figura. BOX inline: Position relative

En la Figura se observa el posicionamiento relative para el elemento BOX, trasladándose 100px hacia abajo y 100px a la derecha, contando desde su posición inicial en el flujo del elemento. Es decir, se desplaza esas cantidades relativamente a su posición inicial, desde 38px 123px hasta 138px 223px. El espacio que inicialmente ocupaba BOX es preservado.

Si usamos el scroll del elemento CONTAINER veremos que el elemento BOX se mueve con el scroll. Con el posicionamiento relativo se ignoran las propiedades width y height que le hemos dado a BOX, pues es declarado como inline. En el ejemplo existe la posibilidad de hacerlo inline-block o block, en cuyo caso si aplicarán esas propiedades.

Figura
Figura. BOX inline: Position sticky

En la Figura tenemos el posicionamiento sticky para el elemento BOX. En vertical se desplaza hasta la posición top 100px. Vea que no se desplaza esta cantidad, sino a esa posición. Por lo tanto no es relativa a su posición inicial, sino absoluta respecto a CONTAINER. Sin embargo en horizontal no lo hace. Al ser un elemento inline cuya posición horizontal izquierda inicial en el flujo era 123px, mientras esa posición sea inferior a esa medida no se aplicará. En el ejemplo puede probar a aplicar una posición izquierda superior a 123px para observar que entonces si lo aplica. También se aplicarán ambos si cambiamos BOX a inline-block o block. Vea que también se preserva el espacio inicial en el flujo de BOX.

El posicionamiento sticky de BOX funciona como una combinación de relative y absolute, a excepción de cuando actúamos sobre el scroll de CONTAINER, observándose que se queda fijo en la misma posición donde se desplazó. Esto será útil para fijar cabeceras de una tabla, como veremos en apartados posteriores.

Figura
Figura. BOX inline: Position absolute (CONTAINER relative)

En la Figura vemos el posicionamiento absolute para el elemento BOX. En primer lugar observamos que no se preserva el espacio inicial de BOX. De hecho no forma parte del flujo de texto del elemento CONTAINER. Por otro lado, aunque BOX es declarado inline, el valor computado es block, aplicándose las propiedades width y height.

En este ejemplo dimos posicionamiento relative a CONTAINER, con lo que BOX se desplaza a la posición 100px 100px de forma absoluta respecto a CONTAINER. Si dejamos CONTAINER con posicionamiento static, entonces la ubicación sería 100px 100px respecto a la ventana del navegador, comportándose como el posicionamiento fixed que veremos a continuación.

Recordar que antes dijimos que el bloque de contención para el posicionamiento absoluto era el ascendiente más cercano con posicionamiento distinto de static. Posicionar relative el elemento CONTAINER sin aplicar posiciones top, left, right o bottom no produce ningún efecto en su presentación, comportándose visualmente como si fuera static. Por eso usamos relative en un elemento padre cuando queremos posicionar absolute algún elemento hijo respecto a su padre.

Figura
Figura. BOX inline: Position fixed

Por último vemos en la Figura un posicionamiento fixed para BOX. Se ubica en la posición 100px 100px de la ventana del navegador, permaneciendo fija cuando actuamos sobre el scroll de la ventana. Observe que también preserva el espacio inicial y se computa a block, aplicándose ancho y alto a pesar de ser un elemento inline.

Aplicación práctica de Sticky: fijar cabeceras de una tabla

Figura
Figura. Posicionamiento sticky en celdas de una tabla

Una aplicación práctica para sticky es fijar las filas o columnas cabeceras de una tabla. En la Figura vemos una tabla cuyo ancho y alto es mayor que el contenedor donde se ubica y donde hemos fijado la primera fila y la primera columna. Actuando sobre las barras de desplazamiento (scroll) del contenedor exterior veremos en el siguiente ejemplo interactivo que la primera fila permanece fija respecto al scroll vertical. Y que la primera columna permanece fija respecto al scroll horizontal.

Exponemos en este apartado el ejemplo y el CSS con notas explicativas necesarias para implementarlo. En siguientes apartados analizaremos características de las tablas que influyen sobre este comportamiento.

Ejemplo: Tabla con cabeceras fijas con sticky

ColorRGBHEXHSLBG
black0,0,0#0000000,0%,0%0
gray128,128,128#8080800,0%,50.2%8421504
silver192,192,192#C0C0C00,0%,75.29%12632256
white255,255,255#FFFFFF0,0%,100%16777215
navy0,0,128#000080240,100%,25.1%128
blue0,0,255#0000FF240,100%,50%255
aqua0,255,255#00FFFF180,100%,50%65535
green0,128,0#008000120,100%,25.1%32768
teal0,128,128#008080180,100%,25.1%32896
olive128,128,0#80800060,100%,25.1%8421376
lime0,255,0#00FF00120,100%,50%65280
yellow255,255,0#FFFF0060,100%,50%16776960
maroon128,0,0#8000000,100%,25.1%8388608
purple128,0,128#800080300,100%,25.1%8388736
red255,0,0#FF00000,100%,50%16711680
orange255,165,0#FFA50038.82,100%,50%16753920
fuchsia255,0,255#FF00FF300,100%,50%16711935

Este es el HTML del ejemplo, donde obviamos filas de la tabla para abreviar:

<div id="stickytab">
    <table>
        <tr><th>Color</th><th>RGB</th><th>HEX</th>···</tr>
        ···
    </table>
</div>

Y el CSS para llevarlo a cabo es el siguiente, donde las notas indican lo mínimo imprescindible para su funcionamiento:

#stickytab { /* Outer container */
    display: inline-block; /* (1) */
    height: 20em; /* (1) */
    width: 20em; /* (1) */
    overflow: auto; /* (1) */
    margin: 0.2em;
    min-height: 6em;
    min-width: 6em;
    resize: both;
    font-family: 'Arial', sans-serif;
}
#stickytab table { /* Table */
    border-collapse: separate;  /* (3) Default value */
    border-spacing: 0;  /* (3) */
}
#stickytab table tr * { /* Cells */
    border: black solid 1px; /* (3) */
    border-top: none; /* (3) */
    border-left: none; /* (3) */
    padding: 0.2em;
}
#stickytab table tr:first-child * { /* Cells of the first row */
    position: sticky; /* (2) */
    top: 0; /* (2) */
    z-index: 1; /* (2) */
    border-top: black solid 1px; /* (3) */
    background-color: gainsboro; /* (3) */
}
#stickytab table tr *:first-child { /* Cells of the first column */
    position: sticky; /* (2) */
    left: 0; /* (2) */
    z-index: 2; /* (2) */
    border-left: black solid 1px; /* (3) */
    background-color: white; /* (3) */
}
#stickytab table tr:first-child *:first-child { /* Top left cell */
    z-index: 3; /* (2) */
    background-color: gainsboro; /* (3) */
}

Las anotaciones en el código anterior indican las características mínimas para que el ejemplo funcione como se espera:

  1. Contenedor exterior: Damos ancho y alto al contenedor exterior, siendo en este ejemplo las medidas de 20em que no cubren todo el contenido de la tabla. Declaramos el contenedor como un bloque en línea (display: inline-block), pues así la barra de scroll horizontal se dispone junto al bloque. Controlamos con overflow: auto el desbordamiento, para que se hagan visibles las barras de desplazamiento de forma automática cuando sean necesarias.
  2. Posicionamiento: A las celdas de la primera fila y primera columna le damos position: sticky. Para completar este posicionamiento ubicamos arriba (top: 0) las celdas de la primera fila. E izquierda las de la primera columna (left: 0). A la primera fila la ubicamos en una capa superior (z-index: 1). Y a las de la primera columna en la siguiente capa (z-index: 2). Por último la primera celda superior izquierda debe ir en otra capa superior (z-index: 3). Con estas disposiciones de capas controlamos por donde se desplazan las celdas.
  3. Bordes: La tabla ha de tener border-collapse: separate, valor por defecto que no sería necesario incluir en el CSS. El espaciado de bordes ha de ser cero (border-spacing: 0). Todas las celdas tendrán un borde (border: black solid 1px), pero a continuación anulamos el borde superior e izquierdo. A las de la primera fila le ponemos el borde superior y a las de la primera columna el borde izquierdo. Es necesario dotar de colores de fondo (background-color) para ocultar los contenidos de las celdas que se desplazan por debajo de otras.

En los siguientes apartados explicaremos algunas particularidades de una tabla para entender el motivo de usar el anterior CSS para lograr ese objetivo.

He actualizado el componente tabla de datos para permitir fijar con sticky los selectores de la primera fila y primera columna.

Ancho y alto de una tabla

Para entender la aplicación de sticky para fijar filas y columnas cabeceras de una tabla empezaremos por ver como responde una tabla a las propiedades ancho y alto. Usaremos el HTML siguiente, donde omitimos filas para abreviar:

<table id="tab1">
    <tr><th>Color</th><th>RGB</th><th>HEX</th>···</tr>
    ···
</table>

El siguiente ejemplo interactivo presenta ese HTML donde podemos modificar las propiedades CSS table-layout, width, height y overflow:

Ejemplo: Ancho y alto de una tabla

table-layout
width
#tab1 {
    table-layout: auto; /* default value */
    width: auto; /* default value */
    height: 10em; /* ignored */
    overflow: visible; /* default value */
    font-family: 'Arial', sans-serif;
}
#tab1 tr * {
    border: gray solid 1px;
}
ColorRGBHEXHSLBG
black0,0,0#0000000,0%,0%0
gray128,128,128#8080800,0%,50.2%8421504
silver192,192,192#C0C0C00,0%,75.29%12632256
white255,255,255#FFFFFF0,0%,100%16777215
navy0,0,128#000080240,100%,25.1%128
blue0,0,255#0000FF240,100%,50%255
aqua0,255,255#00FFFF180,100%,50%65535
green0,128,0#008000120,100%,25.1%32768
teal0,128,128#008080180,100%,25.1%32896
olive128,128,0#80800060,100%,25.1%8421376
lime0,255,0#00FF00120,100%,50%65280
yellow255,255,0#FFFF0060,100%,50%16776960
maroon128,0,0#8000000,100%,25.1%8388608
purple128,0,128#800080300,100%,25.1%8388736
red255,0,0#FF00000,100%,50%16711680
orange255,165,0#FFA50038.82,100%,50%16753920
fuchsia255,0,255#FF00FF300,100%,50%16711935

En primer lugar hemos de decir que una tabla ignora la propiedad height. La altura final se computa por la suma de alturas de sus filas, es decir, por el contenido de la tabla. Y en cuanto a overflow sólo responde al desbordamiento horizontal con los valores visible y hidden, nada de scroll. Así que no hay forma de activar barras de desplazamiento en una tabla fijando un ancho y alto inferior al contenido de la misma. Por eso en el ejemplo para fijar cabeceras la tabla se introduce en un contenedor cuyas dimensiones son inferiores a las de la tabla, lo que activa las barras de desplazamiento del contenedor exterior, no de la tabla.

La tabla (<table>) dispone de la propiedad table-layout y width, ambas con valor por defecto auto. Con width:auto y cualquier valor de table-layout, si no se especifican anchos de celdas (<td> o <th>) o columnas (<col>), hará que el navegador calcule el ancho de la tabla partiendo de los contenidos de las celdas. Cada columna tomará el ancho del contenido de la celda con mayor ancho. En el ejemplo en su situación inicial vemos que el ancho de la primera columna se deduce de lo que ocupa la palabra "maroon".

Con table-layout:auto y width:100%, el navegador primero calcula el ancho de cada columna como antes, observando el ancho de la celda con contenido más largo en cada columna. Luego agranda los anchos de todas las columnas hasta ocupar el 100% del contenedor que alberga la tabla.

Con table-layout:fixed y width:100% se divide el ancho disponible del contenedor exterior por el número de columnas, adjudicando a cada columna el mismo ancho. Si algún contenido de una celda no cabe entonces será desbordado. En el siguiente apartado veremos como controlar el desbordamiento (overflow) en las celdas.

Si especificamos un ancho para la tabla, el funcionamiento es el mismo que lo visto antes para 100% de ancho, sólo que ahora el ancho disponible no es el del contenedor exterior, sino el que hayamos declarado.

Como hemos dicho, la tabla responde a overflow solo con los valores visible (valor por defecto) y hidden. Con hidden podemos ocultar los contenidos de las celdas que se desborden por la parte derecha de la tabla. Usando en el ejemplo table-layout:fixed y with:15em observará que hay contenidos que se desbordan en las celdas, desbordes que podemos ocultar por la parte derecha de la tabla.

Bordes y desbordamientos en celdas de una tabla

Otro aspecto que necesitamos entender en el ejemplo de fijar cabeceras de una tabla con sticky tiene que ver con los bordes de la tabla. Usaremos el mismo HTML que en el ejemplo del apartado anterior:

<table id="tab2">
    <tr><th>Color</th><th>RGB</th><th>HEX</th>···</tr>
    ···
</table>

En este ejemplo interactivo podemos modificar los bordes de la tabla. Y también el control del desbordamiento en las celdas. El desbordamiento no afecta al ejemplo con sticky y no vamos a entrar en detalles. Puede probar valores de overflow, word-break y overflow-wrap (alias word-wrap) para ver como actuan sobre el contenido de las celdas.

Ejemplo: Bordes y desbordamiento en celdas

border-collapse
#tab2 {
    table-layout: fixed;
    width: 20em;
    border-collapse: separate; /* default value */
    border-spacing: 2px; /* default value */
    border: red solid 1px;
    padding: 0.5em;
    font-family: 'Arial', sans-serif;
}
#tab2 tr * {
    border: gray solid 1px;
    overflow: visible; /* default value */
    word-break: normal; /* default value */
    overflow-wrap: normal; /* default value */
}
ColorRGBHEXHSLBG
black0,0,0#0000000,0%,0%0
gray128,128,128#8080800,0%,50.2%8421504
silver192,192,192#C0C0C00,0%,75.29%12632256
white255,255,255#FFFFFF0,0%,100%16777215
navy0,0,128#000080240,100%,25.1%128
blue0,0,255#0000FF240,100%,50%255
aqua0,255,255#00FFFF180,100%,50%65535
green0,128,0#008000120,100%,25.1%32768
teal0,128,128#008080180,100%,25.1%32896
olive128,128,0#80800060,100%,25.1%8421376
lime0,255,0#00FF00120,100%,50%65280
yellow255,255,0#FFFF0060,100%,50%16776960
maroon128,0,0#8000000,100%,25.1%8388608
purple128,0,128#800080300,100%,25.1%8388736
red255,0,0#FF00000,100%,50%16711680
orange255,165,0#FFA50038.82,100%,50%16753920
fuchsia255,0,255#FF00FF300,100%,50%16711935
Figura
Figura. Table border separate 2px

En el ejemplo vemos que damos borde de 1px a todas las celdas con el selector #tab2 tr *. Mientras que a la tabla le hemos puesto un borde rojo también de 1px, con un relleno (padding) de 0.5em.

Una tabla tiene la propiedad border-collapse que afecta a los bordes con valores separate (valor por defecto) y collapse. Con el valor separate los bordes de las celdas se separan el espacio indicado en border-spacing, cuyo valor por defecto es de 2px. Con el valor collapse se ignora border-spacing y los bordes de dos celdas contiguas collapsan en un único borde.

En la Figura vemos la tabla con los valores por defecto border-collapse: separate y border-spacing: 2px.

Figura
Figura. Table border separate 0px

Si en una tabla con bordes separados ponemos border-spacing: 0px veremos que los bordes contiguos se unen líneas de 2px, como vemos en la Figura. No es la situación que deseamos para nuestro ejemplo con sticky, pues queremos bordes de 1px.

Figura
Figura. Table border collapse

Como observamos en la Figura, podemos usar el valor collapse, colapsándose todos los bordes contiguos de las celdas. E incluso el borde exterior rojo de la tabla también se ha colapsado, desapareciendo junto al padding de 0.5em que le pusimos a la tabla. Tampoco es esta la situación que deseamos para nuestro ejemplo con sticky.

Además el uso de sticky en celdas con bordes colapsados tiene un efecto no deseado, como se observa en el siguiente ejemplo.

Ejemplo: Uso no deseado de Sticky en celdas con bordes colapsados

ColorRGBHEXHSLBG
black0,0,0#0000000,0%,0%0
gray128,128,128#8080800,0%,50.2%8421504
silver192,192,192#C0C0C00,0%,75.29%12632256
white255,255,255#FFFFFF0,0%,100%16777215
navy0,0,128#000080240,100%,25.1%128
blue0,0,255#0000FF240,100%,50%255
aqua0,255,255#00FFFF180,100%,50%65535
green0,128,0#008000120,100%,25.1%32768
teal0,128,128#008080180,100%,25.1%32896
olive128,128,0#80800060,100%,25.1%8421376
lime0,255,0#00FF00120,100%,50%65280
yellow255,255,0#FFFF0060,100%,50%16776960
maroon128,0,0#8000000,100%,25.1%8388608
purple128,0,128#800080300,100%,25.1%8388736
red255,0,0#FF00000,100%,50%16711680
orange255,165,0#FFA50038.82,100%,50%16753920
fuchsia255,0,255#FF00FF300,100%,50%16711935
Figura
Figura. Table border collapse and sticky

El CSS es básicamente el mismo que el usado para el ejemplo con sticky. Solo que la tabla ahora tiene border-collapse con valor collapse. Y que se dibujan todos los bordes de todas las celdas. El efecto no deseado se observa en la Figura, donde vemos que al usar los scroll se fijan las celdas de la primera fila y primera columna, pero sus bordes desaparecen, pues los bordes colapsados no se desplazan con sticky.

Por eso hay que usar border-collpase: separate con border-spacing: 0px. Y declarar sólo los bordes derecho e inferior de todas las celdas. Y luego declarar el borde superior de la celdas de la primera fila a fijar. Y el borde izquierdo de las celdas de la primera columna a fijar.