Handoff для фронтенда · строка популярных фильтров
Пара к интерактивному демо (механика вживую). Пункты с крестиком — анти-паттерны, которые ломают схему.
Смысл всей схемы: краулер получает полный список фильтров в первом HTML, скрытие — только косметика на клиенте.
<a href> на реальный URL фильтра.Не <button onClick> и не <div>. SPA-переход можно перехватывать через onClick + preventDefault, но href обязан быть — иначе краулеру нечего обходить и SSR теряет смысл.visibility:hidden, жертва ради активного → display:none. Оба варианта остаются в DOM: контент индексируется, ссылки краулятся (вес скрытого текста у Google ниже — для задачи «найти страницы фильтров» этого достаточно).{visible && <Chip/>}.После гидрации ссылки исчезнут из DOM. Скрытие только через data-state/класс, элемент рендерится всегда.overflow:hidden) уже прячет лишнее без JS. Никакого CLS.curl / view-source содержит все <a> фильтров; с отключённым JS строка выглядит корректно (одна строка, ничего не торчит).Кнопка живёт вне измеряемого списка и резервирует место всегда — её появление не двигает чипсы. Визуально она прижата к последнему видимому чипсу через transform: слот и резерв остаются в потоке, а свободный воздух собирается справа от кнопки.
Резерв мягкий. План А: когда активные чипсы расширяются, сначала они съедают эти 0,5 ширины — ни один фильтр не пропадает. План Б (схлопывание неактивных) включается только после исчерпания резерва. Неактивный хвост в резерв не пускаем: он клипуется по границе «ширина − резерв».
visibility:hidden + disabled, пока нет активных.Не opacity:0 (остаётся кликабельной и в дереве доступности) и не display:none (слот схлопнется, появление кнопки сдвинет чипсы, замер станет нестабильным).margin-left:55px). Динамический вариант — один замер при маунте и на fonts.ready:
const w = clearRef.current.getBoundingClientRect().width; // работает и при visibility:hidden
clearRef.current.style.marginLeft = `${Math.round(w * 0.5)}px`;Ширины не суммируем — браузер уже разложил чипсы, JS только читает результат.
flex-wrap:wrap + max-height одного ряда + overflow:hidden.Плюс min-width:0 и align-content:flex-start. Невлезшие чипсы переносятся на обрезанную вторую строку — целиком, без полусрезанных.offsetTop: перенёсся — значит не влез.Триггеры: маунт (useLayoutEffect), ResizeObserver контейнера, изменение [activeFilters], document.fonts.ready.aria-hidden="true" + tabindex="-1".Иначе Tab ходит по невидимым ссылкам.display:none) последний неактивный перед ним и померить снова (план Б), активный поднимется в первый ряд.fonts.ready), «прыжка» нет.Мобильная версия живёт по своему паттерну (горизонтальный скролл) — рассинхрон видимости с десктопом не является багом, зафиксировано с дизайном.