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);
}
Варіанти hover, active, tint та translucent — це всі вирази, які базуються на трьох числах. Зміна значення --brand-h у елементі :root, чи то через JavaScript, чи за налаштуванням користувача, послідовно змінює основний колір, його стани, відтінок та накладки, без необхідності використання бібліотек для обробки кольорів.
Існує одна нюанс, яка стосується кожного похідного токена: використання var() усередині користувацької властивості обробляється на елементі, де ця властивість оголошена, а нащадки успадковують отримане значення. Оскільки --brand-color та подібні властивості оголошені у :root, їх переопределення в глибших рівнях ієрархії не призводить до перерозрахунку цих значень. Треба або змінити параметри у :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, що саме й є тим глобальним, часто виконуваним оновленням, проти якого попереджає розділ про продуктивність; встановлення значення безпосередньо у саму панель обмежує перерахунок лише одного елемента. Крім того, у браузерах, які підтримують анімації, засновані на прокрутці CSS, подібну панель прогресу можна створити за допомогою 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, реагують на взаємодію користувача та можуть керуватися під час виконання. Такий підхід дозволяє зробити системи дизайну меншими та легкішими для змін.