Bottom drawer
A bottom sheet with a drag handle that also closes from the keyboard.
npx shadcn@latest add https://ui.goodtabs.co/r/bottom-drawer.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/bottom-drawer
A short list of actions. Trigger shown, drawer closed.
Scrolling body
Content taller than the drawer scrolls inside it.
Usage
import { BottomDrawer } from "@/components/ui/bottom-drawer"
import { Button } from "@/components/ui/button"
<BottomDrawer trigger={<Button variant="outline">More</Button>} title="More actions">
...
</BottomDrawer>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | ReactElement | The control that opens the drawer. | |
| title | string | The drawer title. Also names the scrolling body. | |
| description | string | One line under the title. |
Source
bottom-drawer/bottom-drawer.tsx
"use client";
import * as React from "react";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
type BottomDrawerProps = {
/** The control that opens the drawer. Passed to SheetTrigger as the child element. */
trigger: React.ReactElement;
/** The drawer title. */
title: string;
/** One line under the title. */
description?: string;
/** The drawer body. It scrolls when it is taller than the drawer. */
children: React.ReactNode;
};
/** Pull further than this many pixels, or flick faster than the speed below, and the drawer closes. */
const CLOSE_DISTANCE = 96;
/** Pixels per millisecond. */
const CLOSE_SPEED = 0.6;
/**
* A bottom sheet with a drag handle. Drag the handle down to dismiss, or focus it and press
* the down arrow, Enter or Space. Escape and the overlay work as on any sheet.
*/
export function BottomDrawer({ trigger, title, description, children }: BottomDrawerProps) {
const [open, setOpen] = React.useState(false);
const panel = React.useRef<HTMLDivElement>(null);
const drag = React.useRef<{ y: number; t: number; dy: number } | null>(null);
const move = (dy: number) => {
const el = panel.current;
if (!el) return;
el.style.transform = dy > 0 ? `translateY(${dy}px)` : "";
};
const onPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { y: event.clientY, t: event.timeStamp, dy: 0 };
if (panel.current) panel.current.style.transition = "none";
};
const onPointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const d = drag.current;
if (!d) return;
d.dy = Math.max(0, event.clientY - d.y);
move(d.dy);
};
const onPointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
const d = drag.current;
drag.current = null;
if (!d) return;
const speed = d.dy / Math.max(1, event.timeStamp - d.t);
if (panel.current) panel.current.style.transition = "";
if (d.dy > CLOSE_DISTANCE || (d.dy > 24 && speed > CLOSE_SPEED)) setOpen(false);
else move(0);
};
return (
<Sheet
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) requestAnimationFrame(() => move(0));
}}
>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent
ref={panel}
side="bottom"
showCloseButton={false}
className="mx-auto max-h-[85dvh] gap-0 rounded-t-xl p-0 sm:max-w-md"
>
<button
type="button"
aria-label="Close drawer. Drag down or press the down arrow."
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onClick={(event) => {
if (event.detail === 0) setOpen(false);
}}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(false);
}
}}
className="group/handle flex h-6 w-full shrink-0 cursor-grab touch-none items-center justify-center rounded-t-xl outline-none active:cursor-grabbing"
>
<span className="h-1 w-9 rounded-full bg-foreground/20 transition-colors group-hover/handle:bg-foreground/35 group-focus-visible/handle:bg-foreground/60 group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring group-focus-visible/handle:ring-offset-4 group-focus-visible/handle:ring-offset-popover" />
</button>
<SheetHeader className="px-4 pt-1 pb-3">
<SheetTitle>{title}</SheetTitle>
{description ? (
<SheetDescription>{description}</SheetDescription>
) : (
<SheetDescription className="sr-only">{title}</SheetDescription>
)}
</SheetHeader>
<div
role="region"
aria-label={title}
tabIndex={0}
className="flex-1 overflow-y-auto px-4 pb-6 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
{children}
</div>
</SheetContent>
</Sheet>
);
}
For AI agents
Hand your agent this page, the markdown at /components/bottom-drawer/prompt.md, or the Copy AI prompt button above.
When to use
- Action lists and pickers on phones
- Secondary content that should keep the page visible behind it
- Anywhere a thumb should reach the controls
When not to use
- Wide desktop layouts, which want a side panel or popover
- Blocking confirmations, which want a dialog
- Content with its own horizontal drag, which conflicts with the handle
Agent notes
- Client component; the handle uses pointer events and closes past 96px or a quick flick
- Keyboard path: focus the handle and press ArrowDown, or use Escape
- Only the handle drags, so the body can scroll freely on touch
- Drag moves the panel with an inline translateY; no layout properties change
- Built on @/components/ui/sheet with side bottom and the default close button hidden