docs / core / drawer

Drawer

the same anatomy as Dialog, pinned to an edge instead of centered. a panel slides in from any side; a bottom sheet is just side="bottom".

intensity · recedesrsc · client

provenance

shaped from personal-website · sisu-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

settings panels, widget libraries, filters that keep the page visible behind the scrimthe slide-up sheet is this primitive with side="bottom"not navigation chrome. a persistent sidebar is layout, not a Drawernever two edges open at once

a11y

role="dialog" named by its Title · focus is trapped, Escape closes · Drawer.Close keeps a 44px minimum targetjest-axe

dependencies

@base-ui/react · Card from the same package
live demo · try it out
customize
sidewhich edge it pins to
sizeinline size, or block size on horizontal edges
surfacehow the panel sits above the scrim
highlightwash, inner edge, or glow ring
titlethe heading that names the dialog
descriptionsupporting line under the title
usagecopy
import { Button } from "usva/primitives/button";
import { Drawer } from "usva/primitives/drawer";

<Drawer>
  <Drawer.Trigger render={<Button>Open</Button>} />
  <Drawer.Content>
    <Drawer.Title>Widget library</Drawer.Title>
    <Drawer.Description>Drag a widget onto the grid to add it.</Drawer.Description>
    <Drawer.Close render={<Button variant="ghost">Done</Button>} />
  </Drawer.Content>
</Drawer>

props

proptypedefaultnotes
side"top" | "right" | "bottom" | "left""right"which edge the panel is pinned to.
size"sm" | "md" | "lg""md"inline size on the vertical edges, block size on the horizontal ones.
surface"elevated" | "flat" | "glass""elevated"how the panel sits above the scrim. shared with Card and Dialog.
highlight"none" | "wash" | "edge" | "ring""none"accent treatment on Drawer.Content: a wash, a top edge hairline, or a glow ring. the same vocabulary as Card and Dialog.
backdropClassNamestringextra classes on the scrim, on Drawer.Content.

get it

npx shadcn add https://usva.build/r/drawer.jsoncopy
source · components/ui/drawer.tsxexactly what this command copiescopy
"use client";
import { Dialog as Base } from "@base-ui/react/dialog";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  type CardHighlight,
  type CardSurface,
  SURFACE_ELEVATED,
  SURFACE_SKIN,
} from "./card";
import { useScopedTheme } from "./use-scoped-theme";

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

export type DrawerProps = Base.Root.Props;

function DrawerRoot(props: DrawerProps) {
  return <Base.Root {...props} />;
}
DrawerRoot.displayName = "Drawer";

export const DrawerTrigger = Base.Trigger;

/** Edge anchoring, plus the border and radius on the inner edge only. */
const sideAnchor: Record<DrawerSide, string> = {
  right: "inset-y-0 right-0 h-full border-l rounded-l-2xl",
  left: "inset-y-0 left-0 h-full border-r rounded-r-2xl",
  top: "inset-x-0 top-0 w-full border-b rounded-b-2xl",
  bottom: "inset-x-0 bottom-0 w-full border-t rounded-t-2xl",
};

/** Inline size on the vertical edges, block size on the horizontal ones. */
const sideSize: Record<DrawerSide, Record<DrawerSize, string>> = {
  right: {
    sm: "w-full max-w-xs",
    md: "w-full max-w-md",
    lg: "w-full max-w-2xl",
  },
  left: {
    sm: "w-full max-w-xs",
    md: "w-full max-w-md",
    lg: "w-full max-w-2xl",
  },
  top: { sm: "max-h-[30vh]", md: "max-h-[50vh]", lg: "max-h-[80vh]" },
  bottom: { sm: "max-h-[30vh]", md: "max-h-[50vh]", lg: "max-h-[80vh]" },
};

const sideEnter: Record<DrawerSide, string> = {
  right:
    "data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full",
  left: "data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full",
  top: "data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full",
  bottom:
    "data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full",
};

export type DrawerContentProps = React.ComponentPropsWithoutRef<
  typeof Base.Popup
