# Drawer

An accessible, swipeable panel with a simple trigger and content API.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import {
  Drawer, DrawerBody, DrawerFooter, useDrawer,
} from "@/components/ui/drawer";

function DoneButton() {
  const { close } = useDrawer();
  return <button type="button" onClick={close}>Done</button>;
}

export function DrawerDemo() {
  return (
    <Drawer swipeDirection="right"
      title="Project settings"
      description="Manage the details and preferences for this project."
      trigger={<button type="button">Open drawer</button>}
      contentProps={{ showCloseButton: true }}
    >

        <DrawerBody>Your settings form</DrawerBody>
        <DrawerFooter>
          <DoneButton />
        </DrawerFooter>

    </Drawer>
  );
}
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/drawer.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install @base-ui/react cn lucide-react
```

## Usage [#usage]

```tsx
import { Drawer, DrawerBody, DrawerFooter } from "@/components/ui/drawer";

export function ProjectSettings() {
  return (
    <Drawer
      trigger={<button type="button">Open settings</button>}
      title="Project settings"
      description="Manage the details and preferences for this project."
    >
      {({ close }) => (
        <>
          <DrawerBody>Your settings go here.</DrawerBody>
          <DrawerFooter>
            <button type="button" onClick={close}>
              Done
            </button>
          </DrawerFooter>
        </>
      )}
    </Drawer>
  );
}
```

The registry installs editable source in your configured UI directory, rather than a fixed `components/ui` target. Use your configured UI alias if it differs from `@/components/ui`. When updating an older installation with a custom alias, check for an existing copy at the old target and move your customizations deliberately; the CLI does not remove that copy. Review overwrite prompts before replacing customized source.

## Examples [#examples]

<ComponentPreview name="drawer-demo" title="drawer-demo.tsx">
  <DrawerDemo />
</ComponentPreview>

## Panel API [#panel-api]

`Drawer` owns the trigger, portal, optional backdrop, swipeable panel, header, and built-in X. Only the optional trigger occupies space in the page; the panel and its portal wrapper stay out of normal flow, including with `contentProps.keepMounted`. The public API has one panel composition, not a separate root/content composition.

Nested Drawers and Dialogs share the outermost panel's theme host, outside its transformed panel. Child panels avoid parent clipping and retain the outer theme; their triggers remain inside their parent. Update both components when using mixed nesting with older installed source.

| Prop               | Purpose                                                                                                                                           |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title`            | Required meaningful accessible title. Accepts a React node.                                                                                       |
| `description`      | Optional supporting content.                                                                                                                      |
| `trigger`          | Optional React element composed into one associated Base UI trigger. Custom button components must forward their ref and native button props.     |
| `children`         | Panel content, or a function receiving `DrawerControl`.                                                                                           |
| `contentProps`     | Panel props, including `className`, `ref`, `finalFocus`, `showCloseButton`, and `closeButtonLabel`. Children belong to `Drawer`, not this object. |
| State and behavior | `control`, `open`, `defaultOpen`, `onOpenChange`, `modal`, `swipeDirection`, snap points, and other supported Base UI root props.                 |

`DrawerBody` and `DrawerFooter` are the layout helpers for panel content. They forward native `div` props and refs. `Drawer` does **not** automatically wrap children in `DrawerBody`, so a form can own both its scrolling body and stationary footer.

The top-right X is visible by default, with a compact 32px button and a 16px icon. The header reserves space for it. Use `contentProps={{ closeButtonLabel: "Close settings" }}` to translate its accessible label. Set `showCloseButton: false` only when supplying another visible dismissal control.

### Custom Header Contents [#custom-header-contents]

Use `renderHeader` to reorder the title and description or add an icon, badge, or action. Its arguments are ready-to-render semantic elements, not the original text props. The header wrapper stays above the body and outside its scroll area, retaining its padding and space for the X.

```tsx
<Drawer
  title="Edit project"
  description="Update project details"
  trigger={<button type="button">Edit</button>}
  renderHeader={({ title, description }) => (
    <>
      {description}
      <div className="flex items-center gap-2">
        <span aria-hidden="true">✎</span>
        {title}
      </div>
    </>
  )}
>
  <DrawerBody>Your project form</DrawerBody>
</Drawer>
```

Always include the provided `title` exactly once to preserve the panel's accessible name. `description` is `null` when not supplied; if provided, render it once to keep the supporting description association. Do not replace these elements with plain text. Without `renderHeader`, title is followed by description as before.

`renderHeader` returns contents, not a replacement for the wrapper or a way to move the header below the body. Keep hooks inside a component rendered by the callback, not inside the callback itself. The exported `DrawerHeaderElements` type describes the callback's elements.

### Migration from the Compound API [#migration-from-the-compound-api]

This is a breaking API change. Replace the old root, trigger, content, and header composition with `Drawer` props. `DrawerPanel` is now named `Drawer`, without a compatibility alias. Primitive components such as `DrawerContent`, `DrawerTrigger`, and `DrawerHeader` are internal and no longer exported. Replace action buttons using the old close primitive with `useDrawer().close()` or the children render function.

## Direction and Snap Points [#direction-and-snap-points]

The default `swipeDirection` is `right`. Set it to `left`, `up`, or `down` for the other edges. Side panels have a responsive width capped at `28rem`; vertical panels use the available viewport height.

```tsx
<Drawer
  title="Settings"
  trigger={<button type="button">Open</button>}
  swipeDirection="down"
  snapPoints={[0.5, 0.9]}
  showSwipeHandle
>
  <DrawerBody>Your settings</DrawerBody>
</Drawer>
```

Base UI manages the active snap point and swipe gestures. Snap points are fractions between `0` and `1` and are most useful with vertical panels. `showSwipeHandle` adds an optional visual grabber, not a replacement for the gesture area.

## Controlled State and Modal Behavior [#controlled-state-and-modal-behavior]

Use `defaultOpen` for an initially open, uncontrolled panel. Use `open` and `onOpenChange` when application state must control it. Base UI root props, including `disabled`, `actionsRef`, and change event details, are forwarded.

```tsx
"use client";

import { useState } from "react";
import { Drawer, DrawerBody } from "@/components/ui/drawer";

export function ControlledDrawer() {
  const [open, setOpen] = useState(false);
  return (
    <Drawer
      title="Settings"
      trigger={<button type="button">Open settings</button>}
      open={open}
      onOpenChange={setOpen}
    >
      <DrawerBody>Your settings</DrawerBody>
    </Drawer>
  );
}
```

Panels are modal by default, with a backdrop and modal focus management. Set `modal={false}` only when the surrounding interface should remain interactive. Non-modal panels omit the backdrop; provide suitable dismissal and focus behavior.

## Scrollable Body [#scrollable-body]

`DrawerBody` uses `min-h-0 flex-1 overflow-y-auto`. The panel constrains its height to the viewport, so only the body scrolls. The header, footer, and X remain outside its scroll area.

```tsx
<Drawer title="Settings" trigger={<button type="button">Open settings</button>}>
  {({ close }) => (
    <>
      <DrawerBody>
        {Array.from({ length: 30 }, (_, index) => (
          <p key={index} className="mb-4">
            Setting {index + 1}
          </p>
        ))}
      </DrawerBody>
      <DrawerFooter>
        <button type="button" onClick={close}>
          Done
        </button>
      </DrawerFooter>
    </>
  )}
</Drawer>
```

## Drawer Controls [#drawer-controls]

`useDrawerControl()` creates a stable controller for one mounted Drawer. `useDrawer()` selects the nearest Drawer, including through portals. `useDrawer(control)` selects a specific controller. All return `DrawerControl`:

| Member    | Behavior                                        |
| --------- | ----------------------------------------------- |
| `isOpen`  | Read-only reactive state of the mounted Drawer. |
| `open()`  | Requests opening through Base UI.               |
| `close()` | Requests closing through Base UI.               |

### Control from Outside [#control-from-outside]

Omit `trigger` when the opener lives elsewhere. An ordinary external button is not an associated trigger, so explicitly restore focus with `contentProps.finalFocus`.

```tsx
"use client";

import { useRef } from "react";
import { Drawer, DrawerBody, useDrawerControl } from "@/components/ui/drawer";

export function SettingsDrawer() {
  const control = useDrawerControl();
  const opener = useRef<HTMLButtonElement>(null);
  return (
    <>
      <button ref={opener} type="button" onClick={control.open}>
        Open settings
      </button>
      <Drawer
        control={control}
        title="Settings"
        contentProps={{ finalFocus: opener }}
      >
        <DrawerBody>Your settings</DrawerBody>
      </Drawer>
    </>
  );
}
```

### Control from a Child [#control-from-a-child]

```tsx
import { useDrawer } from "@/components/ui/drawer";

export function DoneButton() {
  const { close } = useDrawer();
  return (
    <button type="button" onClick={close}>
      Done
    </button>
  );
}
```

Mount this child inside `DrawerBody` or `DrawerFooter`. Children render functions receive the same controller as `useDrawer()`. Call hooks at the top level of a component, never inside the callback.

### Ownership, Nesting, and Lifecycle [#ownership-nesting-and-lifecycle]

* Without `control`, each Drawer owns its state or uses `open` / `onOpenChange`. Nested drawers do not inherit another panel's state.
* `useDrawer()` selects the nearest panel. Use `useDrawer(outerControl)` to target an outer panel. Outside a Drawer, pass a controller or the hook throws.
* One controller binds to one mounted Drawer. Sharing it between simultaneous panels throws. It can be reused after the previous panel unmounts.
* Do not combine `control` with `open`, `defaultOpen`, or `handle`. Types and runtime checks reject ambiguous state ownership. `onOpenChange` remains available for observation or cancellation.
* `isOpen` is false before attachment and after unmount. Actions without a mounted panel are ignored, not queued. Base UI emits a development warning.
* `open()` and `close()` emit `onOpenChange` with reason `imperative-action`. `details.cancel()` can reject a request without desynchronizing the controller.
* In controlled mode, `close()` requests a change; the panel stays open until its parent updates `open`.
* `close()` returns void, not an exit-animation promise. Use `onOpenChangeComplete` to wait for animation completion.
* Panel children normally mount when opened. Use `contentProps.keepMounted` when state must survive closure; reset form values explicitly when changing the edited record.

```tsx
<Drawer
  title="Settings"
  control={control}
  onOpenChange={(nextOpen, details) => {
    if (!nextOpen && hasUnsavedChanges) details.cancel();
  }}
>
  <DrawerBody>Your settings</DrawerBody>
</Drawer>
```

## Forms with a Stationary Footer [#forms-with-a-stationary-footer]

Keep the native form **around** `DrawerBody` and `DrawerFooter`, not inside the body. Give it `flex min-h-0 flex-1 flex-col`. No DrawerForm abstraction or form-library dependency is needed. Never nest a second form around an application form wrapper; apply these classes to that wrapper instead. Any DOM wrapper in the flex chain must also preserve the constrained height.

```tsx
"use client";

import { useId, useState } from "react";
import type { FormEvent } from "react";
import {
  Drawer,
  DrawerBody,
  DrawerFooter,
  useDrawer,
} from "@/components/ui/drawer";

export function EditProjectDrawer({
  save,
}: {
  save: (name: string) => Promise<void>;
}) {
  return (
    <Drawer
      trigger={<button type="button">Edit project</button>}
      title="Edit project"
    >
      <ProjectForm save={save} />
    </Drawer>
  );
}

function ProjectForm({ save }: { save: (name: string) => Promise<void> }) {
  const { close } = useDrawer();
  const nameId = useId();
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (pending) return;
    const name = String(new FormData(event.currentTarget).get("name") ?? "");
    setPending(true);
    setError(null);
    try {
      await save(name);
      close();
    } catch {
      setError("Could not save. Please try again.");
    } finally {
      setPending(false);
    }
  }
  return (
    <form
      className="flex min-h-0 flex-1 flex-col"
      onSubmit={handleSubmit}
      aria-busy={pending}
    >
      <DrawerBody>
        <label htmlFor={nameId}>Project name</label>
        <input id={nameId} name="name" required disabled={pending} />
        {error && <p role="alert">{error}</p>}
      </DrawerBody>
      <DrawerFooter>
        <button type="submit" disabled={pending}>
          {pending ? "Saving…" : "Save"}
        </button>
        <button type="button" onClick={close} disabled={pending}>
          Cancel
        </button>
      </DrawerFooter>
    </form>
  );
}
```

The form owns validation, loading, errors, and persistence. It closes only after saving succeeds. To block closing during a mutation, use `onOpenChange` and `details.cancel()`. Disabling Cancel alone does not block Escape, swipe, the backdrop, or X.

### React Hook Form [#react-hook-form]

Use your application's installed `react-hook-form`. `FormProvider` adds context without a DOM wrapper. Mount this component as a child of Drawer:

```tsx
"use client";

import { useId } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { DrawerBody, DrawerFooter, useDrawer } from "@/components/ui/drawer";

export function ReactHookFormFields({
  save,
}: {
  save: (values: { name: string }) => Promise<void>;
}) {
  const form = useForm({ defaultValues: { name: "" } });
  const nameId = useId();
  const { close } = useDrawer();
  const { errors, isSubmitting } = form.formState;
  const submit = form.handleSubmit(async (values) => {
    form.clearErrors("root");
    try {
      await save(values);
      close();
    } catch {
      form.setError("root", { message: "Could not save. Please try again." });
    }
  });
  return (
    <FormProvider {...form}>
      <form
        className="flex min-h-0 flex-1 flex-col"
        onSubmit={submit}
        aria-busy={isSubmitting}
      >
        <DrawerBody>
          <label htmlFor={nameId}>Project name</label>
          <input
            id={nameId}
            {...form.register("name", { required: "Enter a project name." })}
            aria-invalid={!!errors.name}
          />
          {errors.name && <p role="alert">{errors.name.message}</p>}
          {errors.root && <p role="alert">{errors.root.message}</p>}
        </DrawerBody>
        <DrawerFooter>
          <button type="submit" disabled={isSubmitting}>
            Save
          </button>
          <button type="button" onClick={close} disabled={isSubmitting}>
            Cancel
          </button>
        </DrawerFooter>
      </form>
    </FormProvider>
  );
}
```

### TanStack Form [#tanstack-form]

Use your application's installed `@tanstack/react-form`. Subscribe to footer state without moving it outside the native form. Mount this component inside Drawer:

```tsx
"use client";

import { useId, useState } from "react";
import { useForm } from "@tanstack/react-form";
import { DrawerBody, DrawerFooter, useDrawer } from "@/components/ui/drawer";

export function TanStackFormFields({
  save,
}: {
  save: (values: { name: string }) => Promise<void>;
}) {
  const { close } = useDrawer();
  const nameId = useId();
  const [error, setError] = useState<string | null>(null);
  const form = useForm({
    defaultValues: { name: "" },
    onSubmit: async ({ value }) => {
      setError(null);
      try {
        await save(value);
        close();
      } catch {
        setError("Could not save. Please try again.");
      }
    },
  });
  return (
    <form
      className="flex min-h-0 flex-1 flex-col"
      onSubmit={(event) => {
        event.preventDefault();
        event.stopPropagation();
        void form.handleSubmit();
      }}
    >
      <DrawerBody>
        <form.Field
          name="name"
          validators={{
            onChange: ({ value }) =>
              value.trim() ? undefined : "Enter a project name.",
          }}
        >
          {(field) => (
            <>
              <label htmlFor={nameId}>Project name</label>
              <input
                id={nameId}
                name={field.name}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(event) => field.handleChange(event.target.value)}
                aria-invalid={!field.state.meta.isValid}
              />
              {field.state.meta.errors.map((message, index) => (
                <p key={index} role="alert">
                  {message}
                </p>
              ))}
            </>
          )}
        </form.Field>
        {error && <p role="alert">{error}</p>}
      </DrawerBody>
      <DrawerFooter>
        <form.Subscribe
          selector={(state) => [state.canSubmit, state.isSubmitting]}
        >
          {([canSubmit, isSubmitting]) => (
            <>
              <button type="submit" disabled={!canSubmit || isSubmitting}>
                Save
              </button>
              <button type="button" onClick={close} disabled={isSubmitting}>
                Cancel
              </button>
            </>
          )}
        </form.Subscribe>
      </DrawerFooter>
    </form>
  );
}
```

For forms reused outside drawers, pass `onSuccess={close}` through Drawer's render function instead of calling `useDrawer()` in the form. Choose **panel-form** and enable **Long content** in the playground to inspect the stationary footer.

## Accessibility and Interaction [#accessibility-and-interaction]

* Base UI supplies dialog semantics, focus management, Escape dismissal, and focus restoration to the associated trigger.
* `title` supplies the accessible name; `description` adds supporting context, not a replacement for the title.
* Triggers and action buttons must be keyboard-operable. Icon-only buttons require an accessible label.
* Keep a visible dismissal control even when swipe is available. The built-in X has a keyboard focus ring and accessible label.
* Touch users can drag toward the closing edge. The backdrop follows swipe progress; reduced-motion preferences disable transitions.
* Keep long content inside DrawerBody. The portal stays in the caller's theme scope, preserving inherited tokens and nested panels.

See the [Base UI Drawer API](https://base-ui.com/react/components/drawer) for underlying behavior and root props.

## Portable Storybook stories [#portable-storybook-stories]

Install Drawer first, then optionally install its colocated stories without overwriting the component:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/drawer-stories.json
```

The item installs only `drawer.stories.tsx` in your configured UI directory. It does not install Storybook packages or modify `.storybook/`. Stories are typechecked and composed with Storybook 10.6.1; use matching Storybook package versions in your consumer. Discover `**/*.stories.tsx` and import your Tailwind/shadcn global CSS and theme in the preview. No additional runtime dependency is required. Scenarios include directions, snap points, long content, persistent content, non-modal, controlled state, and nesting. Storybook UI was not verified because this repository has no Storybook server.

## Props [#props]

| Prop             | Type                                                 | Default        | Description                                                                                                                           |
| ---------------- | ---------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| example          | Example preset                                       | Not applicable | Playground only: choose trigger, external control, render function, or a form.                                                        |
| longContent      | boolean                                              | Not applicable | Playground only: adds content to demonstrate DrawerBody scrolling.                                                                    |
| control          | DrawerControl                                        | Not set        | Drawer: stable controller returned by useDrawerControl. Cannot be combined with open, defaultOpen, or handle.                         |
| title            | ReactNode                                            | Required       | Drawer: meaningful accessible panel title.                                                                                            |
| renderHeader     | (elements: DrawerHeaderElements) => ReactNode        | Not set        | Drawer: custom header contents receiving ready-to-render title and optional description elements. Keep title in the returned content. |
| description      | ReactNode                                            | Not set        | Drawer: optional supporting content beneath the title.                                                                                |
| trigger          | ReactElement                                         | Not set        | Drawer: optional button element composed as an associated trigger.                                                                    |
| children         | ReactNode \| ((control: DrawerControl) => ReactNode) | Not set        | Drawer: content or a function receiving the panel controller.                                                                         |
| contentProps     | object                                               | Not set        | Drawer: popup props, focus restoration, close button options, and keepMounted. Does not accept children.                              |
| swipeDirection   | "right" \| "left" \| "up" \| "down"                  | "right"        | Drawer: edge the panel enters from and swipes toward to close.                                                                        |
| modal            | boolean                                              | true           | Drawer: locks modal focus and renders a backdrop when enabled.                                                                        |
| showSwipeHandle  | boolean                                              | false          | Drawer: shows a visual grabber for touch gestures.                                                                                    |
| showCloseButton  | boolean                                              | true           | contentProps.showCloseButton: shows the top-right close button.                                                                       |
| closeButtonLabel | string                                               | "Close drawer" | contentProps.closeButtonLabel: accessible label for the close button. Use a localized label in translated interfaces.                 |
| snapPoints       | number\[]                                            | Not set        | Drawer: optional vertical drawer resting heights.                                                                                     |
| defaultOpen      | boolean                                              | false          | Drawer: initially opens the panel.                                                                                                    |
| open             | boolean                                              | Not set        | Drawer: controlled open state.                                                                                                        |
| onOpenChange     | (open, details) => void                              | Not set        | Drawer: receives the next open state and Base UI event details.                                                                       |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
import { cn } from "cn";
import { XIcon } from "lucide-react";
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useState,
  useSyncExternalStore,
} from "react";
import type { ComponentProps, ReactElement, ReactNode } from "react";

const drawerControlKey = Symbol("drawer-control");
type DrawerHandle = ReturnType<typeof DrawerPrimitive.createHandle>;

interface DrawerControl {
  readonly isOpen: boolean;
  open: () => void;
  close: () => void;
  readonly [drawerControlKey]: {
    handle: DrawerHandle;
    subscribe: (listener: () => void) => () => void;
    notify: () => void;
  };
}

const createDrawerControl = (handle: DrawerHandle): DrawerControl => {
  const listeners = new Set<() => void>();
  return {
    [drawerControlKey]: {
      handle,
      notify: () => {
        for (const listener of listeners) {
          listener();
        }
      },
      subscribe: (listener) => {
        listeners.add(listener);
        return () => {
          listeners.delete(listener);
        };
      },
    },
    close: () => handle.close(),
    get isOpen() {
      return handle.isOpen;
    },
    open: () => handle.open(null),
  };
};

const getServerOpen = () => false;

const useDrawerSubscription = (control: DrawerControl) => {
  useSyncExternalStore(
    control[drawerControlKey].subscribe,
    () => control.isOpen,
    getServerOpen
  );
  return control;
};

const useDrawerControl = (): DrawerControl => {
  const [control] = useState(() =>
    createDrawerControl(DrawerPrimitive.createHandle())
  );
  return useDrawerSubscription(control);
};

interface DrawerContextValue {
  container: HTMLElement | null;
  control: DrawerControl;
  hasSnapPoints: boolean;
  modal: DrawerPrimitive.Root.Props["modal"];
  showSwipeHandle: boolean;
  swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>;
}

const DrawerContext = createContext<DrawerContextValue | null>(null);

const useDrawer = (control?: DrawerControl): DrawerControl => {
  const context = useContext(DrawerContext);
  const selected = control ?? context?.control;
  if (!selected) {
    throw new Error(
      "useDrawer must be used inside Drawer or receive a control"
    );
  }
  return useDrawerSubscription(selected);
};

type DrawerRootProps = Omit<
  DrawerPrimitive.Root.Props,
  "handle" | "open" | "defaultOpen"
> & {
  showSwipeHandle?: boolean;
} & (
    | {
        control: DrawerControl;
        handle?: never;
        open?: never;
        defaultOpen?: never;
      }
    | {
        control?: never;
        handle?: DrawerPrimitive.Root.Props["handle"];
        open?: boolean;
        defaultOpen?: boolean;
      }
  );

const controlOwners = new WeakMap<DrawerControl, object>();

const DrawerRoot = ({
  control,
  handle,
  onOpenChange,
  modal = true,
  showSwipeHandle = false,
  snapPoints,
  swipeDirection = "right",
  ...props
}: DrawerRootProps) => {
  const [localHandle] = useState(() => DrawerPrimitive.createHandle());
  const localControl = useMemo(
    () => createDrawerControl(handle ?? localHandle),
    [handle, localHandle]
  );
  const selected = control ?? localControl;
  const [owner] = useState(() => ({}));
  useEffect(() => {
    if (controlOwners.has(selected)) {
      throw new Error(
        "A DrawerControl can only be bound to one Drawer at a time"
      );
    }
    controlOwners.set(selected, owner);
    selected[drawerControlKey].notify();
    return () => {
      if (controlOwners.get(selected) === owner) {
        controlOwners.delete(selected);
      }
      queueMicrotask(selected[drawerControlKey].notify);
    };
  }, [owner, selected]);
  useEffect(() => {
    selected[drawerControlKey].notify();
  }, [selected, props.open]);
  if (
    control &&
    (props.open !== undefined ||
      props.defaultOpen !== undefined ||
      handle !== undefined)
  ) {
    throw new Error(
      "Drawer control cannot be combined with open, defaultOpen, or handle"
    );
  }
  const [container, setContainer] = useState<HTMLElement | null>(null);
  const setHost = useCallback((node: HTMLSpanElement | null) => {
    // Shared with Dialog: choose the outermost theme host, outside animated panels.
    let host: HTMLElement | null = node;
    let ancestor = node?.parentElement?.closest<HTMLElement>(
      "[data-vandor-overlay-host]"
    );
    while (ancestor) {
      host = ancestor;
      ancestor = ancestor.parentElement?.closest<HTMLElement>(
        "[data-vandor-overlay-host]"
      );
    }
    setContainer(host);
  }, []);
  const hasSnapPoints =
    snapPoints !== undefined && snapPoints !== null && snapPoints.length > 0;
  const value = useMemo(
    () => ({
      container,
      control: selected,
      hasSnapPoints,
      modal,
      showSwipeHandle,
      swipeDirection,
    }),
    [container, selected, hasSnapPoints, modal, showSwipeHandle, swipeDirection]
  );

  return (
    <DrawerContext.Provider value={value}>
      {/* Keep nested Dialog/Drawer portals in the outer caller's theme scope. */}
      <span
        ref={setHost}
        data-vandor-overlay-host=""
        style={{ display: "contents" }}
      >
        <DrawerPrimitive.Root
          handle={selected[drawerControlKey].handle}
          onOpenChange={(nextOpen, details) => {
            try {
              onOpenChange?.(nextOpen, details);
            } finally {
              // Read the accepted state after Base UI has applied or cancelled the request.
              queueMicrotask(selected[drawerControlKey].notify);
            }
          }}
          modal={modal}
          snapPoints={snapPoints}
          swipeDirection={swipeDirection}
          {...props}
        />
      </span>
    </DrawerContext.Provider>
  );
};

const DrawerTrigger = ({
  className,
  ...props
}: DrawerPrimitive.Trigger.Props) => (
  <DrawerPrimitive.Trigger
    data-slot="drawer-trigger"
    className={cn(
      "cursor-pointer disabled:cursor-not-allowed data-disabled:cursor-not-allowed",
      className
    )}
    {...props}
  />
);

const DrawerPortal = (props: DrawerPrimitive.Portal.Props) => (
  <DrawerPrimitive.Portal
    data-slot="drawer-portal"
    style={{ position: "absolute" }}
    {...props}
  />
);

const DrawerClose = ({ className, ...props }: DrawerPrimitive.Close.Props) => (
  <DrawerPrimitive.Close
    data-slot="drawer-close"
    className={cn(
      "cursor-pointer disabled:cursor-not-allowed data-disabled:cursor-not-allowed",
      className
    )}
    {...props}
  />
);

const DrawerOverlay = ({
  className,
  ...props
}: DrawerPrimitive.Backdrop.Props) => (
  <DrawerPrimitive.Backdrop
    data-slot="drawer-overlay"
    className={cn(
      "fixed inset-0 z-50 min-h-dvh bg-black/55 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-250 ease-out select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 data-snap-points:[--drawer-overlay-min-opacity:0.5] motion-reduce:transition-none supports-[-webkit-touch-callout:none]:absolute",
      className
    )}
    {...props}
  />
);

const DrawerSwipeHandle = ({ className, ...props }: ComponentProps<"div">) => (
  <div
    data-slot="drawer-swipe-handle"
    aria-hidden="true"
    className={cn(
      "relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted-foreground/40 group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 active:cursor-grabbing motion-reduce:transition-none",
      className
    )}
    {...props}
  />
);

type DrawerContentProps = Omit<DrawerPrimitive.Popup.Props, "children"> & {
  children?: ReactNode | ((control: DrawerControl) => ReactNode);
  showCloseButton?: boolean;
  closeButtonLabel?: string;
  keepMounted?: boolean;
};

const DrawerContent = ({
  className,
  children,
  showCloseButton = true,
  closeButtonLabel = "Close drawer",
  keepMounted = false,
  ...props
}: DrawerContentProps) => {
  const control = useDrawer();
  const context = useContext(DrawerContext);
  if (!context) {
    throw new Error("DrawerContent must be used inside Drawer");
  }
  const { hasSnapPoints, modal, showSwipeHandle, swipeDirection, container } =
    context;
  const swipeAxis =
    swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";

  return (
    <DrawerPortal container={container} keepMounted={keepMounted}>
      {modal === true && (
        <DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />
      )}
      <DrawerPrimitive.Viewport
        data-slot="drawer-viewport"
        data-modal={modal}
        className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
      >
        <DrawerPrimitive.Popup
          data-slot="drawer-popup"
          data-swipe-axis={swipeAxis}
          data-snap-points={hasSnapPoints ? "" : undefined}
          data-close-button={showCloseButton ? "" : undefined}
          className={cn(
            "group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col border-border bg-background text-sm text-foreground shadow-xl transition-[transform,height,opacity,filter] duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords] motion-reduce:animate-none motion-reduce:transition-none data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
            "data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
            "data-[swipe-axis=y]:data-snap-points:[--drawer-content-max-height:100dvh]",
            "[--drawer-visible-offset:max(var(--drawer-snap-point-offset,0px),calc(-1*var(--drawer-snap-point-offset,0px)))]",
            "after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--background)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
            "[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:calc(100%-var(--drawer-inset)*2)] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:28rem]",
            "data-[swipe-axis=x]:[--drawer-inset:0.5rem] data-[swipe-axis=x]:sm:[--drawer-inset:0.75rem] data-[swipe-axis=x]:rounded-2xl data-[swipe-axis=x]:border data-[swipe-axis=x]:after:hidden",
            "[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
            "data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-starting-style:transform-(--closed-transform) data-nested-drawer-swiping:duration-0 data-swiping:duration-0",
            "data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height) data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
            "data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
            "data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
            "data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
            "data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
            className
          )}
          {...props}
        >
          {showSwipeHandle && <DrawerSwipeHandle />}
          <DrawerPrimitive.Content
            data-slot="drawer-content"
            className="flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-200 select-text group-data-snap-points/drawer-popup:max-h-[calc(100%-var(--drawer-visible-offset))] group-data-[swipe-direction=up]/drawer-popup:group-data-snap-points/drawer-popup:mt-(--drawer-visible-offset) group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none motion-reduce:transition-none"
          >
            {typeof children === "function" ? children(control) : children}
            {showCloseButton && (
              <DrawerClose
                aria-label={closeButtonLabel}
                data-slot="drawer-close-button"
                className="absolute top-3 right-3 z-20 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 group-data-[swipe-direction=up]/drawer-popup:group-data-snap-points/drawer-popup:top-[calc(0.75rem+var(--drawer-visible-offset))] motion-reduce:transition-none"
              >
                <XIcon aria-hidden="true" className="size-4" />
              </DrawerClose>
            )}
          </DrawerPrimitive.Content>
        </DrawerPrimitive.Popup>
      </DrawerPrimitive.Viewport>
      {modal === true && (
        <span
          data-vandor-modal-toast-host=""
          data-toast-host-active={control.isOpen ? "true" : "false"}
          ref={(node) => {
            if (!node) return;
            if (control.isOpen && !node.dataset.toastHostOrder)
              node.dataset.toastHostOrder = String(performance.now());
            if (!control.isOpen) delete node.dataset.toastHostOrder;
          }}
          style={{ display: "contents" }}
        />
      )}
    </DrawerPortal>
  );
};

