Всем привет, на связи снова я — Дмитрий, 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-заголовках, независимо от дробных пикселей и масштаба страницы. Баг достаточно интересный, а решение простое, но немного нестандартное, как оказалось.