Анатомия оверлея
| Часть | Роль |
|---|---|
Dialog (корень) | хранит состояние «открыт / закрыт» |
DialogTrigger | элемент, открывающий диалог; получает aria-haspopup и связь с окном |
DialogPortal | переносит содержимое в конец <body> |
DialogOverlay (Backdrop) | затемнение; клик по нему закрывает окно |
DialogContent (Popup) | само окно: фокус-ловушка, Esc, ARIA |
DialogTitle / DialogDescription | заголовок и описание; связываются с окном через ARIA |
DialogClose | любой элемент, закрывающий окно |
// src/components/delete-report-dialog.tsx
import {
Dialog, DialogClose, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
export function DeleteReportDialog() {
return (
<Dialog>
<DialogTrigger render={<Button variant="destructive">Удалить отчёт</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Удалить отчёт?</DialogTitle>
<DialogDescription>
Действие нельзя отменить. Отчёт и его выгрузки будут удалены.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="ghost">Отмена</Button>} />
<Button variant="destructive" onClick={handleDelete}>Удалить</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Портал — как служебный лифт в здании: он доставляет груз прямо на
крышу, минуя все этажи и их узкие коридоры. Если тащить диван по
лестнице (то есть рисовать модальное окно внутри карточки), он застрянет
на первом же повороте — overflow: hidden у родителя.
Зачем нужен портал
Три конкретные проблемы, которые он решает:
- Обрезка. Родитель с
overflow-hiddenобрежет выпадающее меню. - Контекст наложения.
transformилиopacityу предка запираютz-indexпотомков — окно окажется под шапкой (урок 10). - Позиционирование. Всплывающему блоку нужны координаты относительно окна, а не относительно прокручиваемого контейнера.
Что делает примитив, пока вы пишете классы
- переносит фокус внутрь окна при открытии;
- запирает Tab внутри окна;
- закрывает по Esc и по клику на подложку;
- возвращает фокус на триггер после закрытия;
- блокирует прокрутку страницы под окном;
- ставит
role="dialog",aria-modalи связывает окно с заголовком; - скрывает остальную страницу от скринридера.
Анимации по состояниям
Примитив выставляет на элементе атрибуты data-open
и data-closed. Оформление привязывается к ним:
// src/components/ui/dialog.tsx — фрагмент
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 \
supports-backdrop-filter:backdrop-blur-xs \
data-open:animate-in data-open:fade-in-0 \
data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({ className, children, showCloseButton = true, ...props }: DialogPrimitive.Popup.Props) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] \
-translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 \
text-sm text-popover-foreground ring-1 ring-foreground/10 sm:max-w-sm \
data-open:animate-in data-open:zoom-in-95 \
data-closed:animate-out data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
Классы animate-in, fade-in-0,
zoom-in-95, slide-in-from-top-2 приходят
из пакета tw-animate-css, который ставится вместе
с shadcn/ui. Приятная деталь: data-closed позволяет
анимировать и закрытие — примитив дожидается конца анимации,
прежде чем убрать элемент из DOM.
Удалить отчёт?
Действие нельзя отменить.
<div class="relative h-56 overflow-hidden rounded-xl bg-muted">
<div class="p-4 text-sm text-muted-foreground">Страница под окном</div>
<div class="absolute inset-0 z-40 bg-black/10 backdrop-blur-xs"></div>
<div class="absolute top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10">
<div class="grid gap-1.5">
<h3 class="text-base leading-snug font-medium">Удалить отчёт?</h3>
<p class="text-sm text-muted-foreground">Действие нельзя отменить.</p>
</div>
<div class="flex justify-end gap-2">
<button class="inline-flex h-8 items-center justify-center rounded-lg px-3 text-sm font-medium hover:bg-muted">Отмена</button>
<button class="inline-flex h-8 items-center justify-center rounded-lg bg-destructive/10 px-3 text-sm font-medium text-destructive hover:bg-destructive/20">Удалить</button>
</div>
</div>
</div>
Sheet: та же механика сбоку
Sheet — тот же диалог, только выезжающий с края экрана.
Применяется для мобильной навигации, фильтров и панелей редактирования.
<div class="relative h-56 overflow-hidden rounded-xl bg-muted">
<div class="p-4 text-sm text-muted-foreground">Содержимое страницы</div>
<div class="absolute inset-0 z-40 bg-black/10"></div>
<aside class="absolute inset-y-0 right-0 z-50 flex w-64 flex-col gap-4 bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10">
<div class="grid gap-1">
<h3 class="text-base font-medium">Фильтры</h3>
<p class="text-sm text-muted-foreground">Уточните выборку</p>
</div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" checked class="size-4 accent-primary"> Только активные</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="size-4 accent-primary"> С вложениями</label>
<button class="mt-auto inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground">Применить</button>
</aside>
</div>
Меню действий
DropdownMenu добавляет к механике оверлея навигацию
стрелками, подсветку активного пункта, поиск по первым буквам и
закрытие при выборе.
<div class="w-56 rounded-xl bg-popover p-1 text-sm text-popover-foreground ring-1 ring-foreground/10">
<div class="px-2 py-1.5 text-xs text-muted-foreground">Действия</div>
<button class="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-muted">Открыть <span class="text-xs text-muted-foreground">↵</span></button>
<button class="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-muted">Переименовать</button>
<button class="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-muted">Дублировать</button>
<div class="my-1 h-px bg-border"></div>
<button class="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left text-destructive hover:bg-destructive/10">Удалить <span class="text-xs opacity-60">⌫</span></button>
</div>
Вкладки
Tabs — не оверлей, но такой же «поведенческий» компонент:
стрелки переключают вкладки, активная связана с панелью через
aria-controls, панели скрываются корректно для скринридера.
<div class="w-full max-w-md">
<div class="inline-flex h-8 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground">
<button class="inline-flex h-full items-center justify-center rounded-md bg-background px-3 text-sm font-medium text-foreground shadow-sm">Обзор</button>
<button class="inline-flex h-full items-center justify-center rounded-md px-3 text-sm font-medium">Аналитика</button>
<button class="inline-flex h-full items-center justify-center rounded-md px-3 text-sm font-medium">Отчёты</button>
</div>
<div class="mt-3 rounded-lg bg-card p-4 text-sm text-card-foreground ring-1 ring-border">
Содержимое вкладки «Обзор».
</div>
</div>
Когда какой компонент
| Задача | Компонент |
|---|---|
| Подтвердить опасное действие | AlertDialog (или Dialog) |
| Короткая форма поверх страницы | Dialog |
| Панель фильтров, мобильное меню | Sheet |
| Действия над элементом списка | DropdownMenu |
| Выбор значения | Select |
| Пояснение к элементу | Tooltip |
| Дополнительная информация по клику | Popover |
| Разделы одного экрана | Tabs |
| Сообщение о результате действия | Sonner (уведомления) |