Аккордеон, выпадающее меню, блок «Показать ещё» — всё это элементы, которые должны плавно раскрываться до высоты своего содержимого. Казалось бы, хватит transition: height и смены height: 0 на height: auto. На деле блок просто прыгает из одного состояния в другое, и анимации нет.

Годами это обходили хаками. Теперь в CSS есть два инструмента, которые решают задачу напрямую: свойство interpolate-size и функция calc-size(). Разберём оба, посмотрим, где они не помогут, и соберём запасной вариант для браузеров, которые их пока не знают.

Почему height: auto не анимировался

Transitions в CSS работают так: браузер берёт начальное и конечное значения и на каждом кадре вычисляет промежуточное. Между 0 и 200px посчитать середину легко. А auto — не число, а ключевое слово: «сколько потребует содержимое». Точку посередине между нулём и словом браузер не вычисляет, поэтому значение меняется сразу.

Самый популярный обход — анимировать max-height до заведомо большого числа:

.dropdown {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.4s;
}

.dropdown.is-open {
  max-height: 1000px; /* «с запасом» */
}

У приёма две проблемы. Время перехода растягивается на все 1000 пикселей, а реальный контент занимает, скажем, 150. Поэтому раскрытие выглядит слишком резким, а закрытие начинается с паузы: браузер сначала «проматывает» пустые 850 пикселей. И если контент однажды окажется выше 1000 пикселей, он обрежется.

Второй обход — измерить высоту скриптом и подставить её числом:

const open = (element) => {
  element.style.height = `${element.scrollHeight}px`;
  element.addEventListener('transitionend', () => {
    element.style.height = 'auto';
  }, { once: true });
};

Работает точно, но это код, который нужно поддерживать: учитывать ресайз окна, подгрузку картинок внутри и обратную анимацию при закрытии.

Одна строка на весь сайт: interpolate-size

Свойство interpolate-size разрешает браузеру интерполировать, то есть вычислять промежуточные значения, между числом и ключевым словом размера. У него два значения:

  • numeric-only — поведение по умолчанию, анимируются только числа;
  • allow-keywords — анимируются и переходы к ключевым словам вроде auto.

Свойство наследуется, поэтому его достаточно объявить один раз на корневом элементе — и все потомки получат анимацию размеров по ключевым словам:

:root {
  interpolate-size: allow-keywords;
}

.panel {
  height: 0;
  overflow: clip;
  transition: height 0.4s ease;
}

.panel.is-open {
  height: auto;
}

Больше ничего не нужно: панель плавно раскрывается до высоты содержимого и так же плавно схлопывается обратно. Сколько текста окажется внутри, неважно — браузер сам вычисляет конечную высоту на старте перехода.

Кроме transition, так же работают и @keyframes. Например, можно добавить небольшой «отскок» при раскрытии:

@keyframes reveal {
  0%   { height: 0; }
  80%  { height: calc-size(auto, size + 12px); }
  100% { height: auto; }
}

.panel.is-open {
  height: auto;
  animation: reveal 0.5s ease-out;
}

Функцию calc-size() из этого примера разберём ниже: здесь она даёт перелёт на 12 пикселей выше итоговой высоты.

Не только высота

Анимироваться могут переходы к любому из ключевых слов, которые описывают размер по содержимому: auto, min-content, max-content и fit-content (у flex-basis к ним добавляется ещё content). Чем отличаются последние три, подробно разобрано в статье про min-content, max-content и fit-content.

Пример с шириной: круглый значок, который при наведении раскрывается в плашку с текстом. Конечная ширина зависит от длины надписи, но считать её не нужно:

.badge {
  width: 44px;
  overflow: clip;
  white-space: nowrap;
  transition: width 0.35s ease;
}

.badge:hover,
.badge:focus-visible {
  width: fit-content;
}

details без скрипта, но с анимацией

Элементы details и summary — готовый аккордеон без единой строки JavaScript, а общий атрибут name собирает несколько блоков в группу, где открыт только один. Подробнее о таких приёмах — в статье о том, что CSS теперь умеет без JavaScript. Долго не удавалось одно: анимировать раскрытие. Содержимое details появлялось мгновенно.

Теперь раскрываемая часть доступна через псевдоэлемент ::details-content, и ей можно задать transition по высоте. Есть нюанс: браузер прячет содержимое закрытого details через content-visibility: hidden. Это свойство дискретное: у него нет промежуточных значений, оно переключается мгновенно, и закрытие оборвалось бы на первом кадре. Значение allow-discrete в переходе откладывает переключение до конца анимации:

