Коротко: Разбираем архитектуру 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 объединяет внутри себя:
- Логику открытия/закрытия и позиционирования.
- Поддержку экранных дикторов: атрибуты
aria-expanded,aria-haspopup,aria-controls. - Управление фокусом: замыкание фокуса (focus trap), возврат фокуса на триггер при закрытии, перемещение клавишами
ArrowUp/ArrowDown/Home/End. - Визуальные стили и 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, сигнатуры типов компонентов строятся с использованием двух утилит:
React.ElementRef<typeof Component>— точно извлекает тип DOM-узла, ассоциированного с компонентом (например,HTMLDivElementилиHTMLButtonElement).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">×</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 не реагирует на события или фокус?
Убедитесь, что пользовательский компонент:
- Обернут в
React.forwardRefи передаетrefна корневой DOM-узел. - Пробрасывает все входящие
...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-уровня без компромиссов в доступности и производительности.
Чеклист для создания типобезопасного компонента:
- Разделяйте API: Экспортируйте Compound-компоненты (
Root,Trigger,Content) с понятным интерфейсом. - Типизируйте через утилиты: Используйте
React.ElementRefиReact.ComponentPropsWithoutRefвместо ручного указания HTML-типов. - Поддерживайте ref: Всегда используйте
React.forwardRefдля компонентов, отображающих разметку. - Сохраняйте WAI-ARIA: Не ломайте связи между
Title,DescriptionиContent. ИспользуйтеVisuallyHiddenпри скрытии заголовков. - Изолируйте стили: Применяйте CVA или CSS-модули для добавления вариантов без потери автодополнения пропсов в TypeScript.




.svg.webp)



