For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Drawer

The Drawer component from this design system, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/drawer

Source

"use client";

import { useDirection } from "@base-ui/react/direction-provider";
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
import { RiCloseLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { useDialogScrollFade } from "@/components/hooks/use-dialog-scroll-fade";
import { Button } from "@/components/ui/button";

// A panel that slides in from an edge over a dimmed page. The panel floats
// 8px inside the viewport on phones and 12px from sm up, at a 24px radius on a
// 1px hairline border with the soft shadow-xs, so every edge is free of the
// screen. Start and end follow the reading direction in the source; Base UI
// only knows swipe directions, so start/end map to left/right.
//
// Motion: the panel slides in from past its edge and sharpens from a 4px blur
// over 300ms on the iOS sheet curve, and plays the same transition back out.
// The black/40 backdrop fades with it. Reduced motion keeps the fade and drops
// the slide and the blur.

/** The edge the panel slides in from. */
type DrawerPlacement = "start" | "end" | "top" | "bottom";

/** Width of a start or end drawer. Top and bottom drawers span the viewport. */
type DrawerSize = "sm" | "default" | "lg";

const PLACEMENT_DIRECTION: Record<
  DrawerPlacement,
  NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>
> = {
  bottom: "down",
  end: "right",
  start: "left",
  top: "up",
};

// Side drawers only; top and bottom drawers span the viewport.
const SIZE: Record<DrawerSize, string> = {
  lg: "data-[swipe-axis=x]:max-w-[600px]",
  default: "data-[swipe-axis=x]:max-w-[400px]",
  sm: "data-[swipe-axis=x]:max-w-[320px]",
};

interface DrawerContextProps {
  hasSnapPoints: boolean;
  modal: DrawerPrimitive.Root.Props["modal"];
  showSwipeHandle: boolean;
  size: DrawerSize;
  swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>;
}

const DrawerContext = React.createContext<DrawerContextProps | null>(null);

function useDrawer() {
  const context = React.useContext(DrawerContext);

  if (!context) {
    throw new Error("useDrawer must be used within a Drawer.");
  }

  return context;
}

function Drawer({
  modal = true,
  placement,
  showSwipeHandle = false,
  size = "default",
  snapPoints,
  swipeDirection,
  ...props
}: DrawerPrimitive.Root.Props & {
  /** The edge the panel slides in from. */
  placement?: DrawerPlacement;
  showSwipeHandle?: boolean;
  /** Width of a start or end drawer: 320, 400 (default), or 600px. */
  size?: DrawerSize;
}) {
  const direction = useDirection();
  const hasSnapPoints = snapPoints != null && snapPoints.length > 0;
  const resolvedSwipeDirection =
    swipeDirection ??
    (placement &&
    direction === "rtl" &&
    (placement === "start" || placement === "end")
      ? PLACEMENT_DIRECTION[placement === "start" ? "end" : "start"]
      : placement
        ? PLACEMENT_DIRECTION[placement]
        : "down");
  const contextValue = React.useMemo(
    () => ({
      hasSnapPoints,
      modal,
      showSwipeHandle,
      size,
      swipeDirection: resolvedSwipeDirection,
    }),
    [hasSnapPoints, modal, showSwipeHandle, size, resolvedSwipeDirection]
  );

  return (
    <DrawerContext.Provider value={contextValue}>
      <DrawerPrimitive.Root
        data-slot="drawer"
        modal={modal}
        snapPoints={snapPoints}
        swipeDirection={resolvedSwipeDirection}
        {...props}
      />
    </DrawerContext.Provider>
  );
}

function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}

function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
  return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}

function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
  return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}

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

function DrawerSwipeHandle({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-swipe-handle"
      aria-hidden="true"
      className={cn(
        "relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 motion-reduce:transition-none 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:h-full 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:w-full 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 group-data-[swipe-axis=x]/drawer-popup:after:h-[100px] group-data-[swipe-axis=x]/drawer-popup:after:w-1.5 group-data-[swipe-axis=y]/drawer-popup:after:h-1.5 group-data-[swipe-axis=y]/drawer-popup:after:w-[100px] active:cursor-grabbing",
        className
      )}
      {...props}
    />
  );
}

