Перейти к основному содержимому

Web Components

Разработчику

Web Components

Основы

Web Components (веб-компоненты) — набор стандартов браузера (W3C), с помощью которых можно объявить свой HTML-тег с собственной разметкой, стилями и поведением. Такой тег работает в React, Vue, Angular и в странице без фреймворка.

DOM (Document Object Model) — дерево узлов, из которого браузер строит страницу. Web Components добавляют к обычному DOM изолированное поддерево и регистрацию новых имён тегов.

Четыре части стандарта:

  1. Custom Elements — свои теги вроде <my-button>
  2. Shadow DOM — изолированная разметка и стили внутри элемента
  3. <template> — заготовка HTML, которая не показывается сразу
  4. Slots — отверстия, куда страница вставляет свой текст

Руководство — MDN: Web Components. Базовая разметка — HTML — основы, события — JavaScript — события. Стили снаружи — Tailwind CSS.


Custom Elements (пользовательские элементы)

Браузер знает встроенные теги (<button>, <div>). Custom Element — тег, который регистрируете вы через customElements.define.

Имя обязательно с дефисом (my-button, app-header), чтобы не пересечься со стандартными тегами HTML.

class MyButton extends HTMLElement {
connectedCallback() {
this.textContent = this.textContent || "Нажми меня";
this.addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("pressed", { bubbles: true }));
});
}
}

customElements.define("my-button", MyButton);
<my-button>Сохранить</my-button>

Жизненный цикл элемента — подробнее в отдельном разделе ниже.


Атрибуты и свойства

В HTML атрибут — строка в разметке. В JavaScript у DOM-узла свойство (property) может быть числом, boolean или объектом.

<input type="checkbox" checked>
  • атрибут checked в HTML
  • свойство element.checked === true (boolean)

У custom elements та же пара:

<user-badge score="42" active></user-badge>
class UserBadge extends HTMLElement {
static get observedAttributes() { return ["score", "active"]; }

get score() {
return Number(this.getAttribute("score") || 0);
}
set score(v) {
this.setAttribute("score", String(v));
}

get active() {
return this.hasAttribute("active");
}
set active(v) {
if (v) this.setAttribute("active", "");
else this.removeAttribute("active");
}
}
АтрибутСвойство
Типвсегда строка (или отсутствует)любой JS-тип
Отражение в HTMLвиден в outerHTMLне всегда сериализуется
Изменение из JSsetAttributeelement.prop = value
Booleandisabled / отсутствиеtrue / false

Reflecting attributes — когда свойство синхронизируют с атрибутом для SSR и DevTools:

set value(v) {
this._value = v;
this.setAttribute("value", v);
}

Не отражайте тяжёлые объекты в атрибутах — только JSON-строки или id ссылок.

React передаёт props; для custom elements часть props мапится на attributes, часть — только на properties (см. React 19 ниже).


observedAttributes и attributeChangedCallback

observedAttributesстатический список имён атрибутов, за которыми браузер следит. При setAttribute, removeAttribute или изменении в HTML вызывается attributeChangedCallback.

class PriceTag extends HTMLElement {
static get observedAttributes() {
return ["amount", "currency"];
}

constructor() {
super();
this._shadow = this.attachShadow({ mode: "open" });
this._shadow.innerHTML = `
<style>
:host { font-variant-numeric: tabular-nums; }
.price { font-weight: 700; }
</style>
<span class="price"></span>
`;
}

connectedCallback() {
this._render();
}

attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
this._render();
}

_render() {
const amount = this.getAttribute("amount") ?? "0";
const currency = this.getAttribute("currency") ?? "₽";
this._shadow.querySelector(".price").textContent = `${amount} ${currency}`;
}
}
customElements.define("price-tag", PriceTag);
<price-tag amount="1990" currency=""></price-tag>

Правила:

  • без observedAttributes callback не вызовется
  • при первом подключении к DOM порядок: constructorattributeChangedCallback (если атрибуты уже есть) → connectedCallback
  • не делайте тяжёлую работу в callback на каждый символ — debounce или сравнивайте oldValue === newValue

getAttribute vs dataset

<my-widget data-user-id="7"></my-widget>
this.dataset.userId === "7" // camelCase в JS

data-* удобны для конфигурации из HTML; typed API лучше через observed attributes.


Расширенный жизненный цикл

