
Всем привет, на связи снова я — Дмитрий, React-разработчик. Сегодня хочу рассказать об интересном баге, который был замечен в большой и сложной таблице.
Проблема заключается в том, что в таблице на React с колонками, у которых есть свойство position: sticky, иногда пропадала граница между соседними ячейками по вертикали. Причём проявлялась она не всегда и носит случайный характер. Забавно, что изменение масштаба страницы (Ctrl + колесико мыши) мгновенно возвращает исчезнувший бордер. При этом в CSS все прописано и никуда не исчезает — это чисто визуальный баг рендера.
Вот как это выглядит в таблице. Данные я вам показывать не могу, даже тестовые - поэтому блюр. Прошу понять и простить.

Сразу отмечу - React здесь ни при чём. Этот баг связан с HTML/CSS и особенностью браузерного рендеринга sticky-элементов. Однако, обнаружен он был в реальном React-проекте с большой динамической таблицей, поэтому все примеры и разбор я привожу именно на React-коде.
Исходный код таблицы и стили
Прежде чем разбираться с решением, полезно взглянуть на исходный код ком��онента и стили, с которым потребовалось разобраться. Это обычная React-таблица с поддержкой sticky-заголовков в первой колонке.
Я немного почистил код от лишних элементов для наглядности и компактности, вот так выглядит кусочек кода таблицы, в котором проблема. Здесь мы видим стандартное отображение строк заголовка и возможность сортировки по колонкам. Также учтены rowSpan и colSpan для объединённых ячеек.
<thead className="ft_table-head"> <tr> {visibleColumns.map((col, index) => ( <th key={col.id} rowSpan={col.rowSpan} colSpan={col.colSpan} className={`ft_table-header${col.stycky ? ' ft_sticky-column' : ''}${index === 0 ? ' ft_table-cell-first' : ''}`} onClick={col.sorted ? () => setSortLocal(col.id) : undefined} style={{ width: col.width, minWidth: col.width, maxWidth: col.width, cursor: col.sorted ? 'pointer' : 'default', background: backgroundHeader, ...(stylesHeaderTr ?? {}), }} > {col.name} </th> ))} </tr> </thead>
А стили выглядят вот так:
.ft_table-cell { padding: 10px; border-bottom: 1px solid #ebecee; border-right: 1px solid #ebecee; text-align: center; } .ft_sticky-column { position: sticky; left: 0; background: #ffffff; z-index: 9; border-right: 1px solid #ebecee; border-bottom: 1px solid #ebecee; }
Эти стили задают границы ячеек, а также sticky-позиционирование для заголовка и первой колонки.
Почему это происходит
Когда я начал разбираться с багом, стало понятно, что причина кроется не в CSS как таковом, а в том, как браузер рендерит sticky-элементы.
В моем случае заголовок и первая колонка таблицы имеют position: sticky, а сами ячейки используют границы через border-bottom и border-right. И что бы могло пойти не так? Границы заданы, sticky работает, ячейки позиционируются, но на практике странное поведение - граница между ячейками исчезает, хотя в DOM она присутствует и CSS не меняется.
Почему так
Первая причина

Обычно так оно и есть, но в данном случае причина в так называемом Sub-pixel rendering’е. Современные экраны и браузеры используют дробные пиксели для более точного позиционирования элементов. Например, при вычислении sticky-позиции браузер может поставить элемент на 12.5px вместо целого пикселя. В Chrome это иногда приводит к тому, что 1px border визуально срезается, хотя в DOM она есть. Элемент находится на дробном пикселе, а граница рисуется с округлением — и мы её не видим.
Вторая причина
Комбинация sticky + border. Когда элемент с position: sticky движется относительно контейнера, браузер пересчитывает его позицию каждый кадр скролла. Если одновременно у него есть border-bottom или border-right, браузер может не пересчитать линию корректно при дробных пикселях, и визуально она исчезает. В итоге создаётся баг с пропадающей границей.
Третья причина, в данном случае снятие симптомов
Когда пользователь меняет масштаб (Ctrl + колесико мыши), браузер заново пересчитывает layout и рендеринг всех элементов. Sticky-позиция пересчитывается на новой сетке пикселей, и исчезнувшая граница внезапно появляется. Это временное исправление только для визуального восприятия, баг при этом остаётся в коде.
Итог:
Это чисто визуальный баг браузера, связанный с "подпиксельными" вычислениями и рендером sticky-элементов с границами.
Кросс-браузерное поведение
Я решил проверить это на нескольких браузерах, как подобает true-фронтендеру.
Chrome и Edge — проявляется чаще всего, особенно при высоких DPI и дробных пикселях.
Яндекс браузер основан на Chromium, поэтому поведение там почти полностью совпадает с Chrome и Edge
Firefox — ведёт себя более стабильно, но иногда тоже можно увидеть небольшие рассинхронизации.
Safari — относительно стабильный, но при динамических изменениях высоты строк иногда границы визуально «прыгают».
Решение
После экспериментов стало понятно, что привычные border для нижней линии у sticky-ячейки — это корень проблемы. Из-за дробных пикселей и рендеринга Chrome граница иногда просто не прорисовывается.
Чтобы исправить это, я заменил border-bottom на box-shadow с inset и добавили outline с outline-offset.
.ft_table-cell { padding: 10px; border-right: 1px solid #ebecee; text-align: center; outline: 1px solid transparent; outline-offset: -1px; box-shadow: inset 0 -1px 0 #ebecee; }
И это помогло, потому что, как выяснилось, box-shadow не зависит от пиксельной сетки так сильно, как border. Тень прорисовывается поверх содержимого, и браузер не срезает ее при дробных пикселях или скролле. И outline с outline-offset гарантирует стабильную зону рендеринга, даже если ячейка содержит sticky-колонку, outline помогает держать пространство для линии, предотвращая визуальные дыры.
Оговорюсь, что технически, outline сам по себе не рисует линию, а служит дополнительным пространством для рендера, что помогает браузеру правильно вычислить границы.
Sticky колонки
Для первой колонки с position: sticky я тоже убрал нижний border и заменил на box-shadow.
.ft_sticky-column { position: sticky; left: 0; background: #ffffff; z-index: 9; border-right: 1px solid #ebecee; border-bottom: none; box-shadow: inset 0 -1px 0 #ebecee; }
Заключение
В итоге баг с исчезающими границами полностью устранён. Решение работает стабильно при скролле и sticky-заголовках, независимо от дробных пикселей и масштаба страницы. Баг достаточно интересный, а решение простое, но немного нестандартное, как оказалось.

