Columna de acciones fija en tabla ancha: sticky right-0

Tienes una tabla con muchas columnas y, en la última, los botones de acciones (editar, eliminar, ver detalle). Para llegar a ellos hay que desplazarse en horizontal, la barra de desplazamiento no se ve y, si intentas fijar una columna con position: sticky, la celda no se queda pegada. Este es el patrón típico que produce el problema:

<div className="overflow-x-auto">
  <Table>
    <TableBody>
      <TableRow>
        {/* ...muchas celdas... */}
        <TableCell className="sticky right-0 bg-background">
          <Button size="sm">Editar</Button>
        </TableCell>
      </TableRow>
    </TableBody>
  </Table>
</div>

Este artículo explica por qué sticky se ancla al contenedor equivocado, cómo dejar un único contenedor con scroll, cómo fijar la columna con Tailwind y qué alternativas de interfaz existen. Los ejemplos usan Next.js, Tailwind CSS y el componente Table de shadcn/ui, pero la causa es CSS puro y aplica a cualquier tabla.

Por qué pasa

Según MDN, un elemento con position: sticky se mantiene fijo respecto al ancestro más cercano que tenga un "mecanismo de desplazamiento", es decir, un overflow con valor hidden, scroll, auto u overlay. Esto ocurre aunque ese ancestro no sea el que realmente se desplaza. Además, hace falta al menos un desplazamiento (top, right, bottom o left) distinto de auto en el eje correspondiente; sin él, sticky se comporta como relative.

El componente Table de shadcn/ui envuelve el <table> en un div con su propio overflow. En versiones anteriores del componente la clase es overflow-auto y en la versión actual del registro es overflow-x-auto. En ambos casos es un contenedor con scroll: cuando uno de los ejes de overflow no es visible, el otro eje también se comporta como auto.

Si además envuelves la tabla en otro overflow-x-auto, tienes dos contenedores anidados. Cuando el interno crece hasta el ancho de la tabla (por ejemplo, porque un elemento padre flex o grid sin min-w-0 se expande a su contenido), el que desborda es el externo o la propia página. La celda sticky sigue anclada al interno, que nunca se desplaza, así que no se pega a ningún borde. La barra horizontal tampoco se ve, porque vive en el contenedor que desborda: al final de la tabla o de la página, fuera de pantalla si hay muchas filas.

En una prueba mínima en Chromium con CSS equivalente, un contenedor externo de 600 px con scroll horizontal y un contenedor interno ensanchado dejó la celda con sticky right-0 fuera del área visible tras desplazar 300 px. Con un único contenedor, la misma celda quedó pegada al borde derecho. El resultado fue el mismo cuando el desbordamiento lo causaba la página por un hijo flex sin min-w-0.

Solución

  1. Localiza qué contenedor hace scroll. En la consola del navegador, lista los ancestros de una celda que tienen mecanismo de desplazamiento:

    const celda = document.querySelector("tbody td:last-child")
    let el = celda.parentElement
    while (el) {
      const { overflowX, overflowY } = getComputedStyle(el)
      if (/auto|scroll|hidden|overlay/.test(overflowX + overflowY)) {
        console.log(el, overflowX, overflowY, "desborda en horizontal:", el.scrollWidth > el.clientWidth)
      }
      el = el.parentElement
    }

    Si el primer elemento de la lista (el más cercano a la celda) imprime false y el desplazamiento horizontal ocurre en otro sitio, ya sea un contenedor más externo con true o la propia página, sticky está anclado al contenedor equivocado.

  2. Deja un único contenedor con scroll. Quita el overflow-x-auto de tu envoltorio y usa el que ya trae Table:

    // Antes: dos contenedores con scroll
    <div className="overflow-x-auto">
      <Table>...</Table>
    </div>
    
    // Después: solo el contenedor de Table
    <Table>...</Table>

    Si la tabla está dentro de un elemento flex o grid, agrega min-w-0 a ese hijo para que no se expanda al ancho de la tabla:

    <main className="min-w-0 flex-1">
      <Table>...</Table>
    </main>
  3. Fija la columna de acciones en el encabezado y en las celdas:

    <TableHead className="sticky right-0 bg-background shadow-[-6px_0_6px_-6px_rgba(0,0,0,0.25)]">
      Acciones
    </TableHead>
    
    <TableCell className="sticky right-0 bg-background shadow-[-6px_0_6px_-6px_rgba(0,0,0,0.25)]">
      <Button size="sm" variant="outline">Editar</Button>
    </TableCell>

    sticky activa el comportamiento y right-0 es el umbral. El fondo opaco es obligatorio: sin él, el contenido que pasa por debajo se ve a través de la celda. bg-background es un color del tema de shadcn/ui; en un proyecto de Tailwind sin ese tema la clase no existe y debes usar un color propio (por ejemplo bg-white dark:bg-neutral-950). La sombra a la izquierda es opcional y deja claro que hay contenido debajo.

  4. Haz visible la barra y fija también el encabezado. La barra horizontal aparece en el borde inferior del contenedor. Si el contenedor mide lo que mide la tabla, queda al final de una lista larga. Limita su altura y la barra estará siempre a la vista; además, un encabezado con sticky top-0 funcionará dentro del mismo contenedor. Como los componentes de shadcn/ui se copian a tu proyecto, puedes agregar una propiedad para el contenedor en components/ui/table.tsx:

    function Table({
      className,
      containerClassName,
      ...props
    }: React.ComponentProps<"table"> & { containerClassName?: string }) {
      return (
        <div
          data-slot="table-container"
          className={cn("relative w-full overflow-x-auto", containerClassName)}
        >
          <table
            data-slot="table"
            className={cn("w-full caption-bottom text-sm", className)}
            {...props}
          />
        </div>
      )
    }

    Y úsala así, con encabezados que se quedan arriba y la columna de acciones a la derecha:

    <Table containerClassName="max-h-[70vh]">
      <TableHeader>
        <TableRow>
          {/* ...columnas... */}
          <TableHead className="sticky top-0 right-0 z-30 bg-background">Acciones</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow>
          {/* ...celdas... */}
          <TableCell className="sticky right-0 z-10 bg-background">...</TableCell>
        </TableRow>
      </TableBody>
    </Table>

    Los demás TableHead necesitan sticky top-0 z-20 bg-background. La jerarquía de z-index (30 para la esquina, 20 para el encabezado, 10 para la columna) evita que unas celdas tapen a otras. En los sistemas donde las barras de desplazamiento solo aparecen al desplazar, la columna fija y su sombra son la pista visual de que hay más contenido.

  5. Considera una alternativa de interfaz. Con muchas acciones por fila, agrúpalas en un menú "⋯" (en shadcn/ui, DropdownMenu): la columna fija pasa a medir un solo botón. En móvil puedes mostrar tarjetas apiladas en lugar de la tabla. El prefijo md: de Tailwind aplica desde 768 px: un envoltorio con hidden md:block oculta la tabla en pantallas pequeñas y otro con md:hidden oculta la lista de tarjetas en pantallas grandes.

