All patterns

Drawer Slide In

A side drawer travels in over the page, and the page eases back a little to hand it the foreground.

navigationcalmpremiuminteraction · finite · starter · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

451 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Drawer Slide In
 *
 * A side drawer travels in over the page, and the page eases back a
 * little to hand it the foreground. Escape or the scrim sends it away.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The page behind is mixed from the inherited text color and the drawer
 * follows the host app's color scheme, so both land correctly on a light
 * page and on a dark one.
 * Works with zero props; tune via `variant`, `side`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type DrawerSlideInProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Edge the drawer travels in from. */
  side?: "right" | "left";
  /** Notified whenever the drawer opens or closes. */
  onOpenChange?: (open: boolean) => void;
};

type VariantConfig = {
  spring: { type: "spring"; stiffness: number; damping: number };
  /** How far the page behind gives way, in pixels. */
  pageShift: number;
  scrimFade: number;
  exitDuration: number;
};

// Quality rule: the drawer is a large surface, and a large surface makes
// overshoot look like a mistake. Every spring is at or above a 0.8 damping
// ratio, so it arrives once and stops. The page behind translates and dims
// rather than scaling — scaling the page would scale its text, which is
// exactly the artifact this library refuses to ship.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Lands flat. For a filter panel that opens on every other click.
  subtle: {
    spring: { type: "spring", stiffness: 480, damping: 44 },
    pageShift: 8,
    scrimFade: 0.16,
    exitDuration: 0.18,
  },
  // One soft settle as it arrives — the weight that makes a drawer feel
  // like an object rather than a layer. All-purpose.
  default: {
    spring: { type: "spring", stiffness: 380, damping: 34 },
    pageShift: 14,
    scrimFade: 0.2,
    exitDuration: 0.2,
  },
  // More give from the page behind, for drawers that are the whole point
  // of the screen.
  playful: {
    spring: { type: "spring", stiffness: 340, damping: 30 },
    pageShift: 20,
    scrimFade: 0.24,
    exitDuration: 0.22,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const STATUSES = ["All orders", "Paid", "Refunded", "Pending"] as const;
type Status = (typeof STATUSES)[number];

const ORDERS: readonly { id: string; customer: string; total: string; status: Status }[] = [
  { id: "10482", customer: "Priya Raman", total: "$248.00", status: "Paid" },
  { id: "10481", customer: "Dana Whitfield", total: "$92.40", status: "Pending" },
  { id: "10479", customer: "Marco Silva", total: "$1,150.00", status: "Paid" },
  { id: "10476", customer: "Aiko Tanaka", total: "$64.00", status: "Refunded" },
  { id: "10473", customer: "Sam Okonjo", total: "$310.75", status: "Paid" },
];

export default function DrawerSlideIn({
  variant = "default",
  side = "right",
  onOpenChange,
}: DrawerSlideInProps) {
  const [open, setOpen] = useState(false);
  const [applied, setApplied] = useState<Status>("All orders");
  const [draft, setDraft] = useState<Status>("All orders");
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const setDrawer = (next: boolean) => {
    setOpen(next);
    onOpenChange?.(next);
  };

  useEffect(() => {
    if (!open) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setOpen(false);
      onOpenChange?.(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onOpenChange]);

  const fromRight = side === "right";
  const offscreen = fromRight ? "100%" : "-100%";
  const pageShift = fromRight ? -cfg.pageShift : cfg.pageShift;

  // Reduced motion: the drawer still takes the foreground over a dimmed
  // page, it just stops travelling across the frame to get there.
  const drawerMotion = reduceMotion
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0, transition: { duration: 0.12 } },
        transition: { duration: 0.16, ease: "easeOut" as const },
      }
    : {
        initial: { x: offscreen },
        animate: { x: "0%" },
        // Dismissal is quicker than arrival and plainly eased: a drawer
        // that leaves as slowly as it came feels reluctant.
        exit: {
          x: offscreen,
          transition: { duration: cfg.exitDuration, ease: "easeIn" as const },
        },
        transition: cfg.spring,
      };

  const visible = ORDERS.filter(
    (order) => applied === "All orders" || order.status === applied
  );

  return (
    <div
      style={{
        position: "relative",
        width: 330,
        height: 372,
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 16px 40px rgba(0,0,0,0.18)",
        // The drawer and scrim are positioned against this box rather than
        // the viewport, so the pattern drops into a preview or an embedded
        // card. For an app-level drawer, swap `absolute` for `fixed` on the
        // scrim and the drawer.
        overflow: "hidden",
      }}
    >
      {/* The page gives way rather than shrinking: it translates a few
          pixels and dims. Scaling it would scale its text, and text that
          resizes for a passing overlay is the tell of a cheap transition. */}
      <motion.div
        initial={false}
        animate={{ x: open ? pageShift : 0 }}
        transition={
          reduceMotion
            ? { duration: 0 }
            : open
              ? cfg.spring
              : { duration: cfg.exitDuration, ease: "easeOut" as const }
        }
        style={{ height: "100%", display: "flex", flexDirection: "column" }}
      >
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 10,
            padding: "0 14px",
            height: 52,
            borderBottom: `1px solid ${tone(12)}`,
          }}
        >
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 14, fontWeight: 650 }}>Orders</div>
            <div style={{ fontSize: 11, opacity: 0.5, marginTop: 1 }}>
              {visible.length} of {ORDERS.length} · {applied}
            </div>
          </div>
          <button
            type="button"
            onClick={() => {
              setDraft(applied);
              setDrawer(true);
            }}
            aria-expanded={open}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 7,
              padding: "7px 11px",
              fontSize: 12.5,
              fontWeight: 600,
              fontFamily: "inherit",
              borderRadius: 9,
              border: `1px solid ${tone(14)}`,
              background: tone(8),
              color: "inherit",
              cursor: "pointer",
            }}
          >
            <svg
              width="14"
              height="14"
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.7"
              strokeLinecap="round"
              aria-hidden
            >
              <path d="M3 5.5h14" />
              <path d="M5.5 10h9" />
              <path d="M8 14.5h4" />
            </svg>
            Filters
          </button>
        </div>

        <div style={{ flex: 1, padding: "6px 8px", overflow: "hidden" }}>
          {visible.map((order) => (
            <div
              key={order.id}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 10,
                padding: "9px 10px",
                borderRadius: 10,
              }}
            >
              <span style={{ flex: 1, minWidth: 0 }}>
                <span
                  style={{
                    display: "block",
                    fontSize: 12.5,
                    fontWeight: 600,
                    whiteSpace: "nowrap",
                  }}
                >
                  {order.customer}
                </span>
                <span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
                  Order {order.id} · {order.status}
                </span>
              </span>
              <span style={{ fontSize: 12.5, fontWeight: 600 }}>
                {order.total}
              </span>
            </div>
          ))}
        </div>
      </motion.div>

      {/* The scrim and the drawer are siblings so AnimatePresence tracks
          both directly — inside a fragment it would see neither and skip
          the exit. */}
      <AnimatePresence>
        {open && (
          <motion.div
            key="scrim"
            aria-hidden
            onClick={() => setDrawer(false)}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: cfg.exitDuration } }}
            transition={{ duration: cfg.scrimFade, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              // A scrim darkens in both themes — light or dark, the page
              // behind an overlay recedes — so this one stays literal.
              background: "rgba(0,0,0,0.42)",
              cursor: "pointer",
            }}
          />
        )}
        {open && (
          <motion.div
            key="drawer"
            role="dialog"
            aria-modal="true"
            aria-label="Filter orders"
            {...drawerMotion}
            style={{
              position: "absolute",
              top: 0,
              bottom: 0,
              left: fromRight ? "auto" : 0,
              right: fromRight ? 0 : "auto",
              width: 234,
              display: "flex",
              flexDirection: "column",
              padding: "16px 16px 14px",
              // The one surface here that cannot be translucent: it sits on
              // top of the scrim, and a see-through panel would read as
              // more scrim. `Canvas`/`CanvasText` are the CSS system colors
              // for page background and page text, so the drawer lands
              // light in a light app and dark in a dark one. Everything
              // inside then mixes from `currentColor`.
              background: "Canvas",
              color: "CanvasText",
              borderLeft: fromRight ? `1px solid ${tone(14)}` : undefined,
              borderRight: fromRight ? undefined : `1px solid ${tone(14)}`,
              boxShadow: fromRight
                ? "-16px 0 38px rgba(0,0,0,0.3)"
                : "16px 0 38px rgba(0,0,0,0.3)",
            }}
          >
            <div
              style={{
                display: "flex",
                alignItems: "center",
                marginBottom: 12,
              }}
            >
              <div style={{ flex: 1, fontSize: 14, fontWeight: 650 }}>
                Filters
              </div>
              <button
                type="button"
                onClick={() => setDrawer(false)}
                aria-label="Close filters"
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 26,
                  height: 26,
                  borderRadius: 8,
                  border: 0,
                  background: tone(8),
                  color: "inherit",
                  cursor: "pointer",
                }}
              >
                <svg
                  width="13"
                  height="13"
                  viewBox="0 0 20 20"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  aria-hidden
                >
                  <path d="M5.5 5.5 14.5 14.5" />
                  <path d="M14.5 5.5 5.5 14.5" />
                </svg>
              </button>
            </div>

            <div
              role="radiogroup"
              aria-label="Order status"
              style={{ flex: 1, display: "flex", flexDirection: "column", gap: 2 }}
            >
              <div style={{ fontSize: 11, opacity: 0.5, padding: "0 2px 4px" }}>
                Status
              </div>
              {STATUSES.map((status) => {
                const checked = draft === status;
                return (
                  <button
                    key={status}
                    type="button"
                    role="radio"
                    aria-checked={checked}
                    onClick={() => setDraft(status)}
                    style={{
                      display: "flex",
                      alignItems: "center",
                      gap: 9,
                      padding: "8px 8px",
                      borderRadius: 9,
                      border: 0,
                      background: checked ? tone(8) : "transparent",
                      color: "inherit",
                      fontSize: 12.5,
                      fontWeight: checked ? 600 : 500,
                      fontFamily: "inherit",
                      textAlign: "left",
                      cursor: "pointer",
                    }}
                  >
                    <span
                      aria-hidden
                      style={{
                        display: "grid",
                        placeItems: "center",
                        width: 15,
                        height: 15,
                        borderRadius: 999,
                        border: `1.5px solid ${checked ? ACCENT : tone(28)}`,
                      }}
                    >
                      <motion.span
                        initial={false}
                        animate={{ opacity: checked ? 1 : 0 }}
                        transition={{ duration: 0.12, ease: "easeOut" }}
                        style={{
                          width: 7,
                          height: 7,
                          borderRadius: 999,
                          background: ACCENT,
                        }}
                      />
                    </span>
                    {status}
                  </button>
                );
              })}
            </div>

            <button
              type="button"
              onClick={() => {
                setApplied(draft);
                setDrawer(false);
              }}
              style={{
                width: "100%",
                padding: "11px 14px",
                fontSize: 13,
                fontWeight: 600,
                fontFamily: "inherit",
                borderRadius: 11,
                border: 0,
                background: ACCENT,
                color: "#ffffff",
                cursor: "pointer",
              }}
            >
              Apply
            </button>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

The panel for work that belongs beside the page rather than instead of it — filters, details, a settings pane. The drawer covers one edge on a spring damped hard enough that a surface that size never overshoots, and the page underneath gives way by a few pixels as it arrives. That give is the part most implementations get wrong: they scale the page down, which scales its text, and resizing type for a passing overlay is the tell of a cheap transition. Here the page translates and dims instead, so every glyph behind the scrim stays exactly the size it was. Dismissal is quicker than arrival and plainly eased, because a drawer that leaves as slowly as it came feels reluctant.

Filter panelDetail inspectorMobile navigation menuCart or checkout panel

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Dashboard

    Filter and detail panels arrive from the edge over the current list.

Related patterns