Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Headless UI на практике: как создавать типобезопасные компоненты поверх Radix UI в React и TypeScript

Тимофей Ищенко

Коротко: Разбираем архитектуру Headless UI на базе Radix Primitives и TypeScript: паттерн asChild, типизация с ElementRef и ComponentPropsWithoutRef, реализация UI-kit.

Создание корпоративной дизайн-системы часто упирается в архитектурную развилку. Использование готовых библиотек со встроенными стилями (MUI, Ant Design) быстро приводит к борьбе с переопределением CSS и раздуванию бандла. Написание сложных интерактивных компонентов (модальные окна, выпадающие списки, тултипы) с нуля требует сотен часов на реализацию фокус-траппинга, правильной клавиатурной навигации и спецификаций WAI-ARIA.

Концепция Headless UI решает эту проблему за счет четкого разделения ответственности: низкоуровневая логика и доступность инкапсулируются в готовых примитивах, а разметка, стили и расширенная типизация остаются под полным контролем разработчика. Библиотека Radix Primitives стала де-факто стандартом для построения таких систем. Разберем, как правильно проектировать архитектуру компонентов поверх Radix UI и выстраивать строгую типизацию на TypeScript.


Что такое Headless UI и архитектура Radix Primitives

Headless-подход в интерфейсах подразумевает отделение логики состояния, доступности (a11y) и событийной модели от визуального представления (CSS-классов и разметки). Компоненты поставляются абсолютно «чистыми» — без встроенных стилей, runtime-генераторов CSS и привязки к конкретным методологиям верстки.

Разделение ответственности: состояние, a11y и визуальный слой

В традиционном подходе компонент Dropdown объединяет внутри себя:

  1. Логику открытия/закрытия и позиционирования.
  2. Поддержку экранных дикторов: атрибуты aria-expanded, aria-haspopup, aria-controls.
  3. Управление фокусом: замыкание фокуса (focus trap), возврат фокуса на триггер при закрытии, перемещение клавишами ArrowUp/ArrowDown/Home/End.
  4. Визуальные стили и DOM-структуру.

Radix Primitives берет на себя первые три пункта. Разработчик дизайн-системы отвечает только за визуальный слой и интеграцию с бизнес-требованиями. Это исключает дублирование сложной инженерной логики в разных командах и снижает накопление технического долга в UI-слое.

+-------------------------------------------------------------+
|                     Ваш UI-Kit / Дизайн-система             |
|   (Кастомные стили: Tailwind / CVA / CSS Modules / Stitches)|
+-------------------------------------------------------------+
                              │
                              ▼
+-------------------------------------------------------------+
|                     Слой типизации (TypeScript)             |
|        (Кастомные пропсы, варианты, строгие дженерики)      |
+-------------------------------------------------------------+
                              │
                              ▼
+-------------------------------------------------------------+
|                     Radix UI Primitives                     |
|    (WAI-ARIA, Keyboard Nav, Focus Management, State Context)|
+-------------------------------------------------------------+

Паттерн Compound Components и дерево контекстов

Radix построен на модульных независимых пакетах (@radix-ui/react-dialog, @radix-ui/react-dropdown-menu, @radix-ui/react-popover) и использует паттерн составных компонентов (Compound Components).

Вместо монолитного компонента с десятками пропсов-конфигураций API разбивается на логические части: Root, Trigger, Portal, Content, Overlay, Close. Корневой компонент (Root) хранит внутренний контекст состояния и синхронизирует все дочерние узлы без необходимости прокидывать колбэки через глубокое дерево пропсов.


Механика композиции: паттерн asChild и библиотека Slot

Одной из главных архитектурных сложностей в React-компонентах всегда был полиморфизм — возможность рендерить компонент другим HTML-тегом или пользовательским компонентом (например, рендерить кнопку как ссылку из Next.js или React Router).

Проблема классического полиморфизма через проп as

Традиционный подход с использованием пропа as (или component):

// Проблемный подход
<Button as={Link} href="/dashboard">Перейти</Button>

создает существенные сложности:

  • Сигнатуры типов становятся перегруженными и требуют сложных вложенных дженериков.
  • Замедляется работа TypeScript Language Server при выводе типов.
  • Возникают коллизии пропсов между базовым компонентом и целевым элементом.

Делегирование DOM-узлов через @radix-ui/react-slot

