Web InnoventixFreeCode

Side Drawer Modal

Original · free

right side drawer modal

byWeb InnoventixReact + Tailwind
modalsidedrawermodals
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/modal-side-drawer.json
modal-side-drawer.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type CartItem = {
  id: string;
  name: string;
  variant: string;
  price: number;
  qty: number;
};

const INITIAL_ITEMS: CartItem[] = [
  {
    id: "eth-yirg",
    name: "Ethiopia Yirgacheffe",
    variant: "Whole bean · 340g · Light roast",
    price: 22,
    qty: 1,
  },
  {
    id: "col-huila",
    name: "Colombia Huila Decaf",
    variant: "Ground · 340g · Medium roast",
    price: 19.5,
    qty: 2,
  },
  {
    id: "dripper",
    name: "Ceramic Pour-Over Dripper",
    variant: "Matte charcoal · Size 02",
    price: 34,
    qty: 1,
  },
];

const FREE_SHIP_THRESHOLD = 75;
const FLAT_SHIPPING = 6;
const PROMO_CODE = "WELCOME10";
const PROMO_RATE = 0.1;

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

export default function ModalSideDrawer() {
  const reduce = useReducedMotion();

  const [open, setOpen] = useState(false);
  const [items, setItems] = useState<CartItem[]>(INITIAL_ITEMS);
  const [promo, setPromo] = useState("");
  const [applied, setApplied] = useState<string | null>(null);
  const [promoError, setPromoError] = useState<string | null>(null);

  const drawerRef = useRef<HTMLDivElement | null>(null);
  const closeBtnRef = useRef<HTMLButtonElement | null>(null);
  const openBtnRef = useRef<HTMLButtonElement | null>(null);

  const totalCount = useMemo(
    () => items.reduce((sum, it) => sum + it.qty, 0),
    [items],
  );
  const subtotal = useMemo(
    () => items.reduce((sum, it) => sum + it.price * it.qty, 0),
    [items],
  );
  const discount = applied ? subtotal * PROMO_RATE : 0;
  const netGoods = subtotal - discount;
  const shipping =
    items.length === 0 || netGoods >= FREE_SHIP_THRESHOLD ? 0 : FLAT_SHIPPING;
  const total = netGoods + shipping;
  const remaining = Math.max(0, FREE_SHIP_THRESHOLD - netGoods);
  const shipPct = Math.min(100, (netGoods / FREE_SHIP_THRESHOLD) * 100);

  // Focus trap + escape + scroll lock while the drawer is open.
  useEffect(() => {
    if (!open) return;
    const node = drawerRef.current;
    if (!node) return;

    const previous = document.activeElement as HTMLElement | null;
    const raf = requestAnimationFrame(() => closeBtnRef.current?.focus());

    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        setOpen(false);
        return;
      }
      if (e.key !== "Tab") return;
      const focusables = node.querySelectorAll<HTMLElement>(
        'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
      );
      if (focusables.length === 0) return;
      const first = focusables[0];
      const last = focusables[focusables.length - 1];
      const active = document.activeElement;
      if (e.shiftKey && active === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && active === last) {
        e.preventDefault();
        first.focus();
      }
    };

    document.addEventListener("keydown", onKeyDown);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    return () => {
      cancelAnimationFrame(raf);
      document.removeEventListener("keydown", onKeyDown);
      document.body.style.overflow = prevOverflow;
      (previous ?? openBtnRef.current)?.focus();
    };
  }, [open]);

  function changeQty(id: string, delta: number) {
    setItems((prev) =>
      prev.map((it) =>
        it.id === id ? { ...it, qty: Math.max(1, it.qty + delta) } : it,
      ),
    );
  }

  function removeItem(id: string) {
    setItems((prev) => prev.filter((it) => it.id !== id));
  }

  function restoreBag() {
    setItems(INITIAL_ITEMS);
    setApplied(null);
    setPromo("");
    setPromoError(null);
  }

  function applyPromo(e: { preventDefault: () => void }) {
    e.preventDefault();
    const code = promo.trim().toUpperCase();
    if (!code) {
      setPromoError("Enter a code to apply.");
      setApplied(null);
      return;
    }
    if (code === PROMO_CODE) {
      setApplied(code);
      setPromoError(null);
    } else {
      setApplied(null);
      setPromoError("That code isn't valid.");
    }
  }

  const spring = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 380, damping: 40 };
  const panelVariants = reduce
    ? { hidden: { opacity: 0 }, visible: { opacity: 1 } }
    : { hidden: { x: "100%" }, visible: { x: 0 } };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        .msd-dot-pulse { animation: msd-dot-pulse 1.8s ease-in-out infinite; }
        .msd-bar-shimmer {
          background-image: linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent);
          background-size: 200% 100%;
          animation: msd-bar-shimmer 2.4s linear infinite;
        }
        @keyframes msd-dot-pulse {
          0%, 100% { transform: scale(1); opacity: 1; }
          50% { transform: scale(1.65); opacity: 0.35; }
        }
        @keyframes msd-bar-shimmer {
          0% { background-position: 200% 0; }
          100% { background-position: -200% 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .msd-dot-pulse, .msd-bar-shimmer { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl px-6 py-20 sm:px-8 sm:py-28">
        <div className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <span className="relative flex h-2 w-2">
              <span className="msd-dot-pulse absolute inline-flex h-2 w-2 rounded-full bg-emerald-500" />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
            </span>
            Meridian Roasters — Summer lot, freshly roasted
          </span>

          <h2 className="mt-6 text-3xl font-semibold tracking-tight sm:text-4xl">
            Your bag, without leaving the page
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A right-side drawer keeps checkout in reach while the customer keeps
            browsing. Open it to adjust quantities, apply a code, and watch the
            free-shipping bar fill in real time.
          </p>

          <div className="mt-8 flex flex-wrap items-center gap-4">
            <button
              ref={openBtnRef}
              type="button"
              onClick={() => setOpen(true)}
              aria-haspopup="dialog"
              aria-expanded={open}
              className="group inline-flex items-center gap-3 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950"
            >
              <BagIcon className="h-5 w-5" />
              Open bag
              <span className="inline-flex min-w-6 items-center justify-center rounded-full bg-white/20 px-2 py-0.5 text-xs font-bold tabular-nums">
                {totalCount}
              </span>
            </button>

            <span className="text-sm text-slate-500 dark:text-slate-400">
              Subtotal{" "}
              <span className="font-semibold text-slate-800 tabular-nums dark:text-slate-200">
                {usd.format(subtotal)}
              </span>
            </span>
          </div>
        </div>
      </div>

      <AnimatePresence>
        {open ? (
          <div className="fixed inset-0 z-50">
            <motion.button
              type="button"
              aria-label="Close bag"
              tabIndex={-1}
              onClick={() => setOpen(false)}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={reduce ? { duration: 0 } : { duration: 0.2 }}
              className="absolute inset-0 h-full w-full cursor-default bg-slate-900/60 backdrop-blur-sm"
            />

            <motion.div
              ref={drawerRef}
              role="dialog"
              aria-modal="true"
              aria-labelledby="msd-title"
              variants={panelVariants}
              initial="hidden"
              animate="visible"
              exit="hidden"
              transition={spring}
              className="absolute inset-y-0 right-0 flex h-full w-full max-w-md flex-col border-l border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"
            >
              <header className="flex items-start justify-between gap-4 border-b border-slate-200 px-6 py-5 dark:border-slate-800">
                <div>
                  <h3
                    id="msd-title"
                    className="text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100"
                  >
                    Your bag
                  </h3>
                  <p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
                    {totalCount} {totalCount === 1 ? "item" : "items"} · roasted
                    to order
                  </p>
                </div>
                <button
                  ref={closeBtnRef}
                  type="button"
                  onClick={() => setOpen(false)}
                  aria-label="Close bag"
                  className="-mr-2 -mt-1 inline-flex h-10 w-10 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
                >
                  <CloseIcon className="h-5 w-5" />
                </button>
              </header>

              {items.length > 0 ? (
                <div className="border-b border-slate-200 px-6 py-4 dark:border-slate-800">
                  {remaining > 0 ? (
                    <p className="text-sm text-slate-600 dark:text-slate-300">
                      Add{" "}
                      <span className="font-semibold text-slate-900 tabular-nums dark:text-slate-100">
                        {usd.format(remaining)}
                      </span>{" "}
                      more for free shipping.
                    </p>
                  ) : (
                    <p className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
                      <CheckIcon className="h-4 w-4" />
                      You've unlocked free shipping.
                    </p>
                  )}
                  <div
                    className="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
                    role="progressbar"
                    aria-valuemin={0}
                    aria-valuemax={FREE_SHIP_THRESHOLD}
                    aria-valuenow={Math.round(netGoods)}
                    aria-label="Progress toward free shipping"
                  >
                    <div
                      className="msd-bar-shimmer h-full rounded-full bg-emerald-500 transition-[width] duration-500 ease-out"
                      style={{ width: `${shipPct}%` }}
                    />
                  </div>
                </div>
              ) : null}

              <div className="flex-1 overflow-y-auto px-6 py-4">
                {items.length === 0 ? (
                  <div className="flex h-full flex-col items-center justify-center py-16 text-center">
                    <div className="flex h-14 w-14 items-center justify-center rounded-full bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500">
                      <BagIcon className="h-7 w-7" />
                    </div>
                    <p className="mt-4 text-base font-medium text-slate-800 dark:text-slate-200">
                      Your bag is empty
                    </p>
                    <p className="mt-1 max-w-xs text-sm text-slate-500 dark:text-slate-400">
                      Fresh single-origin coffee ships within a day of roasting.
                    </p>
                    <button
                      type="button"
                      onClick={restoreBag}
                      className="mt-6 inline-flex items-center gap-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                    >
                      Restore sample bag
                    </button>
                  </div>
                ) : (
                  <ul className="flex flex-col gap-4">
                    {items.map((it) => (
                      <li
                        key={it.id}
                        className="flex gap-4 rounded-xl border border-slate-200 p-3 dark:border-slate-800"
                      >
                        <div
                          aria-hidden="true"
                          className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-amber-100 to-indigo-100 text-amber-700 dark:from-amber-500/15 dark:to-indigo-500/15 dark:text-amber-300"
                        >
                          <BeanIcon className="h-7 w-7" />
                        </div>

                        <div className="min-w-0 flex-1">
                          <div className="flex items-start justify-between gap-2">
                            <p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
                              {it.name}
                            </p>
                            <p className="shrink-0 text-sm font-semibold tabular-nums text-slate-900 dark:text-slate-100">
                              {usd.format(it.price * it.qty)}
                            </p>
                          </div>
                          <p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
                            {it.variant}
                          </p>

                          <div className="mt-3 flex items-center justify-between gap-2">
                            <div className="inline-flex items-center rounded-lg border border-slate-300 dark:border-slate-700">
                              <button
                                type="button"
                                onClick={() => changeQty(it.id, -1)}
                                disabled={it.qty <= 1}
                                aria-label={`Decrease quantity of ${it.name}`}
                                className="inline-flex h-8 w-8 items-center justify-center rounded-l-lg text-slate-600 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:text-slate-300 dark:hover:bg-slate-800"
                              >
                                <MinusIcon className="h-4 w-4" />
                              </button>
                              <span
                                aria-live="polite"
                                className="w-8 text-center text-sm font-semibold tabular-nums text-slate-900 dark:text-slate-100"
                              >
                                {it.qty}
                              </span>
                              <button
                                type="button"
                                onClick={() => changeQty(it.id, 1)}
                                aria-label={`Increase quantity of ${it.name}`}
                                className="inline-flex h-8 w-8 items-center justify-center rounded-r-lg text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:text-slate-300 dark:hover:bg-slate-800"
                              >
                                <PlusIcon className="h-4 w-4" />
                              </button>
                            </div>

                            <button
                              type="button"
                              onClick={() => removeItem(it.id)}
                              className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-rose-600 transition-colors hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:text-rose-400 dark:hover:bg-rose-500/10 dark:focus-visible:ring-offset-slate-900"
                            >
                              <TrashIcon className="h-3.5 w-3.5" />
                              Remove
                            </button>
                          </div>
                        </div>
                      </li>
                    ))}
                  </ul>
                )}
              </div>

              {items.length > 0 ? (
                <footer className="border-t border-slate-200 px-6 py-5 dark:border-slate-800">
                  <form
                    onSubmit={applyPromo}
                    className="flex items-start gap-2"
                    noValidate
                  >
                    <div className="flex-1">
                      <label htmlFor="msd-promo" className="sr-only">
                        Promo code
                      </label>
                      <input
                        id="msd-promo"
                        type="text"
                        value={promo}
                        onChange={(e) => {
                          setPromo(e.target.value);
                          if (promoError) setPromoError(null);
                        }}
                        placeholder="Promo code (try WELCOME10)"
                        aria-invalid={promoError ? true : undefined}
                        aria-describedby="msd-promo-msg"
                        className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500"
                      />
                    </div>
                    <button
                      type="submit"
                      className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                    >
                      Apply
                    </button>
                  </form>
                  <p
                    id="msd-promo-msg"
                    aria-live="polite"
                    className="mt-1.5 min-h-4 text-xs"
                  >
                    {promoError ? (
                      <span className="text-rose-600 dark:text-rose-400">
                        {promoError}
                      </span>
                    ) : applied ? (
                      <span className="text-emerald-600 dark:text-emerald-400">
                        Code {applied} applied — 10% off.
                      </span>
                    ) : null}
                  </p>

                  <dl className="mt-4 space-y-1.5 text-sm">
                    <div className="flex justify-between text-slate-600 dark:text-slate-400">
                      <dt>Subtotal</dt>
                      <dd className="tabular-nums">{usd.format(subtotal)}</dd>
                    </div>
                    {discount > 0 ? (
                      <div className="flex justify-between text-emerald-600 dark:text-emerald-400">
                        <dt>Discount</dt>
                        <dd className="tabular-nums">−{usd.format(discount)}</dd>
                      </div>
                    ) : null}
                    <div className="flex justify-between text-slate-600 dark:text-slate-400">
                      <dt>Shipping</dt>
                      <dd className="tabular-nums">
                        {shipping === 0 ? "Free" : usd.format(shipping)}
                      </dd>
                    </div>
                    <div className="flex justify-between border-t border-slate-200 pt-2 text-base font-semibold text-slate-900 dark:border-slate-800 dark:text-slate-100">
                      <dt>Total</dt>
                      <dd className="tabular-nums">{usd.format(total)}</dd>
                    </div>
                  </dl>

                  <button
                    type="button"
                    className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                  >
                    Checkout · {usd.format(total)}
                    <ArrowIcon className="h-4 w-4" />
                  </button>
                  <button
                    type="button"
                    onClick={() => setOpen(false)}
                    className="mt-2 inline-flex w-full items-center justify-center rounded-xl px-5 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                  >
                    Continue shopping
                  </button>
                </footer>
              ) : null}
            </motion.div>
          </div>
        ) : null}
      </AnimatePresence>
    </section>
  );
}

type IconProps = { className?: string };

function BagIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M6 8h12l-1 12H7L6 8Z" />
      <path d="M9 8V6a3 3 0 0 1 6 0v2" />
    </svg>
  );
}

function CloseIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="m6 6 12 12M18 6 6 18" />
    </svg>
  );
}

function PlusIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M12 5v14M5 12h14" />
    </svg>
  );
}

function MinusIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M5 12h14" />
    </svg>
  );
}

function TrashIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13h10l1-13" />
    </svg>
  );
}

function CheckIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="m5 12 4 4 10-10" />
    </svg>
  );
}

function ArrowIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M5 12h14M13 6l6 6-6 6" />
    </svg>
  );
}

function BeanIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M7 4c5 0 10 4 10 9s-5 7-10 7a8 8 0 0 1 0-16Z" />
      <path d="M9 5c3 3 3 11-1 14" />
    </svg>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →