Radix UI: компоненты без стилей

27 октября 2026
Автор

Антон Ларичев

Что такое Radix UI и зачем нужны компоненты без стилей

Radix UI — это библиотека примитивов для React, которая предоставляет полнофункциональные компоненты без каких-либо встроенных стилей. Такой подход называют headless (безголовым): компонент содержит всю логику, управление состоянием и ARIA-атрибуты для доступности, но не навязывает никакого визуального оформления.

Классические библиотеки компонентов — Material UI, Ant Design, Chakra UI — идут с готовыми стилями. Это удобно для быстрого старта, но создаёт проблемы, когда нужно кастомизировать внешний вид: приходится бороться со специфичностью CSS, переопределять темы и в итоге поддерживать хрупкий код.

Radix UI решает эту проблему иначе. Он даёт:

  • Логику — управление открытием/закрытием, фокусом, клавиатурной навигацией
  • Доступность — правильные роли, ARIA-атрибуты, поведение по спецификации WAI-ARIA
  • Свободу — ноль собственных стилей, полный контроль у разработчика

Курс по теме

Пройти курс React и Redux Toolkit

40 000+ студентов · рейтинг 4.8 · гарантия возврата 30 дней

Установка

Каждый примитив Radix UI публикуется как отдельный пакет. Устанавливаем только то, что нужно:

npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tooltip
npm install @radix-ui/react-tabs

Либо сразу несколько:

npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tooltip

Принцип работы: составные компоненты

Radix UI использует паттерн составных компонентов (Compound Components). Каждый примитив — это набор взаимосвязанных компонентов, которые работают вместе через контекст.

Рассмотрим Dialog как первый пример:

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

function App() {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button>Открыть диалог</button>
      </Dialog.Trigger>

      <Dialog.Portal>
        <Dialog.Overlay className="dialog-overlay" />
        <Dialog.Content className="dialog-content">
          <Dialog.Title>Заголовок</Dialog.Title>
          <Dialog.Description>Описание диалога</Dialog.Description>

          <Dialog.Close asChild>
            <button>Закрыть</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Dialog.Root управляет состоянием. Dialog.Portal рендерит содержимое в конец body, избегая проблем с z-index и overflow: hidden. Dialog.Overlay и Dialog.Content — визуальные части, которые нужно стилизовать самостоятельно.

Проп asChild

Одна из ключевых возможностей Radix UI — проп asChild. Он позволяет передать поведение компонента дочернему элементу вместо рендера собственного DOM-узла.

// Без asChild: рендерится <button> внутри <button>
<Dialog.Trigger>
  <button>Открыть</button>
</Dialog.Trigger>

// С asChild: Radix «вливает» свои пропы в дочерний элемент
<Dialog.Trigger asChild>
  <button>Открыть</button>
</Dialog.Trigger>

С asChild можно использовать собственные компоненты:

import { Link } from 'react-router-dom'

<NavigationMenu.Link asChild>
  <Link to="/about">О нас</Link>
</NavigationMenu.Link>

Link получит все необходимые атрибуты (роль, aria-current, обработчики) без лишней обёртки в DOM.

Стилизация: три подхода

CSS-классы

Простейший способ — подключить обычный CSS-файл и присвоить классы:

import './dialog.css'

<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
.dialog-overlay {
  background-color: rgba(0, 0, 0, 0.5);
  position: fixed;
  inset: 0;
  animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
}

.dialog-content {
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 10px 60px rgba(0, 0, 0, 0.15);
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 90vw;
  max-width: 480px;
  padding: 24px;
}

@keyframes overlayShow {
  from { opacity: 0; }
  to { opacity: 1; }
}

Tailwind CSS

Tailwind — наиболее популярный выбор в связке с Radix UI:

<Dialog.Overlay className="fixed inset-0 bg-black/50 animate-in fade-in" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[90vw] max-w-md bg-white rounded-lg shadow-xl p-6 animate-in zoom-in-95">
  <Dialog.Title className="text-xl font-semibold text-gray-900 mb-2">
    Заголовок
  </Dialog.Title>
  <Dialog.Description className="text-sm text-gray-500 mb-6">
    Описание
  </Dialog.Description>
</Dialog.Content>

CSS Modules

Для проектов без Tailwind CSS Modules дают нужную изоляцию:

import styles from './Dialog.module.css'

<Dialog.Overlay className={styles.overlay} />
<Dialog.Content className={styles.content}>

Пример: Dropdown Menu

Выпадающее меню — один из сложнейших компонентов с точки зрения доступности. Radix UI обрабатывает всё автоматически: фокус-трап, закрытие по Escape, навигацию стрелками.

import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import styles from './DropdownMenu.module.css'

function UserMenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button className={styles.trigger}>
          Аккаунт
        </button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content className={styles.content} sideOffset={5}>
          <DropdownMenu.Item className={styles.item} onSelect={() => console.log('Профиль')}>
            Профиль
          </DropdownMenu.Item>
          <DropdownMenu.Item className={styles.item} onSelect={() => console.log('Настройки')}>
            Настройки
          </DropdownMenu.Item>

          <DropdownMenu.Separator className={styles.separator} />

          <DropdownMenu.Item className={styles.item} onSelect={() => console.log('Выйти')}>
            Выйти
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}
.content {
  min-width: 180px;
  background: white;
  border-radius: 8px;
  padding: 4px;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
  animation: slideDown 150ms ease;
}

.item {
  display: flex;
  align-items: center;
  padding: 8px 12px;
  border-radius: 4px;
  font-size: 14px;
  cursor: pointer;
  outline: none;
}

.item:hover,
.item[data-highlighted] {
  background: #f4f4f5;
}

.separator {
  height: 1px;
  background: #e4e4e7;
  margin: 4px 0;
}

@keyframes slideDown {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: translateY(0); }
}

Data-атрибуты для стилизации состояний

Radix UI добавляет data-атрибуты, отражающие текущее состояние компонента. Это позволяет стилизовать разные состояния без JavaScript:

/* Открытый/закрытый триггер */
.trigger[data-state='open'] {
  background: #f4f4f5;
}

.trigger[data-state='closed'] {
  background: transparent;
}

/* Активный пункт меню */
.item[data-highlighted] {
  background: #6d28d9;
  color: white;
}

/* Отключённый элемент */
.item[data-disabled] {
  opacity: 0.4;
  pointer-events: none;
}

Пример с Tabs:

import * as Tabs from '@radix-ui/react-tabs'

function ProductTabs() {
  return (
    <Tabs.Root defaultValue="description">
      <Tabs.List className="tabs-list">
        <Tabs.Trigger className="tabs-trigger" value="description">
          Описание
        </Tabs.Trigger>
        <Tabs.Trigger className="tabs-trigger" value="reviews">
          Отзывы
        </Tabs.Trigger>
        <Tabs.Trigger className="tabs-trigger" value="specs">
          Характеристики
        </Tabs.Trigger>
      </Tabs.List>

      <Tabs.Content value="description">
        <p>Полное описание товара...</p>
      </Tabs.Content>
      <Tabs.Content value="reviews">
        <p>Отзывы покупателей...</p>
      </Tabs.Content>
      <Tabs.Content value="specs">
        <p>Технические характеристики...</p>
      </Tabs.Content>
    </Tabs.Root>
  )
}
.tabs-trigger[data-state='active'] {
  border-bottom: 2px solid #6d28d9;
  color: #6d28d9;
  font-weight: 600;
}

.tabs-trigger[data-state='inactive'] {
  color: #71717a;
}

Tooltip: практический пример с анимацией

import * as Tooltip from '@radix-ui/react-tooltip'

function IconButton({ icon, label }: { icon: React.ReactNode; label: string }) {
  return (
    <Tooltip.Provider delayDuration={300}>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>
          <button className="icon-btn" aria-label={label}>
            {icon}
          </button>
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content className="tooltip" sideOffset={6}>
            {label}
            <Tooltip.Arrow className="tooltip-arrow" />
          </Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
    </Tooltip.Provider>
  )
}
.tooltip {
  background: #18181b;
  color: white;
  border-radius: 4px;
  padding: 4px 10px;
  font-size: 13px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  user-select: none;
  animation-duration: 200ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
  will-change: transform, opacity;
}

.tooltip[data-side='top'] {
  animation-name: slideUp;
}

.tooltip[data-side='bottom'] {
  animation-name: slideDown;
}

.tooltip-arrow {
  fill: #18181b;
}

@keyframes slideUp {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes slideDown {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: translateY(0); }
}

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

Bольшинство примитивов Radix UI поддерживают оба режима работы.

Неконтролируемый (состояние внутри компонента):

<Dialog.Root defaultOpen={false}>
  ...