> & {
  side?: DrawerSide;
  size?: DrawerSize;
  backdropClassName?: string;
  /** How the panel sits above the scrim. Defaults to elevated. */
  surface?: CardSurface;
  /** Accent treatment, shared with Card: a wash, an inner edge, or a glow ring. */
  highlight?: CardHighlight;
};

/**
 * Base UI owns focus trap, scroll lock, Esc, and portalling. This adds only the
 * edge anchoring and the slide, both CSS. kajo's slide-up sheet is side="bottom".
 */
export const DrawerContent = React.forwardRef<
  HTMLDivElement,
  DrawerContentProps
>(
  (
    {
      className,
      backdropClassName,
      side = "right",
      size = "md",
      surface = "elevated",
      highlight = "none",
      children,
      ...props
    },
    ref,
  ) => {
    const [probe, scopedTheme] = useScopedTheme();

    return (
      <>
        <span ref={probe} hidden />
        <Base.Portal>
          <Base.Backdrop
            data-theme={scopedTheme}
            className={cn(
              "fixed inset-0 z-overlay transition-opacity duration-base motion-reduce:transition-none",
              "data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
              surface === "glass"
                ? "bg-scrim/40 backdrop-blur-[3px]"
                : "bg-scrim backdrop-blur-sm",
              backdropClassName,
            )}
          />
          <Base.Popup
            ref={ref}
            data-theme={scopedTheme}
            data-side={side}
            data-surface={surface}
            data-highlight={highlight !== "none" ? highlight : undefined}
            className={cn(
              "fixed z-overlay flex flex-col overflow-y-auto",
              "isolate border-border p-6 text-ink",
              sideAnchor[side],
              sideSize[side][size],
              SURFACE_SKIN[surface],
              highlight === "ring"
                ? "glow-ring"
                : SURFACE_ELEVATED[surface] && "shadow-overlay",
              highlight === "wash" && "wash-accent",
              highlight === "edge" &&
                "after:absolute after:inset-x-6 after:top-0 after:h-px after:hairline-accent",
              "transition-enter duration-[350ms] ease-spring",
              "motion-reduce:transition-none motion-reduce:transform-none",
              "data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
              "data-[ending-style]:duration-base data-[ending-style]:ease-soft",
              sideEnter[side],
              className,
            )}
            {...props}
          >
            {children}
          </Base.Popup>
        </Base.Portal>
      </>
    );
  },
);
DrawerContent.displayName = "DrawerContent";

export const DrawerTitle = React.forwardRef<
  HTMLHeadingElement,
  React.ComponentPropsWithoutRef<typeof Base.Title>
>(({ className, ...props }, ref) => (
  <Base.Title
    ref={ref}
    className={cn(
      "text-balance text-lg font-semibold tracking-[-0.01em] text-ink",
      className,
    )}
    {...props}
  />
));
DrawerTitle.displayName = "DrawerTitle";

export const DrawerDescription = React.forwardRef<
  HTMLParagraphElement,
  React.ComponentPropsWithoutRef<typeof Base.Description>
>(({ className, ...props }, ref) => (
  <Base.Description
    ref={ref}
    className={cn("mt-1 text-pretty text-sm text-muted", className)}
    {...props}
  />
));
DrawerDescription.displayName = "DrawerDescription";

export const DrawerClose = React.forwardRef<
  HTMLButtonElement,
  React.ComponentPropsWithoutRef<typeof Base.Close>
>(({ className, ...props }, ref) => (
  <Base.Close
    ref={ref}
    className={cn(
      "inline-flex min-h-11 min-w-11 items-center justify-center rounded-md text-muted outline-none",
      "transition-control duration-fast ease-soft",
      "hover:bg-surface-2 hover:text-ink active:scale-[0.96] focus-visible:ring-focus",
      "motion-reduce:transition-none motion-reduce:transform-none",
      className,
    )}
    {...props}
  />
));
DrawerClose.displayName = "DrawerClose";

export const Drawer = Object.assign(DrawerRoot, {
  Trigger: DrawerTrigger,
  Content: DrawerContent,
  Title: DrawerTitle,
  Description: DrawerDescription,
  Close: DrawerClose,
});