Полная цепочка для класса, наследующего HTMLElement:

МетодКогдаТипичное использование
constructornew или document.createElementattachShadow один раз, bind, без детей/light DOM
static observedAttributesопределение классасписок watched attrs
attributeChangedCallbackизменился observed атрибутобновить UI
connectedCallbackузел вставлен в documentlisteners, fetch, render
disconnectedCallbackузел удалёнcleanup timers, observers
adoptedCallbackузел перенесён в другой documentредко
class TimerWidget extends HTMLElement {
constructor() {
super();
this._interval = null;
}

connectedCallback() {
if (this._interval) return;
this._interval = setInterval(() => this._tick(), 1000);
}

disconnectedCallback() {
clearInterval(this._interval);
this._interval = null;
}

_tick() {
const n = Number(this.textContent || 0) + 1;
this.textContent = String(n);
}
}
customElements.define("timer-widget", TimerWidget);

Customized built-in elements — расширяют встроенный тег через extends:

class PrimaryButton extends HTMLButtonElement {
connectedCallback() {
this.classList.add("primary");
}
}
customElements.define("primary-button", PrimaryButton, { extends: "button" });
<button is="primary-button">OK</button>

Safari долго не поддерживал; для кросс-браузерности чаще autonomous custom elements (<primary-button>).

Upgrade — когда класс регистрируют после того, как HTML уже в DOM:

<my-alert>Текст</my-alert>
<script>
class MyAlert extends HTMLElement { connectedCallback() { ... } }
customElements.define("my-alert", MyAlert);
// браузер "upgrade" существующий тег
</script>

customElements.whenDefined("my-alert").then(...).


Shadow DOM (теневой DOM)

Shadow DOM — отдельное дерево DOM, прикреплённое к элементу. Стили из основной страницы не перекрашивают кнопку внутри компонента. Стили компонента не ломают заголовки на странице.

class UserCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
.card { padding: 1rem; border: 1px solid #ccc; border-radius: 8px; }
h2 { margin: 0; font-size: 1.1rem; }
</style>
<div class="card">
<h2><slot name="title">Без имени</slot></h2>
<slot></slot>
</div>
`;
}
}
customElements.define("user-card", UserCard);

mode: "open" — к shadow root можно обратиться через element.shadowRoot (удобно в отладке). closed — скрыт от внешнего кода.

Селекторы CSS со страницы не проникают в shadow. Наследуются только свойства вроде color и font-family. Тему передают через CSS-переменные (:host, var(--accent)) — см. функции и переменные в CSS, каскад.

/* внутри shadow */
:host {
display: block;
--accent: var(--brand-color, #4f46e5);
}
:host([disabled]) { opacity: 0.5; pointer-events: none; }

Declarative Shadow DOM

Declarative Shadow DOM (DSD) — shadow tree прямо в HTML, без JavaScript attachShadow:

<user-card>
<template shadowrootmode="open">
<style>
.card { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; }
</style>
<div class="card">
<h2><slot name="title">Без имени</slot></h2>
<slot></slot>
</div>
</template>
<span slot="title">Мария</span>
<p>Аналитик</p>
</user-card>

Плюсы:

  • SSR и статический HTML без flash of unstyled content
  • компонент виден до загрузки JS

Ограничения:

  • нужна регистрация custom element для поведения, DSD даёт только разметку+стили
  • старые браузеры — fallback script
// polyfill pattern
if (!HTMLTemplateElement.prototype.hasOwnProperty("shadowRoot")) {
document.querySelectorAll("template[shadowrootmode]").forEach(t => {
const host = t.parentNode;
const mode = t.getAttribute("shadowrootmode");
host.attachShadow({ mode }).append(t.content.cloneNode(true));
t.remove();
});
}

Стили part и slotted в CSS

::part — стилизация элементов внутри shadow, которые автор компонента явно экспортировал:

shadow.innerHTML = `
<style>
button { padding: 0.5rem 1rem; }
</style>
<button part="base icon">OK</button>
`;
/* глобальный CSS страницы */
my-button::part(base) {
border-radius: 9999px;
background: #4f46e5;
color: white;
}

Несколько part через пробел: part="tab tab-active".

exportparts — проброс part из вложенного shadow:

<div part="container" exportparts="inner: inner-part">
<span part="inner">...</span>
</div>

::slotted — стили контента из light DOM, попавшего в slot:

/* внутри shadow */
::slotted(h2) {
margin: 0;
color: var(--accent);
}
::slotted(.highlight) {
background: yellow;
}

::slotted не меняет layout light DOM — только визуальные inherited/limited правила. Для полного контроля используйте обёртку в shadow.

С Tailwind на host + CSS variables + ::part получают themeable design system без пробива shadow.


HTML-шаблон <template>

Тег <template> хранит кусок разметки вне видимой страницы. Браузер не рисует его, пока вы не склонируете в DOM через JavaScript.

<template id="row-tpl">
<tr>
<td class="name"></td>
<td class="score"></td>
</tr>
</template>
const tpl = document.getElementById("row-tpl");
const row = tpl.content.cloneNode(true);
row.querySelector(".name").textContent = "Иван";
document.querySelector("tbody").appendChild(row);

В Web Components шаблон часто лежит в <template> на странице или в строке внутри класса элемента.

const template = document.createElement("template");
template.innerHTML = `<style>...</style><div>...</div>`;

class MyEl extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
}
}

Клонирование template.content эффективнее, чем innerHTML на каждый instance.


Slots (слоты) — базовый и продвинутый

Слот — место внутри shadow DOM, куда страница подставляет свой контент.

<user-card>
<span slot="title">Мария Петрова</span>
<p>Аналитик данных</p>
</user-card>
  • <slot name="title"> — именованный слот
  • <slot></slot> без имени — слот по умолчанию для остального содержимого

Fallback content — текст внутри <slot>, если снаружи ничего не передали.

Несколько default slots — нельзя; один unnamed slot на компонент.

slotchange event

const slot = shadow.querySelector("slot[name=title]");
slot.addEventListener("slotchange", () => {
const nodes = slot.assignedNodes({ flatten: true });
console.log("assigned", nodes);
});

assignedNodes vs children — light DOM дети <user-card> не равны визуальному месту; slot переназначает их в shadow.

Named slot + CSS

<slot name="actions" class="actions"></slot>

Conditional slots pattern

<!-- если нужен optional header -->
<slot name="header"></slot>
<div class="body"><slot></slot></div>
get hasHeader() {
return [...this.children].some(el => el.getAttribute("slot") === "header");
}

Menu / list composition

<tab-bar>
<tab-item slot="tab" id="one">Обзор</tab-item>
<tab-item slot="tab" id="two">Детали</tab-item>
<panel-item slot="panel" for="one">...</panel-item>
<panel-item slot="panel" for="two">...</panel-item>
</tab-bar>

Компонент в connectedCallback связывает tab и panel по id — паттерн без framework.


Form-associated custom elements

Form-associated custom elements (FACE) — custom element участвует в <form> как native control: name, value, validation, submit.

class MyInput extends HTMLElement {
static formAssociated = true;

constructor() {
super();
this._internals = this.attachInternals();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<input part="field" type="text">
`;
this.shadowRoot.querySelector("input").addEventListener("input", (e) => {
this._internals.setFormValue(e.target.value);
});
}

get value() {
return this._internals.formValue ?? "";
}
}
customElements.define("my-input", MyInput);
<form>
<label>Email <my-input name="email" required></my-input></label>
<button type="submit">Отправить</button>
</form>

ElementInternals API:

  • setFormValue(value, state?)
  • setValidity(flags, message, anchor?)
  • checkValidity(), reportValidity()
  • form, labels

Поддержка — современные Chromium и Firefox; проверяйте caniuse.

Связь с HTML-формами и доступностью.


События из shadow и composed

События в DOM всплывают (bubble), но shadow boundary по умолчанию обрезает всплытие наружу для некоторых событий.

composed: true — событие пересекает shadow границу:

this.dispatchEvent(new CustomEvent("item-select", {
bubbles: true,
composed: true,
detail: { id: this.itemId },
}));
document.querySelector("my-list").addEventListener("item-select", (e) => {
console.log(e.detail.id);
});
bubblescomposedвыходит на document
click (native)truetrueда
CustomEvent defaultfalsefalseнет
CustomEvent для hosttruetrueда

Не полагайтесь на event.target после shadow — используйте event.composedPath()[0] или detail.

Подробнее о модели событий — JavaScript — события.

host.addEventListener("click", (e) => {
const path = e.composedPath();
console.log("path", path);
});