</Dialog.Root>

Контролируемый (состояние снаружи):

const [open, setOpen] = useState(false)

<Dialog.Root open={open} onOpenChange={setOpen}>
  <Dialog.Trigger asChild>
    <button onClick={() => setOpen(true)}>Открыть</button>
  </Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Content>
      <button onClick={() => setOpen(false)}>Закрыть</button>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Контролируемый режим нужен, когда открытие диалога зависит от внешних условий: результата запроса, другого состояния, программной навигации.

Создание собственной библиотеки компонентов

Радикальное преимущество Radix UI — возможность создать собственную библиотеку поверх примитивов. Пример обёртки:

// components/ui/dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@/lib/utils'

const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger

const DialogOverlay = ({ className, ...props }: DialogPrimitive.DialogOverlayProps) => (
  <DialogPrimitive.Overlay
    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}
  />
)

const DialogContent = ({ className, children, ...props }: DialogPrimitive.DialogContentProps) => (
  <DialogPrimitive.Portal>
    <DialogOverlay />
    <DialogPrimitive.Content
      className={cn(
        'fixed z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
        'bg-white rounded-xl shadow-2xl p-6 w-[90vw] max-w-lg',
        'data-[state=open]:animate-in data-[state=closed]:animate-out',
        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
        'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
        className
      )}
      {...props}
    >
      {children}
    </DialogPrimitive.Content>
  </DialogPrimitive.Portal>
)

export { Dialog, DialogTrigger, DialogContent }

Использование такой обёртки:

import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog'

function Page() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <button>Открыть</button>
      </DialogTrigger>
      <DialogContent>
        <h2>Заголовок</h2>
        <p>Содержимое диалога</p>
      </DialogContent>
    </Dialog>
  )
}

По этому принципу устроена популярная библиотека shadcn/ui — набор копируемых компонентов на базе Radix UI и Tailwind.

Доступность из коробки

Radix UI полностью соответствует спецификации WAI-ARIA. При открытии Dialog:

  • фокус автоматически перемещается внутрь
  • фокус-трап не позволяет уйти за пределы диалога
  • при закрытии фокус возвращается на триггер
  • Escape закрывает диалог
  • добавляются role="dialog", aria-modal, aria-labelledby, aria-describedby

Всё это работает без единой строки дополнительного кода.

Сравнение с альтернативами

Библиотека Стили Доступность Кастомизация
Radix UI Нет Полная Максимальная
Headless UI Нет Хорошая Максимальная
Floating UI Нет Частичная Максимальная
shadcn/ui Tailwind Полная (через Radix) Высокая
MUI Есть Полная Средняя

Radix UI выигрывает там, где важен полный контроль над дизайном при строгом соблюдении стандартов доступности.

Итог

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

Ключевые принципы работы с Radix UI:

  • устанавливать только нужные примитивы
  • использовать asChild для интеграции с собственными компонентами
  • стилизовать через data-атрибуты для обработки состояний
  • создавать обёртки для многократного использования в проекте

Для углублённого изучения React и современных паттернов разработки компонентов — курс на PurpleSchool: https://purpleschool.ru/course/react?utm_source=knowledgebase&utm_medium=text&utm_campaign=radix-ui-unstyled-components

Стрелочка влевоReact Native Skia для графикиАнимации в React с GSAPСтрелочка вправо

Постройте личный план изучения React до уровня Middle — бесплатно!

React — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по React