:root {
  interpolate-size: allow-keywords;
}

details::details-content {
  height: 0;
  overflow: clip;
  transition:
    height 0.4s ease,
    content-visibility 0.4s allow-discrete;
}

details[open]::details-content {
  height: auto;
}

Сам ::details-content уже поддерживается во всех основных браузерах, то есть оформить раскрываемую часть (фон, отступы) можно везде. А вот анимация высоты из примера сработает только там, где есть interpolate-size.

Поддержка ::details-content браузерами
chrome
Chrome
131
firefox
Firefox
143
edge
Edge
131
safari
Safari
18.4
opera
Opera
116

calc-size(): когда нужна арифметика

Обычная calc() работает только с числами: написать calc(auto + 20px) нельзя. Функция calc-size() закрывает этот пробел. Она принимает два аргумента:

  1. ключевое слово, от которого считаем: auto, min-content, max-content, fit-content;
  2. выражение, в котором слово size означает размер, вычисленный по первому аргументу.
/* ровно размер по содержимому — то же, что просто auto */
height: calc-size(auto, size);

/* по содержимому плюс запас */
height: calc-size(auto, size + 2rem);
width: calc-size(max-content, size - 40px);

/* половина ширины по содержимому */
width: calc-size(fit-content, size / 2);

/* внутри доступны и математические функции: округляем вверх до шага 50px */
height: calc-size(auto, round(up, size, 50px));

Главное следствие: значение с calc-size() анимируется само по себе, даже без interpolate-size. То есть панель из первого раздела можно раскрыть и так, точечно:

.panel.is-open {
  height: auto;                   /* для браузеров без calc-size() */
  height: calc-size(auto, size);
}

Первая строка нужна для браузеров, которые calc-size() не понимают: неизвестное значение они проигнорируют и возьмут auto. Если задача — просто разрешить анимацию до auto, удобнее один раз включить interpolate-size на корне. А calc-size() оставить для случаев, где к размеру по содержимому действительно нужно что-то прибавить или от него отнять. Подробнее о самой calc() — в мануале про свойство calc().

Пример такого случая: превью картинки, которое по клику плавно вырастает до половины натурального размера. Ключевое слово size у картинки — её собственный размер из файла. Управляем высотой, а ширина подстраивается под пропорции:

.photo img {
  width: auto;
  height: 80px;
  transition: height 0.4s ease;
}

.photo.is-zoomed img {
  height: calc-size(auto, size / 2);
}

Второй пример — кнопка, которая при наведении расширяется ровно на ширину выезжающей стрелки. Стрелка вынесена из потока через position: absolute, поэтому к собственной ширине кнопки нужно прибавить место под неё. Кнопки с текстом разной длины получат каждая свою ширину плюс одинаковые 28 пикселей:

.cta {
  position: relative;
  width: auto;
  overflow: clip;
  transition: width 0.3s ease;
}

.cta:hover,
.cta:focus-visible {
  width: calc-size(auto, size + 28px);
}

Где не сработает

Между двумя ключевыми словами

Одна из сторон перехода обязательно должна быть числом или calc-size() от того же ключевого слова. Переход от auto к fit-content или от min-content к max-content не анимируется ни с interpolate-size, ни с calc-size():

/* на :root включён interpolate-size: allow-keywords */
.tag         { width: min-content; transition: width 0.3s; }
.tag:hover   { width: max-content; } /* прыжок, анимации не будет */

.tag-2       { width: 3rem; transition: width 0.3s; }
.tag-2:hover { width: max-content; } /* плавно: число → ключевое слово */

За пределами свойств размера

Оба инструмента работают только со свойствами, которые задают размер блока: width, height, их логические пары inline-size и block-size, варианты с min- и max-, а также flex-basis. Например, центрирование через margin: auto так не анимировать: auto в отступе означает «раздели свободное место», а не размер по содержимому. Запись вида margin: calc-size(auto, size) браузер считает ошибкой и отбрасывает всё объявление.

В старом проекте без проверки

Значение allow-keywords не сделали поведением по умолчанию как раз ради совместимости: где-то высоту уже анимирует JavaScript, а где-то вёрстка опирается на то, что переход к auto происходит мгновенно. В новый проект строку на :root можно смело добавить в базовые стили. В существующий — сначала включить на конкретных компонентах и проверить, что ничего не поехало.

