В этом небольшом туториале я покажу вам, как с помощью нескольких новых возможностей CSS можно создать действительно прикольную динамичную систему тематизации (theming). Эта техника позволяет создавать тематизированные компоненты с тенями в светлом режиме и светящимися границами в темном режиме, с текстом, который всегда остается читаемым, независимо от динамичного цвета его фона. Все на CSS. Доступно во всех современных браузерах с мая 2026 года.

Вот что мы будем использовать:

  • @property для регистрации значения кастомного свойства (custom property) с осмысленным синтаксисом

  • light-dark() - разрешает значения на основе вычисленной (computed) color-scheme (учитывающей настройки пользователей (user preferences))

  • contrast-color() автоматически выбирает черный или белый текст для любого фона на основе алгоритма определения контраста WCAG

  • @container style() - ветвление (branching) кастомных цветовых палитр с помощью запросов стилей (style queries) на основе того, во что разрешается (resolve) contrast-color()

И опциональная директива @function для того, чтобы сделать код чище (хотя она пока имеет ограниченную поддержку браузеров).

Шаг 1: light-dark() как основа

light-dark() - это, по сути, более простая альтернатива медиа-запросам настроек пользователей, касающихся цветовой темы. Функция принимает два значения цвета и возвращает первое в светлой цветовой схеме и второе в темной. Она “читает” из вычисленной color-scheme элемента. Это означает, что она реагирует как на prefers-color-scheme, так и на любое явное свойство color-scheme, установленное в CSS или с помощью JavaScript.

html {
  color-scheme: light dark;
}

body {
  /* Устанавливаем кастомное свойство --bg с помощью light-dark() */
  --bg: light-dark(lightblue, black);
  background: var(--bg);
}

Следует отметить одну вещь: light-dark() реагирует на вычисленную color-scheme, а не на медиа-запросы prefers-color-scheme. Установка color-scheme: light dark на корневом элементе (root) указывает браузеру учитывать настройки пользовательской ОС, но мы можем перезаписать схему путем явной установки color-scheme: dark (или light). Это означает, что мы можем перезаписать схему на любом уровне:

/* По умолчанию, учитываем настройки ОС */
html {
  color-scheme: light dark;
}

/* Переключаем раздел в темный режим, независимо от настроек ОС */
.dark-section {
  color-scheme: dark;
}

Каждый вызов light-dark() внутри .dark-section - включая вызовы внутри @function --elevation() (см. ниже) - будут разрешаться в темное значение. В данном случае преимуществами по сравнению с медиа-запросами являются наследование и композиция.

Шаг 2: переключение механизма выделения

В светлых темах карточки выглядят объемными благодаря теням. Но в темных темах тени растворяются в фоне… поэтому нам нужен другой механизм выделения (elevation - дословно, подъем). Мы создадим едва заметное неоновое свечение краев темных поверхностей.

light-dark() принимает только значения <color>, поэтому ей нельзя передать определения теней целиком. Вместо этого, можно использовать light-dark() для цветов слоев теней, чтобы сделать нежелательные слои transparent (прозрачными) в неправильном режиме:

.card {
  box-shadow:
    /* Небольшая видимая тень в светлом режиме, прозрачная в темном */
    0 1px 2px light-dark(lightgray, transparent),
    0 4px 12px light-dark(lightgray, transparent),
    0 12px 32px light-dark(lightgray, transparent),

    /* Светящаяся прозрачная граница в светлом режиме, видимая в темном */
    inset 0 0 0 1px light-dark(transparent, oklch(from var(--bg)
    calc(l + 0.5) c h / 0.5)),
    0 0 16px light-dark(transparent, oklch(from var(--bg)
    calc(l + 0.5) c h / 0.3)),
    0 0 3px light-dark(transparent, oklch(from var(--bg)
    calc(l + 0.5) c h / 0.5));
}

Все 6 слоев теней технически всегда присутствуют, но 3 из них являются transparent в любом режиме.

Кроме того, для светящейся границы синтаксис относительного цвета (oklch(from var(--bg) ...)) генерирует (derive) цвета свечения из фона карточки, поэтому выделение всегда ощущается привязанным к карточке.

Это работает, но код придется повторять для каждого выделяемого элемента. @function позволяет сделать код чище:

