{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "bottom-drawer", "type": "registry:ui", "title": "BottomDrawer", "description": "Bottom sheet on mobile, centered dialog on desktop. Swipe, backdrop, Escape, and hardware Back all dismiss it.", "categories": [ "overlays" ], "registryDependencies": [ "https://whiskeyjack.net/r/scroll-indicator.json", "https://whiskeyjack.net/r/use-focus-trap.json", "https://whiskeyjack.net/r/utils.json" ], "files": [ { "path": "components/ui/bottom-drawer.tsx", "type": "registry:ui", "target": "components/ui/bottom-drawer.tsx", "content": "import * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport { useFocusTrap } from \"@/hooks/use-focus-trap\";\nimport { ScrollIndicator } from \"@/components/ui/scroll-indicator\";\n\nexport interface BottomDrawerProps {\n /**\n * Whether the drawer is open\n */\n open: boolean;\n /**\n * Callback when the drawer should close (backdrop click, Escape key, swipe\n * down, or hardware/browser Back). For deferred-edit forms this is\n * \"cancel/discard\" -- the footer's presence sets the expectation that nothing\n * applies until Save.\n */\n onClose: () => void;\n /**\n * Optional title shown in the sticky frosted header. Scrollable content\n * passes behind it.\n */\n title?: string;\n /**\n * Optional sticky frosted action bar pinned to the bottom of the sheet,\n * mirroring the header. Lay actions out as `DrawerAction`s in a row, most-\n * destructive leftmost (e.g. `Delete | Reset | Cancel | Save`); the footer\n * spreads them evenly. Content scrolls behind it.\n */\n footer?: React.ReactNode;\n /**\n * Where focus lands when the drawer opens. `'first'` (default) focuses the\n * first focusable child; `'container'` focuses the sheet itself so a form\n * drawer does NOT auto-focus its first text field and pop the mobile keyboard\n * (which obscures the form). Prefer `'container'` for drawers with several\n * fields/options. The drawer stays fully keyboard-navigable either way.\n */\n initialFocus?: \"first\" | \"container\";\n /**\n * Forces the presentation instead of the default viewport-responsive switch.\n * `'auto'` (default) is a bottom sheet below `md` and a centered dialog at\n * `md`+. `'sheet'` and `'dialog'` pin one form at every width -- for a demo or\n * a surface that should not follow the viewport. Regular apps leave this\n * unset.\n */\n presentation?: \"auto\" | \"sheet\" | \"dialog\";\n /**\n * Portal target for the overlay. Defaults to `document.body` (the drawer\n * covers the viewport). Pass an element that establishes a containing block\n * (e.g. `transform`/`contain: paint`) to scope the drawer to it instead -- the\n * `fixed inset-0` overlay then fills that element rather than the page. Used\n * by demos that render the drawer inside a device canvas.\n */\n container?: Element | DocumentFragment | null;\n children: React.ReactNode;\n}\n\n// Movement below this is treated as a tap, not a drag (px).\nconst DRAG_SLOP = 10;\n// Drag distance that dismisses the sheet on release (px).\nconst DISMISS_DISTANCE = 120;\n// Release velocity that dismisses regardless of distance (px/ms).\nconst DISMISS_VELOCITY = 0.5;\n\n// Frosted surface used by both sticky bars -- translucent surface + blur so\n// content is legibly visible behind them (matches the BottomNav recipe but\n// surface-tinted rather than background-tinted).\nconst FROSTED =\n \"backdrop-blur-lg bg-[color-mix(in_srgb,var(--color-surface-light)_85%,transparent)] dark:bg-[color-mix(in_srgb,var(--color-surface-dark)_85%,transparent)]\";\nconst DIVIDER = \"border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]\";\n\n/**\n * A responsive drawer component.\n *\n * - Mobile: slides up as a bottom sheet with a drag handle; swiping the sheet\n * down dismisses it (only when its content is scrolled to the top, so the\n * gesture never fights an inner scroll)\n * - Desktop (md+): centered dialog with scale animation\n *\n * The card itself scrolls; an optional `title` header and `footer` action bar\n * are sticky and frosted, so content passes behind both. Use the `footer` slot\n * for modal actions (`DrawerAction`s) instead of placing buttons in content.\n *\n * @example\n * ```tsx\n * setIsOpen(false)}\n * title=\"Add Reading\"\n * footer={\n * <>\n * } label=\"Cancel\" onClick={() => setIsOpen(false)} />\n * } label=\"Save\" type=\"submit\" form=\"add-reading\" disabled={!dirty} />\n * \n * }\n * >\n *
...
\n * \n * ```\n */\nexport function BottomDrawer({ open, onClose, title, footer, initialFocus = \"first\", presentation = \"auto\", container, children }: BottomDrawerProps) {\n // Responsive-vs-forced presentation. `auto` keeps the original viewport `md:`\n // switch verbatim (so existing consumers are byte-identical); `sheet`/`dialog`\n // pin one form at every width.\n const sheet = presentation === \"sheet\";\n const dialog = presentation === \"dialog\";\n const cls = {\n // Overlay flex alignment: sheet pins to the bottom, dialog centers.\n overlay: dialog\n ? \"flex-row items-center justify-center\"\n : sheet\n ? \"flex-col justify-end\"\n : \"flex-col justify-end md:flex-row md:items-center md:justify-center\",\n // Sheet wrapper width: dialog is a capped centered card, sheet is full width.\n wrapperWidth: dialog ? \"max-w-2xl mx-4\" : sheet ? \"\" : \"md:max-w-2xl md:mx-4\",\n // Open/closed transform: sheet slides from the bottom, dialog scales in.\n wrapperOpen: dialog ? \"scale-100\" : sheet ? \"translate-y-0\" : \"translate-y-0 md:scale-100\",\n wrapperClosed: dialog\n ? \"scale-95 opacity-0\"\n : sheet\n ? \"translate-y-full\"\n : \"translate-y-full md:translate-y-0 md:scale-95 md:opacity-0\",\n // Card corners + border: dialog is fully rounded/bordered, sheet is top-only.\n cardShape: dialog ? \"rounded-2xl border\" : sheet ? \"rounded-t-2xl border-t\" : \"rounded-t-2xl md:rounded-2xl border-t md:border\",\n // The drag handle is a sheet affordance; hide it in dialog form.\n handle: dialog ? \"hidden\" : sheet ? \"flex\" : \"flex md:hidden\",\n };\n // The card is the scroll container; sticky header/footer pin within it.\n const scrollRef = React.useRef(null);\n const titleId = React.useId();\n const [dragY, setDragY] = React.useState(0);\n\n // The custom scroll indicator's track must start below the sticky header\n // and end above the sticky footer (the native scrollbar - now hidden - ran\n // behind both). Their heights vary with title presence, footer content, and\n // safe-area padding, so measure them.\n const headerRef = React.useRef(null);\n const footerRef = React.useRef(null);\n const [chromeOffsets, setChromeOffsets] = React.useState({ top: 8, bottom: 8 });\n const hasFooter = !!footer;\n\n React.useLayoutEffect(() => {\n const measure = () => {\n setChromeOffsets({\n top: (headerRef.current?.offsetHeight ?? 0) + 4,\n bottom: (footerRef.current?.offsetHeight ?? 0) + 4,\n });\n };\n measure();\n const ro = new ResizeObserver(measure);\n if (headerRef.current) ro.observe(headerRef.current);\n if (footerRef.current) ro.observe(footerRef.current);\n return () => ro.disconnect();\n }, [open, title, hasFooter]);\n\n // Trap focus inside the card while open; restores trigger focus on close.\n useFocusTrap(scrollRef, open, { initialFocus });\n const dragState = React.useRef<{\n startY: number;\n lastY: number;\n lastT: number;\n velocity: number;\n dragging: boolean;\n } | null>(null);\n\n React.useEffect(() => {\n if (!open) return;\n\n const handleKeyDown = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [open, onClose]);\n\n // Keep the latest onClose without re-running the history effect every render\n // (it must depend only on `open`).\n const onCloseRef = React.useRef(onClose);\n onCloseRef.current = onClose;\n\n // Hardware / browser Back closes the drawer instead of navigating the app.\n // While open, push a throwaway history entry; Back (Android maps the hardware\n // button to webview history-back) pops it and fires popstate, which we treat\n // as a dismiss. If the drawer is closed another way (swipe, backdrop, a\n // footer action), we pop our own entry so Back isn't left on a phantom state.\n // Safe with react-router: pushState doesn't fire popstate, and Back lands on\n // the original (untouched) entry, so the router's location never changes here.\n React.useEffect(() => {\n if (!open) return;\n window.history.pushState({ ...window.history.state, __wjDrawer: true }, \"\");\n const onPopState = () => onCloseRef.current();\n window.addEventListener(\"popstate\", onPopState);\n return () => {\n window.removeEventListener(\"popstate\", onPopState);\n if (window.history.state?.__wjDrawer) {\n window.history.back();\n }\n };\n }, [open]);\n\n React.useEffect(() => {\n if (open) {\n document.body.style.overflow = \"hidden\";\n } else {\n document.body.style.overflow = \"\";\n }\n // Reset any in-progress drag whenever the drawer opens or closes so the\n // open/close transitions start from a clean transform.\n setDragY(0);\n dragState.current = null;\n return () => {\n document.body.style.overflow = \"\";\n };\n }, [open]);\n\n const handleTouchStart = (e: React.TouchEvent) => {\n dragState.current = {\n startY: e.touches[0].clientY,\n lastY: e.touches[0].clientY,\n lastT: performance.now(),\n velocity: 0,\n dragging: false,\n };\n };\n\n // A downward drag that starts inside a nested scroll region should scroll that\n // region, not dismiss the sheet. Walk from the touch target up to the card and\n // report whether any scrollable ancestor sits scrolled away from its own top.\n // Without this, swiping down to scroll back up inside an inner scroll box (e.g.\n // the goal icon picker's expanded \"more\" grid) would fight the swipe-to-dismiss\n // gesture, since the card itself is still at scrollTop 0.\n const nestedScrollerNotAtTop = (target: EventTarget | null): boolean => {\n let el = target instanceof HTMLElement ? target : null;\n const card = scrollRef.current;\n while (el && el !== card) {\n if (el.scrollTop > 0) {\n const overflowY = getComputedStyle(el).overflowY;\n if (overflowY === \"auto\" || overflowY === \"scroll\") return true;\n }\n el = el.parentElement;\n }\n return false;\n };\n\n const handleTouchMove = (e: React.TouchEvent) => {\n const s = dragState.current;\n if (!s) return;\n const y = e.touches[0].clientY;\n const dy = y - s.startY;\n\n if (!s.dragging) {\n // Upward movement, the card not at its scroll top, or a nested scroller\n // not at ITS top: this is a scroll, not a dismiss gesture -- hand the\n // touch back so the inner content scrolls.\n if (\n dy < -DRAG_SLOP ||\n (scrollRef.current?.scrollTop ?? 0) > 0 ||\n nestedScrollerNotAtTop(e.target)\n ) {\n dragState.current = null;\n return;\n }\n if (dy < DRAG_SLOP) return; // within slop: could still be a tap\n s.dragging = true;\n }\n\n const now = performance.now();\n s.velocity = (y - s.lastY) / Math.max(1, now - s.lastT);\n s.lastY = y;\n s.lastT = now;\n setDragY(Math.max(0, dy));\n };\n\n const handleTouchEnd = () => {\n const s = dragState.current;\n dragState.current = null;\n if (!s?.dragging) return;\n if (dragY > DISMISS_DISTANCE || s.velocity > DISMISS_VELOCITY) {\n onClose();\n }\n setDragY(0);\n };\n\n // No DOM to portal into during server rendering. An overlay has no meaningful\n // server output anyway -- it is chrome that opens in response to interaction,\n // and the client renders it on mount. Without this guard the component reads\n // `document.body` during render and throws in any prerender / SSG pass.\n if (typeof document === \"undefined\") return null;\n\n // Portal to so the fixed overlay escapes any transformed / will-change\n // ancestor (e.g. a swipe-navigation content wrapper). A `position: fixed`\n // element inside such an ancestor is positioned relative to IT, not the\n // viewport, which clips/breaks the drawer -- so callers can mount the drawer\n // anywhere in the tree, including inside swipeable tab content.\n return createPortal(\n \n {/* Backdrop */}\n \n\n {/* Sheet wrapper -- bottom sheet on mobile, centered dialog on desktop.\n Carries the layout, open/close transition, and the swipe-down drag\n transform; the card inside is the scroll container and the custom\n scroll indicator sits beside it (outside the scroller, so it stays\n pinned while content scrolls). */}\n 0\n ? { transform: `translateY(${dragY}px)`, transition: \"none\" }\n : undefined\n }\n // When closed: hidden from AT and not reachable by keyboard. `inert` is\n // an HTML boolean attribute enabled by its presence; pass the empty\n // string (not `true`) so React 18 emits `inert` without warning.\n {...(!open ? { inert: \"\", \"aria-hidden\": \"true\" } : {})}\n className={cn(\n \"relative z-10\",\n \"flex flex-col max-h-[85dvh]\",\n \"w-full\",\n cls.wrapperWidth,\n \"transition-all duration-300 ease-out\",\n open ? cls.wrapperOpen : cls.wrapperClosed\n )}\n >\n {/* Drawer card -- the scroll container; the sticky header/footer pin\n within it and content scrolls behind them. Its native scrollbar is\n hidden in favour of the bounded indicator. */}\n \n {/* Sticky frosted header: drag handle (mobile) + optional title.\n Frosted only -- no divider; content passes behind it. */}\n
\n
\n
\n
\n {title && (\n
\n \n {title}\n \n
\n )}\n
\n\n {/* Scrollable content -- extra bottom padding when a footer pill floats\n over it so the last item clears the pill. */}\n
\n {children}\n
\n\n {/* Sticky frosted footer bar: full-width with a top divider, content\n scrolls behind it. The actions row matches the app's bottom-nav\n height; safe-area padding sits below the row. */}\n {footer && (\n \n
\n {footer}\n
\n
\n )}\n \n\n {/* Custom scroll indicator, bounded between the sticky header and\n footer. Shown at every size: the card hides its native scrollbar\n everywhere, unlike page scrollers which keep the platform overlay\n scrollbar on mobile. */}\n \n \n ,\n container ?? document.body,\n );\n}\n" } ], "docs": "BottomDrawer is for interaction: adding, editing, confirming. Read-only display content (stats, charts, detail views) belongs in the app's main chrome instead. Put actions in the sticky `footer` as DrawerActions, keep field edits deferred until Save, and treat every dismissal as a free cancel. Pass initialFocus=\"container\" for multi-field forms so opening it does not pop the mobile keyboard. It portals to body, so it is safe to mount inside a transformed ancestor.", "meta": { "group": "overlays", "related": [ "drawer-action", "scroll-indicator", "use-focus-trap" ], "exports": [ "BottomDrawer" ], "siteSlug": "bottom-drawer" } }