Проект: интернет-магазин
Каталог с фильтрами без единой строки скрипта, галерея товара на якорях, состояние «нет в наличии» и микроразметка для поисковиков.
Что ты научишься делать
- Сделаешь работающие фильтры каталога на :has() и чекбоксах
- Соберёшь галерею товара с превью без JavaScript
- Разметишь товар так, чтобы поисковик показал цену прямо в выдаче
#Что собираем
Каталог кофейни: список товаров, фильтры сбоку, карточка товара с галереей. Всё интерактивное — на CSS, потому что здесь это честно работает.
Оговорка сразу: настоящий магазин фильтрует на сервере, иначе на тысяче товаров страница станет неподъёмной. Приём из этого урока хорош для каталога на два-три десятка позиций — и как способ понять, насколько далеко зашёл современный CSS.
#Шаг 1. Сетка каталога
Карточки должны хорошо выглядеть и в широкой сетке, и в узкой колонке рядом с фильтрами. Это ровно задача из двадцать восьмого урока — контейнерные запросы.
@layer layout { .shop { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: var(--space-6); } @media (max-width: 860px) { .shop { grid-template-columns: minmax(0, 1fr); } } .catalog { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--space-4); } /* Каждая ячейка — контейнер: карточка сама решит, как ей собраться в доставшейся ширине */ .catalog > * { container-type: inline-size; container-name: product; }}@container product (min-width: 320px) { .product { grid-template-columns: 110px 1fr; } .product__desc { display: block; }}#Шаг 2. Фильтры без скриптов
Приём держится на трёх вещах: чекбоксы с состоянием, :has() у контейнера и правило, прячущее неподходящие карточки.
Логика читается почти словами: «если у каталога есть отмеченный фильтр по молочным напиткам, спрячь все карточки, которые не молочные».
#Шаг 3. Сортировка порядком
Сортировку на CSS тоже можно изобразить — свойством order у флекс- или грид-элементов. Значение берётся из данных товара.
/* Каждой карточке при генерации проставлен --price */.shop:has(#sort-price:checked) .product { order: var(--price);}#Шаг 4. Галерея товара
Крупная картинка и полоса превью. Без скриптов это делается прокруткой с привязкой: превью — обычные якорные ссылки, лента прокручивается к нужному снимку и фиксируется на нём.
#Шаг 5. Состояние «нет в наличии»
Товара нет — карточку не прячут, а помечают. Иначе человек будет искать её и думать, что ошибся.
Важно не переусердствовать с приглушением: карточка должна остаться читаемой. Обычно достаточно перечеркнуть цену, приглушить картинку и заменить кнопку.
@layer components { /* Состояние в data-атрибуте: видно, что оно временное */ .product[data-stock="out"] .product__media { filter: grayscale(1); opacity: .6; } .product[data-stock="out"] .product__price { color: var(--color-muted); text-decoration: line-through; } /* Кнопка меняется целиком: покупать нечего, но можно попросить сообщить о поступлении */ .product[data-stock="out"] .product__buy { display: none; } .product:not([data-stock="out"]) .product__notify { display: none; }}#Шаг 6. Микроразметка товара
Микроразметка — это данные о товаре в машиночитаемом виде. Благодаря ей поисковик показывает в выдаче цену, наличие и рейтинг прямо под ссылкой.
Проще всего добавить её отдельным блоком в формате JSON-LD: он не смешивается с вёрсткой и не мешает менять разметку.
<script type="application/ld+json">{ "@context": "https://schema.org", "@type": "Product", "name": "Раф на кокосовом молоке", "description": "Мягкий напиток на растительном молоке", "brand": { "@type": "Brand", "name": "Полка" }, "offers": { "@type": "Offer", "price": "240", "priceCurrency": "RUB", "availability": "https://schema.org/InStock" }}</script>#Корзина и её состояния
Корзина — место, где вёрстка чаще всего оказывается неготовой к реальности. Состояний у неё больше, чем кажется, и половину обычно не рисуют.
| Состояние | Что нужно предусмотреть |
|---|---|
| Пустая | Объяснение и ссылка в каталог, а не просто «0 товаров» |
| Один товар | Раскладка не должна выглядеть сломанной из-за одной строки |
| Много товаров | Прокрутка внутри списка, итог всегда виден |
| Товар кончился, пока лежал в корзине | Пометка и предложение убрать — самая забываемая ситуация |
| Изменение количества | Итог пересчитывается, число не дёргается: tabular-nums |
| Отправка заказа | Кнопка блокируется, показывается ход выполнения |
#Счётчик товаров в шапке
Мелочь, на которой видно разницу между «работает» и «сделано хорошо». Счётчик меняется во время работы страницы — значит это живая область из сорок третьего урока.
<a class="cart" href="/cart"> <svg class="icon" aria-hidden="true"><use href="#i-cart"/></svg> <!-- Число видно глазами --> <span class="cart__count" aria-hidden="true">3</span> <!-- А это слышит читалка: полная фраза вместо голой цифры --> <span class="visually-hidden" role="status"> Корзина: 3 товара </span></a>#Грабли этого проекта
| Что случилось | Почему | Решение |
|---|---|---|
| Фильтры перестали складываться | Комбинаций условий больше, чем правил | Ограничить число фильтров либо перенести на сервер |
| Карточка сжалась в узкой колонке | Медиазапрос смотрит на экран, а не на колонку | Контейнерные запросы у ячеек каталога |
| Сортировка сбила порядок чтения | order меняет вид, но не разметку | Сортировать на сервере или менять сам порядок элементов |
| Галерея не листается с клавиатуры | Превью сделаны из div | Настоящие ссылки на якоря снимков |
| Поисковик не показал цену | Данные в разметке расходятся с видимыми | Привести к одному значению |
| Пропавшие товары путают | Отсутствующие позиции скрыты полностью | Помечать, а не прятать |
Где спотыкаются почти все
Фильтры на CSS в большом каталоге
Почему Скрытые карточки остаются в разметке: тысяча товаров грузится вся целиком. Плюс комбинации условий растут быстрее, чем их можно описать.
Как надо До нескольких десятков позиций — можно. Дальше только сервер.
Сортировка через <code>order</code>
Почему Видимый порядок расходится с порядком обхода клавиатурой и чтения голосом.
Как надо Менять сам порядок элементов, а не только их отображение.
Товар без наличия скрыт из каталога
Почему Человек помнит, что видел позицию, и решает, что ошибся страницей.
Как надо Показывать с пометкой и предложением сообщить о поступлении.
Микроразметка не совпадает со страницей
Почему Поисковики это проверяют и перестают показывать расширенные сведения для всего сайта.
Как надо Генерировать разметку из тех же данных, что и видимую цену.
- Привязка прокрутки scroll-snap
- Остановка прокрутки на заданных точках — основа галерей без скриптов.
- Микроразметка structured data
- Данные о содержимом в машиночитаемом виде для поисковиков.
- Фасетный фильтр faceted filter
- Набор независимых условий отбора, работающих совместно.
- Расширенные сведения rich results
- Цена, рейтинг и наличие в поисковой выдаче под ссылкой.
Проверь себя
Приём хорош на паре десятков позиций. Дальше страница становится тяжёлой, а правил — неуправляемо много.
Человек видит один порядок, а слышит другой. Для сортировки это неприемлемо.
Всё поведение встроено в браузер — оно не сломается при ошибке в скрипте.
Исчезнувшая позиция путает: человек помнит, что видел её, и думает, что ошибся.
Расхождение проверяется. Санкция распространяется на весь сайт, а не только на одну страницу.
Задание
Фильтр каталога
В каталоге четыре товара с атрибутами data-type и data-stock. Фильтры уже размечены, но не работают.
Напиши правила на :has(): отмеченный фильтр «С молоком» прячет всё, что не молочное; «Только в наличии» — всё, чего нет. Плюс оформи состояние «нет в наличии»: перечёркнутая приглушённая цена.
Подсказка 1
Условие вешается на общий контейнер: .shop:has(#f-milk:checked) .product:not([data-type="milk"]) { display: none; }
Подсказка 2
Второй фильтр — по тому же образцу, только с #f-stock и [data-stock="yes"].
Подсказка 3
Состояние: .product[data-stock="no"] .price { color: #7a7f8c; text-decoration: line-through; }
Показать решение
<div class="shop"> <form class="filters"> <label><input type="checkbox" id="f-milk"> С молоком</label> <label><input type="checkbox" id="f-stock"> Только в наличии</label> </form> <div class="catalog"> <article class="product" data-type="milk" data-stock="yes"><h3>Латте</h3><p class="price">210 ₽</p></article> <article class="product" data-type="black" data-stock="yes"><h3>Эспрессо</h3><p class="price">120 ₽</p></article> <article class="product" data-type="milk" data-stock="no"><h3>Раф</h3><p class="price">240 ₽</p></article> <article class="product" data-type="black" data-stock="no"><h3>Фильтр</h3><p class="price">180 ₽</p></article> </div></div>/* Отмечен фильтр — прячем то, что ему не подходит */.shop:has(#f-milk:checked) .product:not([data-type="milk"]) { display: none;}.shop:has(#f-stock:checked) .product:not([data-stock="yes"]) { display: none;}/* Товара нет: помечаем, но не прячем */.product[data-stock="no"] .price { color: #7a7f8c; text-decoration: line-through;}.shop { display: grid; grid-template-columns: 150px 1fr; gap: 18px; }.filters { display: grid; gap: 8px; align-content: start; font-size: 14px; }.filters label { display: flex; align-items: center; gap: 8px; }.catalog { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; align-content: start; }.product { display: grid; gap: 4px; align-content: start; padding: 12px; background: #f5f5fa; border-radius: 10px;}.product h3 { margin: 0; font-size: 15px; }.price { margin: 0; font-weight: 700; }