Обычная сетка из прямоугольных превью — надёжный, но скучный вариант. Если тот же набор фотографий уложить шестиугольниками, как пчелиные соты, галерея сразу выглядит как отдельный элемент дизайна: подойдёт для портфолио, страницы команды, витрины проектов или блока с партнёрами.
Всё собирается на чистом HTML и CSS, без единой строчки JavaScript. Готовый результат — в демо ниже, а дальше разберём его по частям: как получить именно правильный шестиугольник, как выстроить соты так, чтобы они сами перестраивались на мобильном, как добавить рамку и блик при наведении и какие ловушки расставляет clip-path.
Разметка: просто список картинок
HTML здесь максимально скучный, и это хорошо: каждая сота — ссылка с картинкой внутри, все они лежат в одном контейнере. Никаких обёрток под ряды, никаких номеров позиций.
<div class="honeycomb">
<a class="hex" href="/photos/pier-sunset.jpg">
<img src="/photos/pier-sunset-thumb.jpg" alt="Люди на пирсе на фоне заката">
</a>
<a class="hex" href="/photos/skyscraper.jpg">
<img src="/photos/skyscraper-thumb.jpg" alt="Небоскрёб в лучах вечернего солнца">
</a>
<!-- и так далее -->
</div>
Два момента, которые часто упускают в подобных примерах:
- Повторяющиеся элементы помечаем классом, а не id. Атрибут id обязан быть уникальным на странице: если повесить одинаковый id="photo" на пять картинок, CSS это стерпит, но document.getElementById() найдёт только первую, а валидатор выдаст ошибку.
- У каждой картинки — содержательный alt. Подписи вида «Фото 1» или «Kid 3» ничего не сообщают тому, кто пользуется скринридером.
Правильный шестиугольник одним polygon()
Форму задаёт свойство clip-path: всё, что за пределами указанной фигуры, браузер просто не рисует. Если со свойством ещё не сталкивались, начните с обзора обрезки блоков через clip-path, там разобраны все базовые фигуры. Для шестиугольника нужна функция polygon() с шестью вершинами:
.hex {
aspect-ratio: 0.866;
clip-path: polygon(
50% 0%, /* верхняя вершина */
100% 25%, /* правая верхняя */
100% 75%, /* правая нижняя */
50% 100%, /* нижняя вершина */
0% 75%, /* левая нижняя */
0% 25% /* левая верхняя */
);
}
Координаты считаются от левого верхнего угла элемента: первое число — по горизонтали, второе — по вертикали. Получается шестиугольник «остриём вверх»: вершины сверху и снизу, вертикальные стороны слева и справа.
Самая важная строчка тут — не clip-path, а aspect-ratio: 0.866. У правильного шестиугольника ширина относится к высоте как √3 / 2, то есть примерно 0,866. Если обрезать по тем же процентам квадратный блок, шестиугольник выйдет приплюснутым: стороны разной длины, углы не по 120°. На глаз это заметно, особенно когда соты стоят вплотную. Подробнее про само свойство пропорций — в статье про aspect-ratio.
Частый обходной путь — оставить блок квадратным, а боковые вершины сдвинуть внутрь, например на 5% и 95%. Форма становится ближе к правильной, но по краям остаются прозрачные полосы, которые занимают место в раскладке и мешают ставить соты вплотную.
/* так получается почти правильно, но по бокам пустые поля */
.hex-square {
aspect-ratio: 1;
clip-path: polygon(50% 0%, 95% 25%, 95% 75%, 50% 100%, 5% 75%, 5% 25%);
}
Ещё одна деталь из старых туториалов — дублирование свойства с префиксом -webkit-clip-path. Для фигур вроде polygon() он нужен был только старым версиям Safari и Chrome, сегодня хватает одного clip-path без префикса.
Соты без абсолютного позиционирования
Самый прямолинейный способ выложить соты — сделать каждую position: absolute и вручную прописать ей top и left. Это работает ровно до первого изменения: добавили фото — пересчитываем координаты, открыли на телефоне — пишем медиа-запрос с новыми координатами для каждой ячейки. Гораздо удобнее отдать раскладку гриду.
Идея с полуколонками
Каждый второй ряд сот сдвинут вправо ровно на половину соты. Чтобы грид умел такой сдвиг, делим ширину на полуколонки: сота занимает две, а сдвинутый ряд начинается со второй. Для четырёх сот в ряд нужно 4 × 2 = 8 полуколонок плюс ещё одна под сдвиг — итого девять.
.honeycomb {
--gap: 8px;
display: grid;
grid-template-columns: repeat(9, 1fr);
column-gap: var(--gap);
}
.hex {
grid-column: span 2;
aspect-ratio: 0.866;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
}
Осталось сказать, с какой соты начинается каждый сдвинутый ряд. При четырёх сотах в ряду это 5-я, 13-я, 21-я и так далее — шаг 8, старт с 5. Ровно это и записывается через :nth-child() (как работает формула An + B, разобрано в статье про nth-child и nth-of-type):
/* первая сота каждого второго ряда начинается со 2-й полуколонки */
.hex:nth-child(8n + 5) {
grid-column: 2 / span 2;
}
Остальные соты этого ряда грид расставит сам: авто-размещение продолжит с того места, где закончилась предыдущая. А когда следующая сота уже не помещается, она переносится в новый ряд и встаёт в первую полуколонку.
Нахлёст рядов
Ряды шестиугольников должны заходить друг на друга: острая вершина нижнего ряда входит в ложбинку между двумя верхними. Делается это отрицательным нижним отступом размером в четверть высоты соты. Удобно, что проценты в margin у элемента грида считаются от ширины его области, то есть от ширины самой соты:
.hex {
/* 28.87% ширины = 25% высоты, плюс поправка, чтобы зазор был одинаковым со всех сторон */
margin-bottom: calc(var(--gap) * 0.866 - 28.87%);
}
Откуда 28,87%: высота соты равна ширине, делённой на 0,866, а четверть такой высоты — это 0,2887 ширины. Слагаемое с --gap нужно, чтобы диагональный зазор между рядами совпал с горизонтальным.
Адаптив одной парой правил
На узком экране меняем число полуколонок и формулу сдвига, координаты по-прежнему никто не трогает. Для трёх сот в ряд полуколонок семь, сдвинутые ряды начинаются с 4-й, 10-й, 16-й соты:
@media (max-width: 560px) {
.honeycomb {
grid-template-columns: repeat(7, 1fr);
}
.hex:nth-child(8n + 5) {
grid-column: span 2; /* сбрасываем правило для четырёх в ряд */
}
.hex:nth-child(6n + 4) {
grid-column: 2 / span 2;
}
}
Общая формула: для N сот в ряду полуколонок 2N + 1, а селектор сдвига — :nth-child(2Nn + N + 1). Добавили в галерею ещё десяток фотографий — CSS менять не нужно вообще.
Рамка, увеличение и блик при наведении
Рамка через padding
Обычный border здесь не поможет: clip-path обрежет его вместе со всем остальным, и рамка останется только на вертикальных сторонах. Поэтому рамку рисуем фоном: у ссылки-обёртки задаём градиентный фон и небольшой padding, а картинку внутри обрезаем той же фигурой. Сквозь зазор между ними и виден фон — это и есть рамка.
:root {
--hex: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
}
.hex {
position: relative;
padding: 5px;
background: linear-gradient(#8b93a7, #d5d9e2);
clip-path: var(--hex);
}
.hex img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
clip-path: var(--hex);
}
Фигура вынесена в кастомное свойство --hex, чтобы не дублировать координаты. А object-fit: cover заполняет соту фотографией любых пропорций без искажений (подробно — в статье про object-fit и object-position).
Увеличение и блик
При наведении сота подрастает на 15% и поднимается над соседями через z-index. Для блика используем псевдоэлемент ::before: это диагональная полупрозрачная полоса, которая в покое прячется за верхним левым углом, а при наведении проезжает в правый нижний. За пределы соты блик не вылезет: clip-path на обёртке обрезает и его.
.hex {
transition: scale 0.25s;
}
.hex::before {
content: "";
position: absolute;
inset: -50%;
z-index: 1;
background: linear-gradient(135deg, transparent 42%, rgb(255 255 255 / 0.55) 50%, transparent 58%);
translate: -100% -100%;
transition: translate 0.6s;
pointer-events: none;
}
.hex:hover {
scale: 1.15;
z-index: 1;
}
.hex:hover::before {
translate: 100% 100%;
}
Здесь используются отдельные свойства scale и translate вместо общего transform: их удобно анимировать по отдельности, и одно не затирает другое. Про transform и его функции — в статье про CSS-трансформации.
Без анимации для тех, кто её отключил
Пролетающий блик и пульсирующие при каждом движении мыши фото кому-то мешают читать страницу, а кому-то физически неприятны. Если в системе включено уменьшение движения, убираем переходы и блик, увеличение оставляем мгновенным:
@media (prefers-reduced-motion: reduce) {
.hex,
.hex::before {
transition: none;
}
.hex::before {
display: none;
}
}
Подробнее о медиа-запросе — в статье про prefers-reduced-motion.
Где clip-path подводит
clip-path отрезает от элемента вообще всё, что не попало в фигуру. Из этого вытекают три неочевидных следствия.
Пропадает тень
box-shadow рисуется снаружи элемента, а значит, целиком попадает в отрезанную область. Свойство вроде бы задано, а тени нет. Решение — повесить на родителя фильтр drop-shadow(): фильтр применяется к уже обрезанной картинке и рисует тень точно по контуру шестиугольника.
/* не сработает: тень обрезается вместе со всем, что вне фигуры */
.hex {
box-shadow: 0 12px 24px rgb(0 0 0 / 0.45);
}
/* сработает: родитель отбрасывает тень по контуру сот */
.honeycomb {
filter: drop-shadow(0 10px 10px rgb(40 30 80 / 0.35));
}
Заодно это заменяет приём, который часто встречается в подобных галереях: отдельный размытый овал под сотами, который изображает тень и который приходится позиционировать вручную. Как устроены drop-shadow() и другие функции фильтров — в статье про фильтры в CSS.
Пропадает рамка фокуса
Если соты — ссылки, по ним можно ходить клавишей Tab. Но стандартная обводка outline тоже рисуется снаружи и тоже срезается: пользователь клавиатуры не видит, где находится. Раз рамка у нас сделана фоном, при фокусе просто перекрашиваем её в заметный цвет и показываем то же увеличение, что и при наведении:
.hex:focus-visible {
outline: none;
scale: 1.15;
z-index: 1;
background: linear-gradient(#ff7a45, #ffc53d);
}
Почему :focus-visible, а не :focus, и какие ещё есть приёмы для клавиатурной навигации, разобрано в советах по вёрстке под клавиатуру.
Наведение срабатывает только внутри фигуры
По спецификации отрезанная часть элемента не получает событий мыши: курсор над прозрачным углом соты считается наведённым на то, что лежит под ним. Для галереи это плюс: прямоугольные габариты соседних сот перекрываются из-за нахлёста рядов, но подсвечивается всегда та сота, над которой курсор находится визуально. Обратная сторона — у маленьких сот область клика заметно меньше, чем кажется по размеру блока, поэтому совсем мелкие соты на мобильном лучше не делать.
Новые способы: corner-shape и shape()
Шестиугольник с настоящей рамкой и тенью
Свойство corner-shape меняет форму скругления, которое задаёт border-radius. Значение bevel превращает дугу в прямой срез. Если сделать срезы от середины верхней стороны до четверти высоты, получится тот же шестиугольник. Отличие в том, что это форма самого блока, а не обрезка, поэтому border и box-shadow идут ровно по контуру и не теряются.
.hex {
aspect-ratio: 0.866;
border-radius: 50% / 25%; /* по горизонтали 50% ширины, по вертикали 25% высоты */
corner-shape: bevel;
border: 6px solid #4c6ef5;
box-shadow: 0 12px 24px rgb(0 0 0 / 0.35);
overflow: hidden;
}
/* фолбэк для браузеров без corner-shape */
@supports not (corner-shape: bevel) {
.hex {
border: 0;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
}
}
Свойство пока экспериментальное и поддерживается только в браузерах на Chromium, поэтому без проверки через @supports не обойтись: в остальных браузерах сота откатится на обрезку через clip-path и просто останется без рамки.
Скруглённые углы через shape()
У polygon() углы всегда острые. Если хочется соты помягче, пригодится функция shape() для того же clip-path. Она описывает контур командами, как путь в SVG: line to ведёт прямую, curve to … with … рисует кривую с контрольной точкой. В отличие от SVG-пути, координаты можно писать в процентах, и фигура тянется вместе с элементом.
Принцип такой: не доводим каждую сторону до вершины, останавливаемся чуть раньше и огибаем угол кривой, контрольной точкой которой служит сама вершина.
.hex-rounded {
/* фолбэк: острые углы */
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
/* браузер без shape() отбросит эту строку и оставит polygon() */
clip-path: shape(from 56% 3%,
line to 94% 22%, curve to 100% 31% with 100% 25%,
line to 100% 69%, curve to 94% 78% with 100% 75%,
line to 56% 97%, curve to 44% 97% with 50% 100%,
line to 6% 78%, curve to 0% 69% with 0% 75%,
line to 0% 31%, curve to 6% 22% with 0% 25%,
line to 44% 3%, curve to 56% 3% with 50% 0%,
close);
}
Фолбэк здесь не требует @supports: браузер, который не понимает значение, отбрасывает объявление целиком и применяет предыдущее. В отличие от corner-shape, функция shape() уже работает во всех основных браузерах.
Итог
Галерея-соты собирается из нескольких простых деталей:
- clip-path: polygon() с шестью вершинами и aspect-ratio: 0.866 — правильный шестиугольник без пустых полей;
- грид на 2N + 1 полуколонок, сдвиг каждого второго ряда через :nth-child() и отрицательный margin-bottom для нахлёста — соты перестраиваются сами, без координат;
- рамка фоном обёртки, блик псевдоэлементом, увеличение отдельным свойством scale и отключение анимации через prefers-reduced-motion;
- тень через drop-shadow() на родителе и подсветка рамки при фокусе вместо срезанного outline;
- на будущее — corner-shape: bevel для сот с настоящей рамкой и shape() для скруглённых углов.
Тот же подход работает не только с шестиугольниками: поменяйте точки в polygon() — и получатся ромбы или восьмиугольники (раскладку, правда, придётся подстроить под новую форму). Идеи других фигур можно подсмотреть в подборке фигур на CSS.
Комментарии (0)