В ранних поколениях фронтенд-разработки доминировали монолитные компонентные библиотеки (Material UI, Ant Design, Chakra UI). Они объединяли в единый неделимый пакет бизнес-логику, разметку доступности (WAI-ARIA) и жестко преднастроенные стили. В масштабах enterprise-продуктов это неизбежно приводило к конфликтам CSS-специфичности, раздуванию бандла рантаймами CSS-in-JS (Emotion, styled-components) и невозможности быстрой адаптации под корпоративные бренд-гайды.
Современный фронтенд опирается на трехслойную компонентную архитектуру:
@theme в Tailwind CSS v4.Разделение компонентов на логическое ядро и презентационный слой решает четыре фундаментальные проблемы разработки:
<style> на лету во время выполнения кода, что создавало задержки блокировки главного потока (Total Blocking Time, TBT) и усложняло серверный рендеринг (SSR). Headless-стек с Tailwind v4 генерирует статический CSS на этапе сборки без runtime-нагрузки.Escape, блокировки скролла подложки (body-scroll-lock), связывания aria-labelledby и возврата фокуса на исходный элемент при закрытии. Написание этой логики с нуля отнимает сотни часов и изобилует багами.@radix-ui/react-dialog или @base-ui/react) может использоваться в разных проектах компании с кардинально различающимся дизайном.В парадигме Headless UI компоненты общаются с системой стилей через состояние данных в DOM (Data Attributes). Вместо динамической генерации специфических CSS-классов вроде .is-open или .has-focus, безголовый примитив автоматически транслирует состояние своего внутреннего автомата (State Machine) в декларативные HTML-атрибуты: data-state="open", data-disabled, data-orientation="vertical", data-side="bottom".
Tailwind CSS v4 с поддержкой селекторов вариантов данных (data-[state=open]:...) позволяет описывать анимации и стили состояний декларативно прямо в верстке.
Определение палитры цветов, шкалы отступов, радиусов и шрифтов через директиву @theme в Tailwind CSS v4.
Интеграция Radix Primitives или Base UI для делегирования логики состояний, навигации клавишами и WAI-ARIA.
Оборачивание примитивов в экспортируемые компоненты с безопасным слиянием классов (twMerge + clsx) и CVA.
Привязка анимаций появления/скрытия и состояний к атрибутам data-[state=open] и data-[disabled].
Почему в современной архитектуре дизайн-систем стили состояний (открытие, закрытие, отключение) привязываются к data-атрибутам (data-state), а не к динамическим классам (.is-active)?
Один и тот же пакет логики компонентов используется несколькими брендами компании. Бренд «A» использует закругленные неоновые кнопки с градиентами, Бренд «Б» — строгий минималистичный монохром. При этом 100% логики доступности, фокуса и клавиатурных шорткатов переиспользуются без единой строчки дублирования.
Полный отказ от рантайма CSS-in-JS (Emotion, styled-components) в пользу связки Radix UI + Tailwind v4 устраняет задержки гидратации (Total Blocking Time, TBT), снижает First Input Delay / INP и сокращает размер клиентского JS-бандла более чем на 40%.
Обеспечение безошибочного соблюдения контрактов доступности WCAG 2.2: удержание фокуса внутри модального окна (Focus Trap), возврат фокуса на вызвавший триггер при закрытии, закрытие по нажатию клавиши Escape и блокировка скролла страницы (body-scroll-lock).
Архитектура современной дизайн-системы представляет собой слоеный конвейер: от дизайн-токенов в CSS до чистого доступного DOM-дерева:
Токены цветов, отступов и теней объявляются через директиву @theme в глобальном CSS, генерируя нативные CSS-переменные.
Radix управляет порталом, Focus Trap, scroll-lock и синхронизирует состояние открытости с атрибутом data-state='open'|'closed'.
Утилита CVA вычисляет итоговый набор классов Tailwind на основе пропсов variant, size и data-селекторов.
Рендеринг чистого DOM-узла с прозрачной интеграцией клавиатурного управления и скринридер-атрибутов.
Почему при создании анимированных модальных окон на Radix UI категорически нельзя использовать ручной условный рендеринг вроде {isOpen && <DialogContent />}?
Tailwind CSS v4 полностью отказывается от конфигуратора tailwind.config.js в пользу нативной директивы @theme в файле стилей:
@import "tailwindcss";
@theme {
/* Цветовая палитра дизайн-системы */
--color-bg-base: #0a0a0c;
--color-bg-surface: #121118;
--color-bg-elevated: #1a1824;
--color-border: #272535;
--color-border-hover: #3d3a52;
--color-text-primary: #f4f4f6;
--color-text-secondary: #9896a6;
--color-text-muted: #5d5b6d;
--color-accent-violet: #8b5cf6;
--color-accent-violet-glow: rgba(139, 92, 246, 0.15);
/* Шрифтовые гарнитуры */
--font-sans: var(--font-inter), system-ui, sans-serif;
--font-mono: var(--font-jetbrains-mono), monospace;
/* Радиусы скругления */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-full: 9999px;
/* Кастомные анимации появления модальных окон */
--animate-fade-in: fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-fade-out: fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-scale-in: scaleIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-scale-out: scaleOut 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes scaleIn {
from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes scaleOut {
from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
to { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
}Создадим законченный модуль модального диалога, объединяющий Radix UI Primitives, безопасное слияние стилей через clsx + tailwind-merge и анимации по data-state:
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export 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;
// ─── Backdrop (Overlay) ───────────────────────────────────────────
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/70 backdrop-blur-sm',
// Стилизация состояний через data-атрибуты Radix
'data-[state=open]:animate-fade-in',
'data-[state=closed]:animate-fade-out',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
// ─── Modal Content Panel ──────────────────────────────────────────
export interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
showCloseButton?: boolean;
}
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
// Позиционирование по центру экрана
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
'p-6 rounded-2xl bg-bg-surface border border-border shadow-2xl shadow-accent-violet-glow',
'focus:outline-none',
// Анимации раскрытия и закрытия по data-state
'data-[state=open]:animate-scale-in',
'data-[state=closed]:animate-scale-out',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 p-1.5 rounded-lg text-text-muted transition-colors',
'hover:text-text-primary hover:bg-bg-elevated focus:outline-none focus:ring-2 focus:ring-accent-violet'
)}
aria-label="Закрыть модальное окно"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
// ─── Вспомогательные семантические блоки ──────────────────────────
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 text-left mb-4', className)} {...props} />;
}
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-xl font-bold text-text-primary tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-text-secondary leading-relaxed', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 gap-2 mt-6', className)}
{...props}
/>
);
}'use client';
import * as React from 'react';
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
interface ConfirmDeleteModalProps {
itemName: string;
onConfirm: () => Promise<void>;
triggerButton?: React.ReactNode;
}
export function ConfirmDeleteModal({
itemName,
onConfirm,
triggerButton,
}: ConfirmDeleteModalProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [isPending, setIsPending] = React.useState(false);
const handleConfirm = async () => {
try {
setIsPending(true);
await onConfirm();
setIsOpen(false);
} finally {
setIsPending(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{triggerButton || (
<button
type="button"
className="px-3 py-1.5 text-sm font-medium text-red-400 bg-red-950/40 border border-red-900/50 rounded-lg hover:bg-red-900/40 transition-colors cursor-pointer"
>
Удалить ресурс
</button>
)}
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Подтверждение удаления</DialogTitle>
<DialogDescription>
Вы собираетесь безвозвратно удалить <strong className="text-text-primary">{itemName}</strong>.
Это действие приведет к удалению всех связанных записей и не может быть отменено.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<button
type="button"
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-text-secondary bg-bg-elevated hover:text-text-primary rounded-lg transition-colors cursor-pointer"
>
Отмена
</button>
</DialogClose>
<button
type="button"
onClick={handleConfirm}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors inline-flex items-center gap-2 cursor-pointer"
>
{isPending ? 'Удаление...' : 'Да, удалить'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Разрешение продуктовым инженерам импортировать @radix-ui/react-dialog напрямую в страницы приводит к фрагментации UI: каждый разработчик пишет собственные стили Tailwind, забывает оверлеи или ломает анимации.
// ❌ АНТИПАТТЕРН: Прямой импорт сырой headless-библиотеки на странице
import * as RadixDialog from '@radix-ui/react-dialog';
export function Page() {
// Нарушение DRY: хаотичные стили на каждой странице
return (
<RadixDialog.Root>
<RadixDialog.Content className="p-4 bg-white text-black">...</RadixDialog.Content>
</RadixDialog.Root>
);
}
// ✅ ПРАВИЛЬНО: Использование инкапсулированного слоя дизайн-системы
import { Dialog, DialogContent } from '@/components/ui/dialog';Если монтировать и размонтировать DialogContent через наивный флаг {isOpen && <Modal />}, анимация исчезновения (fadeOut / scaleOut) мгновенно прервется, так как элемент удалится из DOM до завершения кадров анимации. Radix UI управляет жизненным циклом и ждет завершения анимации через animationend по атрибуту data-state="closed". Не используйте ручной условный рендеринг вокруг DialogContent.
Спецификация WAI-ARIA требует, чтобы каждый доступный диалог имел aria-labelledby, указывающий на заголовок модального окна. Если внутри DialogContent не отрендерить DialogTitle, Radix UI выдаст предупреждение в консоли разработчика, а скринридер не сможет объявить незрячему пользователю цель открывшегося окна.
Использование произвольных шестнадцатеричных цветов (bg-[#121118]) прямо в компонентах уничтожает гибкость дизайн-системы. При смене темы или ребрендинге придется вручную править сотни файлов. Все цвета обязаны резолвиться через токены Tailwind v4 @theme.