React тестирование с Vitest
Zustand: продвинутые паттерны, persist middleware и slicesUncontrolled Components: когда DOM управляет даннымиБезопасность в React: защита от XSS, CSRF и утечек данныхRender Props: гибкое управление рендерингом в ReactРефакторинг React-кода: техники и лучшие практикиReact с WebSocket — реалтайм в компонентахReact Native приложение с нуля до публикации в сторахПрофилирование React: как найти и устранить узкие местаЧастичное применение: как создавать компоненты без лишнего кодаИменование компонентов в React: соглашения и лучшие практикиЛенивая загрузка: как ускорить React-приложение в разыОшибки гидратации в React и способы их устраненияHOC в React: мастерство композиции компонентовАрхитектура больших React приложений: Feature-Sliced DesignuseMemo: как спасти производительность от тяжелых вычисленийError Boundaries: создаем надежные React-приложенияКонтролируемые компоненты в React: полный контроль над формамиCompound Components в React: создаем гибкие компоненты с мощным APIДокументирование компонентов в React: Storybook, JSDoc и READMEКомпозиция компонентов в React: строим гибкие интерфейсыКомментирование кода в React: когда и как писать комментарииCode Splitting в React: как уменьшить бандл и ускорить загрузку приложенияАсинхронные компоненты в React: новый стандарт работы с даннымиДоступность (a11y) в React: ARIA, семантика и клавиатурная навигация
React с TanStack Router
Zustand — управление состоянием в ReactZod - валидация с TypeScriptYup - валидация схемXState - конечные автоматыТемизация в ReactТестирование хуковTailwind CSS с ReactSWR - библиотека для запросовStyled Components — стилизация через JSStorybook - документация компонентовSnapshots тестированиеRTK Query - работа с APIRedux Toolkit - современный ReduxRecoil — библиотека управления состоянием от FacebookВиртуализация списков с react-window: как отображать тысячи элементов без лаговReact Toastify - уведомления в ReactReact Testing LibraryСоздание таблиц в React гайд по react-tableReact Spring - анимацииРабота с формами и селектами в ReactReact Query (TanStack Query) - работа с серверомПлагины в React что это и как их использоватьReact PDF - работа с PDF файламиОбзор популярных библиотек для ReactReact Icons - библиотека иконок для ReactReact Hook Form — валидация форм в ReactReact Dropzone — загрузка файловПодключение Bootstrap к React-приложениюReact Beautiful DnD - перетаскивание элементовАнимация при монтировании компонентов в ReactМокирование APIMobX — реактивное управление состоянием в ReactМикрофронтенды с React (micro-frontends)Загрузка и индикаторыАнимация списков в ReactJotai - атомарное состояниеБесконечная прокруткаFramer Motion - библиотека анимацийEmotion — библиотека CSS-in-JSДинамические стили в ReactE2E тестирование с CypressCSSTransition - переходыCSS-in-JS — плюсы и минусыКонтекст vs Redux — когда что использоватьИспользование Chart.js в ReactAxios с ReactТестирование асинхронных компонентовОбработка ошибок API
Valtio: атомарный state management в ReactuseState в React что это и как использоватьuseTransition - плавные переходы между состояниямиuseRef в React — создание ссылок на DOM и значенияuseSyncExternalStore — работа с внешними сторамиuseLayoutEffect в React — эффект до отрисовкиuseOptimistic — оптимистичные обновления UIuseInsertionEffect — внедрение стилей до мутаций DOMuseImperativeHandle в React — настройка ref дочернего компонентаuseId — генерация уникальных идентификаторовuseFormStatus - отслеживание статуса отправки формыuseDeferredValue — отложенное обновление состоянияuseDebugValue — отладка кастомных хуковuseCallback в React — мемоизация функцийuseReducer — альтернатива useState для сложной логикиuseMemo в React: как и когда оптимизировать тяжелые вычисленияХук use() в React 19useEffect в React что это и как использоватьuseContext — работа с контекстом в ReactuseCallback в React — мемоизация функций и оптимизация ре-рендеровuseActionState в React 19Оптимизация рендеринга в React: от теории к глубокой практикеЧто такое useRef и как его применять в ReactКак и зачем использовать React HooksУправление состоянием в React через ContextНовые возможности React 19Как предотвратить лишние ре-рендеры в React: полное руководствоuseMemo vs useCallback: подробное руководство по мемоизации в ReactПравила хуков — правила использованияflushSync — синхронные обновления состояния в ReactuseEffect vs useLayoutEffect: в чём разница и какой хук выбрать?Кастомные хуки в React — создание собственных хуковuseState продвинутое использование в React
Transition API — плавные обновления интерфейса в ReactReact Suspense — приостановка рендераStrictMode в React — как находить ошибки на этапе разработкиСерверные компоненты React (RSC) — подробный разбор и практикаКак работает рендеринг в ReactЧто такое props в React и как их правильно использоватьКак работает JSX связка React и HTMLЧто такое React.js и как его использоватьКак использовать элементы в ReactКак использовать React DOM в проектеЧто такое компоненты в React и как их применятьРабота с children в ReactПорталы в React: рендер компонентов вне иерархии DOMFragment в React: группировка элементов без лишних узлов DOMCSS Modules в ReactConcurrent Mode — конкурентный режим в React
Открыть базу знаний

Лучшие курсы по теме

изображение курса

React и Redux Toolkit

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий