For the complete documentation index, see 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.
1
Support

Drawer

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

Playground

Live preview
Open the drawer to preview it. Choose up or down to try snap points. Swipe toward the opening edge or press Escape to close.
Customize Drawer
Customize
drawer-demo.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

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

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/drawer.json

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies3 required packages
  • @base-ui/react
  • cn
  • lucide-react
$ pnpm add @base-ui/react cn lucide-react

Usage

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

drawer-demo.tsx
"use client";

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

export const DrawerDemo = () => (
  <Drawer
    title="Project settings"
    description="Manage the details and preferences for this project."
    trigger={
      <button
        type="button"
        className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        Open drawer
      </button>
    }
  >
    {({ close }) => (
      <>
        <DrawerBody className="pt-5 text-sm text-muted-foreground">
          Drawer content can hold forms, navigation, or supporting actions. On
          touch devices, swipe toward the right edge to dismiss it.
        </DrawerBody>
        <DrawerFooter>
          <button
            type="button"
            onClick={close}
            className="inline-flex h-9 cursor-pointer items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Done
          </button>
        </DrawerFooter>
      </>
    )}
  </Drawer>
);

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.

PropPurpose
titleRequired meaningful accessible title. Accepts a React node.
descriptionOptional supporting content.
triggerOptional React element composed into one associated Base UI trigger. Custom button components must forward their ref and native button props.
childrenPanel content, or a function receiving DrawerControl.
contentPropsPanel props, including className, ref, finalFocus, showCloseButton, and closeButtonLabel. Children belong to Drawer, not this object.
State and behaviorcontrol, 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

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.

<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

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

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.

<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

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.

"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

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.

<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

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:

MemberBehavior
isOpenRead-only reactive state of the mounted Drawer.
open()Requests opening through Base UI.
close()Requests closing through Base UI.

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.

"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

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

  • 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.
<Drawer
  title="Settings"
  control={control}
  onOpenChange={(nextOpen, details) => {
    if (!nextOpen && hasUnsavedChanges) details.cancel();
  }}
>
  <DrawerBody>Your settings</DrawerBody>
</Drawer>

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.

"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

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

"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

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

"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

  • 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 for underlying behavior and root props.

Portable Storybook stories

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

$ pnpm dlx 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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
exampleExample preset

Playground only: choose trigger, external control, render function, or a form.

Not applicable
longContentboolean

Playground only: adds content to demonstrate DrawerBody scrolling.

Not applicable
controlDrawerControl

Drawer: stable controller returned by useDrawerControl. Cannot be combined with open, defaultOpen, or handle.

Not set
titleReactNode

Drawer: meaningful accessible panel title.

Required
renderHeader(elements: DrawerHeaderElements) => ReactNode

Drawer: custom header contents receiving ready-to-render title and optional description elements. Keep title in the returned content.

Not set
descriptionReactNode

Drawer: optional supporting content beneath the title.

Not set
triggerReactElement

Drawer: optional button element composed as an associated trigger.

Not set
childrenReactNode | ((control: DrawerControl) => ReactNode)

Drawer: content or a function receiving the panel controller.

Not set
contentPropsobject

Drawer: popup props, focus restoration, close button options, and keepMounted. Does not accept children.

Not set
swipeDirection"right" | "left" | "up" | "down"

Drawer: edge the panel enters from and swipes toward to close.

"right"
modalboolean

Drawer: locks modal focus and renders a backdrop when enabled.

true
showSwipeHandleboolean

Drawer: shows a visual grabber for touch gestures.

false
showCloseButtonboolean

contentProps.showCloseButton: shows the top-right close button.

true
closeButtonLabelstring

contentProps.closeButtonLabel: accessible label for the close button. Use a localized label in translated interfaces.

"Close drawer"
snapPointsnumber[]

Drawer: optional vertical drawer resting heights.

Not set
defaultOpenboolean

Drawer: initially opens the panel.

false
openboolean

Drawer: controlled open state.

Not set
onOpenChange(open, details) => void

Drawer: receives the next open state and Base UI event details.

Not set

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

"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

  • Base UI: Drawer primitive, gesture handling, snap points, nested drawers, and accessible interaction foundation.