# Drawer Bottom Sheet Drag — React Dialog Component

> A bottom sheet you can drag down to dismiss: a slow pull past the threshold or a quick flick closes it, and the scrim fades with the drag distance.

- Category: [Dialogs & Drawers](https://pixelpapa.com/components/dialogs.md)
- Tags: drawer, sheet, drag, dismiss, velocity
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/dialogs/drawer-bottom-sheet-drag

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/drawer-bottom-sheet-drag.json"
```

The file is written to `components/pixelpapa/drawer-bottom-sheet-drag.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Minus, Plus, ShoppingBag } from "lucide-react";
import { AnimatePresence, motion, useMotionValue, useTransform } from "motion/react";
import { useEffect, useId, useRef, useState, type ReactNode, type RefObject } from "react";
import { cn } from "@/lib/utils";

const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

/** Moves focus into `ref` while open, keeps Tab inside it, closes on Escape and restores focus afterwards. */
function useModalFocus(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    const node = ref.current;
    if (!open || !node) return;
    const opener = document.activeElement as HTMLElement | null;
    (node.querySelector<HTMLElement>("[data-autofocus]") ?? node).focus({ preventScroll: true });
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") return close.current();
      if (e.key !== "Tab") return;
      const items = [...node.querySelectorAll<HTMLElement>(FOCUSABLE)];
      const first = items[0];
      const last = items[items.length - 1];
      const at = document.activeElement;
      if (!first || (e.shiftKey ? at === first || at === node : at === last)) {
        e.preventDefault();
        (e.shiftKey ? last : first)?.focus();
      }
    };
    node.addEventListener("keydown", onKeyDown);
    return () => {
      node.removeEventListener("keydown", onKeyDown);
      opener?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

type BottomSheetProps = {
  open: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
  /** Pixels of downward drag that dismiss the sheet. */
  threshold?: number;
  /** Downward flick speed (px/s) that dismisses it regardless of distance. */
  flickVelocity?: number;
  /** Covers the nearest positioned ancestor. Pass "fixed" to cover the viewport instead. */
  className?: string;
};

export function BottomSheet({ open, onClose, title, children, threshold = 110, flickVelocity = 500, className }: BottomSheetProps) {
  const id = useId();
  const sheet = useRef<HTMLDivElement>(null);
  const y = useMotionValue(0);
  const scrim = useTransform(y, [0, 260], [1, 0.15]);
  const handle = useTransform(y, [0, 120], [1, 0.55]);
  useModalFocus(sheet, open, onClose);

  return (
    <AnimatePresence onExitComplete={() => y.set(0)}>
      {open && (
        <motion.div key="sheet" className={cn("absolute inset-0 z-50 flex items-end justify-center overflow-hidden", className)}>
          <motion.div aria-hidden onClick={onClose} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0">
            <motion.div style={{ opacity: scrim }} className="size-full bg-black/45 backdrop-blur-[2px]" />
          </motion.div>

          {/* Outer layer handles enter/exit in %, the inner one owns the drag offset in px. */}
          <motion.div
            initial={{ y: "100%" }}
            animate={{ y: "0%" }}
            exit={{ y: "110%" }}
            transition={{ type: "spring", stiffness: 360, damping: 36 }}
            className="relative w-full max-w-md"
          >
            <motion.div
              ref={sheet}
              role="dialog"
              aria-modal="true"
              aria-labelledby={`${id}-title`}
              tabIndex={-1}
              drag="y"
              style={{ y }}
              dragConstraints={{ top: 0, bottom: 0 }}
              dragElastic={{ top: 0.05, bottom: 0.85 }}
              onDragEnd={(_, info) => {
                if (info.offset.y > threshold || info.velocity.y > flickVelocity) onClose();
              }}
              className="relative cursor-grab touch-none rounded-t-3xl border border-b-0 border-border bg-card px-5 pb-5 text-card-foreground shadow-2xl shadow-black/25 outline-none after:absolute after:inset-x-0 after:top-full after:h-48 after:bg-card active:cursor-grabbing"
            >
              <div className="flex justify-center py-3">
                <motion.span style={{ scaleX: handle }} className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
              </div>
              <h2 id={`${id}-title`} className="text-lg font-semibold tracking-tight">
                {title}
              </h2>
              {children}
            </motion.div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

const basket = [
  { name: "Oat flat white", note: "Large · extra shot", price: 5.4, tone: "bg-chart-4/25" },
  { name: "Cardamom bun", note: "Warmed", price: 4.2, tone: "bg-chart-1/20" },
  { name: "Sourdough loaf", note: "Sliced", price: 7.5, tone: "bg-chart-5/25" },
];

export default function DrawerBottomSheetDragDemo() {
  const [open, setOpen] = useState(true);
  const [qty, setQty] = useState([1, 2, 1]);
  const total = basket.reduce((sum, item, i) => sum + item.price * qty[i], 0);
  const count = qty.reduce((a, b) => a + b, 0);

  return (
    <div className="relative isolate flex h-[430px] w-full max-w-[680px] items-start justify-center overflow-hidden rounded-3xl border border-border bg-muted/40 pt-16 [background-image:radial-gradient(var(--border)_1px,transparent_1px)] [background-size:18px_18px]">
      <button
        onClick={() => setOpen(true)}
        className="inline-flex h-10 items-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-transform hover:-translate-y-0.5 active:scale-95"
      >
        <ShoppingBag className="size-4" /> View basket · {count}
      </button>

      <BottomSheet open={open} onClose={() => setOpen(false)} title="Your basket">
        <p className="text-sm text-muted-foreground">Pick-up at Harbour Street in about 8 minutes.</p>
        <ul className="mt-3 divide-y divide-border">
          {basket.map((item, i) => (
            <li key={item.name} className="flex items-center gap-3 py-2.5">
              <span className={cn("size-10 shrink-0 rounded-xl", item.tone)} />
              <span className="min-w-0 flex-1">
                <span className="block truncate text-sm font-medium">{item.name}</span>
                <span className="block text-xs text-muted-foreground">{item.note}</span>
              </span>
              <span className="flex items-center gap-1 rounded-full border border-border p-0.5">
                {[-1, 1].map((d) => (
                  <button
                    key={d}
                    aria-label={`${d < 0 ? "Remove one" : "Add one"} ${item.name}`}
                    onPointerDownCapture={(e) => e.stopPropagation()}
                    onClick={() => setQty(qty.map((q, j) => (j === i ? Math.max(0, q + d) : q)))}
                    className={cn("flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", d > 0 && "order-last")}
                  >
                    {d < 0 ? <Minus className="size-3" /> : <Plus className="size-3" />}
                  </button>
                ))}
                <span className="w-4 text-center text-xs font-medium tabular-nums">{qty[i]}</span>
              </span>
              <span className="w-12 text-right text-sm tabular-nums">${(item.price * qty[i]).toFixed(2)}</span>
            </li>
          ))}
        </ul>
        <button
          onPointerDownCapture={(e) => e.stopPropagation()}
          onClick={() => setOpen(false)}
          className="mt-3 flex h-11 w-full items-center justify-between rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm shadow-primary/30 transition-transform active:scale-[0.98]"
        >
          <span>Checkout</span>
          <span className="tabular-nums">${total.toFixed(2)}</span>
        </button>
      </BottomSheet>
    </div>
  );
}
```

## AI prompt

```text
Add the "Drawer Bottom Sheet Drag" component from PixelPapa UI to this project.

What it is: A bottom sheet you can drag down to dismiss: a slow pull past the threshold or a quick flick closes it, and the scrim fades with the drag distance.
Category: Dialogs & Drawers. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/drawer-bottom-sheet-drag.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/drawer-bottom-sheet-drag.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/drawer-bottom-sheet-drag" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More dialogs & drawers

- [Dialog Command Style Search](https://pixelpapa.com/components/dialogs/dialog-command-style-search.md) (Pro): A search dialog in the style of a command palette: scope chips, matches highlighted as you type, and a selection bar that springs between results with the arrow keys.
- [Dialog Confirm Destructive](https://pixelpapa.com/components/dialogs/dialog-confirm-destructive.md) (Pro): A type-to-confirm dialog: the delete button fills up as each character of the resource name matches, unlocks at the last one and shakes on a wrong try.
- [Dialog Morph From Button](https://pixelpapa.com/components/dialogs/dialog-morph-from-button.md): A button that grows into the dialog itself through a shared layout, with its label travelling to become the title, then shrinks back on close.
- [Dialog Multi Step Wizard](https://pixelpapa.com/components/dialogs/dialog-multi-step-wizard.md) (Pro): A wizard dialog whose steps slide in the direction of travel while the card springs to each step's height and a segmented progress bar fills.
- [Dialog Scale Spring](https://pixelpapa.com/components/dialogs/dialog-scale-spring.md): A modal that springs up from 86% scale over a blurred scrim, traps focus, closes on Escape or outside click and hands focus back to its trigger.
- [Drawer Side Panel](https://pixelpapa.com/components/dialogs/drawer-side-panel.md) (Pro): A floating settings panel that slides in from the right with staggered rows while the page behind recedes and dims.
