Drawer

A sheet that slides in from the right, left or bottom for details read alongside the page: a run, a trace, a record, a settings panel.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/drawer

Once per app, add the registry to components.json:

components.json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}

Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/drawer.json

Adds components/pixilui/drawer.tsx. Needs @base-ui/react, motion, clsx, tailwind-merge.

Preview#

Side
Size
State
Theme
Accent

On a phone, flick the handle and the sheet leaves at the speed you threw it, on a spring that never bounces back.

Code#

components/pixilui/drawer.tsx
"use client";

import {
  forwardRef,
  isValidElement,
  useCallback,
  useId,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
  type ComponentPropsWithoutRef,
  type ReactElement,
  type ReactNode,
} from "react";
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { AnimatePresence, motion, useDragControls, useReducedMotion, type PanInfo } from "motion/react";
import { cn } from "./lib/utils";
import { motionTokens, reducedTransition } from "./motion-tokens";
import {
  DialogActions,
  DialogClose,
  DialogDescription,
  DialogProvider,
  DialogTitle,
  dialogTriggerIsNativeButton,
  type DialogContainer,
} from "./dialog";

export type DrawerSide = "right" | "left" | "bottom";
export type DrawerSize = "sm" | "md" | "lg";

export type DrawerProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> & {
  /** The drawer's parts: DrawerTitle, DrawerDescription, your content, DrawerActions. */
  children?: ReactNode;
  /** Controlled open state. Pair with onOpenChange. */
  open?: boolean;
  /** Starting open state when uncontrolled. */
  defaultOpen?: boolean;
  /** Runs whenever the drawer opens or closes, from the trigger, Esc, an outside press, a swipe or an action. */
  onOpenChange?: (open: boolean) => void;
  /** The element that opens the drawer, usually a Button. Rendered through Base UI's render prop, so any element works. */
  trigger?: ReactElement;
  /** The edge the sheet slides from. */
  side?: DrawerSide;
  /** Width for left and right (320, 384 or 512px); maximum height for bottom (40, 65 or 90% of the screen). */
  size?: DrawerSize;
  /** Close on Esc, an outside press and, for bottom drawers on touch screens, a swipe down. */
  dismissible?: boolean;
  /** Where the drawer portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. */
  container?: DialogContainer;
  /** true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. */
  modal?: boolean | "trap-focus";
};

const panelSides: Record<DrawerSide, string> = {
  right: "inset-y-2 right-2 w-[calc(100%-1rem)]",
  left: "inset-y-2 left-2 w-[calc(100%-1rem)]",
  bottom: "inset-x-2 bottom-2 mx-auto max-w-2xl",
};

const panelSizes: Record<DrawerSide, Record<DrawerSize, string>> = {
  right: { sm: "max-w-xs", md: "max-w-sm", lg: "max-w-lg" },
  left: { sm: "max-w-xs", md: "max-w-sm", lg: "max-w-lg" },
  bottom: { sm: "max-h-[40%]", md: "max-h-[65%]", lg: "max-h-[90%]" },
};

/** How far off screen the sheet waits: past its own size plus the 8px inset. */
const offscreen: Record<DrawerSide, { x?: string; y?: string }> = {
  right: { x: "110%" },
  left: { x: "-110%" },
  bottom: { y: "110%" },
};

/** A swipe closes the sheet past this share of its height, or faster than this many px/s. */
const SWIPE_DISTANCE = 0.3;
const SWIPE_VELOCITY = 600;
/** Upward pull resistance: the sheet gives about 10px before it stops. */
const PULL_ELASTIC = 0.04;

const coarseQuery = "(pointer: coarse)";
const subscribeCoarse = (onChange: () => void) => {
  const list = window.matchMedia(coarseQuery);
  list.addEventListener("change", onChange);
  return () => list.removeEventListener("change", onChange);
};
const readCoarse = () => window.matchMedia(coarseQuery).matches;
const readCoarseOnServer = () => false;

