Capítulo 47 de 108
Painel deslizante (bottom sheet ou lateral) com suporte a swipe/gestos, snap points e drawers aninhados. Desde a migração recente, usa Base UI Drawer em vez de Vaul.
"up" | "right" | "down" | "left" — substitui o antigo direction do Vaul (bottom→down, top→up).px/rem (ex. ["31rem", 1]).activeSnapPoint/setActiveSnapPoint do Vaul).disablePointerDismissal pra impedir fechar clicando fora; modal="trap-focus" mantém foco preso sem travar scroll/pointer.DrawerContent, que já compõe portal+overlay+viewport+popup).<Drawer
open={open}
onOpenChange={setOpen}
showSwipeHandle={isMobile}
swipeDirection={isMobile ? "down" : "right"}
>
<DrawerTrigger render={<Button variant="secondary" />}>Open Drawer</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Pick a delivery time</DrawerTitle>
<DrawerDescription>...</DrawerDescription>
</DrawerHeader>
<div className="flex-1 scroll-fade overflow-y-auto p-4">{/* conteúdo */}</div>
<DrawerFooter>
<Button onClick={handleConfirm}>Confirm Delivery Time</Button>
<DrawerClose render={<Button variant="outline" />}>Cancel</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
swipeDirection condicional por dispositivo (bottom sheet no mobile, side drawer no desktop) usando useIsMobile."use client"
import { useMediaQuery } from "@/hooks/use-media-query"
if (isDesktop) {
return <Dialog>...</Dialog>
}
return <Drawer>...</Drawer>
Dialog no desktop (min-width: 768px), Drawer no mobile, reaproveitando o mesmo form (ProfileForm).Composição:
Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
└── DrawerFooter
Variáveis CSS (setar em DrawerContent, exceto overlay):
| Variable | Default | Description |
|---|---|---|
--drawer-inset | 0px | Distância do drawer às bordas do viewport |
--drawer-bleed-background | var(--color-popover) | Preenche o vão atrás do drawer no overshoot do swipe |
--drawer-overlay-min-opacity | 0 | Opacidade mínima do overlay (padrão 0.5 com snap points ativos) |
Data attributes:
| Attribute | Values | Quando |
|---|---|---|
data-swipe-direction | up, right, down, left | Sempre |
data-swipe-axis | x, y | Sempre |
data-snap-points | presente | Drawer tem snap points |
data-expanded | presente | Drawer no snap point máximo |
data-swiping | presente | Swipe em progresso |
data-nested-drawer-open | presente | Drawer aninhado aberto por cima |
Migração Vaul → Base UI (resumo):
| Vaul | Base UI |
|---|---|
npm install vaul | npm install @base-ui/react |
direction="bottom"/"top" | swipeDirection="down"/"up" (left/right iguais) |
asChild | render={<Elemento />} |
activeSnapPoint/setActiveSnapPoint | snapPoint/onSnapPointChange |
snapToSequentialPoint | snapToSequentialPoints |
onAnimationEnd | onOpenChangeComplete |
onOpenAutoFocus={preventDefault} | initialFocus={false} |
dismissible={false} | disablePointerDismissal |
data-vaul-drawer-direction | data-swipe-direction |
handleOnly, repositionInputs, shouldScaleBackground | sem equivalente direto — usar modal, snapPoints, open controlado |
h-full numa região que deveria rolar: não resolve dentro de um drawer content-sized; use flex-1 overflow-y-auto na div de conteúdo, com header/footer fora dela.handleOnly etc.) não têm equivalente 1:1; recriar comportamento com props Base UI.direction→swipeDirection, asChild→render, e renomear props de snap point.snapPoints só se aplica a drawers verticais (up/down); aceita frações (0-1), pixels (>1) ou strings com unidade.flex-1 overflow-y-auto, nunca h-full.data-nested-drawer-open.useMediaQuery.body { position: relative; } nos estilos globais para o overlay cobrir a viewport corretamente após scroll.