whiskeyjack
uibottom-drawersource: ok · hit
BottomDrawer
Bottom sheet on mobile, centered dialog on desktop. Swipe, backdrop, Escape, and hardware Back all dismiss it.
npx shadcn add "https://whiskeyjack.net/r/bottom-drawer.json"free
Interaction
modal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
https://whiskeyjack.net/r/scroll-indicator.jsonhttps://whiskeyjack.net/r/use-focus-trap.jsonhttps://whiskeyjack.net/r/utils.json
Files1 · source cached 10/9/2026, 6:06:16 PM
components/ui/bottom-drawer.tsx
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { ScrollIndicator } from "@/components/ui/scroll-indicator";
export interface BottomDrawerProps {
/**
* Whether the drawer is open
*/
open: boolean;
/**
* Callback when the drawer should close (backdrop click, Escape key, swipe
* down, or hardware/browser Back). For deferred-edit forms this is
* "cancel/discard" -- the footer's presence sets the expectation that nothing
* applies until Save.
*/
onClose: () => void;
/**
* Optional title shown in the sticky frosted header. Scrollable content
* passes behind it.
*/
title?: string;
/**
* Optional sticky frosted action bar pinned to the bottom of the sheet,
* mirroring the header. Lay actions out as `DrawerAction`s in a row, most-
* destructive leftmost (e.g. `Delete | Reset | Cancel | Save`); the footer
* spreads them evenly. Content scrolls behind it.
*/
footer?: React.ReactNode;
/**
* Where focus lands when the drawer opens. `'first'` (default) focuses the
* first focusable child; `'container'` focuses the sheet itself so a form
* drawer does NOT auto-focus its first text field and pop the mobile keyboard
* (which obscures the form). Prefer `'container'` for drawers with several
* fields/options. The drawer stays fully keyboard-navigable either way.
*/
initialFocus?: "first" | "container";
/**
* Forces the presentation instead of the default viewport-responsive switch.
* `'auto'` (default) is a bottom sheet below `md` and a centered dialog at
* `md`+. `'sheet'` and `'dialog'` pin one form at every width -- for a demo or
* a surface that should not follow the viewport. Regular apps leave this
* unset.
*/
presentation?: "auto" | "sheet" | "dialog";
/**
* Portal target for the overlay. Defaults to `document.body` (the drawer
* covers the viewport). Pass an element that establishes a containing block
* (e.g. `transform`/`contain: paint`) to scope the drawer to it instead -- the
* `fixed inset-0` overlay then fills that element rather than the page. Used
* by demos that render the drawer inside a device canvas.
*/
container?: Element | DocumentFragment | null;
children: React.ReactNode;
}
// Movement below this is treated as a tap, not a drag (px).
const DRAG_SLOP = 10;
// Drag distance that dismisses the sheet on release (px).
const DISMISS_DISTANCE = 120;
// Release velocity that dismisses regardless of distance (px/ms).
const DISMISS_VELOCITY = 0.5;
// Frosted surface used by both sticky bars -- translucent surface + blur so
// content is legibly visible behind them (matches the BottomNav recipe but
// surface-tinted rather than background-tinted).
const FROSTED =
"wj-frost";
const DIVIDER = "border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]";
/**
* A responsive drawer component.
*
* - Mobile: slides up as a bottom sheet with a drag handle; swiping the sheet
* down dismisses it (only when its content is scrolled to the top, so the
* gesture never fights an inner scroll)
* - Desktop (md+): centered dialog with scale animation
*
* The card itself scrolls; an optional `title` header and `footer` action bar
* are sticky and frosted, so content passes behind both. Use the `footer` slot
* for modal actions (`DrawerAction`s) instead of placing buttons in content.
*
* @example
* ```tsx
* <BottomDrawer
* open={isOpen}
* onClose={() => setIsOpen(false)}
* title="Add Reading"
* footer={
* <>
* <DrawerAction icon={<X />} label="Cancel" onClick={() => setIsOpen(false)} />
* <DrawerAction icon={<Check />} label="Save" type="submit" form="add-reading" disabled={!dirty} />
* </>
* }
* >
* <form id="add-reading" onSubmit={...}>...</form>
* </BottomDrawer>
* ```
*/
export function BottomDrawer({ open, onClose, title, footer, initialFocus = "first", presentation = "auto", container, children }: BottomDrawerProps) {
// Responsive-vs-forced presentation. `auto` keeps the original viewport `md:`
// switch verbatim (so existing consumers are byte-identical); `sheet`/`dialog`
// pin one form at every width.
const sheet = presentation === "sheet";
const dialog = presentation === "dialog";
const cls = {
// Overlay flex alignment: sheet pins to the bottom, dialog centers.
overlay: dialog
? "flex-row items-center justify-center"
: sheet
? "flex-col justify-end"
: "flex-col justify-end md:flex-row md:items-center md:justify-center",
// Sheet wrapper width: dialog is a capped centered card, sheet is full width.
wrapperWidth: dialog ? "max-w-2xl mx-4" : sheet ? "" : "md:max-w-2xl md:mx-4",
// Open/closed transform: sheet slides from the bottom, dialog scales in.
wrapperOpen: dialog ? "scale-100" : sheet ? "translate-y-0" : "translate-y-0 md:scale-100",
wrapperClosed: dialog
? "scale-95 opacity-0"
: sheet
? "translate-y-full"
: "translate-y-full md:translate-y-0 md:scale-95 md:opacity-0",
// Card corners + border: dialog is fully rounded/bordered, sheet is top-only.
cardShape: dialog ? "rounded-2xl border" : sheet ? "rounded-t-2xl border-t" : "rounded-t-2xl md:rounded-2xl border-t md:border",
// The drag handle is a sheet affordance; hide it in dialog form.
handle: dialog ? "hidden" : sheet ? "flex" : "flex md:hidden",
};
// The card is the scroll container; sticky header/footer pin within it.
const scrollRef = React.useRef<HTMLDivElement>(null);
const titleId = React.useId();
const [dragY, setDragY] = React.useState(0);
// The header's divider appears only once content has scrolled under it
// (same idiom as the app header / tab bar); at rest the header stays
// frost-only. The border is always present and only its color toggles, so
// no height jump.
const [scrolledUnder, setScrolledUnder] = React.useState(false);
React.useEffect(() => {
if (open) setScrolledUnder((scrollRef.current?.scrollTop ?? 0) > 0);
}, [open]);
// The custom scroll indicator's track must start below the sticky header
// and end above the sticky footer (the native scrollbar - now hidden - ran
// behind both). Their heights vary with title presence, footer content, and
// safe-area padding, so measure them.
const headerRef = React.useRef<HTMLDivElement>(null);
const footerRef = React.useRef<HTMLDivElement>(null);
const [chromeOffsets, setChromeOffsets] = React.useState({ top: 8, bottom: 8 });
const hasFooter = !!footer;
// Deliberately a passive effect, not a layout effect: these offsets only
// bound the scroll indicator, and reading offsetHeight before paint forced a
// synchronous layout in the first frame of the open transition -- the one
// frame that has the least to spare on mobile. Landing a paint later is
// invisible; the jank was not.
React.useEffect(() => {
if (!open) return;
const measure = () => {
const top = (headerRef.current?.offsetHeight ?? 0) + 4;
const bottom = (footerRef.current?.offsetHeight ?? 0) + 4;
// Same numbers must not commit a render. The ResizeObserver below fires
// an initial callback on the next frame, which otherwise guaranteed a
// second commit mid-slide for values that had not moved.
setChromeOffsets((prev) =>
prev.top === top && prev.bottom === bottom ? prev : { top, bottom },
);
};
measure();
const ro = new ResizeObserver(measure);
if (headerRef.current) ro.observe(headerRef.current);
if (footerRef.current) ro.observe(footerRef.current);
return () => ro.disconnect();
}, [open, title, hasFooter]);
// Trap focus inside the card while open; restores trigger focus on close.
useFocusTrap(scrollRef, open, { initialFocus });
const dragState = React.useRef<{
startY: number;
lastY: number;
lastT: number;
velocity: number;
dragging: boolean;
} | null>(null);
React.useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
// Keep the latest onClose without re-running the history effect every render
// (it must depend only on `open`).
const onCloseRef = React.useRef(onClose);
onCloseRef.current = onClose;
// Hardware / browser Back closes the drawer instead of navigating the app.
// While open, push a throwaway history entry; Back (Android maps the hardware
// button to webview history-back) pops it and fires popstate, which we treat
// as a dismiss. If the drawer is closed another way (swipe, backdrop, a
// footer action), we pop our own entry so Back isn't left on a phantom state.
// Safe with react-router: pushState doesn't fire popstate, and Back lands on
// the original (untouched) entry, so the router's location never changes here.
React.useEffect(() => {
if (!open) return;
window.history.pushState({ ...window.history.state, __wjDrawer: true }, "");
const onPopState = () => onCloseRef.current();
window.addEventListener("popstate", onPopState);
return () => {
window.removeEventListener("popstate", onPopState);
if (window.history.state?.__wjDrawer) {
window.history.back();
}
};
}, [open]);
// A layout effect on purpose: this invalidates layout document-wide, and as
// a passive effect it landed one frame into the slide.
React.useLayoutEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
// Reset any in-progress drag whenever the drawer opens or closes so the
// open/close transitions start from a clean transform.
setDragY(0);
dragState.current = null;
return () => {
document.body.style.overflow = "";
};
}, [open]);
const handleTouchStart = (e: React.TouchEvent) => {
dragState.current = {
startY: e.touches[0].clientY,
lastY: e.touches[0].clientY,
lastT: performance.now(),
velocity: 0,
dragging: false,
};
};
// A downward drag that starts inside a nested scroll region should scroll that
// region, not dismiss the sheet. Walk from the touch target up to the card and
// report whether any scrollable ancestor sits scrolled away from its own top.
// Without this, swiping down to scroll back up inside an inner scroll box (e.g.
// the goal icon picker's expanded "more" grid) would fight the swipe-to-dismiss
// gesture, since the card itself is still at scrollTop 0.
const nestedScrollerNotAtTop = (target: EventTarget | null): boolean => {
let el = target instanceof HTMLElement ? target : null;
const card = scrollRef.current;
while (el && el !== card) {
if (el.scrollTop > 0) {
const overflowY = getComputedStyle(el).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return true;
}
el = el.parentElement;
}
return false;
};
const handleTouchMove = (e: React.TouchEvent) => {
const s = dragState.current;
if (!s) return;
const y = e.touches[0].clientY;
const dy = y - s.startY;
if (!s.dragging) {
// Upward movement, the card not at its scroll top, or a nested scroller
// not at ITS top: this is a scroll, not a dismiss gesture -- hand the
// touch back so the inner content scrolls.
if (
dy < -DRAG_SLOP ||
(scrollRef.current?.scrollTop ?? 0) > 0 ||
nestedScrollerNotAtTop(e.target)
) {
dragState.current = null;
return;
}
if (dy < DRAG_SLOP) return; // within slop: could still be a tap
s.dragging = true;
}
const now = performance.now();
s.velocity = (y - s.lastY) / Math.max(1, now - s.lastT);
s.lastY = y;
s.lastT = now;
setDragY(Math.max(0, dy));
};
const handleTouchEnd = () => {
const s = dragState.current;
dragState.current = null;
if (!s?.dragging) return;
if (dragY > DISMISS_DISTANCE || s.velocity > DISMISS_VELOCITY) {
onClose();
}
setDragY(0);
};
// No DOM to portal into during server rendering. An overlay has no meaningful
// server output anyway -- it is chrome that opens in response to interaction,
// and the client renders it on mount. Without this guard the component reads
// `document.body` during render and throws in any prerender / SSG pass.
if (typeof document === "undefined") return null;
// Portal to <body> so the fixed overlay escapes any transformed / will-change
// ancestor (e.g. a swipe-navigation content wrapper). A `position: fixed`
// element inside such an ancestor is positioned relative to IT, not the
// viewport, which clips/breaks the drawer -- so callers can mount the drawer
// anywhere in the tree, including inside swipeable tab content.
return createPortal(
<div
className={cn(
"fixed inset-0 z-[60]",
// Mobile: pin to bottom. Desktop: center. (Or forced via `presentation`.)
"flex",
cls.overlay,
// No opacity transition on THIS element. It covers the viewport, and an
// element whose opacity animates is composited and rasterised as one
// layer -- a full-screen one here, which on a phone's GPU can be drawn
// before its tiles are ready, so part of the overlay is briefly
// transparent. The two children below each carry their own fade, so
// this box never needed one: the backdrop is a solid fill (a colour,
// not a raster) and the sheet already animates its own transform and
// opacity.
open ? "pointer-events-auto" : "pointer-events-none"
)}
aria-modal="true"
role="dialog"
{...(title ? { "aria-labelledby": titleId } : {})}
>
{/* Backdrop. Carries the overlay's fade, since it is the part of it that
dims the page -- one solid colour, so the compositor has no pixels to
rasterise for it. */}
<div
className={cn(
"absolute inset-0 bg-black/40",
"transition-opacity duration-300",
open ? "opacity-100" : "opacity-0"
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Sheet wrapper -- bottom sheet on mobile, centered dialog on desktop.
Carries the layout, open/close transition, and the swipe-down drag
transform; the card inside is the scroll container and the custom
scroll indicator sits beside it (outside the scroller, so it stays
pinned while content scrolls). */}
<div
style={
dragY > 0
? { transform: `translateY(${dragY}px)`, transition: "none" }
: undefined
}
// When closed: hidden from AT and not reachable by keyboard. `inert` is
// an HTML boolean attribute enabled by its presence; pass the empty
// string (not `true`) so React 18 emits `inert` without warning.
{...(!open ? { inert: "", "aria-hidden": "true" } : {})}
className={cn(
"relative z-10",
"flex flex-col max-h-[85dvh]",
"w-full",
cls.wrapperWidth,
// Transform and opacity only. This element also carries
// `max-h-[85dvh]` and a responsive max-width, both of which `all`
// animates -- so a content or breakpoint change ran a 300ms size
// transition alongside the open/close one.
"transition-[transform,opacity] duration-300 ease-out",
open ? cls.wrapperOpen : cls.wrapperClosed
)}
>
{/* Drawer card -- the scroll container; the sticky header/footer pin
within it and content scrolls behind them. Its native scrollbar is
hidden in favour of the bounded indicator.
It carries no focus ring, the one place in the system that opts out.
`tabIndex={-1}` makes it a focus TARGET without making it a stop on
the Tab order: it exists so opening the drawer seats focus inside
the dialog, never as something a keyboard user operates. When the
opening gesture was a key press the browser is in keyboard
modality, so `:focus-visible` matches here and paints a ring around
the whole card -- announcing nothing, since the dialog role and its
label already carry that, and the next Tab reaches a real control
that rings properly. */}
<div
ref={scrollRef}
tabIndex={-1}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onScroll={(e) => setScrolledUnder(e.currentTarget.scrollTop > 0)}
className={cn(
"min-h-0 overflow-y-auto overscroll-contain scrollbar-hide",
"focus:outline-none",
cls.cardShape,
"bg-[var(--color-surface-light)] dark:bg-[var(--color-surface-dark)]",
DIVIDER,
"shadow-2xl"
)}
>
{/* Sticky frosted header: drag handle (mobile) + optional title.
Frost-only at rest; the divider fades in once content scrolls
under it. */}
<div
ref={headerRef}
className={cn(
"sticky top-0 z-10 border-b transition-colors duration-200",
FROSTED,
scrolledUnder ? DIVIDER : "border-transparent"
)}
>
<div className={cn("justify-center pt-3 pb-1", cls.handle)}>
<div className="w-10 h-1.5 rounded-full bg-[var(--color-neutral-300)] dark:bg-[var(--color-neutral-600)]" />
</div>
{title && (
<div className="px-6 pt-1 pb-3 md:pt-5">
<h2
id={titleId}
className="text-lg font-semibold text-center text-[var(--color-text-primary-light)] dark:text-[var(--color-text-primary-dark)]"
>
{title}
</h2>
</div>
)}
</div>
{/* Scrollable content -- extra bottom padding when a footer pill floats
over it so the last item clears the pill. */}
<div className={cn("px-6 pt-2", footer ? "pb-6" : "pb-8")}>
{children}
</div>
{/* Sticky frosted footer bar: full-width with a top divider, content
scrolls behind it. The actions row matches the app's bottom-nav
height; safe-area padding sits below the row. */}
{footer && (
<div
ref={footerRef}
className={cn(
"sticky bottom-0 z-10",
FROSTED,
"border-t",
DIVIDER,
"drawer-safe-bottom"
)}
>
<div className="flex items-stretch gap-1 min-h-16 px-3">
{footer}
</div>
</div>
)}
</div>
{/* Custom scroll indicator, bounded between the sticky header and
footer. Shown at every size: the card hides its native scrollbar
everywhere, unlike page scrollers which keep the platform overlay
scrollbar on mobile. */}
<ScrollIndicator
scrollRef={scrollRef}
hideBelowMd={false}
topOffset={chromeOffsets.top}
bottomOffset={chromeOffsets.bottom}
/>
</div>
</div>,
container ?? document.body,
);
}