Skip to main content
Nexus Design System
GitHub

Drawer

Drawer slides in a panel from a screen edge that the user can drag to dismiss. Use it for mobile-style panels, especially bottom drawers. Install vaul in your app to use it.

  • For a side panel without drag gestures, use Sheet.
  • For a centered modal task, use Dialog.
  • For a confirmation that forces a choice, use AlertDialog.
  • Set showHandle={false} on DrawerContent to remove the handle and its spacing. Dragging and dismissal stay the same.
'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/nxds/components/drawer/drawer';

export default function DrawerDefault() {
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">Open Drawer</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Move goal</DrawerTitle>
          <DrawerDescription>
            Drag down or press Escape to dismiss this drawer.
          </DrawerDescription>
        </DrawerHeader>
        <DrawerBody>
          <p className="nx:typography-body-default nx:text-foreground">
            Drawer content goes here. On touch devices it can be dragged to
            dismiss.
          </p>
        </DrawerBody>
        <DrawerFooter>
          <Button>Submit</Button>
          <DrawerClose asChild>
            <Button variant="outline">Cancel</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add drawer

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/nxds/components/drawer/drawer';

Composition

Drawer
├── DrawerTrigger
└── DrawerContent
    ├── DrawerHeader
    │   ├── DrawerTitle
    │   ├── DrawerDescription
    │   └── DrawerClose
    ├── DrawerBody
    └── DrawerFooter
        └── DrawerClose

Examples

Scrollable content

Manual check: on a touch device, scroll DrawerBody, then drag the handle or frame to dismiss the drawer.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/nxds/components/drawer/drawer';

const SCROLLABLE_ITEMS = Array.from({ length: 18 }, (_, index) => ({
  title: `Audit item ${index + 1}`,
  description:
    'A representative row that keeps the drawer body scrollable while actions remain available.',
}));

export default function DrawerScrollableContent() {
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">Open Details</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Review changes</DrawerTitle>
          <DrawerDescription>
            Long drawers keep the action footer reachable while the body
            scrolls.
          </DrawerDescription>
        </DrawerHeader>
        <DrawerBody className="nx:max-h-[45svh] nx:pb-2">
          <ul className="nx:flex nx:flex-col nx:gap-3">
            {SCROLLABLE_ITEMS.map((item) => (
              <li
                key={item.title}
                className="nx:rounded-md nx:border-default nx:border-border-default nx:bg-background nx:p-4"
              >
                <p className="nx:typography-label-default nx:text-foreground">
                  {item.title}
                </p>
                <p className="nx:typography-body-default nx:text-muted-foreground">
                  {item.description}
                </p>
              </li>
            ))}
          </ul>
        </DrawerBody>
        <DrawerFooter className="nx:border-t-default nx:border-border-default">
          <Button>Save changes</Button>
          <DrawerClose asChild>
            <Button variant="outline">Close</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  );
}

With header actions

Place a close button in DrawerHeader next to the title.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/nxds/components/drawer/drawer';
import { IconX } from '@/components/nxds/lib/icons';

export default function DrawerWithHeaderActions() {
  return (
    <Drawer direction="right">
      <DrawerTrigger asChild>
        <Button variant="outline">Open Actions</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader className="nx:flex-row nx:items-start nx:justify-between nx:gap-4">
          <div className="nx:flex nx:min-w-0 nx:flex-col nx:gap-1.5">
            <DrawerTitle>Filters</DrawerTitle>
            <DrawerDescription>
              Refine the visible records without leaving the current view.
            </DrawerDescription>
          </div>
          <DrawerClose asChild>
            <Button variant="ghost" size="icon" aria-label="Close drawer">
              <IconX aria-hidden="true" />
            </Button>
          </DrawerClose>
        </DrawerHeader>
        <DrawerBody className="nx:pb-6 nx:typography-body-default nx:text-foreground">
          Header actions are regular composition: consumers place controls in
          the header and keep Vaul responsible for dismissal.
        </DrawerBody>
      </DrawerContent>
    </Drawer>
  );
}

All variants

Set direction on the root to open the drawer from any edge.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/nxds/components/drawer/drawer';

const DIRECTIONS = ['top', 'right', 'bottom', 'left'] as const;

export default function DrawerAllVariants() {
  return (
    <div className="nx:flex nx:flex-wrap nx:gap-4">
      {DIRECTIONS.map((direction) => (
        <Drawer key={direction} direction={direction}>
          <DrawerTrigger asChild>
            <Button variant="outline" className="nx:capitalize">
              {direction}
            </Button>
          </DrawerTrigger>
          <DrawerContent>
            <DrawerHeader>
              <DrawerTitle className="nx:capitalize">
                {direction} drawer
              </DrawerTitle>
              <DrawerDescription>
                Slides in from the {direction} edge.
              </DrawerDescription>
            </DrawerHeader>
            <DrawerFooter>
              <DrawerClose asChild>
                <Button>Close</Button>
              </DrawerClose>
            </DrawerFooter>
          </DrawerContent>
        </Drawer>
      ))}
    </div>
  );
}

Props

Drawer

Drawer adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerBody

DrawerBody adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerClose

DrawerClose adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerContent

DrawerContent props
PropTypeDefaultDescription
showHandlebooleantrue

Shows the handle on bottom drawers. Hiding it does not change dragging or dismissal.

DrawerDescription

DrawerDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerFooter adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerHeader

DrawerHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerOverlay

DrawerOverlay adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerPortal

DrawerPortal adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerTitle

DrawerTitle adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerTrigger

DrawerTrigger adds no props of its own; it accepts the props of the element or primitive it wraps.

Drawer slides in a panel from a screen edge that the user can drag to dismiss. Use it for mobile-style panels, especially bottom drawers. Install vaul-vue in your app to use it.

  • For a side panel without drag gestures, use Sheet.
  • For a centered modal task, use Dialog.
  • For a confirmation that forces a choice, use AlertDialog.
  • Set :show-handle="false" on DrawerContent to remove the handle and its spacing. Dragging and dismissal stay the same.
<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/drawer';
</script>

<template>
  <Drawer>
    <DrawerTrigger as-child>
      <Button variant="outline">Open Drawer</Button>
    </DrawerTrigger>
    <DrawerContent>
      <DrawerHeader>
        <DrawerTitle>Move goal</DrawerTitle>
        <DrawerDescription
          >Drag down or press Escape to dismiss this drawer.</DrawerDescription
        >
      </DrawerHeader>
      <DrawerBody>
        <p class="nx:typography-body-default nx:text-foreground">
          Drawer content goes here. On touch devices it can be dragged to
          dismiss.
        </p>
      </DrawerBody>
      <DrawerFooter>
        <Button>Submit</Button>
        <DrawerClose as-child>
          <Button variant="outline">Cancel</Button>
        </DrawerClose>
      </DrawerFooter>
    </DrawerContent>
  </Drawer>
</template>

This example also needs: Button.

Installation

  1. Install the following dependencies:

    npm install @vueuse/core@^14.1.0 class-variance-authority@^0.7.1 reka-ui@^2.11.0 vaul-vue@^0.4.1
    
  2. Copy and paste the following code into your project.

    components/drawer/Drawer.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import { useForwardProps } from 'reka-ui';
    import {
      DrawerRoot,
      type DrawerRootEmits,
      type DrawerRootProps,
    } from 'vaul-vue';
    import { computed, ref } from 'vue';
    
    export type DrawerProps = DrawerRootProps;
    
    const props = withDefaults(defineProps<DrawerProps>(), {
      dismissible: true,
      modal: true,
      open: undefined,
      defaultOpen: undefined,
    });
    const emits = defineEmits<DrawerRootEmits>();
    
    const delegated = reactiveOmit(props, 'open', 'defaultOpen');
    const forwarded = useForwardProps(delegated);
    
    const uncontrolledOpen = ref(props.defaultOpen ?? false);
    const open = computed(() => props.open ?? uncontrolledOpen.value);
    
    // vaul-vue closes on DrawerClose even when the drawer is not dismissible.
    function handleOpenChange(value: boolean) {
      if (!value && !props.dismissible) return;
      uncontrolledOpen.value = value;
      emits('update:open', value);
    }
    </script>
    
    <template>
      <DrawerRoot
        v-bind="forwarded"
        :open="open"
        @update:open="handleOpenChange"
        @drag="(percentage) => emits('drag', percentage)"
        @release="(released) => emits('release', released)"
        @close="emits('close')"
        @update:active-snap-point="
          (value) => emits('update:activeSnapPoint', value)
        "
        @animation-end="(value) => emits('animationEnd', value)"
      >
        <slot />
      </DrawerRoot>
    </template>
    
    components/drawer/DrawerBody.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { overlayBodyClassName } from '../overlay-layout/overlay-layout';
    
    export interface DrawerBodyProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DrawerBodyProps>();
    </script>
    
    <template>
      <div
        data-slot="drawer-body"
        tabindex="0"
        :class="cn(overlayBodyClassName, props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/drawer/DrawerContent.vue
    <script setup lang="ts">
    import { DrawerContent, DrawerPortal } from 'vaul-vue';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import DrawerOverlay from './DrawerOverlay.vue';
    
    export interface DrawerContentProps {
      /**
       * Shows the handle on bottom drawers. Hiding it does not change dragging or
       * dismissal.
       */
      showHandle?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<DrawerContentProps>(), {
      showHandle: true,
    });
    </script>
    
    <template>
      <DrawerPortal>
        <DrawerOverlay />
        <DrawerContent
          v-bind="$attrs"
          data-slot="drawer-content"
          :class="
            cn(
              'nx:group/drawer-content nx:fixed nx:z-modal nx:flex nx:h-auto nx:flex-col nx:overflow-hidden nx:bg-container nx:shadow-lg',
              'nx:data-[vaul-drawer-direction=top]:inset-x-0 nx:data-[vaul-drawer-direction=top]:top-0 nx:data-[vaul-drawer-direction=top]:mb-24 nx:data-[vaul-drawer-direction=top]:max-h-[80svh] nx:data-[vaul-drawer-direction=top]:rounded-b-lg nx:data-[vaul-drawer-direction=top]:border-b-default nx:data-[vaul-drawer-direction=top]:border-border-default',
              'nx:data-[vaul-drawer-direction=bottom]:inset-x-0 nx:data-[vaul-drawer-direction=bottom]:bottom-0 nx:data-[vaul-drawer-direction=bottom]:mt-24 nx:data-[vaul-drawer-direction=bottom]:max-h-[80svh] nx:data-[vaul-drawer-direction=bottom]:rounded-t-lg nx:data-[vaul-drawer-direction=bottom]:border-t-default nx:data-[vaul-drawer-direction=bottom]:border-border-default',
              'nx:data-[vaul-drawer-direction=right]:right-0 nx:data-[vaul-drawer-direction=right]:top-0 nx:data-[vaul-drawer-direction=right]:h-svh nx:data-[vaul-drawer-direction=right]:w-3/4 nx:data-[vaul-drawer-direction=right]:border-l-default nx:data-[vaul-drawer-direction=right]:border-border-default nx:data-[vaul-drawer-direction=right]:sm:max-w-sm',
              'nx:data-[vaul-drawer-direction=left]:left-0 nx:data-[vaul-drawer-direction=left]:top-0 nx:data-[vaul-drawer-direction=left]:h-svh nx:data-[vaul-drawer-direction=left]:w-3/4 nx:data-[vaul-drawer-direction=left]:border-r-default nx:data-[vaul-drawer-direction=left]:border-border-default nx:data-[vaul-drawer-direction=left]:sm:max-w-sm',
              props.class
            )
          "
        >
          <div
            v-if="showHandle"
            data-slot="drawer-handle"
            class="nx:mx-auto nx:mt-4 nx:hidden nx:h-2 nx:w-[100px] nx:shrink-0 nx:rounded-full nx:bg-muted nx:group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
          />
          <slot />
        </DrawerContent>
      </DrawerPortal>
    </template>
    
    components/drawer/DrawerDescription.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import { useForwardProps } from 'reka-ui';
    import {
      DrawerDescription,
      type DrawerDescriptionProps as VaulDrawerDescriptionProps,
    } from 'vaul-vue';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface DrawerDescriptionProps extends VaulDrawerDescriptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DrawerDescriptionProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <DrawerDescription
        v-bind="forwarded"
        data-slot="drawer-description"
        :class="
          cn(
            'nx:m-0 nx:typography-body-default nx:text-muted-foreground',
            props.class
          )
        "
      >
        <slot />
      </DrawerDescription>
    </template>
    
    components/drawer/DrawerFooter.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface DrawerFooterProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DrawerFooterProps>();
    </script>
    
    <template>
      <div
        data-slot="drawer-footer"
        :class="
          cn(
            'nx:mt-auto nx:flex nx:shrink-0 nx:flex-col nx:gap-2 nx:p-6',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/drawer/DrawerHeader.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface DrawerHeaderProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DrawerHeaderProps>();
    </script>
    
    <template>
      <div
        data-slot="drawer-header"
        :class="
          cn(
            'nx:flex nx:shrink-0 nx:flex-col nx:gap-1.5 nx:p-6 nx:group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center nx:group-data-[vaul-drawer-direction=top]/drawer-content:text-center nx:md:text-left',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/drawer/DrawerOverlay.vue
    <script setup lang="ts">
    import { DrawerOverlay } from 'vaul-vue';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { overlayScrimTransitionClassName } from '../overlay-layout/overlay-layout';
    
    export interface DrawerOverlayProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DrawerOverlayProps>();
    </script>
    
    <template>
      <DrawerOverlay
        data-slot="drawer-overlay"
        :class="
          cn(
            'nx:fixed nx:inset-0 nx:z-modal nx:bg-overlay',
            overlayScrimTransitionClassName,
            props.class
          )
        "
      />
    </template>
    
    components/drawer/DrawerTitle.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import { useForwardProps } from 'reka-ui';
    import {
      DrawerTitle,
      type DrawerTitleProps as VaulDrawerTitleProps,
    } from 'vaul-vue';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface DrawerTitleProps extends VaulDrawerTitleProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DrawerTitleProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <DrawerTitle
        v-bind="forwarded"
        data-slot="drawer-title"
        :class="cn('nx:m-0 nx:typography-heading-xsmall', props.class)"
      >
        <slot />
      </DrawerTitle>
    </template>
    
    components/drawer/index.ts
    export { default as Drawer, type DrawerProps } from './Drawer.vue';
    export { default as DrawerBody, type DrawerBodyProps } from './DrawerBody.vue';
    export {
      default as DrawerContent,
      type DrawerContentProps,
    } from './DrawerContent.vue';
    export {
      default as DrawerDescription,
      type DrawerDescriptionProps,
    } from './DrawerDescription.vue';
    export {
      default as DrawerFooter,
      type DrawerFooterProps,
    } from './DrawerFooter.vue';
    export {
      default as DrawerHeader,
      type DrawerHeaderProps,
    } from './DrawerHeader.vue';
    export {
      default as DrawerOverlay,
      type DrawerOverlayProps,
    } from './DrawerOverlay.vue';
    export {
      default as DrawerTitle,
      type DrawerTitleProps,
    } from './DrawerTitle.vue';
    export {
      DrawerClose,
      type DrawerCloseProps,
      DrawerPortal,
      type DrawerPortalProps,
      DrawerTrigger,
      type DrawerTriggerProps,
    } from 'vaul-vue';
    
    components/overlay-layout/overlay-layout.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    /**
     * Popover-family surfaces share the translucent/blurred recipe; Tooltip uses a
     * solid carve-out because its small, brief text appears over arbitrary content.
     */
    export const popoverSurfaceClassName = [
      'nx:rounded-md',
      'nx:border-default',
      'nx:border-border-default',
      'nx:bg-popover-alpha',
      'nx:backdrop-blur-md',
      'nx:text-popover-foreground',
      'nx:typography-body-default',
      'nx:shadow-lg',
    ].join(' ');
    
    export const tooltipSurfaceClassName = [
      'nx:rounded-md',
      'nx:border-default',
      'nx:border-border-default',
      'nx:bg-popover',
      'nx:text-popover-foreground',
      'nx:typography-body-default',
      'nx:shadow-lg',
    ].join(' ');
    
    export const navigationMenuInlinePopoverSurfaceClassName = [
      'nx:group-data-[viewport=false]/navigation-menu:rounded-md',
      'nx:group-data-[viewport=false]/navigation-menu:border-default',
      'nx:group-data-[viewport=false]/navigation-menu:border-border-default',
      'nx:group-data-[viewport=false]/navigation-menu:bg-popover-alpha',
      'nx:group-data-[viewport=false]/navigation-menu:backdrop-blur-md',
      'nx:group-data-[viewport=false]/navigation-menu:text-popover-foreground',
      'nx:group-data-[viewport=false]/navigation-menu:shadow-lg',
    ].join(' ');
    
    export const navigationMenuInlinePopoverTransitionClassName = [
      'nx:group-data-[viewport=false]/navigation-menu:transition-[opacity,scale]',
      'nx:group-data-[viewport=false]/navigation-menu:duration-fast',
      'nx:group-data-[viewport=false]/navigation-menu:ease-move',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:scale-95',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:opacity-0',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-overlay-presence-exit',
    ].join(' ');
    
    // `animate-overlay-presence-exit` is a non-visual "presence bridge": Reka's
    // Presence only waits on `animationName`, not `transitionend`, so this inert
    // animation keeps a closing overlay mounted while the opacity/scale/translate
    // TRANSITIONS below run the visible exit. It animates an unread custom property (see
    // generateMotionUtilitiesCSS in @nxds/core), never a transitioned property, so it
    // cannot override those transitions.
    const overlayPresenceExitClassName =
      'nx:data-[state=closed]:animate-overlay-presence-exit';
    
    export const overlayFloatingTransitionClassName = [
      'nx:transition-[opacity,scale]',
      'nx:duration-fast',
      'nx:ease-move',
      'nx:data-[state=closed]:scale-95',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    const overlayContentTransitionClassName = [
      'nx:transition-[opacity,scale]',
      'nx:duration-default',
      'nx:ease-move',
      'nx:data-[state=closed]:scale-95',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayScrimTransitionClassName = [
      'nx:transition-opacity',
      'nx:duration-default',
      'nx:ease-move',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayPanelTransitionClassName = [
      'nx:transition-[translate]',
      'nx:duration-slow',
      'nx:ease-move',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayContentVariants = cva(
      [
        // Centred with margins, not a translate: a transform would make this the
        // containing block for fixed-position popups a dialog holds.
        'nx:fixed nx:inset-0 nx:z-modal nx:m-auto nx:flex nx:h-fit nx:w-full nx:max-w-lg nx:flex-col',
        'nx:max-h-[calc(100svh-2rem)] nx:overflow-hidden',
        'nx:gap-4 nx:border-default nx:border-border-default nx:bg-container nx:py-6 nx:shadow-lg',
        overlayContentTransitionClassName,
        'nx:sm:rounded-lg',
      ].join(' ')
    );
    
    export const overlayScrimVariants = cva(
      [
        'nx:fixed nx:inset-0 nx:z-modal nx:bg-overlay',
        overlayScrimTransitionClassName,
      ].join(' ')
    );
    
    export const overlayHeaderVariants = cva(
      'nx:flex nx:shrink-0 nx:flex-col nx:gap-1 nx:px-6',
      {
        variants: {
          variant: {
            default: 'nx:text-center nx:sm:text-left',
            center: 'nx:items-center nx:text-center',
          },
        },
        defaultVariants: {
          variant: 'default',
        },
      }
    );
    
    export const overlayBodyClassName = [
      'nx:min-h-0',
      'nx:overflow-y-auto',
      'nx:px-6',
      'nx:focus-visible:outline-2',
      'nx:focus-visible:outline-focus-default',
      'nx:focus-visible:[outline-offset:-2px]',
    ].join(' ');
    
    export const overlayFooterVariants = cva(
      'nx:flex nx:shrink-0 nx:gap-2 nx:px-6',
      {
        variants: {
          orientation: {
            horizontal:
              'nx:flex-col nx:sm:flex-row nx:sm:items-center nx:sm:justify-end',
            vertical: 'nx:flex-col nx:items-stretch nx:*:w-full',
          },
        },
        defaultVariants: {
          orientation: 'horizontal',
        },
      }
    );
    
    export const overlayCloseButtonClassName = [
      'nx:absolute nx:right-6 nx:top-6 nx:rounded-sm nx:border-0 nx:bg-transparent nx:p-1 nx:text-muted-foreground-subtle',
      'nx:transition-control',
      'nx:hover:bg-container-hover nx:hover:text-foreground',
      'nx:focus-visible:bg-container-hover nx:focus-visible:text-foreground',
      'nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default',
      'nx:disabled:pointer-events-none',
    ].join(' ');
    
    export type OverlayHeaderVariants = VariantProps<typeof overlayHeaderVariants>;
    
    export type OverlayVariant = NonNullable<
      VariantProps<typeof overlayHeaderVariants>['variant']
    >;
    
    export type OverlayButtonOrientation = NonNullable<
      VariantProps<typeof overlayFooterVariants>['orientation']
    >;
    
    export type OverlayLayoutContextValue = {
      variant: OverlayVariant;
      buttonOrientation: OverlayButtonOrientation;
    };
    
    export const defaultOverlayLayout: OverlayLayoutContextValue = {
      variant: 'default',
      buttonOrientation: 'horizontal',
    };
    
    export function resolveOverlayButtonOrientation(
      variant: OverlayVariant
    ): OverlayButtonOrientation {
      return variant === 'center' ? 'vertical' : 'horizontal';
    }
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/drawer';

Examples

Scrollable content

Manual check: on a touch device, scroll DrawerBody, then drag the handle or frame to dismiss the drawer.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/drawer';

const items = Array.from({ length: 18 }, (_, index) => ({
  title: `Audit item ${index + 1}`,
  description:
    'A representative row that keeps the drawer body scrollable while actions remain available.',
}));
</script>

<template>
  <Drawer>
    <DrawerTrigger as-child>
      <Button variant="outline">Open Details</Button>
    </DrawerTrigger>
    <DrawerContent>
      <DrawerHeader>
        <DrawerTitle>Review changes</DrawerTitle>
        <DrawerDescription
          >Long drawers keep the action footer reachable while the body
          scrolls.</DrawerDescription
        >
      </DrawerHeader>
      <DrawerBody
        data-testid="drawer-scroll-area"
        class="nx:max-h-[45svh] nx:pb-2"
      >
        <ul class="nx:flex nx:flex-col nx:gap-3">
          <li
            v-for="item in items"
            :key="item.title"
            class="nx:rounded-md nx:border-default nx:border-border-default nx:bg-background nx:p-4"
          >
            <p class="nx:typography-label-default nx:text-foreground">
              {{ item.title }}
            </p>
            <p class="nx:typography-body-default nx:text-muted-foreground">
              {{ item.description }}
            </p>
          </li>
        </ul>
      </DrawerBody>
      <DrawerFooter class="nx:border-t-default nx:border-border-default">
        <Button>Save changes</Button>
        <DrawerClose as-child>
          <Button variant="outline">Close</Button>
        </DrawerClose>
      </DrawerFooter>
    </DrawerContent>
  </Drawer>
</template>

With header actions

Place a close button in DrawerHeader next to the title.

<script setup lang="ts">
import { PhX } from '@phosphor-icons/vue';

import { Button } from '@/components/button';
import {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/drawer';
</script>

<template>
  <Drawer direction="right">
    <DrawerTrigger as-child>
      <Button variant="outline">Open Actions</Button>
    </DrawerTrigger>
    <DrawerContent>
      <DrawerHeader
        class="nx:flex-row nx:items-start nx:justify-between nx:gap-4"
      >
        <div class="nx:flex nx:min-w-0 nx:flex-col nx:gap-1.5">
          <DrawerTitle>Filters</DrawerTitle>
          <DrawerDescription
            >Refine the visible records without leaving the current
            view.</DrawerDescription
          >
        </div>
        <DrawerClose as-child>
          <Button variant="ghost" size="icon" aria-label="Close drawer">
            <PhX aria-hidden="true" />
          </Button>
        </DrawerClose>
      </DrawerHeader>
      <DrawerBody class="nx:pb-6 nx:typography-body-default nx:text-foreground"
        >Header actions are regular composition: consumers place controls in the
        header and keep Vaul responsible for dismissal.</DrawerBody
      >
    </DrawerContent>
  </Drawer>
</template>

This example also needs:

npm install @phosphor-icons/vue@^2.2.1

All variants

Set direction on the root to open the drawer from any edge.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/drawer';

const directions = ['top', 'right', 'bottom', 'left'] as const;
</script>

<template>
  <div class="nx:flex nx:flex-wrap nx:gap-4">
    <Drawer
      v-for="direction in directions"
      :key="direction"
      :direction="direction"
    >
      <DrawerTrigger as-child>
        <Button variant="outline" class="nx:capitalize">{{ direction }}</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle class="nx:capitalize"
            >{{ direction }} drawer</DrawerTitle
          >
          <DrawerDescription
            >Slides in from the {{ direction }} edge.</DrawerDescription
          >
        </DrawerHeader>
        <DrawerFooter>
          <DrawerClose as-child>
            <Button>Close</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  </div>
</template>

Props

Drawer

Drawer adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerBody

DrawerBody adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerContent

DrawerContent props
PropTypeDefaultDescription
showHandlebooleantrue

Shows the handle on bottom drawers. Hiding it does not change dragging or dismissal.

DrawerDescription

DrawerDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerFooter adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerHeader

DrawerHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerOverlay

DrawerOverlay adds no props of its own; it accepts the props of the element or primitive it wraps.

DrawerTitle

DrawerTitle adds no props of its own; it accepts the props of the element or primitive it wraps.