Библиотека Lit — вводный пример

Lit — тонкая обёртка над Web Components: декларативные шаблоны, реактивные properties, lit-html.

npm install lit
import { LitElement, html, css } from "lit";

class HelloBox extends LitElement {
static properties = {
name: { type: String },
count: { type: Number },
};

static styles = css`
:host { display: block; padding: 1rem; border: 1px solid #ccc; }
button { margin-top: 0.5rem; }
`;

constructor() {
super();
this.name = "мир";
this.count = 0;
}

render() {
return html`
<p>Привет, ${this.name}! Счётчик: ${this.count}</p>
<button @click=${this._inc}>+1</button>
`;
}

_inc() {
this.count++;
}
}

customElements.define("hello-box", HelloBox);
<hello-box name="IT Universe"></hello-box>

Lit сам:

  • создаёт shadow root
  • обновляет только изменившиеся части DOM
  • синхронизирует properties и attributes

Другие библиотеки — Shoelace, FAST, Stencil (компилятор).


React 19 и custom elements

React 19 улучшил поддержку Web Components:

  • custom tags в JSX без className warning
  • client-side property assignment для props, не только attributes
  • события on on prefix vs addEventListener — см. документацию React
function App() {
const ref = useRef(null);

useEffect(() => {
const el = ref.current;
el.addEventListener("item-select", (e) => console.log(e.detail));
return () => el.removeEventListener("item-select", (e) => console.log(e.detail));
}, []);

return (
<my-list ref={ref} items={JSON.stringify([1, 2, 3])}>
<span slot="title">Список</span>
</my-list>
);
}

Ограничения:

  • boolean и object props — через ref и property, не attribute
  • children в slot — React 19 передаёт; проверяйте на конкретном CE
  • SSR — нужен declarative shadow DOM или гидрация

Обёртка-паттерн:

function MyListWrapper(props) {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
ref.current.items = props.items;
}
}, [props.items]);
return <my-list ref={ref} />;
}

Компоненты-рецепты React — для сравнения подхода JSX.


Vue 3 и custom elements

Vue 3 умеет defineCustomElement:

import { defineCustomElement } from "vue";

const MyWidget = defineCustomElement({
props: { msg: String },
template: `<p>{{ msg }}</p>`,
});

customElements.define("my-widget", MyWidget);

Или использовать чужие CE в SFC:

<template>
<user-card>
<span slot="title">{{ user.name }}</span>
<p>{{ user.role }}</p>
</user-card>
</template>

compilerOptions.isCustomElement — чтобы Vue не ругался на неизвестные теги:

// vite.config.js
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith("app-"),
},
},
});

События — @item-select на host, если emits настроен в defineCustomElement.


Доступность (ARIA) в custom elements

Custom elements не accessible автоматически.

Role и aria- на host*

<expand-panel expanded aria-label="Детали заказа">
...
</expand-panel>
connectedCallback() {
this.setAttribute("role", "button");
if (!this.hasAttribute("tabindex")) this.tabIndex = 0;
this.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.toggle();
}
});
}

Shadow и aria — idref (aria-labelledby) не пересекают shadow в старых браузерах; ставьте aria на light DOM host или используйте ElementInternals role.

Focus management — modal в shadow должен ловить Tab (focus trap).

Labels для form-associatedattachInternals().labels.

Скрытый decorativearia-hidden="true" на слотах с иконками.

Чеклист:

  • keyboard access (Tab, Enter, Escape)
  • focus-visible стили (Tailwind focus-visible)
  • contrast в shadow
  • live regions (aria-live) для динамических alert

Доступность в CSS, <dialog>.


Тестирование Web Components

@open-wc/testing + Web Test Runner:

import { expect, fixture, html } from "@open-wc/testing";
import "./alert-box.js";

describe("alert-box", () => {
it("renders slot content", async () => {
const el = await fixture(html`
<alert-box type="warn">Внимание</alert-box>
`);
expect(el.shadowRoot.querySelector(".box.warn")).to.exist;
expect(el.textContent).to.include("Внимание");
});

it("updates on attribute change", async () => {
const el = await fixture(html`<alert-box type="info"></alert-box>`);
el.setAttribute("type", "warn");
await el.updateComplete; // если Lit
expect(el.getAttribute("type")).to.equal("warn");
});
});

