Drawer

A panel that slides in from the edge of the screen.

Installation

pnpm dlx shadcn@latest add @lumi-ui/drawer
Add global variables to your @theme inline
@theme inline {
  /* --- other variables --- */
  --drawer-bleed: 3rem;
  --ease-smooth: cubic-bezier(0.32, 0.72, 0, 1);
}
Add the following utilities to your globals.css
globals.css
@layer utilities {
  .drawer-popup {
    touch-action: auto;
    transition: transform 450ms var(--ease-smooth);
 
    &[data-swiping] {
      @apply select-none;
    }
 
    &[data-ending-style] {
      transition-duration: calc(var(--drawer-swipe-strength) * 400ms);
    }
  }
 
  /* Bottom */
  .drawer-popup[data-side="bottom"] {
    --drawer-bleed: 0px;
    --viewport-padding: 1rem;
    max-height: 80vh;
    transform: translateY(var(--drawer-swipe-movement-y));
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateY(calc(100% + var(--viewport-padding) + 2px));
    }
  }
 
  /* Bottom fullBleed */
  .drawer-popup[data-side="bottom"][data-layout="fullBleed"] {
    @apply -mb-(--drawer-bleed);
    --viewport-padding: 0px;
    max-height: calc(80vh + var(--drawer-bleed));
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateY(calc(100% - var(--drawer-bleed) + 2px));
    }
  }
 
  /* Bottom responsive */
  .drawer-popup[data-side="bottom"][data-layout="responsive"] {
    @media (min-width: 640px) {
      margin-bottom: calc(-1 * var(--drawer-bleed));
      --viewport-padding: 0px;
      max-height: calc(80vh + var(--drawer-bleed));
 
      &[data-ending-style],
      &[data-starting-style] {
        transform: translateY(calc(100% - var(--drawer-bleed) + 2px));
      }
    }
  }
 
  /* Top */
  .drawer-popup[data-side="top"] {
    --drawer-bleed: 0px;
    --viewport-padding: 1rem;
    max-height: 80vh;
    transform: translateY(var(--drawer-swipe-movement-y));
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateY(calc(-100% - var(--viewport-padding) - 2px));
    }
  }
 
  /* Top fullBleed */
  .drawer-popup[data-side="top"][data-layout="fullBleed"] {
    @apply -mt-(--drawer-bleed);
    --viewport-padding: 0px;
    max-height: calc(80vh + var(--drawer-bleed));
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateY(calc(-100% + var(--drawer-bleed) - 2px));
    }
  }
 
  /* Top responsive */
  .drawer-popup[data-side="top"][data-layout="responsive"] {
    @media (min-width: 640px) {
      margin-top: calc(-1 * var(--drawer-bleed));
      --viewport-padding: 0px;
      max-height: calc(80vh + var(--drawer-bleed));
 
      &[data-ending-style],
      &[data-starting-style] {
        transform: translateY(calc(-100% + var(--drawer-bleed) - 2px));
      }
    }
  }
 
  /* Left */
  .drawer-popup[data-side="left"] {
    @apply h-full w-80 sm:w-96 max-w-[calc(100vw-2rem)];
    --drawer-bleed: 0px;
    --viewport-padding: 1rem;
    transform: translateX(var(--drawer-swipe-movement-x));
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateX(calc(-100% - var(--viewport-padding) - 2px));
    }
  }
 
  /* Left fullBleed */
  .drawer-popup[data-side="left"][data-layout="fullBleed"] {
    @apply -ml-(--drawer-bleed) w-[calc(20rem+var(--drawer-bleed))] sm:w-[calc(24rem+var(--drawer-bleed))] max-w-[100vw];
    --viewport-padding: 0px;
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateX(calc(-100% + var(--drawer-bleed) - 2px));
    }
  }
 
  /* Left responsive */
  .drawer-popup[data-side="left"][data-layout="responsive"] {
    @media (min-width: 640px) {
      margin-left: calc(-1 * var(--drawer-bleed));
      width: calc(24rem + var(--drawer-bleed));
      max-width: 100vw;
      --viewport-padding: 0px;
 
      &[data-ending-style],
      &[data-starting-style] {
        transform: translateX(calc(-100% + var(--drawer-bleed) - 2px));
      }
    }
  }
 
  /* Right */
  .drawer-popup[data-side="right"] {
    @apply h-full w-80 sm:w-96 max-w-[calc(100vw-2rem)];
    --drawer-bleed: 0px;
    --viewport-padding: 1rem;
    transform: translateX(var(--drawer-swipe-movement-x));
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateX(calc(100% + var(--viewport-padding) + 2px));
    }
  }
 
  /* Right fullBleed */
  .drawer-popup[data-side="right"][data-layout="fullBleed"] {
    @apply -mr-(--drawer-bleed) w-[calc(20rem+var(--drawer-bleed))] sm:w-[calc(24rem+var(--drawer-bleed))] max-w-[100vw];
    --viewport-padding: 0px;
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateX(calc(100% - var(--drawer-bleed) + 2px));
    }
  }
 
  /* Right responsive */
  .drawer-popup[data-side="right"][data-layout="responsive"] {
    @media (min-width: 640px) {
      margin-right: calc(-1 * var(--drawer-bleed));
      width: calc(24rem + var(--drawer-bleed));
      max-width: 100vw;
      --viewport-padding: 0px;
 
      &[data-ending-style],
      &[data-starting-style] {
        transform: translateX(calc(100% - var(--drawer-bleed) + 2px));
      }
    }
  }
 
}

Basic Usage

import {
  Drawer,
  DrawerClose,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerContent,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer";
 
export function DrawerDemo() {
  return (
    <Drawer>
      <DrawerTrigger>Open Drawer</DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Are you absolutely sure?</DrawerTitle>
          <DrawerDescription>
            This action cannot be undone. This will permanently delete your
            account and remove your data from our servers.
          </DrawerDescription>
        </DrawerHeader>
        <DrawerFooter>
          <DrawerClose>Cancel</DialogClose>
          <DrawerClose>Continue</DialogClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  );
}

Anatomy

<DrawerProvider>
  <DrawerIndentBackground />
  <DrawerIndent>
  <Drawer>
    <DrawerTrigger />
    <DrawerSwipeArea />
    <DrawerContent>
      <DrawerDragHandle />
      <DrawerSelectable>
        <DrawerHeader>
          <DrawerTitle />
          <DrawerDescription />
        </DrawerHeader>
        <DrawerFooter>
          <DrawerClose />
        </DrawerFooter>
      </DrawerSelectable>
    </DrawerPortal>
  </Drawer>
  </DrawerIndent>
</DrawerProvider>

Directions

Layout variants

Inset

Responsive

Cookbook

Enable content selection inside drawer

Scrollable content

Stacked drawer

To enable stacking, use the Stacked variant for DrawerContent and add the following CSS:

globals.css
/*
   Drawer Stacked Popup
*/
@layer utilities {
  .drawer-stacked-popup {
    touch-action: auto;
    --drawer-bleed: 0px;
    --viewport-padding: 1rem;
    max-height: 80vh;
 
    /* Stacking math */
    --peek: 1rem;
    --stack-progress: clamp(0, var(--drawer-swipe-progress), 1);
    --stack-step: 0.05;
    --stack-peek-offset: max(0px, calc((var(--nested-drawers) - var(--stack-progress)) * var(--peek)));
    --scale-base: calc(max(0, 1 - (var(--nested-drawers) * var(--stack-step))));
    --scale: clamp(0, calc(var(--scale-base) + (var(--stack-step) * var(--stack-progress))), 1);
    --shrink: calc(1 - var(--scale));
    --height: max(0px, calc(var(--drawer-frontmost-height, var(--drawer-height)) - var(--drawer-bleed)));
 
    height: var(--drawer-height, auto);
    transform-origin: 50% calc(100% - var(--drawer-bleed));
    transform:
      translateY(calc(var(--drawer-swipe-movement-y) - var(--stack-peek-offset) - (var(--shrink) * var(--height))))
      scale(var(--scale));
 
    transition:
      transform 450ms var(--ease-smooth),
      height 450ms var(--ease-smooth),
      box-shadow 450ms var(--ease-smooth);
 
    /* Shadow */
    box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
 
    /* Pseudo overlay for nested dimming */
    &::after {
      content: "";
      @apply absolute inset-0 rounded-[inherit] bg-transparent pointer-events-none;
      transition: background-color 450ms var(--ease-smooth);
    }
 
    /* Enter/exit */
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateY(calc(100% + var(--viewport-padding) + 2px));
    }
 
    &[data-ending-style] {
      box-shadow: 0 2px 10px rgb(0 0 0 / 0);
      transition-duration: calc(var(--drawer-swipe-strength) * 400ms);
    }
 
    /* Swipe states */
    &[data-swiping] {
      transition-duration: 0s;
      @apply select-none;
    }
 
    &[data-nested-drawer-swiping] {
      transition-duration: 0s;
    }
 
    /* Nested drawer open */
    &[data-nested-drawer-open] {
      height: calc(var(--height) + var(--drawer-bleed));
      overflow: hidden;
 
      &::after {
        background-color: rgb(0 0 0 / 0.05);
      }
    }
  }
 
  /* Stacked fullBleed */
  .drawer-stacked-popup[data-layout="fullBleed"] {
    @apply -mb-(--drawer-bleed);
    --viewport-padding: 0px;
    max-height: calc(80vh + var(--drawer-bleed));
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateY(calc(100% - var(--drawer-bleed) + 2px));
    }
  }
 
  /* Stacked responsive */
  .drawer-stacked-popup[data-layout="responsive"] {
    @media (min-width: 640px) {
      margin-bottom: calc(-1 * var(--drawer-bleed));
      --viewport-padding: 0px;
      max-height: calc(80vh + var(--drawer-bleed));
 
      &[data-ending-style],
      &[data-starting-style] {
        transform: translateY(calc(100% - var(--drawer-bleed) + 2px));
      }
    }
  }
}

Snap points

For snap points support, add the following CSS:

globals.css
/*
   Drawer Snap Popup
*/
@layer utilities {
  .drawer-snap-popup {
    @apply overflow-visible min-h-0 touch-none;
    --drawer-bleed: 0px;
    --viewport-padding: 1rem;
    max-height: calc(100dvh - var(--drawer-snap-top-margin, 1rem));
 
    /* Snap-based transform + dynamic padding */
    transform: translateY(calc(var(--drawer-snap-point-offset) + var(--drawer-swipe-movement-y)));
    padding-bottom: max(0px, calc(var(--drawer-snap-point-offset) + var(--drawer-swipe-movement-y)));
 
    transition:
      transform 450ms var(--ease-smooth);
 
    /* Bleed pseudo-element below rounded corners */
    &::after {
      content: "";
      @apply pointer-events-none absolute inset-x-0 top-full;
      height: var(--drawer-bleed);
      background-color: inherit;
    }
 
    &[data-swiping] {
      @apply select-none;
    }
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateY(calc(100% + var(--viewport-padding) + 2px));
      padding-bottom: 0;
      box-shadow:
        0 -16px 48px rgb(0 0 0 / 0),
        0 6px 18px rgb(0 0 0 / 0);
    }
 
    &[data-ending-style] {
      transition-duration: calc(var(--drawer-swipe-strength) * 400ms);
    }
  }
 
  /* Snap fullBleed */
  .drawer-snap-popup[data-layout="fullBleed"] {
    @apply -mb-(--drawer-bleed);
    --viewport-padding: 0px;
    max-height: calc(100dvh - var(--drawer-snap-top-margin, 1rem) + var(--drawer-bleed));
 
    &[data-ending-style],
    &[data-starting-style] {
      transform: translateY(calc(100% - var(--drawer-bleed) + 2px));
    }
  }
 
  /* Snap responsive */
  .drawer-snap-popup[data-layout="responsive"] {
    @media (min-width: 640px) {
      margin-bottom: calc(-1 * var(--drawer-bleed));
      --viewport-padding: 0px;
      max-height: calc(100dvh - var(--drawer-snap-top-margin, 1rem) + var(--drawer-bleed));
 
      &[data-ending-style],
      &[data-starting-style] {
        transform: translateY(calc(100% - var(--drawer-bleed) + 2px));
      }
    }
  }
}

Drawer Indent

Visit Playground to try it out.

Setup

import {
  DrawerIndent,
  DrawerIndentBackground,
  DrawerProvider,
} from "@/components/ui/drawer";
 
export default function DrawerIndentPage() {
  return (
    <DrawerProvider>
      <DrawerIndentBackground />
      <DrawerIndent>
        {/* Your drawer */}
      </DrawerIndent>
    </DrawerProvider>
  );
}

More info at Base UI.

CSS

globals.css
/*
   Drawer Indent
*/
@layer utilities {
  .drawer-indent {
    --indent-progress: var(--drawer-swipe-progress);
    --indent-radius: calc(1rem * (1 - var(--indent-progress)));
    --indent-transition: calc(1 - clamp(0, calc(var(--drawer-swipe-progress) * 100000), 1));
 
    @apply origin-[center_top] will-change-transform;
    transform: scale(1) translateY(0);
    transition:
      transform 0.4s var(--ease-smooth),
      border-radius 0.25s var(--ease-smooth);
    transition-duration:
      calc(400ms * var(--indent-transition)),
      calc(250ms * var(--indent-transition));
 
    &[data-active] {
      transform:
        scale(calc(0.98 + (0.02 * var(--indent-progress))))
        translateY(calc(0.5rem * (1 - var(--indent-progress))));
      border-top-left-radius: var(--indent-radius);
      border-top-right-radius: var(--indent-radius);
    }
  }
}

Customization

Mobile navigation

Action sheet

Responsive dialog