function usePointerCoarse() {
  return useSyncExternalStore(subscribeCoarse, readCoarse, readCoarseOnServer);
}

export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(function Drawer(
  {
    children,
    open: openProp,
    defaultOpen = false,
    onOpenChange,
    trigger,
    side = "right",
    size = "md",
    dismissible = true,
    container,
    modal,
    className,
    ...rest
  },
  forwardedRef,
) {
  const reduced = useReducedMotion() ?? false;
  const coarse = usePointerCoarse();
  const dragControls = useDragControls();
  const [innerOpen, setInnerOpen] = useState(defaultOpen);
  const open = openProp ?? innerOpen;
  const generatedTriggerId = useId();
  const triggerId = (isValidElement<{ id?: string }>(trigger) && trigger.props.id) || generatedTriggerId;
  const cancelRef = useRef<HTMLButtonElement | null>(null);
  const pendingRef = useRef(false);
  const panelRef = useRef<HTMLDivElement | null>(null);
  const swipeable = side === "bottom" && coarse && dismissible;

  const setOpen = useCallback(
    (next: boolean) => {
      setInnerOpen(next);
      onOpenChange?.(next);
    },
    [onOpenChange],
  );

  const context = useMemo(
    () => ({
      variant: "default" as const,
      close: () => setOpen(false),
      cancelRef,
      setPending: (pending: boolean) => {
        pendingRef.current = pending;
      },
    }),
    [setOpen],
  );

  const setPanelRef = useCallback(
    (node: HTMLDivElement | null) => {
      panelRef.current = node;
      if (typeof forwardedRef === "function") forwardedRef(node);
      else if (forwardedRef) forwardedRef.current = node;
    },
    [forwardedRef],
  );

  const onDragEnd = (event: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {
    const height = (event.target as HTMLElement | null)?.closest('[data-slot="drawer"]')?.getBoundingClientRect().height ?? 0;
    if (info.offset.y > height * SWIPE_DISTANCE || info.velocity.y > SWIPE_VELOCITY) setOpen(false);
  };

  const backdrop = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
    : {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0, transition: { duration: motionTokens.duration.medium, ease: motionTokens.ease.smoothOut } },
        transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.smoothOut },
      };

  const panel = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
    : {
        initial: { opacity: 0, ...offscreen[side] },
        animate: { opacity: 1, x: 0, y: 0 },
        // The exit is a spring too, so a flick carries its velocity off screen.
        exit: {
          opacity: 0,
          ...offscreen[side],
          transition: { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.medium, ease: motionTokens.ease.exit } },
        },
        transition: { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter } },
      };

  const state = open ? "open" : "closed";

  return (
    <BaseDialog.Root
      open={open}
      triggerId={trigger ? triggerId : undefined}
      modal={modal ?? (container !== undefined ? "trap-focus" : true)}
      disablePointerDismissal={!dismissible}
      onOpenChange={(next, details) => {
        if (!next && (pendingRef.current || (!dismissible && details.reason === "escape-key"))) {
          details.cancel();
          return;
        }
        setOpen(next);
      }}
    >
      {trigger ? <BaseDialog.Trigger id={triggerId} nativeButton={dialogTriggerIsNativeButton(trigger)} render={trigger} /> : null}
      <AnimatePresence>
        {open ? (
          <BaseDialog.Portal keepMounted container={container}>
            <BaseDialog.Backdrop
              data-slot="drawer-backdrop"
              data-state={state}
              className="fixed inset-0 bg-background/70 backdrop-blur-subtle"
              render={<motion.div {...backdrop} />}
            />
            <DialogProvider value={context}>
              <BaseDialog.Popup
                ref={setPanelRef}
                // Focus the sheet itself, so reading starts at the title and long content opens scrolled to the top.
                initialFocus={panelRef}
                data-slot="drawer"
                data-state={state}
                data-side={side}
                data-size={size}
                className={cn(
                  "fixed flex flex-col overflow-hidden rounded-panel bg-surface-raised font-body text-14 text-foreground shadow-floating outline-none hairline",
                  panelSides[side],
                  panelSizes[side][size],
                  className,
                )}
                render={
                  <motion.div
                    {...panel}
                    drag={swipeable && !reduced ? "y" : false}
                    dragControls={dragControls}
                    dragListener={false}
                    dragConstraints={{ top: 0, bottom: 0 }}
                    dragElastic={{ top: PULL_ELASTIC, bottom: 1 }}
                    dragSnapToOrigin
                    onDragEnd={onDragEnd}
                  />
                }
                {...rest}
              >
                {side === "bottom" && coarse ? (
                  <div
                    data-slot="drawer-handle"
                    aria-hidden="true"
                    className="flex h-6 shrink-0 cursor-grab touch-none items-end justify-center"
                    onPointerDown={(event) => swipeable && dragControls.start(event)}
                  >
                    <span className="h-1 w-9 rounded-pill bg-border-strong" />
                  </div>
                ) : null}
                <div
                  data-slot="drawer-body"
                  className={cn("flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain p-6", side === "bottom" && coarse && "pt-3")}
                >
                  {children}
                </div>
              </BaseDialog.Popup>
            </DialogProvider>
          </BaseDialog.Portal>
        ) : null}
      </AnimatePresence>
    </BaseDialog.Root>
  );
});

/** The drawer's name, wired to aria-labelledby. Same part as DialogTitle. */
export const DrawerTitle = DialogTitle;
/** Supporting sentence, wired to aria-describedby. Same part as DialogDescription. */
export const DrawerDescription = DialogDescription;
/** The button row, pinned to the bottom while content scrolls. Same part as DialogActions. */
export const DrawerActions = DialogActions;
/** A Button that closes the drawer. Same part as DialogClose. */
export const DrawerClose = DialogClose;

Usage#

Use it when

  • Details of one row the person opens and closes while scanning a list: a run, a tool call, a customer.
  • A form too long for a dialog that still belongs to the current page: agent settings, a schedule.
  • On phones, actions and filters from the bottom edge where a thumb reaches them.

Use something else when

  • A single decision: use a dialog.
  • A few options anchored to a control: use a popover.
  • Navigation that stays open: use a sidebar in the layout, not an overlay.
Example
import { useState } from "react";
import { Button } from "@/components/pixilui/button";
import { Drawer, DrawerActions, DrawerDescription, DrawerTitle } from "@/components/pixilui/drawer";

// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
  side: ["right", "bottom", "left"],
  size: ["md", "sm", "lg"],
  state: ["open", "closed"],
} as const;

const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

const steps = [
  { time: "09:41:02", text: "Read 48 open invoices from Stripe" },
  { time: "09:41:09", text: "Matched 46 to customers in HubSpot" },
  { time: "09:41:15", text: "Drafted reminders in the Q3 renewal template" },
  { time: "09:42:30", text: "Sent 46 reminders from billing@northwind.co" },
  { time: "09:42:31", text: "Skipped Arden Labs: no billing contact" },
  { time: "09:42:31", text: "Skipped Kova Health: invoice disputed" },
  { time: "09:42:40", text: "Logged the run to #billing-ops in Slack" },
];