Playwright — e2e:

await page.goto("/demo.html");
const box = page.locator("alert-box");
await expect(box).toContainText("Сохранено");
await box.evaluate((el) => el.setAttribute("type", "warn"));

Testing Libraryscreen.getByRole("alert") если role выставлен.

Тестируйте:

  • attributes → UI
  • events composed: true
  • form submit с FACE
  • a11y axe-core

Сборка с Vite

Структура пакета компонентов:

my-ui/
src/
alert-box.js
index.js // re-export all
vite.config.js
package.json
// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
build: {
lib: {
entry: "src/index.js",
formats: ["es"],
fileName: "my-ui",
},
rollupOptions: {
external: /^lit/,
},
},
});
{
"name": "@company/my-ui",
"type": "module",
"exports": "./dist/my-ui.js",
"customElements": "custom-elements.json"
}

custom-elements.json — manifest для Storybook и IDE (CEM analyzer).

Потребление:

<script type="module">
import "@company/my-ui";
</script>
<alert-box>Готово</alert-box>

С Tailwind в компонентах — @tailwind в shadow через adoptedStyleSheets или CSS-in-JS Lit; Play CDN только для demo.


Практический смысл

  • один тег можно вставить на любую страницу и в любой фреймворк
  • стили и логика спрятаны внутри — меньше конфликтов имён классов
  • дизайн-системы поставляют кнопки и поля как npm-пакет с custom elements

Ограничения:

  • нет встроенного роутинга и глобального состояния как в React — только UI-кирпич
  • сложные SPA по-прежнему чаще строят на фреймворке; Web Components — переиспользуемые детали внутри

Пример целиком

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Web Component demo</title>
</head>
<body>
<alert-box type="info">
Данные сохранены в <strong>SQLite</strong>.
</alert-box>

