Главная / Статьи

CSS-собственные свойства во время выполнения: тематизация, мосты JS и @property

Узнайте, в чем отличия пользовательских свойств CSS от переменных Sass, и научитесь использовать их для создания тем, эффектов, управляемых JavaScript, гибкого форматирования текста, вариантов компонентов и анимаций с типизацией.

Издатель: REACTAPP.TOP17 мин чтения3986 слов

Источники: CSS Variables Are Underrated — Here’s What You’re Missing

Многие кодовые базы используют пользовательские свойства CSS точно так же, как переменные Sass: список цветов объявляется один раз в :root и больше не изменяется. Этот подход работает, но игнорирует ту особенность, которая делает пользовательские свойства уникальными. Пользовательские свойства — это динамические значения в браузере, которые наследуются через DOM, реагируют на запросы к медиа-содержимому и изменения атрибутов, а также могут обновляться с помощью JavaScript в любой момент. В этом руководстве показано, что позволяет такое поведение в режиме выполнения на практике: от тематизации и параметризованных компонентов до анимаций с типизацией с использованием @property, а также описаны механизмы и подводные камни, с которыми сталкиваются команды.

Константы времени компиляции против динамических значений

Преобразователи такие как Sass, Less и Stylus на протяжении многих лет формировали стилизацию фронтенда с помощью вложенности, миксинов и переменных. Когда в браузерах появились встроенные пользовательские свойства, распространённой реакцией было мнение, что они ничего не добавляют по сравнению с тем, что уже предоставляло $primary-color. Именно это предположение стало причиной их недостаточного использования. Разница заключается в том, когда значение уже существует:

+-------------------------------------------------------------------+
|                     SASS / SCSS VARIABLES                         |
|  Compile-Time Constants  -->  Evaluated Before Ship  -->  Static   |
+-------------------------------------------------------------------+

+-------------------------------------------------------------------+
|                    CSS CUSTOM PROPERTIES                          |
|  Runtime Live Properties --> Evaluated in Browser --> Dynamic     |
+-------------------------------------------------------------------+

Что становится из переменной преобразователя

Переменная Sass обрабатывается ещё до того, как CSS дойдёт до браузера. В приведённом ниже SCSS второе значение присваивается внутри блока модификатора dark:

$brand-color: #3b82f6;

.card {
  background-color: $brand-color;
}

.card--dark {
  $brand-color: #1e293b;
  background-color: $brand-color;
}

Компилятор заменяет литеральное значение во всех местах, где используется переменная, поэтому готовый файл стилей выглядит просто так:

.card {
  background-color: #3b82f6;
}

.card--dark {
  background-color: #1e293b;
}

После компиляции имя $brand-color больше не существует. Браузер не может понять, что оба цвета происходят из одной переменной, и он не может изменить это значение в ответ на клик, изменение размера окна или состояние наведения. Каждая вариация должна генерироваться заранее в виде дополнительного CSS.

Что остается от пользовательского свойства

Нативная версия сохраняет имя свойства в браузере:

:root {
  --brand-color: #3b82f6;
}

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

.card[data-theme="dark"] {
  --brand-color: #1e293b;
}

Здесь --brand-color является частью вычисляемого стиля каждого элемента. Когда у элемента .card задаётся атрибут data-theme="dark", браузер пересчитывает значение var(--brand-color) для этого элемента и его потомков. Правило, относящееся к .card, не дублируется; достаточно изменить значение только для одной ветви дерева DOM. Короче говоря: переменные Sass заменяются во время сборки и исчезают при выполнении кода, тогда как пользовательские свойства обрабатываются браузером для каждого элемента, участвуют в каскадировании стилей и могут изменяться после загрузки страницы.

Механизмы, которые необходимо правильно понимать

Несколько правил, касающихся синтаксиса, области видимости и обработки ошибок, объясняют большинство неожиданных поведений, с которыми сталкиваются пользователи.

Именование и чтение значений

Имя пользовательского свойства должно начинаться с двух тире, причём имена чувствительны к регистру, поэтому это два отдельных свойства:

:root {
  --main-color: #10b981;
  --Main-Color: #ef4444; /* Different property! */
}

Вы считываете значение с помощью функции var():

.button {
  background-color: var(--main-color);
}

Область видимости определяется по принципу каскадирования

Собственные свойства подчиняются обычным правилам каскадирования и наследования. Если свойство объявить в :root, что соответствует элементу <html>, оно становится доступным везде. Объявление его в другом селекторе заменит его только для этого элемента и его потомков:

/* Global Scope */
:root {
  --spacing-unit: 1rem;
}

/* Sub-tree Scope Override */
.sidebar {
  --spacing-unit: 0.5rem; /* Overrides --spacing-unit for .sidebar and its descendants */
}

.sidebar .widget {
  padding: var(--spacing-unit); /* Computes to 0.5rem */
}

.main-content .widget {
  padding: var(--spacing-unit); /* Computes to 1rem */
}

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

Значения по умолчанию

var() принимает второй аргумент, который используется, когда у ссылочного свойства нет значения. Значения по умолчанию также могут содержать вызовы var(), поэтому их можно объединять в цепочки:

.avatar {
  /* Fallback to standard 40px if --avatar-size is undefined */
  width: var(--avatar-size, 40px);

  /* Nested fallbacks */
  color: var(--user-theme-color, var(--brand-default, #2563eb));
}

Необходимо точно определять момент применения запасного варианта. Он используется только тогда, когда пользовательская свойство не определено для данного элемента (и не унаследовано от предка) или явно установлено в значение initial. Если свойство определено, но содержит значение, которое не имеет смысла для целевого элемента — например, длину вместо цвета — запасной вариант игнорируется. Следует отметить, что использование unset не является надежным способом его активации, поскольку пользовательские свойства по умолчанию наследуются, так что unset ведет себя как inherit и берет значение родителя, если оно существует.

Недопустимо при вычислении значения

Обычное объявление с недопустимым значением отклоняется во время парсинга, поэтому ранее сделанное допустимое объявление продолжает действовать. Объявления, содержащие var(), невозможно проверить во время парсинга, поскольку замененное значение становится известно только после выполнения каскадирования:

.box {
  color: red;
  color: var(--not-a-color, 12px); /* Invalid type for color! */
}

Парсер принимает вторую строку с параметром color и перезаписывает значение color: red. Только при вычислении значений свойства браузер обнаруживает, что 12px не является цветом. К тому моменту предыдущее объявление уже утратило силу, поэтому свойство ведет себя так, как будто оно было установлено в значение unset: наследуемые свойства, такие как color, берут значение у родителя элемента, а ненаследуемые возвращаются к своему начальному значению (например, transparent для свойства background-color). Такая скрытая ошибка является одной из проблем, которые предназначено решить использование директивы @property, рассмотренной позже.

Тематизация без дублирующихся правил

Классическая реализация темного режима дублирует каждое правило, связанное с тематикой:

/* Traditional approach: Duplicating declarations */
.card {
  background-color: #ffffff;
  color: #1e293b;
  border: 1px solid #e2e8f0;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}

body.dark-mode .card {
  background-color: #0f172a;
  color: #f8fafc;
  border: 1px solid #334155;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5);
}

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

Токены делают компоненты независимыми от темы

Если решения по дизайну хранятся в пользовательских свойствах, правила компонентов больше не зависят от того, какая тема активна:

/* 1. Define Design Tokens */
:root {
  --bg-surface: #ffffff;
  --text-primary: #1e293b;
  --border-subtle: #e2e8f0;
  --shadow-alpha: 0.05;
}

[data-theme="dark"] {
  --bg-surface: #0f172a;
  --text-primary: #f8fafc;
  --border-subtle: #334155;
  --shadow-alpha: 0.5;
}

/* 2. Write Component CSS Once */
.card {
  background-color: var(--bg-surface);
  color: var(--text-primary);
  border: 1px solid var(--border-subtle);
  box-shadow: 0 4px 6px rgba(0, 0, 0, var(--shadow-alpha));
}

Правило .card существует только один раз. Добавление третьей темы, например [data-theme="high-contrast"], означает необходимость указания четырёх значений токенов вместо повторного объявления правил для каждого компонента.

Получение палитры из цветовых каналов

Можно пойти дальше и хранить отдельные каналы цвета, например в формате OKLCH, а затем вычислять из них все вариации цвета:

:root {
  /* Store raw OKLCH channels: Lightness, Chromaticity, Hue */
  --brand-l: 0.62;
  --brand-c: 0.19;
  --brand-h: 255;

  /* Base dynamic color */
  --brand-color: oklch(var(--brand-l) var(--brand-c) var(--brand-h));

  /* Automatically derived variations */
  --brand-hover: oklch(calc(var(--brand-l) - 0.1) var(--brand-c) var(--brand-h));
  --brand-active: oklch(calc(var(--brand-l) - 0.2) var(--brand-c) var(--brand-h));
  --brand-tint: oklch(0.95 calc(var(--brand-c) * 0.2) var(--brand-h));
  --brand-alpha-50: oklch(var(--brand-l) var(--brand-c) var(--brand-h) / 0.5);
}

.button-primary {
  background-color: var(--brand-color);
  color: white;
}

.button-primary:hover {
  background-color: var(--brand-hover);
}

.button-primary:active {
  background-color: var(--brand-active);
}

.badge {
  background-color: var(--brand-tint);
  color: var(--brand-color);
}

Варианты оверлея, активного состояния, оттенка и прозрачности — всё это выражения, включающие три числа. Изменение значения --brand-h в элементе :root, будь то из JavaScript или на основе предпочтений пользователя, позволяет единообразно изменить основной цвет, его состояния, оттенок и оверлеи без использования библиотек для обработки цветов.

Для каждого производного токена существует одна нюансная особенность: переменная var(), объявленная внутри пользовательского свойства, резолюционируется в элементе, где это свойство было объявлено, а все потомки наследуют полученное значение. Поскольку --brand-color и подобные параметры объявлены в :root, изменение значения --brand-h в более глубоких уровнях иерархии не приводит к пересчёту этих параметров. Необходимо либо изменить значение в :root, либо заново объявить производные токены в том контексте, где вы изменяете основные параметры.

Управление CSS из JavaScript

Персонализированные свойства обеспечивают чистый интерфейс между скриптами и стилями. Вместо написания строк с встроенными стилями или работы с комбинациями классов JavaScript может передавать необработанные значения, такие как координаты курсора или состояние приложения, в CSS, позволяя таблице стилей самой решать, что с ними делать.

Выделение, следующее за курсором

Начнем с элемента card:

<div class="glow-card" id="card">
  <h3>Interactive Reactivity</h3>
  <p>Hover over this card to see real-time cursor lighting.</p>
</div>

Таблица стилей задает значения координат по умолчанию и использует их для размещения радиальной градиентной заливки в псевдоэлементе:

.glow-card {
  --mouse-x: 50%;
  --mouse-y: 50%;

  position: relative;
  background-color: #1e1e2e;
  border-radius: 12px;
  padding: 2rem;
  color: #cdd6f4;
  overflow: hidden;
}

/* Radial light overlay following cursor coordinates */
.glow-card::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    800px circle at var(--mouse-x) var(--mouse-y),
    rgba(255, 255, 255, 0.08),
    transparent 40%
  );
  pointer-events: none;
}

Скрипт лишь сообщает о местоположении курсора относительно элемента card:

const card = document.getElementById('card');

card.addEventListener('pointermove', (e) => {
  const rect = card.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;

  // Send raw coordinates to CSS
  card.style.setProperty('--mouse-x', `${x}px`);
  card.style.setProperty('--mouse-y', `${y}px`);
});

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

Полоса прогресса прокрутки

Ту же идею можно применить для передачи положения прокрутки в CSS в виде дроби без единиц измерения от 0 до 1:

window.addEventListener('scroll', () => {
  const scrollTop = window.scrollY;
  const docHeight = document.documentElement.scrollHeight - window.innerHeight;
  const scrollPercent = (scrollTop / docHeight);

  // Set unitless fraction (0 to 1)
  document.documentElement.style.setProperty('--scroll-progress', scrollPercent);
});

Полоса использует это значение через метод transform, что позволяет избежать работы по формированию макета, поскольку масштабирование не влияет на размеры других элементов на странице:

/* Progress indicator bar */
.scroll-progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  width: 100%;
  background-color: #3b82f6;
  transform-origin: left;
  /* Hardware-accelerated GPU transform using variable */
  transform: scaleX(var(--scroll-progress, 0));
  will-change: transform;
}

Стоит рассмотреть два улучшения. Скрипт записывает данные в document.documentElement, что приводит к частым глобальным обновлениям, о которых предупреждается в разделе производительности; установка значения непосредственно на саму полоску прогресса ограничивает пересчет только одним элементом. К тому же в браузерах, поддерживающих анимации, запускаемые прокруткой страницы, подобную полоску прогресса можно создать с использованием animation-timeline: scroll() без какого-либо JavaScript.

Гибкая типографика и соотношения масштабирования

Типографика, основанная на точках разрыва, меняет фиксированные размеры при ширинах вроде 768px и 1024px. Однако использование пользовательских свойств вместе с функциями clamp() и calc() позволяет размерам изменяться плавно в диапазоне от минимального до максимального значения.

Вычисление с использованием параметризованной функции clamp()

Идея заключается в том, чтобы описать масштаб простыми числами (диапазон обзорной области и диапазон размера шрифта) и вычислить угол наклона и смещение линейной функции между ними:

:root {
  /* Viewport and layout parameters */
  --fluid-min-width: 320;
  --fluid-max-width: 1200;

  /* Typography scale parameters */
  --font-min-size: 16;
  --font-max-size: 20;

  /* Compute slope and intersection for clamp() */
  --slope: (var(--font-max-size) - var(--font-min-size)) / (var(--fluid-max-width) - var(--fluid-min-width));
  --y-axis-intersection: -1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size);

  /* Fluid base font size expression */
  --fluid-type-base: clamp(
    calc(var(--font-min-size) * 1px),
    calc(var(--y-axis-intersection) * 1px + (var(--slope) * 100vw)),
    calc(var(--font-max-size) * 1px)
  );
}

body {
  font-size: var(--fluid-type-base);
}

Рассматривайте этот фрагмент как отправную точку, а не готовый код. Замена пользовательских свойств происходит в текстовом виде, поэтому при подстановке --y-axis-intersection в выражение calc(var(--y-axis-intersection) * 1px + ...) часть * 1px применяется только к последнему элементу выражения. В результате первый элемент без единицы измерения добавляется к длине, что делает всё выражение clamp() недействительным. Чтобы сохранить желаемую группировку, необходимо заключить промежуточные выражения в скобки, например --y-axis-intersection: (-1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size)); или использовать функцию calc().

Размеры заголовков, определяемые по одному соотношению

При наличии базового размера один коэффициент может определить всю шкалу заголовков:

:root {
  /* Scale multiplier ratio (e.g., 1.25 Major Third) */
  --type-ratio: 1.25;

  --step-0: var(--fluid-type-base);
  --step-1: calc(var(--step-0) * var(--type-ratio));
  --step-2: calc(var(--step-1) * var(--type-ratio));
  --step-3: calc(var(--step-2) * var(--type-ratio));
  --step-4: calc(var(--step-3) * var(--type-ratio));
}

/* Application across elements */
p  { font-size: var(--step-0); }
h3 { font-size: var(--step-1); }
h2 { font-size: var(--step-2); }
h1 { font-size: var(--step-3); }

Каждый шаг умножается на предыдущий, поэтому пропорции между уровнями остаются неизменными. Изменение коэффициента в запросе к медиа-ресурсам пересчитывает все уровни сразу:

@media (min-width: 1024px) {
  :root {
    /* Upgrade to 1.333 Perfect Fourth on large displays */
    --type-ratio: 1.333;
  }
}

Это работает потому, что шаги и коэффициент объявлены в одном элементе :root, поэтому изменения становятся видимыми при вычислении шагов.

Компоненты с параметрами вместо классов-вариантов

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

/* Traditional class bloat */
.btn { padding: 8px 16px; font-size: 14px; background: blue; border-radius: 4px; }
.btn--md { padding: 12px 24px; font-size: 16px; }
.btn--lg { padding: 16px 32px; font-size: 18px; }
.btn--rounded { border-radius: 9999px; }
.btn--danger { background: red; }
.btn--success { background: green; }

Персонализированные свойства как API компонента

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

.btn {
  /* 1. Component Defaults */
  --btn-bg: #3b82f6;
  --btn-color: #ffffff;
  --btn-px: 1.25rem;
  --btn-py: 0.625rem;
  --btn-radius: 0.375rem;
  --btn-font-size: 0.875rem;

  /* 2. Unified Rule Declaration */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--btn-py) var(--btn-px);
  background-color: var(--btn-bg);
  color: var(--btn-color);
  border-radius: var(--btn-radius);
  font-size: var(--btn-font-size);
  border: none;
  cursor: pointer;
  transition: background-color 0.2s, transform 0.1s;
}

.btn:hover {
  filter: brightness(0.9);
}

/* 3. Concise Declarative Modifiers */
.btn--lg {
  --btn-px: 1.75rem;
  --btn-py: 0.875rem;
  --btn-font-size: 1.125rem;
}

.btn--pill {
  --btn-radius: 9999px;
}

.btn--danger {
  --btn-bg: #ef4444;
}

.btn--ghost {
  --btn-bg: transparent;
  --btn-color: #3b82f6;
}

Модификаторы вроде .btn--lg не повторяют значения свойств display, border-radius, cursor или transition. Они лишь устанавливают новые значения для этих параметров. Стильовая таблица остается компактной, модификаторы легко комбинируются (btn--lg btn--pill btn--danger работает без проблем), и если вариант выглядит некорректно, можно с уверенностью предположить, что причина кроется в одном из нескольких возможных модификаторов.

Типизированные свойства и анимации с @property

У незарегистрированных пользовательских свойств есть хорошо известное ограничение: их нельзя использовать в интерполяции во время переходов или ключевых кадров. Браузер рассматривает их значения как последовательности токенов без указания типа, поэтому у него нет возможности анимировать значение от 0deg до 360deg или от red до blue; значение просто меняется в определенный момент анимации.

Правило @property регистрирует пользовательское свойство с указанием синтаксиса, начального значения и настроек наследования. Оно поддерживается в текущих версиях основных браузеров, но перед использованием проверьте поддержку в вашей целевой среде.

Регистрация свойств с указанием типа

/* Register type-checked custom property */
@property --gradient-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --accent-color {
  syntax: "<color>";
  inherits: true;
  initial-value: #3b82f6;
}

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

Переходы по градиентам

Как только браузер понимает, что --gradient-angle — это угол, а --accent-color — цвет, он может интерполировать их, что позволяет реализовывать переходы по градиентам:

.animated-gradient-card {
  /* Reference the typed property inside radial/linear gradient */
  background: linear-gradient(
    var(--gradient-angle),
    #0f172a,
    var(--accent-color),
    #0f172a
  );

  border-radius: 16px;
  padding: 3rem;

  /* Animate custom property on hover! */
  transition: --gradient-angle 0.8s ease, --accent-color 0.5s ease;
}

.animated-gradient-card:hover {
  --gradient-angle: 180deg;
  --accent-color: #ec4899;
}

Непрерывная анимация ключевых кадров

Та же самая возможность регистрации позволяет использовать ключевые кадры для бесконечного вращения градиента:

@keyframes spin-gradient {
  to {
    --gradient-angle: 360deg;
  }
}

.glowing-border {
  animation: spin-gradient 4s linear infinite;
}

Следует помнить, что анимация зарегистрированной свойства, используемой в градиенте, всё равно заставляет элемент перерисовываться на каждом кадре в основном потоке; это не то же самое, что анимация свойств transform или opacity, которая обрабатывается исключительно композитором.

Производительность: где влияют обновления

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

На какую глубину распространяется обновление

Вызов element.style.setProperty('--name', value) приводит к аннулированию стилей для данного элемента и всех элементов, которые могут наследовать их:

+-------------------------------------------------------------------------+
|                  STYLE INVALIDATION CASCADE PATTERN                     |
|                                                                         |
|  [ :root Variable Mutation ]                                            |
|       └─> Invalidates Entire DOM Tree                                   |
|            └─> High Recalculation Cost (Large DOMs)                     |
|                                                                         |
|  [ Scoped Local Element Mutation ]                                      |
|       └─> Invalidates Only Local Component Branch                       |
|            └─> Minimal Engine Cost (60 FPS Safe)                        |
+-------------------------------------------------------------------------+
  • Изменение свойства в :root может заставить браузер пересчитывать стили для каждого элемента, который от него зависит в документе. Это может снижать производительность при каждом событии scroll или mousemove на крупной странице.
  • Установка свойства для конкретного элемента, например карточки, над которой находится курсор, ограничивает пересчет только этим элементом.

Практические рекомендации

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

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

@property --local-x {
  syntax: "<length>";
  inherits: false; /* Performance booster! Stops inheritance traversal */
  initial-value: 0px;
}

Есть важное ограничение: псевдоэлементы наследуют свойства от элемента-источника, поэтому свойство, зарегистрированное с параметром «не наследоваться», установленное на элементе, не будет видно внутри его ::before или ::after. Если псевдоэлемент должен считывать это значение, сохраните наследование свойства или применяйте эффект непосредственно к самому элементу.

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

/* Performant GPU layer usage */
.box {
  transform: translate3d(var(--x, 0), var(--y, 0), 0);
}

Собираем всё вместе: карточка дашборда с тематикой

Следующий виджет объединяет несколько техник: два тематических режима, эффект свечения, следующий за курсором, элемент управления цветом для изменения вида компонента и гибкие размеры заголовков. Маркировка представлена семантическим элементом <article>, включающим заголовок, кнопку переключения и поле ввода диапазона:

<article class="dash-card" id="dashCard" data-theme="dark">
  <header class="dash-card__header">
    <span class="dash-card__badge">Live Metrics</span>
    <button class="dash-card__theme-btn" id="themeToggle">Toggle Theme</button>
  </header>

  <div class="dash-card__body">
    <h2 class="dash-card__title">CSS Variable Architecture</h2>
    <p class="dash-card__text">
      Hover over this component to trigger lighting updates. Use the color slider below to adjust primary hue channels across all child components in real time.
    </p>

    <div class="dash-card__controls">
      <label for="hueSlider">Primary Hue: <span id="hueVal">220</span>°</label>
      <input type="range" id="hueSlider" min="0" max="360" value="220">
    </div>
  </div>
</article>

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

/* ==========================================================================
   1. DESIGN TOKENS & SYSTEM SCALARS
   ========================================================================== */
@property --card-glow-x {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --card-glow-y {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

:root {
  /* Default Color Channels */
  --hue: 220;
  --chroma: 0.18;

  /* Derived Dynamic Palette */
  --color-primary: oklch(0.60 var(--chroma) var(--hue));
  --color-primary-hover: oklch(0.50 var(--chroma) var(--hue));
  --color-primary-tint: oklch(0.95 calc(var(--chroma) * 0.3) var(--hue));

  /* Layout Dimensions */
  --radius-lg: 1rem;
  --radius-sm: 0.375rem;
}

/* ==========================================================================
   2. THEME SCOPES
   ========================================================================== */
.dash-card[data-theme="light"] {
  --bg-card: #ffffff;
  --bg-card-border: #e2e8f0;
  --text-main: #0f172a;
  --text-muted: #64748b;
  --glow-opacity: 0.12;
}

.dash-card[data-theme="dark"] {
  --bg-card: #0f172a;
  --bg-card-border: #1e293b;
  --text-main: #f8fafc;
  --text-muted: #94a3b8;
  --glow-opacity: 0.25;
}

/* ==========================================================================
   3. COMPONENT DECLARATION
   ========================================================================== */
.dash-card {
  position: relative;
  width: 100%;
  max-width: 480px;
  background-color: var(--bg-card);
  border: 1px solid var(--bg-card-border);
  border-radius: var(--radius-lg);
  padding: 2rem;
  color: var(--text-main);
  overflow: hidden;
  transition: background-color 0.3s, border-color 0.3s, color 0.3s;
  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}

/* Interactive Glow Accent Layer */
.dash-card::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    600px circle at var(--card-glow-x) var(--card-glow-y),
    oklch(0.70 var(--chroma) var(--hue) / var(--glow-opacity)),
    transparent 60%
  );
  pointer-events: none;
  transition: opacity 0.3s;
}

/* Header Elements */
.dash-card__header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1.5rem;
}

