RadixUI
Чтобы сделать универсальную модалку, необходимо реализовать:
- Обработку клика за пределами модального окна
- Закрытие по нажатию клавиши
Escape - Поддержку порталов
- Прием
childrenдля динамического содержимого - Стилизацию и кастомизацию: модалка должна поддерживать кастомизацию стилей
- Доступность (
a11y) - Управление фокусом: при открытии модального окна весь фокус должен быть внутри него.
- Блокировку прокрутки
Чтобы реализовать модальное окно, согласно этим требования придется потрудиться не один день. Но в принципе все это реально сделать.
Но возьмем например dropdown. На первый взгляд кажется, что там делать. Для того чтобы глубже понять проблему реализации
кастомных компонент посмотрите видео So You Think You Can Build A Dropdown?
И это касается многих элементов (селекты, чекбоксы, аккордироны, popover и много других). Мы можем их взять из
MUI или других подобных библиотек, но есть определенные трудности:
- Размер библиотеки: Использование
Material-UIможет увеличить размер вашего приложения из-за включения большого количестваCSSиJavaScriptкода для компонентов и их стилей. - Ограниченная кастомизация: В некоторых случаях может быть сложно добиться определенного внешнего вида или поведения
компонентов
Material-UIбез изменения их внутреннего кода или использования обходных решений - Зависимость от дизайна Material Design: Если вы предпочитаете или ваш проект требует другого стиля дизайна,
использование
Material-UIможет ограничить вашу свободу выбора. - Глубокое понинимание нюансов работы в Material Design
И вот тут нам на вырочку идет библиотека Radix UI
Radix UI — это библиотека компонентов React, предназначенная для создания интерфейсов с
высококачественными, доступными и настраиваемыми UI-компонентами. Библиотека охватывает базовые элементы, такие как
модальные окна, выпадающие списки, переключатели, диалоги, тултипы и другие. Radix UI решает множество задач по
управлению интерфейсом и обеспечивает согласованную работу компонентов, особенно при создании доступных интерфейсов.
Основные особенности
- Высокая доступность (a11y):
Radix UIособое внимание уделяет доступности: все компоненты созданы с учетом стандартовARIA, что делает их доступными для пользователей с ограниченными возможностями. Библиотека автоматически добавляет нужные атрибуты и управляет фокусом.- Например, модальное окно
DialogизRadix UIавтоматически настроено для перемещения фокуса внутрь компонента при открытии и его возврата обратно при закрытии.
- Полный контроль над стилями:
- В отличие от многих UI-библиотек,
Radix UIпредоставляет только функциональные компоненты без предустановленных стилей. Это делает библиотеку отличным выбором для проектов, где важен полный контроль над внешним видом, так как стилизация полностью ложится на разработчика. - Компоненты можно стилизовать с нуля с помощью любой CSS-системы (например,
styled-components,Emotion,Tailwind CSS).
- Модульность и составные компоненты:
- Компоненты
Radix UIпостроены по принципу составных компонентов, что позволяет гибко управлять компонентами на уровне их частей. Например, компонентDropdownMenuпредоставляет несколько подпунктов (элементы меню, триггеры, списки и т.д.) , которые можно комбинировать в нужной конфигурации.
- Хорошая производительность:
Radix UIсоздавался с акцентом на производительность и минимализм. Компоненты не добавляют избыточного кода и работают эффективно, поскольку основаны на функциональных хуках React.
Modal
Пример использования компонента Dialog (Модальное окно)
Идем по документации
и внедряем модальное окно используя Radix UI
Первым дело установим компонент в наше приложение
pnpm add @radix-ui/react-dialog
Создадим компонент ModalRadix.tsx который просто скопируем из документации
- Выберите в документации
CSS Modules - Создайте файл
ModalRadix.module.css - Вставьте стили из документации
import * as Dialog from '@radix-ui/react-dialog'
import { Cross2Icon } from '@radix-ui/react-icons'
import styles from './ModalRadix.module.css'
export const ModalRadix = () => (
/*Берем код полностью из документации*/
)Colors / Icons
Иконки и цвета можно брать свои, но в процессе первоначального знакомтсва вопользуемся тем, что предоставляет Radix UI
pnpm add @radix-ui/colors @radix-ui/react-icons
Чтобы цвета применились их нужно прописать глобально
@import "@radix-ui/colors/black-alpha.css";
@import "@radix-ui/colors/green.css";
@import "@radix-ui/colors/mauve.css";
@import "@radix-ui/colors/violet.css";
:root {
/* Accent Colors */
--accent-100: #73a5ff;
/*...*/Отрисуем ModalRadix.tsx в Header.tsx рядом с компонентой Cart.tsx
export const Header = () => {
return (
<div className={s.headerWrapper}>
<div className={s.container}>
<h3>logotype</h3>
<Cart />
<ModalRadix />
</div>
</div>
)
}
Результат. Демо модалка готова 🚀
Анатомия
Давайте разбираться как работать с Radix:
import * as Dialog from '@radix-ui/react-dialog'
export default () => (
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)- Читаем из каких составных частей
Dialog: Root- Содержит все части диалогаTrigger- Содержит все части диалогаPortal- При его использовании оверлей и части контента переносятся вdocument.bodyOverlay- Слой, закрывающий инертную часть вида, когда диалог открытContent- Содержит содержимое, которое будет отображаться в открытом диалогеClose- Кнопка, закрывающая диалогTitle- Доступный заголовок, который будет объявляться при открытии диалогаDescription- Необязательное доступное описание, которое будет объявляться при открытии диалога
Такой подход соответствует compound паттерну
Delete post modal
Удалим все лишнее и реализуем модалку для удаления поста
import s from './ModalRadix.module.css'
export const ModalRadix = () => (
<Dialog.Root>
<Dialog.Trigger asChild>
<button className={`${s.Button} violet`}>Delete post</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className={s.Overlay} />
<Dialog.Content className={s.Content}>
<Dialog.Title className={s.Title}>Delete post</Dialog.Title>
<hr />
<Dialog.Description className={s.Description}>
Are you sure you want to delete this post?
</Dialog.Description>
<Dialog.Close asChild>
<button className={`${s.Button} violet`}>Yes</button>
</Dialog.Close>
<Dialog.Close asChild>
<button className={`${s.Button} green`}>No</button>
</Dialog.Close>
<Dialog.Close asChild>
<button className={s.IconButton} aria-label="Close">
<Cross2Icon />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)Из ModalRadix.module.css удалите стили: Fieldset, Label, Input

Результат. Модалка для удаления поста почти готова 🚀
Универсальная модалка
Мы опять пришли к тому, что модалка у нас не универсальная. Нужно стремиться к тому чтобы так написать модалку, чтобы ее можно было переиспользовать в любом приложении, тогда она действительно будет универсальная. При первоначальном знакомстве трудно сразу же продумать все нюасны, которые могу встретиться в жизни, но стараемся продумать насколько у нас получается.
Это не самая простая задача, но попробуем сделать первые шаги для создания универсальной модалки.
Ярким представителем, куда вы можете заходить, вдохновляться и смотреть исходный код является shadcn
Давайте сразу будем мыслить рамками нашего приложения. Если посмотреть на модалки в фигме, то у всех модалок есть заголовок с иконкой закрытия, разделитель и различный контент.
-
Triggerу каждой модалки свой, давайте его уберем из модалки вообще, а для управления состоянием будем передавать пропс состоянияopenи функцию закрытия модалкиonClose -
titleу всех модалок разный, создадим для него тоже propsmodalTitle -
контент у всех модалок тоже разный. Для отображения контента будем использовать
children -
кнопка закрытия есть у всех модалок в нашем дизайне. И как правило считается хорошей
UX/UIпрактикой показывать кнопку закрытия, поэтому не будем здесь переусложнять
type Props = {
open: boolean
onClose: () => void
children: ReactNode
modalTitle: string
}
export const ModalRadix = ({ modalTitle, onClose, children, open }: Props) => (
<Dialog.Root open={open} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className={s.Overlay} />
<Dialog.Content className={s.Content}>
<Dialog.Title className={s.Title}>{modalTitle}</Dialog.Title>
<hr />
{children}
<Dialog.Close asChild>
<button className={s.IconButton} aria-label="Close">
<Cross2Icon />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)Ну и на основании ModalRadix создадим новый компонент DeletePostModal
type Props = {
open: boolean
onClose: () => void
}
export const DeletePostModal = ({ open, onClose }: Props) => {
return (
<ModalRadix open={open} onClose={onClose} modalTitle={'Delete Post'}>
Are you sure you want to delete this post?
<div>
{/*Будем использовать обычные кнопки*/}
<button onClick={onClose}>Yes</button>
<button onClick={onClose}>No</button>
</div>
</ModalRadix>
)
}Чтобы проверить работоспособность создадим компонент Posts, в котором и применим модалку
import { useState } from 'react'
import { DeletePostModal } from '../DeletePostModal/DeletePostModal.tsx'
import s from './Posts.module.css'
export const Posts = () => {
const [posts, setPosts] = useState([
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' },
{ id: 3, title: 'Post 3' },
])
const [showModal, setShowModal] = useState(false)
const openModalHandler = () => {
setShowModal(true)
}
const closeModalHandler = () => {
setShowModal(false)
}
return (
<>
<div className={s.container}>
<h2>Posts</h2>
{posts.map(post => {
return (
<div className={s.postContainer} key={post.id}>
<h3>{post.title}</h3>
<button onClick={openModalHandler}>х</button>
</div>
)
})}
</div>
<DeletePostModal open={showModal} onClose={closeModalHandler} />
</>
)
}.container {
margin: 100px 0 0 50px;
}
.postContainer {
display: flex;
align-items: center;
gap: 10px;
}Отрисуем Posts под Header
function App() {
return (
<>
<Header />
<Posts />
</>
)
}
Результат. При нажатии на иконку удаления модалка открывается с предупреждающим сообщением 🚀
Итоги
Теперь сравним 2 модалки
| Компонент | Modal | ModalRadix |
|---|---|---|
| Обработка клика за пределами модального окна | ❌ | ✅ |
Закрытие по нажатию клавиши Escape | ❌ | ✅ |
| Поддержка порталов | ✅ | ✅ |
Прием children для динамического содержимого | ✅ | ✅ |
Доступность (a11y) | ❌ | ✅ |
| Стилизация и кастомизация | ❌ | ❌✳️ |
| правление фокусом | ❌ | ✅ |
| Блокировка прокрутки | ❌ | ✅ |
✳️ - реализуем в следующем блоке