<script>
class AlertBox extends HTMLElement {
static get observedAttributes() { return ["type"]; }

connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host { display: block; margin: 1rem 0; }
.box { padding: 0.75rem 1rem; border-radius: 6px; }
.info { background: #e0f2fe; color: #0369a1; }
.warn { background: #fef3c7; color: #92400e; }
</style>
<div class="box" part="container" role="alert">
<slot></slot>
</div>
`;
}
this._updateType();
}

attributeChangedCallback() { this._updateType(); }

_updateType() {
const type = this.getAttribute("type") || "info";
const box = this.shadowRoot.querySelector(".box");
box.className = "box " + type;
}
}
customElements.define("alert-box", AlertBox);
</script>
</body>
</html>

Практика разметки — HTML-страницы целиком. Диалоги без своего компонента — <dialog> в практике HTML.


Сравнение с React и Vue

Web ComponentsReact / Vue
Основастандарт браузерабиблиотека
РазметкаHTML + класс JSJSX или SFC
СтилиShadow DOMCSS Modules, scoped CSS
Состояниевручную или LituseState, stores
SSRDSD, сложнееfirst-class
Размер runtimeнулевой (native)React ~40+ KB

React 19+ и Vue 3 умеют вставлять custom elements как обычные теги. Часто добавляют тонкий адаптер для props и событий.


Упражнения

1. Минимальный CE<greeting-msg name="..."> выводит «Привет, {name}!» через connectedCallback без shadow.

2. Shadow + slot<profile-card> с slot title и default; стили .card не должны затронуть h1 на странице.

3. observedAttributes<color-badge color="red"> меняет фон при смене атрибута color без пересоздания shadow.

4. Attributes vs properties — checkbox CE checked property; убедитесь, что el.checked = true обновляет UI и attribute.

5. ::part — экспортируйте part="label" и перекрасьте с глобального CSS страницы.

6. ::slotted — стилизуйте ::slotted(strong) внутри alert-box.

7. Declarative Shadow DOM — статический HTML с <template shadowrootmode="open"> без JS attachShadow.

8. События — кнопка в shadow dispatch CustomEvent("save", { bubbles: true, composed: true }); слушатель на document.

9. Lit — перепишите alert-box на Lit с @property({ type: String }) type.

10. Form-associated<my-counter name="qty"> с setFormValue; отправка form включает value.

11. React — вставьте CE в React 19, передайте object через ref property.

12. VuedefineCustomElement с prop label.

13. a11y — expandable <faq-item> с role="button", aria-expanded, клавиши Enter/Space.

14. Тест — один unit-тест @open-wc/testing на slot content.

15. Vite lib — соберите один CE в dist/ и подключите на статической странице.

16. Tailwind + CE — прочитайте Tailwind CSS; объясните, почему class="bg-red-500" на host не красит div внутри closed shadow без variables.

17. События — нарисуйте схему bubble vs composed по JS событиям.

18. Lab HTML — сверстайте страницу в Lab 1153, замените один блок на custom element.


Shadow DOM — retargeting и query

event.target после клика внутри shadow часто указывает на host, а не на кнопку в shadow — retargeting. Поэтому делегирование на document требует composedPath():

document.addEventListener("click", (e) => {
const path = e.composedPath();
const host = path.find((n) => n instanceof HTMLElement && n.localName === "my-menu");
if (host) host.close();
});

querySelector не ищет внутри shadow чужих компонентов:

document.querySelector("my-card .title"); // null
document.querySelector("my-card").shadowRoot.querySelector(".title"); // ok если open

Для тестов и Storybook держите mode: "open".

Adopted stylesheets — один CSSStyleSheet на много shadow roots (Chrome, Firefox):

const sheet = new CSSStyleSheet();
sheet.replaceSync(`:host { display: block; } .btn { padding: 0.5rem; }`);

class SharedButton extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: "open" });
root.adoptedStyleSheets = [sheet];
root.innerHTML = `<button class="btn"><slot></slot></button>`;
}
}

Экономит память в design system с сотнями экземпляров.


Продвинутые слоты — проекция и fallback

Multiple named slots — типичный layout shell:

<app-shell>
<header slot="header">Шапка сайта</header>
<nav slot="sidebar">Меню</nav>
<main>Контент по умолчанию</main>
<footer slot="footer">© 2026</footer>
</app-shell>
shadow.innerHTML = `
<div class="grid">
<header><slot name="header"></slot></header>
<aside><slot name="sidebar"><p>Нет меню</p></slot></aside>
<main><slot></slot></main>
<footer><slot name="footer"></slot></footer>
</div>
`;

Dynamic slot assignment — редко нужно; браузер сам распределяет по атрибуту slot.

Slot fallback + aria

<slot name="label">Подпись по умолчанию</slot>

Если автор страницы не передал label, читатель экрана всё равно видит fallback.

Nested components

<outer-card>
<inner-chip slot="badge">NEW</inner-chip>
<p>Текст карточки</p>
</outer-card>

inner-chip в light DOM outer-card попадает в slot badge только если shadow outer-card содержит <slot name="badge">. Вложенный CE внутри light DOM не автоматически монтируется в inner shadow.

Manual slot API (deprecated in favor of declarative slots)HTMLSlotElement.assign() для programmatic slotting в advanced scenarios.


Lit — properties, styles и lifecycle

Полный пример с reactive properties и custom event:

import { LitElement, html, css } from "lit";

export class RatingStars extends LitElement {
static properties = {
value: { type: Number, reflect: true },
max: { type: Number },
readonly: { type: Boolean, reflect: true },
};

static styles = css`
:host { display: inline-flex; gap: 2px; }
button {
border: none; background: none; cursor: pointer;
font-size: 1.25rem; color: #cbd5e1;
}
button.active { color: #f59e0b; }
:host([readonly]) button { cursor: default; }
`;

constructor() {
super();
this.value = 0;
this.max = 5;
this.readonly = false;
}

render() {
return html`
${Array.from({ length: this.max }, (_, i) => {
const star = i + 1;
return html`
<button
class=${star <= this.value ? "active" : ""}
?disabled=${this.readonly}
aria-label="Оценка ${star} из ${this.max}"
@click=${() => this._select(star)}
></button>
`;
})}
`;
}

_select(star) {
if (this.readonly) return;
this.value = star;
this.dispatchEvent(new CustomEvent("rating-change", {
bubbles: true,
composed: true,
detail: { value: star },
}));
}
}

customElements.define("rating-stars", RatingStars);
<rating-stars value="3" max="5"></rating-stars>
<script type="module">
document.querySelector("rating-stars").addEventListener("rating-change", (e) => {
console.log("новая оценка", e.detail.value);
});
</script>

reflect: true синхронизирует property value с attribute — удобно для CSS :host([value="5"]).

updateComplete — Promise после render (для тестов):

const el = await fixture(html`<rating-stars value="2"></rating-stars>`);
el.value = 4;
await el.updateComplete;
expect(el.getAttribute("value")).to.equal("4");

React 19 — детали интеграции

Custom element manifest в TypeScript (JSX):

declare global {
namespace JSX {
interface IntrinsicElements {
"rating-stars": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & { value?: number; max?: number },
HTMLElement
>;
}
}
}

Controlled vs uncontrolled

function ProductForm() {
const [rating, setRating] = useState(0);
const ref = useRef(null);

useEffect(() => {
const node = ref.current;
if (!node) return;
node.value = rating;
const onChange = (e) => setRating(e.detail.value);
node.addEventListener("rating-change", onChange);
return () => node.removeEventListener("rating-change", onChange);
}, [rating]);

return <rating-stars ref={ref} max={5} />;
}

Server Components — CE рендерятся на клиенте; для SSR используйте DSD или placeholder.

Next.js App Router'use client' boundary если CE с state; import side-effect:

'use client';
import '@company/rating-stars';

Vue 3 — defineCustomElement подробнее

import { defineCustomElement } from "vue";

const TodoItem = defineCustomElement({
props: { done: Boolean, text: String },
emits: ["toggle"],
template: `
<label>
<input type="checkbox" :checked="done" @change="$emit('toggle')">
<span :class="{ done }">{{ text }}</span>
</label>
`,
styles: [`
.done { text-decoration: line-through; opacity: 0.6; }
`],
});

customElements.define("todo-item", TodoItem);

Vue автоматически инкапсулирует styles в shadow.

В обычном .vue SFC:

<script setup>
import '@company/todo-item';
</script>

<template>
<todo-item
:done="item.done"
:text="item.text"
@toggle="item.done = !item.done"
/>
</template>

Custom Elements Manifest (CEM)

Для IDE autocomplete и документации генерируют Custom Elements Manifest:

npm install -D @custom-elements-manifest/analyzer
npx cem analyze --litelement --globs "src/**/*.js"
{
"modules": [{
"declarations": [{
"kind": "class",
"name": "AlertBox",
"tagName": "alert-box",
"attributes": [{ "name": "type", "type": "string" }],
"events": [{ "name": "dismiss" }]
}]
}]
}

Storybook 8+ читает manifest для controls panel.


Vite — dev server и HMR

// vite.config.js — dev demo page
import { defineConfig } from "vite";

export default defineConfig({
root: "demo",
build: {
outDir: "../dist-demo",
},
server: {
open: "/index.html",
},
});
<!-- demo/index.html -->
<script type="module" src="/src/main.js"></script>
<alert-box type="info">Hot reload работает</alert-box>
// demo/src/main.js
import "../src/alert-box.js";

При изменении alert-box.js Vite перезагрузит module; CE upgrade существующие узлы через customElements.define один раз.

Import maps на статическом хостинге без bundler:

<script type="importmap">
{
"imports": {
"lit": "https://cdn.jsdelivr.net/npm/lit@3/+esm"
}
}
</script>
<script type="module" src="./app.js"></script>

Сценарии использования в продакшене

СценарийПодход
Design system для 5 команд на разных стекахnpm-пакет CE + CEM
Виджет на чужой CMS (WordPress)один <script type="module"> + DSD
МикрофронтендCE как boundary между React и Vue
Поля формы в legacy jQueryFACE elements
Embed analytics dashboardclosed shadow + iframe fallback

Версионирование — attribute version или npm semver; не меняйте поведение slot без major.

Content Security Policy — inline <script> в demo запрещён; используйте external modules script-src 'self'.


Типичные ошибки

СимптомПричинаРешение
attributeChangedCallback молчитатрибут не в observedAttributesдобавить в static list
Стили страницы ломают CEнет shadowattachShadow
Tailwind не работает внутриshadow boundaryCSS variables, ::part
React не обновляет propattribute vs propertyref + property
Событие не доходитcomposed: falsecomposed: true
Slot пустойневерное slot="name"проверить имя
FOUC до JSнет DSDdeclarative shadow
Form не видит valueне FACEformAssociated + internals
Дубли listenersconnectedCallback без guardфлаг или disconnect cleanup
Имя без дефисаinvalid custom elementmy-widget, не widget

Связанные материалы