Концепция headless UI-компонентов

Headless UI-компоненты представляют собой архитектурный подход, при котором логика взаимодействия и состояние компонента полностью отделены от его визуального представления. В отличие от традиционных UI-библиотек, где компоненты поставляются вместе с готовыми стилями и разметкой, headless-компоненты предоставляют только поведение — без навязанного внешнего вида.

Ключевая идея: поведение (logic) + управление состоянием (state) ≠ внешний вид (presentation)

Такой подход особенно органично вписывается в экосистему SvelteKit благодаря реактивной модели Svelte и гибкости компоновки компонентов.


Архитектурные принципы

Разделение ответственности

Headless-компоненты строго следуют принципу разделения:

  • Логика — управление состоянием, обработка событий, accessibility
  • Разметка — HTML-структура, полностью контролируемая разработчиком
  • Стили — CSS/SCSS/Tailwind или любые другие инструменты

Это позволяет:

  • использовать единый набор логики с разными дизайнами
  • легко интегрировать дизайн-системы
  • избегать избыточной кастомизации готовых UI-компонентов

Контролируемое и неконтролируемое состояние

Headless-компоненты часто поддерживают два режима:

  • Неконтролируемый (uncontrolled) — состояние управляется внутри компонента
  • Контролируемый (controlled) — состояние передаётся извне

Пример для dropdown:

<script>
  import { createDropdown } from '$lib/headless';

  const dropdown = createDropdown();
</script>

<button on:click={dropdown.toggle}>
  Toggle
</button>

{#if dropdown.open}
  <ul>
    <li on:click={() => dropdown.select('A')}>A</li>
    <li on:click={() => dropdown.select('B')}>B</li>
  </ul>
{/if}

Здесь компонент не диктует структуру — только предоставляет API.


Реализация в SvelteKit

Использование store-паттерна

Svelte предоставляет встроенные store, которые идеально подходят для headless-логики.

Пример создания headless toggle:

import { writable } from 'svelte/store';

export function createToggle(initial = false) {
  const { subscribe, set, upd ate } = writable(initial);

  return {
    subscribe,
    toggle: () => upd ate(v => !v),
    on: () => se t(true),
    off: () => se t(false)
  };
}

Использование:

<script>
  import { createToggle } from '$lib/toggle';

  const toggle = createToggle();
</script>

<button on:click={toggle.toggle}>
  Переключить
</button>

{#if $toggle}
  <p>Включено</p>
{/if}

Здесь отсутствует какой-либо UI-компонент — только логика.


Renderless-компоненты

Альтернативный подход — использование компонентов без DOM-вывода:

<script>
  export let let:open;
  export let let:toggle;

  let isOpen = false;

  function toggleFn() {
    isOpen = !isOpen;
  }
</script>

<slot {isOpen} toggle={toggleFn} />

Использование:

<Dropdown let:isOpen let:toggle>
  <button on:click={toggle}>
    Menu
  </button>

  {#if isOpen}
    <div>Контент</div>
  {/if}
</Dropdown>

Компонент выступает как поставщик состояния.


Accessibility (доступность)

Headless UI-компоненты часто включают в себя:

  • управление фокусом
  • ARIA-атрибуты
  • клавиатурную навигацию

Пример для списка:

function createListbox(items) {
  const activeIndex = writable(0);

  function next() {
    activeIndex.update(i => (i + 1) % items.length);
  }

  function prev() {
    activeIndex.update(i => (i - 1 + items.length) % items.length);
  }

  return {
    activeIndex,
    next,
    prev
  };
}

Визуальная часть добавляет:

<ul role="listbox">
  {#each items as item, i}
    <li
      role="option"
      aria-selected={$activeIndex === i}
      class:selected={$activeIndex === i}
    >
      {item}
    </li>
  {/each}
</ul>

Композиция и переиспользование

Headless-подход позволяет строить сложные компоненты из простых:

  • Dropdown = Toggle + Listbox
  • Modal = FocusTrap + Portal + Escape handler
  • Tabs = Store + Context API

Пример композиции:

export function createTabs(tabs) {
  const active = writable(0);

  return {
    active,
    select: (i) => active.set(i)
  };
}

Контекст Svelte (Context API)

Для передачи состояния без проп-дриллинга:

import { setContext, getContext } from 'svelte';

const KEY = {};

export function createTabsContext() {
  const tabs = createTabs([]);
  setContext(KEY, tabs);
  return tabs;
}

export function useTabs() {
  return getContext(KEY);
}

Интеграция с UI-библиотеками

Headless-компоненты легко сочетаются с:

  • Tailwind CSS
  • CSS Modules
  • Styled Components (через адаптацию)

Пример с Tailwind:

<button
  class="px-4 py-2 bg-blue-500 text-white rounded"
  on:click={toggle.toggle}
>
  Toggle
</button>

Логика остаётся независимой.


Преимущества подхода

Гибкость дизайна

Отсутствие жёсткой привязки к стилям позволяет:

  • реализовать любой дизайн
  • соблюдать корпоративные гайдлайны
  • избегать переопределения CSS

Повторное использование логики

Один и тот же headless-модуль:

  • используется в разных проектах
  • адаптируется под разные UI
  • тестируется независимо от DOM

Улучшенная тестируемость

Логика изолирована от UI:

  • проще писать unit-тесты
  • отсутствует зависимость от браузера
  • минимизируется количество side effects

Совместимость с SSR

SvelteKit активно использует серверный рендеринг. Headless-компоненты:

  • не зависят от DOM
  • корректно работают на сервере
  • не ломают гидратацию

Недостатки и ограничения

Увеличение объёма кода

Разработчик должен самостоятельно:

  • писать разметку
  • добавлять стили
  • учитывать accessibility

Более высокий порог входа

Требуется понимание:

  • архитектуры состояния
  • паттернов композиции
  • взаимодействия компонентов

Отсутствие “готовых решений”

Headless UI — это инструмент, а не конечный продукт:

  • нет кнопки «сразу красиво»
  • требуется дизайн-система

Сравнение с традиционными UI-библиотеками

Характеристика Headless UI Классические UI-библиотеки
Стили Нет Есть
Гибкость Максимальная Ограниченная
Быстрый старт Ниже Выше
Контроль над DOM Полный Частичный
Accessibility Часто встроена Часто встроена

Практические сценарии использования

  • дизайн-системы и UI-киты
  • сложные интерактивные компоненты
  • кастомные интерфейсы (дашборды, редакторы)
  • проекты с уникальным UX

Структура проекта с headless-подходом

src/
  lib/
    headless/
      toggle.js
      dropdown.js
      modal.js
    components/
      Button.svelte
      Dropdown.svelte
      Modal.svelte

Разделение:

  • headless/ — чистая логика
  • components/ — визуальные оболочки

Пример: headless modal

import { writable } from 'svelte/store';

export function createModal() {
  const open = writable(false);

  return {
    open,
    show: () => open.set(true),
    hide: () => open.set(false)
  };
}

Использование:

<script>
  import { createModal } from '$lib/modal';

  const modal = createModal();
</script>

<button on:click={modal.show}>
  Открыть
</button>

{#if $modal.open}
  <div class="overlay" on:click={modal.hide}>
    <div class="modal" on:click|stopPropagation>
      Контент
    </div>
  </div>
{/if}

Связь с философией Svelte

Headless UI органично сочетается с:

  • реактивностью $store
  • минимализмом Svelte
  • отсутствием виртуального DOM
  • декларативной разметкой

SvelteKit усиливает этот подход благодаря:

  • SSR
  • маршрутизации
  • серверным действиям

Итоговая концепция

Headless UI в SvelteKit — это не библиотека компонентов, а способ мышления:

  • логика отделена от представления
  • UI собирается как конструктор
  • состояние управляется явно
  • компоненты становятся примитивами

Этот подход формирует основу масштабируемых, поддерживаемых и гибких интерфейсов.