Свёрнутый блок не спрятан по-настоящему

Блок с height: 0 и overflow: clip невидим глазу, но не клавиатуре и не скринридеру. Ссылки и кнопки внутри по-прежнему получают фокус по Tab, а программа экранного доступа зачитает весь текст. Простое решение — добавить к свёрнутому состоянию visibility: hidden. У visibility есть особенность: при переходе между visible и hidden элемент остаётся видимым всю анимацию и прячется только в её конце. Поэтому закрытие не обрывается:

.panel {
  height: 0;
  overflow: clip;
  visibility: hidden;
  transition: height 0.4s ease, visibility 0.4s;
}

.panel.is-open {
  height: auto;
  visibility: visible;
}

У кнопки-переключателя стоит обновлять aria-expanded, чтобы скринридер объявлял, раскрыта панель или нет. С details об этом заботиться не нужно: браузер прячет и озвучивает его содержимое сам.

Поддержка браузерами и запасной вариант

interpolate-size и calc-size() пока есть только в браузерах на движке Chromium:

Поддержка interpolate-size браузерами
chrome
Chrome
129
firefox
Firefox
 
edge
Edge
129
safari
Safari
 
opera
Opera
115
Поддержка calc-size() браузерами
chrome
Chrome
129
firefox
Firefox
 
edge
Edge
129
safari
Safari
 
opera
Opera
115

Для interpolate-size это не страшно: это прогрессивное улучшение в чистом виде. Браузер без поддержки проигнорирует незнакомое свойство, и блок будет раскрываться так же, как раньше, — сразу, без анимации. Ничего не ломается, просто часть пользователей не увидит плавности.

С calc-size() нужно подумать отдельно. Если добавка к размеру декоративная, как место под стрелку в кнопке, её отсутствие ничего не испортит. Но если от неё зависит раскладка, нужен запасной вариант — объявление выше по коду (как height: auto в примере из прошлого раздела) или отдельная ветка в директиве @supports:

@supports not (height: calc-size(auto, size)) {
  .cta:hover,
  .cta:focus-visible {
    padding-inline-end: 48px; /* место под стрелку без анимации ширины */
  }
}

Плавно везде: трюк с grid-template-rows

Если анимация раскрытия нужна во всех браузерах уже сейчас, есть проверенный обходной путь через CSS Grid. Блок делают сеткой из одной строки и анимируют не высоту, а размер этой строки: от 0fr до 1fr. Доли (fr) — это числа, их браузеры интерполировать умеют, а 1fr в такой сетке как раз равен высоте содержимого:

.collapse {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.4s ease;
}

.collapse.is-open {
  grid-template-rows: 1fr;
}

.collapse__inner {
  min-height: 0; /* иначе строка не сожмётся меньше содержимого */
  overflow: hidden;
}

Цена — лишняя обёртка в разметке и display: grid на контейнере, который, возможно, был нужен для другой раскладки. Поэтому для новых проектов логично держать в голове оба способа: interpolate-size как основной, а grid-трюк — там, где плавность во всех браузерах обязательна.

Уважаем настройки пользователя

Любые анимации раскрытия стоит отключать для тех, кто попросил систему уменьшить движение на экране. Для этого есть медиазапрос prefers-reduced-motion:

@media (prefers-reduced-motion: reduce) {
  .panel,
  details::details-content {
    transition: none;
  }
}

Шпаргалка

Задача Что взять
Плавно раскрывать блоки до auto по всему сайту interpolate-size: allow-keywords на :root
Анимировать один конкретный элемент calc-size(auto, size) в конечном состоянии
Размер по содержимому плюс-минус что-то calc-size(auto, size + 28px) и другие выражения с size
Анимированный аккордеон без JavaScript details + ::details-content + content-visibility с allow-discrete
Плавность обязательна во всех браузерах grid-template-rows: 0fr → 1fr
Скрыть содержимое свёрнутого блока от клавиатуры и скринридеров visibility: hidden в свёрнутом состоянии

Главное, что стоит запомнить: строка interpolate-size: allow-keywords ничего не ломает там, где её не понимают, поэтому её можно ставить в базовые стили новых проектов уже сейчас. В Chromium раскрытия станут плавными, в остальных браузерах всё будет работать как прежде.