function DrawerContent({
  className,
  children,
  ...props
}: DrawerPrimitive.Popup.Props) {
  const { hasSnapPoints, modal, showSwipeHandle, size, swipeDirection } =
    useDrawer();
  const direction = useDirection();
  const [viewport, setViewport] = React.useState<{
    height: number;
    top: number;
  } | null>(null);

  React.useEffect(() => {
    const { visualViewport } = window;
    if (!visualViewport) {
      return;
    }
    const update = () =>
      setViewport({
        height: visualViewport.height,
        top: visualViewport.offsetTop,
      });
    update();
    visualViewport.addEventListener("resize", update);
    visualViewport.addEventListener("scroll", update);
    return () => {
      visualViewport.removeEventListener("resize", update);
      visualViewport.removeEventListener("scroll", update);
    };
  }, []);

  const swipeAxis =
    swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";

  return (
    <DrawerPortal data-slot="drawer-portal">
      {modal === true && (
        <DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />
      )}
      <DrawerPrimitive.Viewport
        data-slot="drawer-viewport"
        data-modal={modal}
        dir={direction}
        className="pointer-events-none fixed inset-x-0 top-0 z-50 h-dvh select-none data-[modal=true]:pointer-events-auto"
        style={
          viewport ? { height: viewport.height, top: viewport.top } : undefined
        }
      >
        <DrawerPrimitive.Popup
          data-slot="drawer-popup"
          data-swipe-axis={swipeAxis}
          data-snap-points={hasSnapPoints ? "" : undefined}
          className={cn(
            // Panel inset: 8px on phones, 12px from sm up, on every edge the
            // panel does not span.
            "[--drawer-inset:8px] sm:[--drawer-inset:12px]",
            // Base.
            "group/drawer-popup pointer-events-auto absolute z-50 m-(--drawer-inset) 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 rounded-[24px] border border-[var(--surface-strong,var(--border))] bg-[var(--surface-overlay,var(--popover))] text-sm text-popover-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] transition-[transform,height,opacity,filter] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform outline-none select-none motion-reduce:transition-none [interpolate-size:allow-keywords]",
            // Nested.
            "data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
            // Bleed.
            "after:pointer-events-none after:absolute after:bg-[var(--drawer-bleed-background,var(--surface-overlay,var(--popover)))] 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",
            // Sizing. Side drawers fill the viewport's height; top and bottom
            // drawers stop at 85% of it. The width is the viewport minus the
            // inset on both sides, then capped by the size below.
            "[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:calc(100%_-_calc(var(--drawer-inset)*2))] data-[swipe-axis=y]:[--drawer-content-max-height:85%] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100%]",
            SIZE[size],
            // Stack.
            "[--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]",
            // Transitions.
            "data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*300ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*300ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*300ms)]",
            // Motion: slide and sharpen.
            "motion-safe:data-ending-style:blur-[4px] motion-safe:data-starting-style:blur-[4px]",
            // Reduced motion: fade in place, no slide and no blur.
            "motion-reduce:data-ending-style:transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] motion-reduce:data-ending-style:opacity-0 motion-reduce:data-starting-style:transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] motion-reduce:data-starting-style:opacity-0",
            // Axis: y.
            "data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
            // Axis: x.
            "data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
            // Direction: down.
            "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)))]",
            // Direction: up.
            "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)))]",
            // Direction: left.
            "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%))]",
            // Direction: right.
            "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={cn(
              "flex min-h-0 flex-1 flex-col gap-4 overflow-clip overscroll-contain rounded-[inherit] py-6 transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text motion-reduce:transition-none 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"
            )}
          >
            {children}
          </DrawerPrimitive.Content>
        </DrawerPrimitive.Popup>
      </DrawerPrimitive.Viewport>
    </DrawerPortal>
  );
}

function DrawerHeader({
  className,
  children,
  showCloseButton = true,
  closeLabel = "Close",
  ...props
}: React.ComponentProps<"div"> & {
  /** Show the dismiss button in the top corner. Default true. */
  showCloseButton?: boolean;
  /** Accessible name for the dismiss button. */
  closeLabel?: string;
}) {
  return (
    <div
      data-slot="drawer-header"
      className={cn("flex shrink-0 items-start gap-3 px-6", className)}
      {...props}
    >
      <div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
      {showCloseButton ? (
        <DrawerPrimitive.Close
          data-slot="drawer-close"
          aria-label={closeLabel}
          className="-me-1 shrink-0"
          render={<Button size="icon-xs" variant="close" />}
        >
          <RiCloseLine className="size-6" />
        </DrawerPrimitive.Close>
      ) : null}
    </div>
  );
}

function DrawerBody({
  className,
  onScroll,
  children,
  ...props
}: React.ComponentProps<"div">) {
  const fade = useDialogScrollFade();

  return (
    <div className="relative flex min-h-0 flex-1 flex-col">
      <div
        data-slot="drawer-body"
        onScroll={(event) => {
          fade.onScroll(event);
          onScroll?.(event);
        }}
        className={cn(
          "min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-1 text-[14px] leading-[20px] text-muted-foreground",
          className
        )}
        {...props}
      >
        {children}
      </div>
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6"
      >
        <div
          className="absolute inset-0 backdrop-blur-[1px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
          style={{ opacity: fade.progress }}
        />
        <div
          className="absolute inset-x-0 top-0 h-4 backdrop-blur-[4px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
          style={{ opacity: fade.progress }}
        />
        <div
          className="absolute inset-0 bg-linear-to-b from-[var(--surface-overlay,var(--popover))] to-transparent"
          style={{ opacity: fade.progress }}
        />
      </div>
    </div>
  );
}

function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-footer"
      className={cn(
        "mt-2 flex shrink-0 flex-col-reverse gap-2 px-6 sm:flex-row sm:items-center sm:justify-end",
        className
      )}
      {...props}
    />
  );
}

function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
  return (
    <DrawerPrimitive.Title
      data-slot="drawer-title"
      className={cn(
        "text-[18px] leading-[26px] font-medium text-foreground",
        className
      )}
      {...props}
    />
  );
}

function DrawerDescription({
  className,
  ...props
}: DrawerPrimitive.Description.Props) {
  return (
    <DrawerPrimitive.Description
      data-slot="drawer-description"
      className={cn(
        "text-[14px] leading-[20px] text-balance text-muted-foreground",
        className
      )}
      {...props}
    />
  );
}

export {
  Drawer,
  DrawerPortal,
  DrawerOverlay,
  DrawerSwipeHandle,
  DrawerTrigger,
  DrawerClose,
  DrawerContent,
  DrawerHeader,
  DrawerBody,
  DrawerFooter,
  DrawerTitle,
  DrawerDescription,
};
export type { DrawerPlacement, DrawerSize };