Урок 21. Теория: анатомия оверлея

📁 Раздел: shadcn/ui ⏱️ Время изучения: ~55 мин 🎯 Сложность: Продвинутая

⚡ Кратко

Любой оверлей — это связка «триггер + портал + подложка + всплывающий блок». Портал выносит содержимое в конец документа, поэтому оверлей не обрезается родителем и не воюет за z-index. Анимации привязаны к состояниям data-open и data-closed, которые выставляет примитив.

Анатомия оверлея

ЧастьРоль
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).
  • Позиционирование. Всплывающему блоку нужны координаты относительно окна, а не относительно прокручиваемого контейнера.

Что делает примитив, пока вы пишете классы

  1. переносит фокус внутрь окна при открытии;
  2. запирает Tab внутри окна;
  3. закрывает по Esc и по клику на подложку;
  4. возвращает фокус на триггер после закрытия;
  5. блокирует прокрутку страницы под окном;
  6. ставит role="dialog", aria-modal и связывает окно с заголовком;
  7. скрывает остальную страницу от скринридера.
Каждый пункт — это отдельный класс краевых случаев. Именно поэтому «свой модальный компонент на 30 строк» почти всегда оказывается недоступным.

Анимации по состояниям

Примитив выставляет на элементе атрибуты 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 (уведомления)
Правило: модальное окно прерывает работу пользователя. Используйте его только тогда, когда без ответа продолжать нельзя. Для всего остального есть меню, поповеры и уведомления.