Компонентная вёрстка на чистом CSS и JS: рабочие приёмы
Как разбить интерфейс на компоненты без React: изоляция стилей, состояния, переиспользование. Разбираем BEM, кастомные свойства, Custom Elements и Shadow DOM — с ценой каждого подхода.

Вы верстаете карточку товара. Через неделю такую же карточку просят на другой странице, но с другим фоном и без кнопки. Ещё через месяц дизайнер меняет отступы внутри — и вы правите их в четырёх местах, потому что копипаста разъехалась. Компонентная вёрстка решает именно это: один источник правды на визуальный блок. Разберём, как её собрать без сборщиков и фреймворков — на CSS и ванильном JavaScript, — и где каждый приём начинает мешать.
Речь идёт о браузерах 2023–2024 годов: кастомные свойства, :has(), Custom Elements и Shadow DOM поддерживаются во всех актуальных версиях Chrome, Firefox и Safari. Инкапсуляция стилей через Shadow DOM работает начиная примерно с Chrome 53 и Safari 10, но :has() в Safari появился только с версии 15.4 — если поддерживаете старьё, сверяйтесь с caniuse.com.
Что вообще значит «компонент» без фреймворка
Компонент — это самодостаточный кусок разметки, у которого стили не текут наружу и на который снаружи не текут чужие стили. У фреймворков это дают из коробки: scoped-стили, изоляция состояния. Без фреймворка вы собираете то же руками, и вариантов несколько — от дисциплины именования до настоящей изоляции через Shadow DOM.
Главная проблема, ради которой всё затевается, — каскад. CSS по умолчанию глобален: правило .button { padding: 8px }, написанное для одной кнопки, приложится ко всем элементам с этим классом на странице. Компонентный подход — это способ приручить каскад так, чтобы блок оставался предсказуемым.
Подход 1. Именование по BEM
BEM (Block — Element — Modifier) не изолирует стили технически, но задаёт правило именования, при котором коллизии почти исключены. Блок — .card, элемент внутри — .card__title, модификатор состояния — .card--featured.
<article class="card card--featured">
<h3 class="card__title">Название</h3>
<p class="card__price">1 990 ₽</p>
<button class="card__button">В корзину</button>
</article>.card {
--card-bg: #fff;
--card-pad: 16px;
background: var(--card-bg);
padding: var(--card-pad);
border-radius: 8px;
}
.card__title {
margin: 0 0 8px;
font-size: 18px;
}
/* Модификатор меняет только переменную — тело правил не дублируется */
.card--featured {
--card-bg: #fff8e1;
}Плюс BEM: нулевая зависимость, работает везде, читается в DevTools. Минус: длинные имена классов и то, что дисциплину соблюдают только люди — компилятор вам не напомнит, что вы написали .card_title с одним подчёркиванием.
Подход 2. Кастомные свойства как API компонента
Заметьте приём выше: цвет фона и отступ вынесены в CSS-переменные --card-bg и --card-pad. Это превращает компонент в настраиваемый: снаружи вы меняете не внутренние правила, а объявленный «интерфейс».
/* Другая страница переопределяет переменные, не трогая внутренности карточки */
.sidebar .card {
--card-bg: #f5f5f5;
--card-pad: 12px;
}Так вы избегаете каскадных войн со специфичностью: вместо переписывания десятка правил с !important вы объявляете точки настройки заранее. Договоритесь с командой, какие переменные компонент публикует наружу, а какие — приватные детали.
Кастомное свойство — это публичный контракт компонента. Всё, что вы в него вынесли, разрешено менять снаружи. Всё, что осталось захардкоженным, — приватная деталь, и её изменение под ответственность автора компонента.
Подход 3. Custom Elements для поведения
Когда компоненту нужна логика — обработка клика, состояние, синхронизация с data-атрибутами — начинается JavaScript. Ванильный Web Components API даёт класс, который регистрируется как HTML-тег.
// Работает во всех актуальных браузерах без полифилов
class QuantityStepper extends HTMLElement {
connectedCallback() {
// Начальное значение из атрибута, по умолчанию 1
this.value = Number(this.getAttribute('value')) || 1;
this.render();
this.addEventListener('click', this.onClick);
}
disconnectedCallback() {
// Снимаем слушатель при удалении из DOM — иначе утечка
this.removeEventListener('click', this.onClick);
}
onClick = (event) => {
const step = event.target.dataset.step;
if (!step) return;
this.value = Math.max(1, this.value + Number(step));
this.render();
// Компонент сообщает наружу через событие, а не дёргает соседей
this.dispatchEvent(new CustomEvent('change', {
detail: { value: this.value }, bubbles: true,
}));
};
render() {
this.innerHTML = `
<button data-step="-1">−</button>
<span>${this.value}</span>
<button data-step="1">+</button>`;
}
}
customElements.define('quantity-stepper', QuantityStepper);Использование в разметке — <quantity-stepper value="2"></quantity-stepper>. Ключевой приём здесь: компонент общается с внешним миром через события (dispatchEvent), а не напрямую меняет чужие узлы. Родитель слушает change и обновляет, например, итоговую сумму. Это тот же однонаправленный поток, что во фреймворках, только руками.
Подход 4. Shadow DOM для настоящей изоляции
Всё, что выше, оставляет стили открытыми: глобальный * { box-sizing: border-box } достанет и внутрь карточки. Shadow DOM закрывает эту дверь — стили внутри теневого дерева не видят внешних правил и не протекают наружу.
class ProductCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* Эти стили не выйдут наружу и не примут внешний каскад */
.title { font-size: 18px; margin: 0 0 8px; }
/* :host — сам компонент, настраивается переменной снаружи */
:host { display: block; background: var(--card-bg, #fff); padding: 16px; }
</style>
<h3 class="title"><slot name="title"></slot></h3>
<slot></slot>`;
}
}
customElements.define('product-card', ProductCard);Обратите внимание на :host и <slot>: первое стилизует сам корень компонента, второе — точка, куда попадает переданная снаружи разметка. Кастомные свойства, в отличие от обычных правил, проникают сквозь границу Shadow DOM — поэтому var(--card-bg) внутри по-прежнему настраивается снаружи. Это и есть законный канал настройки изолированного компонента.
Когда что выбирать
| Подход | Изоляция стилей | Логика/состояние | Когда брать |
|---|---|---|---|
| BEM | Только по соглашению | Нет | Статичная вёрстка, лендинги, нет сборщика и JS-логики |
| BEM + кастомные свойства | По соглашению | Нет | Один блок в разных темах и контекстах |
| Custom Elements | Нет (стили глобальны) | Да | Нужно поведение, но верстку хочется видеть в общем CSS |
| Custom Elements + Shadow DOM | Полная | Да | Виджет для встраивания на чужие страницы, дизайн-система |
Простое правило: изоляция стоит денег. Shadow DOM даёт настоящую инкапсуляцию, но за неё вы платите тем, что глобальные стили и утилитарные классы (условный Tailwind) внутрь не попадут — придётся дублировать или прокидывать через переменные. Для лендинга это лишняя сложность. Для встраиваемого виджета, который поедет на сайты, чью CSS вы не контролируете, — единственный надёжный вариант.
Что ломается на практике
- Утечки слушателей. Навесили
addEventListenerвconnectedCallbackи забыли снять вdisconnectedCallback— при частом добавлении и удалении элементов растёт память. В примере со степпером слушатель снимается явно. - Порядок определения. Если
customElements.defineвызывается после того, как элемент уже в DOM, браузер апгрейдит его задним числом — но до этого момента тег ведёт себя как неизвестный. Скрипт с определениями подключайте раньше разметки или черезdefer. - FOUC на кастомных элементах. Пока JS не отработал,
<quantity-stepper>— пустой инлайновый элемент. Задайте ему стиль по умолчанию через CSS для нестилизованного состояния или используйте:not(:defined), чтобы скрыть до готовности. - Дублирование стилей в Shadow DOM. Каждый экземпляр с
<style>внутри держит свою копию правил. Для сотен элементов на странице выносите стили в общийConstructable Stylesheet(adoptedStyleSheets) — одна таблица на все экземпляры. - Каскад с
:has(). Родительский селектор мощный, но пересчёт при частых изменениях DOM недёшев. Не вешайте:has()на глубокие деревья, которые перестраиваются на каждый ввод.
Что делать дальше
Начните с самого дешёвого уровня, который закрывает задачу: для статики хватит BEM плюс кастомных свойств, и это работает без единой строки JS. Логику добавляйте через Custom Elements только там, где она есть. Shadow DOM подключайте, когда компонент реально едет во враждебное окружение.
Дальше смотрите в сторону adoptedStyleSheets для переиспользования таблиц стилей между экземплярами и в декларативный Shadow DOM (<template shadowrootmode>) — он позволяет отдавать теневое дерево прямо в HTML с сервера, без JS-инициализации. Инвалидация и производительность реактивного обновления без фреймворка — отдельная большая тема, здесь мы её не трогали.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Web Components — MDN Web Docs, Using CSS custom properties — MDN Web Docs
Частые вопросы
А если мне нужна поддержка старых браузеров, например IE11?
Custom Elements — это то же самое, что React-компоненты?
Стили из Shadow DOM точно не протекут наружу?
Что делать, если кастомный элемент не отображается и в консоли нет ошибок?
Не будет ли много экземпляров Shadow DOM с одинаковыми стилями тормозить страницу?
Нужен ли сборщик для такого подхода?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.