const DrawerHeader = ({ className, ...props }: ComponentProps<"div">) => (
  <div
    data-slot="drawer-header"
    className={cn(
      "flex shrink-0 flex-col gap-2 p-4 pb-0 group-data-close-button/drawer-popup:pr-14 group-data-[swipe-axis=y]/drawer-popup:text-center md:text-left",
      className
    )}
    {...props}
  />
);

const DrawerBody = ({ className, ...props }: ComponentProps<"div">) => (
  <div
    data-slot="drawer-body"
    className={cn(
      "min-h-0 flex-1 overflow-y-auto overscroll-contain p-4",
      className
    )}
    {...props}
  />
);

const DrawerFooter = ({ className, ...props }: ComponentProps<"div">) => (
  <div
    data-slot="drawer-footer"
    className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)}
    {...props}
  />
);

const DrawerTitle = ({ className, ...props }: DrawerPrimitive.Title.Props) => (
  <DrawerPrimitive.Title
    data-slot="drawer-title"
    className={cn("text-base font-medium text-foreground", className)}
    {...props}
  />
);

const DrawerDescription = ({
  className,
  ...props
}: DrawerPrimitive.Description.Props) => (
  <DrawerPrimitive.Description
    data-slot="drawer-description"
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
);

type DrawerStateProps<T = DrawerRootProps> = T extends unknown
  ? Omit<T, "children">
  : never;

interface DrawerHeaderElements {
  title: ReactElement;
  description: ReactElement | null;
}

type DrawerProps = DrawerStateProps & {
  title: ReactNode;
  description?: ReactNode;
  trigger?: ReactElement;
  children?: DrawerContentProps["children"];
  contentProps?: Omit<DrawerContentProps, "children">;
  renderHeader?: (elements: DrawerHeaderElements) => ReactNode;
};

const Drawer = ({
  title,
  description,
  trigger,
  children,
  contentProps,
  renderHeader,
  ...props
}: DrawerProps) => {
  const headerElements: DrawerHeaderElements = {
    description:
      description === null || description === undefined ? null : (
        <DrawerDescription>{description}</DrawerDescription>
      ),
    title: <DrawerTitle>{title}</DrawerTitle>,
  };
  return (
    <DrawerRoot {...props}>
      {trigger && <DrawerTrigger render={trigger} />}
      <DrawerContent {...contentProps}>
        {(control) => (
          <>
            <DrawerHeader>
              {renderHeader ? (
                renderHeader(headerElements)
              ) : (
                <>
                  {headerElements.title}
                  {headerElements.description}
                </>
              )}
            </DrawerHeader>
            {typeof children === "function" ? children(control) : children}
          </>
        )}
      </DrawerContent>
    </DrawerRoot>
  );
};

export { Drawer, DrawerBody, DrawerFooter, useDrawer, useDrawerControl };

export type { DrawerControl, DrawerHeaderElements, DrawerProps };

```

## Credits [#credits]

* [Base UI](https://base-ui.com/react/components/drawer): Drawer primitive, gesture handling, snap points, nested drawers, and accessible interaction foundation.