export default function DrawerDemo({ side = "right", size = "md", state = "open" }: KnobValues<typeof knobs>) {
  // The drawer portals into this stage, so it keeps the preview's theme and accent and stays inside it.
  const [stage, setStage] = useState<HTMLDivElement | null>(null);

  return (
    <div ref={setStage} className="relative grid h-[460px] w-full max-w-3xl place-items-center overflow-hidden rounded-surface bg-surface hairline [contain:layout]">
      <Drawer
        key={`${side}-${size}-${state}`}
        container={stage}
        defaultOpen={state === "open"}
        side={side}
        size={size}
        trigger={<Button variant="secondary">Open run 4821</Button>}
      >
        <DrawerTitle>Run 4821, Billing agent</DrawerTitle>
        <DrawerDescription>Finished 2 minutes ago. 46 of 48 reminders sent.</DrawerDescription>
        <ol className="mt-3 flex flex-col">
          {steps.map((step, index) => (
            <li key={index} className="flex gap-3 border-t border-border-subtle py-2.5 text-14">
              <span className="numeric shrink-0 text-12 leading-5 text-muted">{step.time}</span>
              <span className="min-w-0 text-foreground">{step.text}</span>
            </li>
          ))}
        </ol>
        <DrawerActions confirm="Retry 2 reminders" onConfirm={() => wait(900)} labels={{ cancel: "Close" }} />
      </Drawer>
    </div>
  );
}

API#

PropTypeDefaultDescription
childrenReactNodeThe drawer's parts: DrawerTitle, DrawerDescription, your content, DrawerActions.
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseStarting open state when uncontrolled.
onOpenChange(open: boolean) => voidRuns whenever the drawer opens or closes, from the trigger, Esc, an outside press, a swipe or an action.
triggerReactElementThe element that opens the drawer, usually a Button. Rendered through Base UI's render prop, so any element works.
side"right" | "left" | "bottom""right"The edge the sheet slides from.
size"sm" | "md" | "lg""md"Width for left and right (320, 384 or 512px); maximum height for bottom (40, 65 or 90% of the screen).
dismissiblebooleantrueClose on Esc, an outside press and, for bottom drawers on touch screens, a swipe down.
containerDialogContainerWhere the drawer portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it.
modalboolean | "trap-focus"true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set.

Also takes every native <div> attribute except role, and a ref to it.

Keyboard#

Enter
On the trigger, opens the drawer and focuses the sheet, so reading starts at its title.
Tab
Moves between the controls in the drawer; focus stays inside while it is open.
Esc
Closes the drawer and returns focus to the trigger, unless dismissible is false or an action is pending.

Accessibility#

  • Built on Base UI Dialog: role dialog, aria-modal, and the title and description wired to aria-labelledby and aria-describedby.
  • The sheet itself takes focus on open, so a screen reader starts at the title instead of the footer button; Tab then moves through its controls.
  • Focus is trapped while open and returns to the trigger on close; page scroll is locked for a full-page drawer.
  • The drag handle is decorative (aria-hidden); keyboard and screen reader users close with Esc or the Close action.
  • Swipe to dismiss only exists on coarse pointers, so a mouse never drags the sheet by accident.

Motion#

  • The sheet slides in on the smooth spring, which never overshoots, and fades in on duration-fast.
  • Closing uses the smooth spring too, so a flick down on the handle carries its velocity off screen.
  • Pulling a bottom sheet up rubber-bands about 10px; letting go short of a third of its height springs it back.
  • Reduced motion: the sheet and backdrop fade only, and dragging is off.

Responsive#

  • Side sheets take the full width minus 16px on phones and cap at 320, 384 or 512px.
  • Bottom sheets center at up to 672px wide and cap their height at 40, 65 or 90% of the screen.
  • Content scrolls inside the sheet; DrawerActions stay pinned to its bottom.
  • Bottom sheets show a drag handle on touch screens only.

Performance#

  • Nothing is mounted while closed. Dragging runs on a motion value, outside React renders.

Notes for AI#

Also in the markdown page and llms.txt
  1. Same API as dialog: trigger, open, defaultOpen, onOpenChange, dismissible, container.
  2. Compose DrawerTitle, DrawerDescription, your content and DrawerActions; they are the dialog parts under drawer names.
  3. Use side bottom for mobile action sheets and side right for detail panels on desktop.
  4. Return a promise from DrawerActions onConfirm to get pending and error handling for free.