Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
triggerReactElementThe control that opens the drawer.
titlestringThe drawer title. Also names the scrolling body.
descriptionstringOne 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