Skip to content

Drawer

Slides from either side with backdrop blur and scroll lock.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/side-drawer.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion radix-ui tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/side-drawer/side-drawer.tsx
    "use client";
    
    import { useState, type ReactNode } from "react";
    import { AnimatePresence, motion, useDragControls, useReducedMotion, type PanInfo } from "motion/react";
    import { Dialog } from "radix-ui";
    import { X } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration } from "@/lib/motion";
    
    export interface SideDrawerProps {
      /** Controlled open state. */
      open?: boolean;
      /** Initial open state when uncontrolled. @default false */
      defaultOpen?: boolean;
      /** Called when the drawer opens or closes. */
      onOpenChange?: (open: boolean) => void;
      /** Element that opens the drawer, e.g. a button. */
      trigger?: ReactNode;
      /** Edge the drawer slides in from. @default "right" */
      side?: "left" | "right";
      /** Drawer width. @default "md" */
      size?: "sm" | "md" | "lg";
      /** Heading; also the dialog's accessible name. */
      title: ReactNode;
      /** Supporting text under the title. */
      description?: ReactNode;
      /** Scrollable body. */
      children?: ReactNode;
      /** Pinned content at the bottom, e.g. actions. */
      footer?: ReactNode;
      /** Classes for the drawer panel. */
      className?: string;
    }
    
    const widths = {
      sm: "w-[min(20rem,88vw)]",
      md: "w-[min(24rem,90vw)]",
      lg: "w-[min(32rem,92vw)]",
    } as const;
    
    const SLIDE = { type: "spring", stiffness: 380, damping: 38, mass: 0.9 } as const;
    
    export function SideDrawer({
      open,
      defaultOpen = false,
      onOpenChange,
      trigger,
      side = "right",
      size = "md",
      title,
      description,
      children,
      footer,
      className,
    }: SideDrawerProps) {
      const [internalOpen, setInternalOpen] = useState(defaultOpen);
      const isOpen = open ?? internalOpen;
      const reduceMotion = useReducedMotion();
      const dragControls = useDragControls();
      const setOpen = (next: boolean) => {
        if (open === undefined) setInternalOpen(next);
        onOpenChange?.(next);
      };
    
      const hidden = side === "right" ? "100%" : "-100%";
      const direction = side === "right" ? 1 : -1;
    
      function onDragEnd(_: unknown, info: PanInfo) {
        // Swiping toward the drawer's own edge far or fast enough dismisses it.
        if (info.offset.x * direction > 90 || info.velocity.x * direction > 500) setOpen(false);
      }
    
      return (
        <Dialog.Root open={isOpen} onOpenChange={setOpen}>
          {trigger ? <Dialog.Trigger asChild>{trigger}</Dialog.Trigger> : null}
          <AnimatePresence>
            {isOpen ? (
              <Dialog.Portal forceMount>
                <Dialog.Overlay asChild forceMount>
                  <motion.div
                    className="fixed inset-0 z-50 bg-zinc-950/20 backdrop-blur-[3px]"
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={{ opacity: 0 }}
                    transition={{ duration: duration.base }}
                  />
                </Dialog.Overlay>
                <Dialog.Content asChild forceMount {...(description ? {} : { "aria-describedby": undefined })}>
                  <motion.div
                    drag="x"
                    dragListener={false}
                    dragControls={dragControls}
                    dragConstraints={{ left: 0, right: 0 }}
                    dragElastic={side === "right" ? { left: 0.05, right: 0.7 } : { left: 0.7, right: 0.05 }}
                    onDragEnd={onDragEnd}
                    initial={reduceMotion ? { opacity: 0 } : { x: hidden }}
                    animate={reduceMotion ? { opacity: 1 } : { x: 0 }}
                    exit={reduceMotion ? { opacity: 0 } : { x: hidden }}
                    transition={reduceMotion ? { duration: duration.quick } : SLIDE}
                    className={cn(
                      "fixed inset-y-0 z-50 flex flex-col bg-popover text-popover-foreground shadow-xl outline-none",
                      side === "right" ? "right-0 border-l border-border" : "left-0 border-r border-border",
                      widths[size],
                      className,
                    )}
                  >
                    <div
                      onPointerDown={(event) => dragControls.start(event)}
                      className="flex shrink-0 touch-pan-y items-start justify-between gap-4 border-b border-border px-5 py-4"
                    >
                      <div className="min-w-0">
                        <Dialog.Title className="text-base font-semibold tracking-tight text-foreground">{title}</Dialog.Title>
                        {description ? <Dialog.Description className="mt-0.5 text-sm text-muted-foreground">{description}</Dialog.Description> : null}
                      </div>
                      <Dialog.Close
                        aria-label="Close"
                        className="-mt-1 -mr-2 inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                      >
                        <X className="size-4" aria-hidden="true" />
                      </Dialog.Close>
                    </div>
                    <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4">{children}</div>
                    {footer ? <div className="shrink-0 border-t border-border px-5 py-4">{footer}</div> : null}
                  </motion.div>
                </Dialog.Content>
              </Dialog.Portal>
            ) : null}
          </AnimatePresence>
        </Dialog.Root>
      );
    }

Usage

import { SideDrawer } from "@/components/ui/side-drawer/side-drawer";

export function Cart() {
  return (
    <SideDrawer trigger={<button>Cart</button>} title="Your cart" footer={<CheckoutButton />}>
      <CartItems />
    </SideDrawer>
  );
}

API reference

PropTypeDefaultDescription
openboolean–Controlled open state.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void–Called when the drawer opens or closes.
triggerReactNode–Element that opens the drawer, e.g. a button.
side"left" | "right""right"Edge the drawer slides in from.
size"sm" | "md" | "lg""md"Drawer width.
title*ReactNode–Heading; also the dialog's accessible name.
descriptionReactNode–Supporting text under the title.
childrenReactNode–Scrollable body.
footerReactNode–Pinned content at the bottom, e.g. actions.
classNamestring–Classes for the drawer panel.
  • The backdrop blur and fade run on their own layer; the panel itself only animates transform.
  • Swiping toward the drawer's edge past 90px, or with a quick flick, closes it.

Accessibility

  • Built on Radix Dialog: focus is trapped, Escape and the close button dismiss it, page scroll is locked and focus returns to the trigger.
  • The title names the dialog and the description is linked automatically when provided.
  • Swipe-to-dismiss starts only from the header, so text selection and inputs in the body behave normally.
  • With reduced motion enabled, the drawer fades in place instead of sliding.