У ранніх поколіннях фронтенд-розробки домінували монолітні компонентні бібліотеки (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), знижує Interaction to Next Paint (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.