Демо для фронтенда · каталог Randewoo
Все чипсы всегда в DOM — SSR отдаёт их для SEO. Сколько показывать, решает браузер: чипсы, перенесённые flex-wrap'ом на обрезанную вторую строку, помечаются скрытыми. Меняйте ширину, активируйте фильтры и включите рентген, чтобы увидеть скрытое. Пара к демо — чеклист SEO и реализации.
Клик по чипсу — активировать · клик по активному — добавить значение (чипс расширяется) · клик по ✕ — снять фильтр
Пунктир — элемент есть в DOM, но скрыт: полупрозрачный — обрезан клэмпом (visibility:hidden), перечёркнутый — схлопнут ради активного (display:none), пунктирная «Очистить» — стелс-слот.
SSR отдаёт все 11 ссылок-фильтров — SEO видит каждую. Лишние переносятся flex-wrap'ом на вторую строку, которую обрезает max-height списка. Первый кадр корректен без единой строчки JS — скелетон и прыжки вёрстки не нужны.
Ширины никто не суммирует: браузер уже разложил чипсы. Кто перенёсся (offsetTop больше, чем у первого ряда) — тот не влез. Триггеры пересчёта: маунт, ResizeObserver, изменение активных фильтров, document.fonts.ready.
Невлезший хвост прячется через visibility:hidden + aria-hidden + tabindex="-1" — чипсы остаются в раскладке, замер стабилен. Но если за границу вылетел активный чипс — сначала ему отдаётся резерв 0,5× у «Очистить», а если и этого мало, схлопываем (display:none) последний неактивный перед ним — и активный поднимается в первый ряд.
Стелс-слот, визуальная привязка к последнему видимому чипсу и мягкий резерв 0,5× — принцип работы кнопки разобран в отдельном блоке ниже.
Кнопка живёт в двух системах координат: в раскладке её слот держит место и резерв, а визуально она прижата к последнему видимому фильтру.
visibility:hidden — слот остаётся в раскладке, и первая активация проявляет кнопку, не сдвинув ни один чипс.transform: translateX(…). Transform не меняет раскладку: клэмп, замер по offsetTop и резерв считаются по слоту в потоке, а не по нарисованной кнопке. Весь свободный «воздух» — остаток ряда плюс резерв — собирается справа от кнопки, перед блоком сортировки.margin-left слота.Он сужает контейнер чипсов на полширины кнопки. План А: активный чипс расширился и не влезает → margin обнуляется, контейнер растёт на эти 0,5×, чипсы перекладываются — ни один фильтр не пропадает. Визуально при этом правый зазор ужимается: резерв «съеден».display:none) последний неактивный перед ним, и активный поднимается в первый ряд. Неактивный хвост в резерв не пускаем: без давления активных воздух справа от кнопки всегда ≥ 0,5 её ширины.function usePopularFilters(listRef, clearRef, activeKeys) {
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const scan = (chips) => {
const inLayout = chips.filter((c) => c.dataset.state !== 'collapsed');
const base = Math.min(...inLayout.map((c) => c.offsetTop));
return { inLayout, wrapped: inLayout.filter((c) => c.offsetTop > base) };
};
const isActive = (c) => c.dataset.active === 'true';
const measure = () => {
const chips = Array.from(list.children);
chips.forEach(reset); // все чипсы обратно в раскладку
const reserve = Math.round(clearRef.current.offsetWidth * 0.5);
clearRef.current.style.marginLeft = `${reserve}px`; // план А: резерв 0,5× держит воздух
let { wrapped } = scan(chips);
let wrappedActive = wrapped.filter(isActive);
if (wrapped.length && !wrappedActive.length) { // не влез только хвост неактивных:
wrapped.forEach(clip); // клипуем, резерв НЕ трогаем
return;
}
if (!wrappedActive.length) return; // всё влезло
clearRef.current.style.marginLeft = '0px'; // активный не влез: отдаём ему резерв
for (let guard = 0; guard <= chips.length; guard++) {
const r = scan(chips);
if (!r.wrapped.length) break; // хватило резерва — план Б не нужен
wrappedActive = r.wrapped.filter(isActive);
if (!wrappedActive.length) { r.wrapped.forEach(clip); break; }
const i = r.inLayout.indexOf(wrappedActive[0]); // план Б: резерв исчерпан —
const victim = r.inLayout.slice(0, i).reverse() // жертвуем последним неактивным
.find((c) => !isActive(c));
if (!victim) { // одни активные не влезают:
r.wrapped.filter((c) => !isActive(c)).forEach(clip);
break; // предел — нужна отдельная строка активных
}
collapse(victim); // display:none → активный поднимется, меряем снова
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(list.parentElement); // ресайз контейнера, не окна
document.fonts?.ready.then(measure); // шрифт догрузился → ширины изменились
return () => ro.disconnect();
}, [activeKeys]); // пересчёт при каждой (де)активации
}
// reset(c) → state='', убрать aria-hidden / tabindex
// clip(c) → state='clipped' (CSS: visibility:hidden) + aria-hidden + tabindex=-1
// collapse(c) → state='collapsed'(CSS: display:none) + aria-hidden
// snapClear() → в конце measure(): transform придвигает «Очистить» к последнему
// видимому чипсу; на раскладку и замер не влияет
Смысл всей схемы: краулер получает полный список фильтров в первом 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 строка выглядит корректно (одна строка, ничего не торчит).