@function --elevation(--bg) {
  result:
    0 1px 2px light-dark(lightgray, transparent),
    0 4px 12px light-dark(lightgray, transparent),
    0 12px 32px light-dark(lightgray, transparent),
    inset 0 0 0 1px light-dark(transparent, oklch(from var(--bg) calc(l + 0.5) c h / 0.5)),
    0 0 16px light-dark(transparent, oklch(from var(--bg) calc(l + 0.5) c h / 0.3)),
    0 0 3px light-dark(transparent, oklch(from var(--bg) calc(l + 0.5) c h / 0.5));
}

Затем достаточно просто применить эту функцию к нужному элементу:

.card {
  box-shadow: --elevation(var(--bg));
}

Обратите внимание, что @function пока имеет ограниченную поддержку браузеров.

Шаг 3: автоматический контраст с помощью contrast-color()

Я писала о contrast-color() несколько месяцев назад. Это новая функция, принимающая любой цвет и возвращающая black или white в зависимости от того, какой цвет больше контрастирует с переданным. Мы используем ее для обеспечения читаемости текста, независимо от цвета бренда:

.card {
  --bg: var(--brand-color);
  background: var(--bg);
  color: contrast-color(var(--bg));
}

Вот и все. Одна строчка для доступного (accessible) текста. Но что если мы не хотим ограничиваться черным и белым?

Шаг 4: кастомизируемые палитры с помощью запросов стилей

Запросы стилей (style queries) позволяют использовать contrast-color() как индикатор, т.е. ответить на вопрос: “На светлой или темной поверхности находится элемент?”, и затем выбрать более богатую цветовую палитру. Я писала об этой технике в статье contrast-color() beyond black & white.

Сначала регистрируем кастомное свойство и присваиваем ему результат contrast-color():

@property --contrast-color {
  syntax: "<color>";
  initial-value: white;
  inherits: true;
}

.card {
  --bg: var(--brand-color);
  --contrast-color: contrast-color(var(--bg));
  color: var(--contrast-color);
}

Затем используем @container style() для ветвления:

/* Если contrast-color имеет значение white, фон темный:
   используем более теплые светлые цвета */
@container style(--contrast-color: white) {
  .card-label,
  .card-body {
    /* Относительная цветовая палитра для основного текста и текста подписей */
    color: oklch(from var(--bg) 0.9 0.1 h);
  }
}

/* Если contrast-color имеет значение black, фон светлый:
   используем глубокие тона на основе оттенка карточки */
@container style(--contrast-color: black) {
  .theming-card-label,
  .theming-card-body {
    color: oklch(from var(--bg) 0.3 0.1 h);
  }
}

Вместо чисто черного или белого цвета, текст окрашивается в соответствии с оттенком карточки - глубокий индиго на светло-лавандовом фоне, теплый кремовый на темно-бирюзовом. Синтаксис относительного цвета (oklch(from var(--bg) ...)) определяет оттенок на основе одного цвета бренда.

Вместо синтаксиса относительного цвета, можно использовать явные значения цвета. Однако в этом случае нельзя полагаться на результат color-contrast() напрямую, поэтому следует тщательно тестировать используемые цветовые комбинации.

Все вместе

Собрав все вместе, можно отделить макро-тематизацию (светлую и темную темы всей страницы, установленные с помощью light-dark()) от микро-тематизации (темы внутри карточки/элемента, использующего преимущества contrast-color()).

Например, у нас может быть темная тема страницы, к которой применяются определенные стили выделения, но при этом у нас также может быть карточка с более светлым или темным фоном. В этом случае мы получим более надежный результат и более динамичные варианты цветовых решений, используя contrast-color() и другие методы, упомянутые в статье.

Вот полное демо - переключайте тему, чтобы увидеть смену теней на светящиеся границы, и заметьте, как цвет текста каждой карточки меняется на основе ее поверхности:

Каждая карточка получает только --brand-color (сама с помощью light-dark() для соответствующих теме значений). Далее система генерирует:

  • фон - сам цвет бренда

  • механизм выделения - серые тени или светящаяся граница на основе фона

  • цвет текста - contrast-color() берет черный или белый, а запросы стилей выполняют ветвление в нужную цветовую палитру

Поддержка браузеров

Как упоминалось в начале статьи, эта техника поддерживается всеми основными браузерами с мая 2026 года.

@function пока поддерживается только в Chrome 139+.

Заключение

Сочетание light-dark(), @container style() и contrast-color() создает систему тематизации, которая является декларативной, компонуемой и использующей преимущества разных уровней цветовой тематизации. Она учитывает настройки пользователей (через цветовую схему) для макро-тематизации и позволяет создавать динамичные микро-темы.

Материалы для дополнительного изучения: