CSS-собственные свойства во время выполнения: тематизация, мосты JS и @property
Узнайте, в чем отличия пользовательских свойств CSS от переменных Sass, и научитесь использовать их для создания тем, эффектов, управляемых JavaScript, гибкого форматирования текста, вариантов компонентов и анимаций с типизацией.
Источники: 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, реагируют на взаимодействие с пользователем и могут управляться во время выполнения. При таком использовании они делают системы дизайна более компактными и удобными для изменений.