.dash-card__badge {
  padding: 0.25rem 0.75rem;
  background-color: var(--color-primary-tint);
  color: var(--color-primary);
  border-radius: var(--radius-sm);
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.dash-card__theme-btn {
  background: transparent;
  border: 1px solid var(--bg-card-border);
  color: var(--text-main);
  padding: 0.5rem 0.875rem;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.875rem;
  transition: background-color 0.2s;
}

.dash-card__theme-btn:hover {
  background-color: var(--color-primary-tint);
}

/* Content Elements */
.dash-card__title {
  font-size: clamp(1.25rem, 2vw + 1rem, 1.75rem);
  margin-bottom: 0.75rem;
  color: var(--text-main);
}

.dash-card__text {
  font-size: 0.95rem;
  line-height: 1.6;
  color: var(--text-muted);
  margin-bottom: 1.5rem;
}

.dash-card__controls {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  background: oklch(0.50 var(--chroma) var(--hue) / 0.05);
  padding: 1rem;
  border-radius: var(--radius-sm);
}

.dash-card__controls label {
  font-size: 0.875rem;
  font-weight: 600;
}

Скрипт связывает элементы управления с этими токенами, всегда записывая данные непосредственно на саму карточку, а не в элемент :root:

/* ==========================================================================
   4. REACTIVE SCRIPT BRIDGE
   ========================================================================== */
const card = document.getElementById('dashCard');
const themeBtn = document.getElementById('themeToggle');
const hueSlider = document.getElementById('hueSlider');
const hueVal = document.getElementById('hueVal');

// 1. Mouse coordinate tracking (Targeted scope prevents full layout invalidation)
card.addEventListener('pointermove', (e) => {
  const rect = card.getBoundingClientRect();
  card.style.setProperty('--card-glow-x', `${e.clientX - rect.left}px`);
  card.style.setProperty('--card-glow-y', `${e.clientY - rect.top}px`);
});

// 2. Interactive Theme Switcher
themeBtn.addEventListener('click', () => {
  const currentTheme = card.getAttribute('data-theme');
  const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
  card.setAttribute('data-theme', nextTheme);
});

// 3. Dynamic Color Engine Adjustment
hueSlider.addEventListener('input', (e) => {
  const newHue = e.target.value;
  hueVal.textContent = newHue;

  // Update hue variable locally on component container
  card.style.setProperty('--hue', newHue);
});

Перед выпуском такого компонента необходимо проверить два аспекта в браузере, поскольку они вытекают из описанных выше механизмов:

  • Координаты свечения регистрируются с параметром inherits: false, но используются в элементе .dash-card::before. Поскольку псевдоэлемент не наследует их, он использует начальные значения 0px, в результате чего свечение остается в углу. Если указать inherits: true или не регистрировать координаты вовсе, эффект будет следовать за курсором.
  • Ползунок устанавливает значение --hue для карточки, но параметры --color-primary и --color-primary-tint вычисляются в элементе :root, поэтому индикатор сохраняет свой первоначальный цвет. Реагируют только те правила, которые напрямую используют var(--hue) (эффект свечения и фон управляющих элементов). Переопределение полученной палитры в элементе .dash-card заставляет всю компоненту следовать за ползунком.

Основные выводы

  • Переместите значения, которые меняются в зависимости от тем и состояний, в пользовательские свойства, и запишите правила для каждого компонента только один раз.
  • Объявляйте токены на уровне, к которому они относятся — будь то :root, область макета или отдельный компонент, — и пусть наследование передаст их дальше. Помните, что производные токены вычисляются в месте их объявления.
  • Позвольте JavaScript передавать необработанные значения с помощью element.style.setProperty() вместо формирования строк стилей, и направляйте операцию на самый мелкий элемент, который нуждается в этих значениях.
  • Зарегистрируйте свойства с помощью @property и синтаксиса вида <color>, <angle> или <length>, чтобы получить проверку типов и плавную интерполяцию.
  • Храните каналы цвета в форматах HSL или OKLCH для получения оттенков, состояний и уровня прозрачности без использования библиотек.
  • Избегайте частых обновлений внутри :root; для анимаций предпочитайте свойства transform и opacity, а использовать inherits: false следует только там, где ни один из потомков или псевдоэлементов не нуждается в этом значении.
  • Пользовательские свойства — это не замена синтаксису препроцессоров, а скорее другая модель: стили, которые учитывают структуру DOM, реагируют на взаимодействие с пользователем и могут управляться во время выполнения. При таком использовании они делают системы дизайна более компактными и удобными для изменений.