Radix UI решает задачу полиморфизма через механизм слотов (@radix-ui/react-slot) и проп asChild. Когда для примитива Radix передается флаг asChild, компонент не создает собственный DOM-элемент, а клонирует своего непосредственного потомка и мерджит в него свои внутренние пропсы, атрибуты доступности, обработчики событий и ref.

import * as Dialog from '@radix-ui/react-dialog';

// Dialog.Trigger не отрендерит лишний <button>,
// а передаст ARIA-атрибуты и onClick прямо в кастомную кнопку
<Dialog.Trigger asChild>
  <button type="button" className="btn-primary">
    Открыть окно
  </button>
</Dialog.Trigger>

Правило композиции через asChild: дочерний элемент обязан быть валидным React-элементом и корректно принимать и пробрасывать ref, а также стандартные DOM-события.


Построение кастомной типизации поверх Radix UI

При создании собственного UI-kit поверх Radix UI нельзя просто сделать реэкспорт примитивов. Необходимо инкапсулировать стили дизайн-системы, добавить систему дизайн-токенов/вариантов (например, через class-variance-authority) и сохранить строгую типизацию DOM-элементов и ref.

Извлечение типов через ElementRef и ComponentPropsWithoutRef

Чтобы сохранить совместимость со стандартным React API и примитивами Radix, сигнатуры типов компонентов строятся с использованием двух утилит:

  1. React.ElementRef<typeof Component> — точно извлекает тип DOM-узла, ассоциированного с компонентом (например, HTMLDivElement или HTMLButtonElement).
  2. React.ComponentPropsWithoutRef<typeof Component> — извлекает все допустимые пропсы компонента без поля ref, предотвращая конфликты при оборачивании в React.forwardRef.
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';

// Извлечение типа DOM-элемента
type DialogContentElement = React.ElementRef<typeof DialogPrimitive.Content>;

// Извлечение базовых пропсов Radix-примитива
type DialogContentBaseProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>;

Добавление кастомных вариантов отображения

Для типизации вариантов визуального оформления используется связка с библиотеками управления вариантами классов (например, cva).

import { cva, type VariantProps } from 'class-variance-authority';

export const dialogVariants = cva(
  'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-xl transition-all',
  {
    variants: {
      size: {
        sm: 'max-w-sm w-full',
        md: 'max-w-lg w-full',
        lg: 'max-w-3xl w-full',
        fullscreen: 'w-screen h-screen max-w-none rounded-none',
      },
    },
    defaultVariants: {
      size: 'md',
    },
  }
);

// Объединение пропсов Radix, вариантов CVA и кастомных полей
export interface CustomDialogContentProps
  extends DialogContentBaseProps,
    VariantProps<typeof dialogVariants> {
  showCloseButton?: boolean;
}

Пошаговая реализация типобезопасного компонента Modal/Dialog

Соберем полноценный, готовый к продакшену компонент модального окна дизайн-системы.

1. Архитектура файлов компонента

components/ui/dialog/
├── dialog.variants.ts   # CVA-конфигурации стилей
├── dialog.types.ts      # TypeScript-интерфейсы и типы
├── dialog.tsx           # Реализация React-компонентов
└── index.ts             # Публичный API модуля

2. Реализация с поддержкой forwardRef и слияния классов

// components/ui/dialog/dialog.tsx
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { dialogVariants, type CustomDialogContentProps } from './dialog.types';

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;

export const DialogOverlay = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
      className
    )}
    {...props}
  />
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

export const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  CustomDialogContentProps
>(({ className, children, size, showCloseButton = true, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(dialogVariants({ size }), className)}
      {...props}
    >
      {children}
      {showCloseButton && (
        <DialogPrimitive.Close
          className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2"
          aria-label="Закрыть"
        >
          <span aria-hidden="true">&times;</span>
        </DialogPrimitive.Close>
      )}
    </DialogPrimitive.Content>
  </DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;

export const DialogHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
    {...props}
  />
);
DialogHeader.displayName = 'DialogHeader';

export const DialogTitle = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Title
    ref={ref}
    className={cn('text-lg font-semibold leading-none tracking-tight', className)}
    {...props}
  />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;

Частые ошибки при создании компонентов поверх Radix UI

При интеграции headless-примитивов в строгую типизированную среду разработчики нередко сталкиваются со скрытыми проблемами.

1. Потеря фокуса из-за отсутствия проброса ref

Если обернуть внутренний элемент в пользовательский компонент с asChild, но забыть обернуть его в React.forwardRef, Radix не сможет привязать фокус-менеджер к реальной DOM-ноде.

// ОШИБКА: ref не проброшен к <button>
const CustomButton = ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
  return <button {...props}>{children}</button>;
};