Cómo verificar que quedó resuelto

  1. Vuelve a ejecutar el script del paso 1. Debe aparecer un único contenedor con desborda en horizontal: true.
  2. Desplaza la tabla en horizontal: las columnas intermedias deben pasar por debajo y la columna de acciones debe permanecer pegada al borde derecho del contenedor.
  3. Comprueba la posición por código. En la versión actual de shadcn/ui el contenedor tiene el atributo data-slot="table-container"; en versiones anteriores usa otro selector:
const contenedor = document.querySelector('[data-slot="table-container"]')
const celda = document.querySelector("tbody tr:first-child td:last-child")
contenedor.scrollLeft = 300
const bordeDerecho = contenedor.getBoundingClientRect().left + contenedor.clientWidth
console.log(Math.abs(celda.getBoundingClientRect().right - bordeDerecho) <= 1) // true si está fijada

Por último, abre las herramientas de desarrollo con un ancho de 375 px y repite la prueba: la barra horizontal debe verse sin tener que bajar hasta el final de la página.

Variantes y errores relacionados

sticky top-0 no funciona dentro de una tarjeta con overflow-hidden

Una tarjeta con esquinas redondeadas suele llevar overflow-hidden. Ese valor crea un mecanismo de desplazamiento aunque nada se desplace allí, y el encabezado se ancla a la tarjeta en lugar de a la ventana. En una prueba con 80 filas y la página desplazada 400 px, el encabezado dentro de overflow: hidden quedó 399 px por encima del borde superior de la ventana, mientras que con overflow: clip se mantuvo pegado. En Tailwind, cambia overflow-hidden por overflow-clip. Según MDN, clip no crea un contenedor con scroll, pero tampoco permite el desplazamiento programático ni trae a la vista el contenido recortado cuando recibe el foco; úsalo solo si no hay elementos interactivos recortados.

Columna fija a la izquierda

Una primera columna con sticky left-0 tiene exactamente la misma causa y el mismo arreglo: un solo contenedor con scroll y un fondo opaco.

El color de la fila no llega a la celda fija

Como la celda fija tiene fondo propio, no cambia de color cuando la fila usa un estado de hover o de selección. Si lo notas, aplica a la celda el mismo color de ese estado.

Otro elemento con overflow entre la celda y el contenedor real

El mismo criterio vale para cualquier ancestro intermedio: si tiene overflow distinto de visible o clip, sticky se ancla a él. El script del paso 1 lo detecta.

Resumen

  • sticky se ancla al ancestro más cercano con overflow distinto de visible, aunque ese ancestro no sea el que se desplaza.
  • El Table de shadcn/ui ya trae su contenedor con scroll; envolverlo en otro overflow-x-auto deja dos contenedores y rompe sticky.
  • Deja un solo contenedor y agrega min-w-0 a los hijos flex o grid que lo contienen.
  • Fija la columna con sticky right-0, fondo opaco y, si quieres, una sombra; limita la altura del contenedor para que la barra horizontal se vea.
  • Para encabezados dentro de una tarjeta redondeada, usa overflow-clip en lugar de overflow-hidden.

Referencias

Comentarios