Обычная сетка из прямоугольных превью — надёжный, но скучный вариант. Если тот же набор фотографий уложить шестиугольниками, как пчелиные соты, галерея сразу выглядит как отдельный элемент дизайна: подойдёт для портфолио, страницы команды, витрины проектов или блока с партнёрами.

Всё собирается на чистом 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 без префикса.

Поддержка браузерами
chrome
Chrome
55
firefox
Firefox
54
edge
Edge
79
safari
Safari
13.1
opera
Opera
42

Соты без абсолютного позиционирования

Самый прямолинейный способ выложить соты — сделать каждую 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 и просто останется без рамки.

Поддержка браузерами
chrome
Chrome
139
firefox
Firefox
 
edge
Edge
139
safari
Safari
 
opera
Opera
123

Скруглённые углы через 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() уже работает во всех основных браузерах.

Поддержка браузерами
chrome
Chrome
135
firefox
Firefox
148
edge
Edge
135
safari
Safari
18.4
opera
Opera
120

Итог

Галерея-соты собирается из нескольких простых деталей:

  • 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.