Реалізація кастомних HTML-елементів для вбудовування віджетів

Сторонні віджети часто ламають верстку та конфліктують зі скриптами сайту, перетворюючи інтеграцію на головний біль. Ми розробляємо кастомні HTML-елементи з Shadow DOM, які повністю ізолюють віджет від оточення хост-сайту. Наша команда реалізує проєкт під ключ — від концепції до підтримки, забезпечуючи безшовну інтеграцію та стабільну роботу без зриву термінів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Реалізація кастомних HTML-елементів для вбудовування віджетів
Середній
~3-5 днів
Часті запитання

Ви вбудовуєте сторонній віджет на свій сайт і за годину виявляєте, що кнопки пофарбувалися в червоний, а скрипти почали конфліктувати з jQuery. Знайома ситуація? Ми пропонуємо рішення: кастомні HTML-елементи з Shadow DOM назавжди усувають цей головний біль. Клієнт вставляє три рядки коду й отримує працюючий віджет, ізольований від оточення хост-сайту. Сторонній віджет на чужому сайті — це чужа DOM, чужі стилі, потенційно конфліктуючі версії бібліотек. Custom Elements v1 вирішують цю проблему кардинально: жодних конфліктів CSS, жодних зламаних скриптів. Час завантаження такого віджета — 0.5–1.5 с (LCP), тоді як iframe-альтернатива потребуватиме 2–3 с. Це реалізація веб-компонентів, яка дає безшовну інтеграцію та повну ізоляцію.

Як кастомні HTML-елементи вирішують проблему конфліктів на сайті?

  • Конфлікт стилів. Внутрішні стилі віджета можуть випадково перевизначити стилі сайту (наприклад, button { background: red }). Shadow DOM гарантує ізоляцію в обидва боки.
  • Управління версіями. Віджет підключається одним скриптом з SRI-хешем. Клієнт не оновить віджет випадково — лише навмисно змінивши версію в URL.
  • Кросбраузерність. Shadow DOM працює у всіх сучасних браузерах. Для IE11 підключаємо поліфіл. Тестуємо на реальних пристроях.
  • SEO. На відміну від iframe, контент у Custom Element індексується пошуковиками. Це критично для віджетів відгуків, рейтингів, каталогів.

Що дає ізоляція Shadow DOM?

Shadow DOM — єдине надійне рішення. Ми створюємо <template> з інкапсульованими стилями та структурою. Ось мінімальний приклад:

<review-widget data-product-id="SKU-12345" data-theme="light" data-locale="ru" ></review-widget> 

Елемент сам завантажує дані через API та рендерить відгуки. Стилі ізольовані — жодні * { all: unset } хост-сторінки не зламають віджет.

Чому Custom Elements кращі за iframe для віджетів?

Критерій Custom Elements iframe
Час завантаження (LCP) < 1.5 с ~3 с
SEO-індексація Так Ні
Адаптивність Автоматична Потрібна обгортка
Розмір скрипта ~15 КБ (мініфікований) > 50 КБ
Ізоляція стилів Повна (Shadow DOM) Повна (окремий документ)
Складність інтеграції 3 рядки коду Налаштування розмірів та postMessage
Розмір бандла після tree-shaking ~8 КБ ~30 КБ
Економія трафіку (на міс.) до 2 000 $ 0 $

Custom Elements в 2–3 рази швидші за iframe за часом завантаження і не приховують контент від пошуковиків. Економія трафіку — до 70% (до 2 000 $ на місяць).

Браузер Підтримка Custom Elements Поліфіл
Chrome 54+ Повна Ні
Firefox 63+ Повна Ні
Safari 10.1+ Повна Ні
Edge 79+ Повна Ні
IE11 Відсутня Потрібен

Як влаштований віджет зсередини

Архітектура включає три шари: Custom Element (реєструє тег, керує життєвим циклом), Shadow DOM (ізольовані стилі та розмітка), API-клієнт (завантажує дані з пагінацією). Нижче — повний код ReviewWidget:

// src/ReviewWidget.ts
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
  <style>
    :host {
      display: block;
      contain: content;
      font-family: var(--rw-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
      font-size: var(--rw-font-size, 14px);
      color: var(--rw-text-color, #1a1a2e);
    }
    :host([hidden]) {
      display: none;
    }
    .container {
      border: 1px solid var(--rw-border-color, #e5e7eb);
      border-radius: var(--rw-radius, 8px);
      padding: 16px;
      background: var(--rw-bg, #ffffff);
    }
    .rating {
      display: flex;
      align-items: center;
      gap: 4px;
      margin-bottom: 12px;
    }
    .star {
      color: #f59e0b;
      font-size: 18px;
    }
    .star.empty {
      color: #d1d5db;
    }
    .reviews-list {
      list-style: none;
      margin: 0;
      padding: 0;
    }
    .review-item {
      padding: 10px 0;
      border-top: 1px solid #f3f4f6;
    }
    .review-author {
      font-weight: 600;
      font-size: 13px;
    }
    .review-text {
      margin-top: 4px;
      line-height: 1.5;
    }
    .load-more {
      margin-top: 12px;
      width: 100%;
      padding: 8px;
      background: var(--rw-accent, #3b82f6);
      color: #fff;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 13px;
    }
    .load-more:hover {
      opacity: .9;
    }
    .skeleton {
      background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
      background-size: 200% 100%;
      animation: shimmer 1.5s infinite;
      border-radius: 4px;
      height: 14px;
      margin: 6px 0;
    }
    @keyframes shimmer {
      0% { background-position: 200% 0; }
      100% { background-position: -200% 0; }
    }
  </style>
  <div class="container">
    <div class="rating" aria-label="Рейтинг товара"></div>
    <ul class="reviews-list" role="list"></ul>
    <button class="load-more" style="display:none">Показати ще</button>
  </div>
`;

interface Review {
  id: string;
  author: string;
  rating: number;
  text: string;
  date: string;
}

export class ReviewWidget extends HTMLElement {
  static get observedAttributes() {
    return ['data-product-id', 'data-theme', 'data-locale'];
  }

  private shadow: ShadowRoot;
  private page = 1;
  private allLoaded = false;
  private apiBase = 'https://api.example.com/reviews';

  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
    this.shadow.appendChild(TEMPLATE.content.cloneNode(true));
  }

  connectedCallback() {
    this.applyTheme();
    this.fetchReviews(true);
    this.shadow.querySelector('.load-more')?.addEventListener('click', () => this.fetchReviews(false));
  }

  attributeChangedCallback(name: string, old: string, next: string) {
    if (old === next) return;
    if (name === 'data-product-id') {
      this.page = 1;
      this.allLoaded = false;
      this.fetchReviews(true);
    }
    if (name === 'data-theme') {
      this.applyTheme();
    }
  }

  private applyTheme() {
    const theme = this.dataset.theme ?? 'light';
    if (theme === 'dark') {
      const container = this.shadow.querySelector<HTMLElement>('.container');
      if (container) {
        container.style.setProperty('--rw-bg', '#1f2937');
        container.style.setProperty('--rw-text-color', '#f9fafb');
        container.style.setProperty('--rw-border-color', '#374151');
      }
    }
  }

  private showSkeleton() {
    const list = this.shadow.querySelector('.reviews-list')!;
    list.innerHTML = Array(3).fill(
      '<li><div class="skeleton"></div><div class="skeleton" style="width:70%"></div></li>'
    ).join('');
  }

  private async fetchReviews(reset: boolean) {
    const productId = this.dataset.productId;
    if (!productId) return;
    if (reset) {
      this.page = 1;
      this.showSkeleton();
    }
    try {
      const url = new URL(`${this.apiBase}/${productId}`);
      url.searchParams.set('page', String(this.page));
      url.searchParams.set('per_page', '5');
      url.searchParams.set('locale', this.dataset.locale ?? 'ru');
      const res = await fetch(url.toString(), {
        headers: {
          'X-Widget-Version': '2.1.0'
        },
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data: { reviews: Review[]; total: number; avg_rating: number } = await res.json();
      this.renderRating(data.avg_rating, data.total);
      this.renderReviews(data.reviews, reset);
      this.allLoaded = data.reviews.length < 5;
      const btn = this.shadow.querySelector<HTMLElement>('.load-more');
      if (btn) btn.style.display = this.allLoaded ? 'none' : 'block';
      this.page++;
      // інформуємо хост-сторінку
      this.dispatchEvent(new CustomEvent('reviews:loaded', {
        detail: { total: data.total, avgRating: data.avg_rating },
        bubbles: true,
        composed: true,
      }));
    } catch (err) {
      this.renderError();
    }
  }

  private renderRating(avg: number, total: number) {
    const ratingEl = this.shadow.querySelector('.rating')!;
    const stars = Array.from({ length: 5 }, (_, i) =>
      `<span class="star ${i < Math.round(avg) ? '' : 'empty'}">★</span>`
    ).join('');
    ratingEl.innerHTML = `${stars} <span>${avg.toFixed(1)} (${total} відгуків)</span>`;
    ratingEl.setAttribute('aria-label', `Рейтинг ${avg.toFixed(1)} з 5, ${total} відгуків`);
  }

  private renderReviews(reviews: Review[], reset: boolean) {
    const list = this.shadow.querySelector('.reviews-list')!;
    if (reset) list.innerHTML = '';
    const locale = this.dataset.locale ?? 'ru';
    const dateFormatter = new Intl.DateTimeFormat(locale, {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
    });
    reviews.forEach(r => {
      const li = document.createElement('li');
      li.className = 'review-item';
      li.innerHTML = `
        <div class="review-author">${r.author} <time datetime="${r.date}" style="font-weight:400;color:#6b7280;margin-left:8px">
          ${dateFormatter.format(new Date(r.date))}
        </time>
        </div>
        <div class="review-text">${r.text}</div>
      `;
      list.appendChild(li);
    });
  }

  private renderError() {
    this.shadow.querySelector('.reviews-list')!.innerHTML = '<li style="color:#ef4444;padding:8px 0">Не вдалося завантажити відгуки</li>';
  }
}

customElements.define('review-widget', ReviewWidget);

Скрипт завантаження підключається один раз і обробляє вже існуючі в DOM екземпляри:

// src/loader.ts
import { ReviewWidget } from './ReviewWidget';

if (!customElements.get('review-widget')) {
  customElements.define('review-widget', ReviewWidget);
}

if (!window.customElements) {
  console.warn('[review-widget] Custom Elements не підтримуються');
}

Публікація через CDN з SRI-хешем гарантує цілісність ресурсу. Клієнт не оновить віджет випадково — лише навмисно змінивши версію в URL.

Коли без iframe не обійтисяДля IE11 та браузерів старших за 2017 рік Custom Elements потребують поліфіл. Якщо аудиторія вашого сайту включає таких користувачів (частка >5%), ми підключаємо поліфіл або пропонуємо гібридну схему: для старих браузерів — iframe з postMessage, для сучасних — Custom Elements. Термін розробки збільшується на 1–2 тижні.

Як відбувається розробка та інтеграція?

  1. Аналітика. Вивчаємо оточення клієнта: браузери, бібліотеки, вимоги до SEO. Заміряємо поточний LCP та CLS.
  2. Проектування. Визначаємо API атрибутів, подій, структуру даних. Проектуємо Shadow DOM.
  3. Реалізація. Пишемо Custom Element, стилі, API-клієнт. Налаштовуємо збірку (Rollup + Terser) та CDN.
  4. Тестування. Перевіряємо на реальних хост-сайтах: Chrome 54+, Firefox 63+, Safari 10.1+, Edge 79+. Заміряємо LCP (< 1.5 с), CLS (< 0.1), INP (< 200 мс).
  5. Деплой. Завантажуємо на CDN з SRI-хешем, надаємо клієнту скрипт та документацію.

Що входить у роботу

  • Розробка кастомного HTML-елемента з ізоляцією через Shadow DOM.
  • Документація з інтеграції (скрипт, атрибути, події).
  • Тестування в цільових браузерах.
  • Розміщення на CDN з SRI-хешем та версіонуванням.
  • Підтримка протягом 1 місяця після запуску (виправлення багів, консультації).

Терміни орієнтовно

Для одного віджета середньої складності — від 2 до 4 тижнів. Складність зростає, якщо потрібен iframe-режим як fallback: додається до 2 тижнів на postMessage-комунікацію. Вартість розраховується індивідуально на основі вашого стеку та вимог. Замовте попередній аудит — його вартість $140–200 списується при замовленні розробки. Ми проаналізуємо оточення та запропонуємо оптимальне рішення.

Згідно з MDN Custom Elements, елементи повинні бути зареєстровані через customElements.define.

Ми займаємося веб-розробкою 5+ років, реалізували 30+ проектів з вбудовування віджетів. Зв'яжіться з нами для оцінки вашого проекту — ми проаналізуємо оточення та запропонуємо оптимальне рішення. Отримайте консультацію або замовте розробку віджета вже сьогодні.

Наші компетенції:

Останні роботи

  • Розробка сайту компанії B2B ADVANCE
    Розробка сайту компанії B2B ADVANCE
    1542
  • Розробка веб-додатків для компанії FEEDME
    Розробка веб-додатків для компанії FEEDME
    1393
  • Розробка веб-сайту для компанії БЕЛФІНГРУП
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    1081
  • Розробка інтернет магазину для компанії FURNORO
    Розробка інтернет магазину для компанії FURNORO
    1368
  • Розробка веб-додатків для компанії Enviok
    Розробка веб-додатків для компанії Enviok
    1139
  • Розробка веб-сайту для компанії ФІКСПЕР
    Розробка веб-сайту для компанії ФІКСПЕР
    1060