// ПРАВИЛЬНО:
const CustomButton = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
  (props, ref) => <button ref={ref} {...props} />
);

2. Конфликты типизации при переопределении событий

При попытке переопределить стандартные события Radix (например, onPointerDownOutside или onOpenAutoFocus) важно соблюдать сигнатуры кастомных событий Radix, а не стандартных React-событий.

// Radix передает CustomEvent в свои перехватчики
interface ExtendedContentProps extends CustomDialogContentProps {
  onInteractOutside?: (event: CustomEvent<{ originalEvent: PointerEvent | FocusEvent }>) => void;
}

Если передать (event: React.MouseEvent) => void, возникнет ошибка несовместимости типов в TypeScript.

3. Нарушение семантики ARIA при кастомизации заголовков

Radix требует, чтобы у диалогового окна всегда присутствовали доступные заголовок (DialogTitle) и описание (DialogDescription) для скринридеров. Если в дизайне модального окна визуально нет заголовка, нельзя просто удалить DialogTitle.

Вместо этого используют компонент доступности @radix-ui/react-visually-hidden:

import * as VisuallyHidden from '@radix-ui/react-visually-hidden';

<DialogContent>
  <VisuallyHidden.Root>
    <DialogTitle>Заголовок для скринридера</DialogTitle>
  </VisuallyHidden.Root>
  <div>Контент без видимого заголовка</div>
</DialogContent>

FAQ

Зачем оборачивать Radix UI в собственные компоненты, если есть готовый shadcn/ui?

shadcn/ui — это отличная коллекция шаблонов, но в рамках масштабного проекта или корпоративного UI-kit она служит лишь отправной точкой. Создание собственного слоя абстракции поверх Radix позволяет зафиксировать архитектурные контракты команды, изолировать зависимости, стандартизировать дизайн-токены и кастомную бизнес-логику без риска получить расхождения в кодовой базе при обновлении стилей.

Как правильно типизировать проп ref при использовании React.forwardRef с примитивами Radix?

Используйте утилиту React.ElementRef<typeof Primitive.Component>:

React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>((props, ref) => <DropdownMenuPrimitive.Content ref={ref} {...props} />);

Это гарантирует, что тип ref точно совпадает с типом DOM-узла, который возвращает примитив.

Что делать, если компонент внутри asChild не реагирует на события или фокус?

Убедитесь, что пользовательский компонент:

  1. Обернут в React.forwardRef и передает ref на корневой DOM-узел.
  2. Пробрасывает все входящие ...props (включая onClick, onKeyDown, aria-* и data-* атрибуты) непосредственно на целевой DOM-элемент.

Влияет ли использование Radix UI на размер бандла приложения?

Каждый примитив Radix поставляется отдельным NPM-пакетом (@radix-ui/react-dialog, @radix-ui/react-tooltip и т.д.). Благодаря модульности и отсутствию встроенного CSS они эффективно подвергаются tree-shaking сборщиками (Webpack, Vite, Rollup). В итоговый бандл попадает только код фактически используемых компонентов.

Можно ли использовать Radix UI с SSR и Server Components в Next.js?

Да. Интерактивные части компонентов Radix, требующие работы с DOM и хуками состояния, объявляются как клиентские компоненты (директива 'use client'). При этом разметка корректно генерируется на сервере без расхождений при гидратации (hydration mismatch), так как Radix изначально спроектирован с поддержкой SSR.


Заключение

Headless-архитектура на базе Radix UI позволяет выстроить гибкую дизайн-систему enterprise-уровня без компромиссов в доступности и производительности.

Чеклист для создания типобезопасного компонента:

  1. Разделяйте API: Экспортируйте Compound-компоненты (Root, Trigger, Content) с понятным интерфейсом.
  2. Типизируйте через утилиты: Используйте React.ElementRef и React.ComponentPropsWithoutRef вместо ручного указания HTML-типов.
  3. Поддерживайте ref: Всегда используйте React.forwardRef для компонентов, отображающих разметку.
  4. Сохраняйте WAI-ARIA: Не ломайте связи между Title, Description и Content. Используйте VisuallyHidden при скрытии заголовков.
  5. Изолируйте стили: Применяйте CVA или CSS-модули для добавления вариантов без потери автодополнения пропсов в TypeScript.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также