Web InnoventixFreeCode

Drawer Right Sidebar

Original · free

slide-in right drawer panel

byWeb InnoventixReact + Tailwind
sidedrawerrightsidebars
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/side-drawer-right.json
side-drawer-right.tsx
"use client";

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

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

const INITIAL_ITEMS: CartItem[] = [
  { id: "aurora-lamp", name: "Aurora Desk Lamp", variant: "Warm brass · dimmable", priceCents: 12800, qty: 1 },
  { id: "meridian-notebook", name: "Meridian Notebook", variant: "A5 · dotted grid", priceCents: 2400, qty: 2 },
  { id: "halden-mug", name: "Halden Ceramic Mug", variant: "Sage · 12 oz", priceCents: 3200, qty: 1 },
  { id: "cascade-throw", name: "Cascade Linen Throw", variant: "Oat · 130 × 170 cm", priceCents: 8600, qty: 1 },
];

const FREE_SHIP_THRESHOLD = 7500;
const SHIP_FEE = 800;
const GIFT_WRAP_FEE = 600;

const FOCUSABLE_SELECTOR =
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';

function formatUSD(cents: number): string {
  return (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD" });
}

function IconCart({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M2.5 3.5h2.2l1.7 11a1.5 1.5 0 0 0 1.5 1.25h8.7a1.5 1.5 0 0 0 1.47-1.19l1.33-6.31H6" />
      <circle cx="9" cy="20" r="1.4" />
      <circle cx="18" cy="20" r="1.4" />
    </svg>
  );
}

function IconClose({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M6 6l12 12M18 6 6 18" />
    </svg>
  );
}

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

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

function IconTrash({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M4 7h16M9 7V5.2A1.2 1.2 0 0 1 10.2 4h3.6A1.2 1.2 0 0 1 15 5.2V7m2 0-.7 11.2A1.6 1.6 0 0 1 14.7 20H9.3a1.6 1.6 0 0 1-1.6-1.5L7 7" />
    </svg>
  );
}

function IconLock({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <rect x="5" y="10.5" width="14" height="9.5" rx="2" />
      <path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
    </svg>
  );
}

function IconTruck({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M2.5 6.5h10.5v9H2.5zM13 9.5h4l3 3v3h-7z" />
      <circle cx="7" cy="18" r="1.6" />
      <circle cx="17" cy="18" r="1.6" />
    </svg>
  );
}

export default function SideDrawerRight() {
  const prefersReduced = useReducedMotion();
  const [open, setOpen] = useState(false);
  const [items, setItems] = useState<CartItem[]>(INITIAL_ITEMS);
  const [giftWrap, setGiftWrap] = useState(false);

  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLElement>(null);
  const closeBtnRef = useRef<HTMLButtonElement>(null);

  const rawTitleId = useId();
  const rawDescId = useId();
  const titleId = `sdr-title-${rawTitleId}`;
  const descId = `sdr-desc-${rawDescId}`;

  const count = useMemo(() => items.reduce((sum, item) => sum + item.qty, 0), [items]);
  const subtotal = useMemo(() => items.reduce((sum, item) => sum + item.priceCents * item.qty, 0), [items]);
  const shipping = items.length === 0 ? 0 : subtotal >= FREE_SHIP_THRESHOLD ? 0 : SHIP_FEE;
  const wrapFee = items.length > 0 && giftWrap ? GIFT_WRAP_FEE : 0;
  const total = subtotal + shipping + wrapFee;
  const towardFree = Math.max(0, FREE_SHIP_THRESHOLD - subtotal);
  const shipProgress = Math.min(100, Math.round((subtotal / FREE_SHIP_THRESHOLD) * 100));

  const closeDrawer = useCallback(() => {
    setOpen(false);
    triggerRef.current?.focus();
  }, []);

  const changeQty = useCallback((id: string, delta: number) => {
    setItems((prev) =>
      prev.map((item) => (item.id === id ? { ...item, qty: Math.max(1, item.qty + delta) } : item)),
    );
  }, []);

  const removeItem = useCallback((id: string) => {
    setItems((prev) => prev.filter((item) => item.id !== id));
  }, []);

  // Lock body scroll + move focus into the drawer while open.
  useEffect(() => {
    if (!open) return;
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const focusTimer = window.setTimeout(() => closeBtnRef.current?.focus(), prefersReduced ? 0 : 60);
    return () => {
      window.clearTimeout(focusTimer);
      document.body.style.overflow = previousOverflow;
    };
  }, [open, prefersReduced]);

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape") {
      event.preventDefault();
      closeDrawer();
      return;
    }
    if (event.key !== "Tab") return;
    const panel = panelRef.current;
    if (!panel) return;
    const nodes = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
      (node) => node.offsetWidth > 0 || node.offsetHeight > 0,
    );
    if (nodes.length === 0) return;
    const first = nodes[0];
    const last = nodes[nodes.length - 1];
    const active = document.activeElement;
    if (event.shiftKey) {
      if (active === first || !panel.contains(active)) {
        event.preventDefault();
        last.focus();
      }
    } else if (active === last || !panel.contains(active)) {
      event.preventDefault();
      first.focus();
    }
  };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-slate-100 to-slate-200 px-6 py-20 text-slate-900 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900 dark:text-slate-100 sm:py-28">
      <style>{`
        @keyframes sdr-badge-pop { 0% { transform: scale(0.55); } 55% { transform: scale(1.22); } 100% { transform: scale(1); } }
        @keyframes sdr-sheen { 0% { background-position: -160% 0; } 100% { background-position: 260% 0; } }
        .sdr-badge-anim { animation: sdr-badge-pop 340ms cubic-bezier(0.34, 1.56, 0.64, 1); }
        .sdr-sheen::after {
          content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
          background-image: linear-gradient(110deg, transparent 32%, rgba(255,255,255,0.35) 50%, transparent 68%);
          background-size: 220% 100%; animation: sdr-sheen 3.2s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .sdr-badge-anim, .sdr-sheen::after { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20"
      />

      <div className="relative mx-auto flex max-w-3xl flex-col items-start gap-8">
        <div className="flex flex-col gap-3">
          <span className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-300/70 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-500 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400">
            Northlight Supply Co.
          </span>
          <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
            Your bag, one tap away.
          </h2>
          <p className="max-w-lg text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A slide-in drawer that keeps the checkout summary within reach. Adjust quantities, add gift wrap,
            and review the running total without ever leaving the page.
          </p>
        </div>

        <div className="flex w-full flex-col items-start gap-4 rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/60 sm:flex-row sm:items-center sm:justify-between">
          <div className="flex items-center gap-4">
            <span className="flex h-12 w-12 items-center justify-center rounded-xl bg-indigo-600/10 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
              <IconCart className="h-6 w-6" />
            </span>
            <div>
              <p className="text-sm font-semibold">{count > 0 ? `${count} item${count === 1 ? "" : "s"} in your bag` : "Your bag is empty"}</p>
              <p className="text-sm text-slate-500 dark:text-slate-400">Subtotal {formatUSD(subtotal)}</p>
            </div>
          </div>

          <button
            ref={triggerRef}
            type="button"
            onClick={() => setOpen(true)}
            aria-haspopup="dialog"
            aria-expanded={open}
            className="group inline-flex items-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-slate-900/10 transition hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
          >
            <IconCart className="h-4 w-4" />
            Open bag
            {count > 0 && (
              <span
                key={count}
                className="sdr-badge-anim ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-indigo-500 px-1.5 text-xs font-bold text-white dark:bg-indigo-400 dark:text-slate-950"
              >
                {count}
              </span>
            )}
          </button>
        </div>
      </div>

      <AnimatePresence>
        {open && (
          <motion.div
            key="sdr-overlay-root"
            className="fixed inset-0 z-50"
            onKeyDown={handleKeyDown}
          >
            <motion.div
              aria-hidden="true"
              onClick={closeDrawer}
              className="absolute inset-0 bg-slate-950/50 backdrop-blur-sm"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: prefersReduced ? 0 : 0.22, ease: "easeOut" }}
            />

            <motion.aside
              ref={panelRef}
              role="dialog"
              aria-modal="true"
              aria-labelledby={titleId}
              aria-describedby={descId}
              className="absolute inset-y-0 right-0 flex w-full max-w-md flex-col border-l border-slate-200 bg-white text-slate-900 shadow-2xl dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100"
              initial={prefersReduced ? { opacity: 0 } : { x: "100%" }}
              animate={prefersReduced ? { opacity: 1 } : { x: 0 }}
              exit={prefersReduced ? { opacity: 0 } : { x: "100%" }}
              transition={prefersReduced ? { duration: 0 } : { type: "spring", stiffness: 360, damping: 38, mass: 0.9 }}
            >
              <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={titleId} className="text-lg font-semibold tracking-tight">
                    Your bag
                  </h3>
                  <p id={descId} className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
                    {count > 0 ? `${count} item${count === 1 ? "" : "s"} ready for checkout` : "Nothing here yet"}
                  </p>
                </div>
                <button
                  ref={closeBtnRef}
                  type="button"
                  onClick={closeDrawer}
                  className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
                  aria-label="Close bag"
                >
                  <IconClose 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">
                  <div className="mb-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
                    <IconTruck className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
                    {towardFree > 0 ? (
                      <span>
                        Add <span className="font-semibold text-slate-900 dark:text-white">{formatUSD(towardFree)}</span> for free shipping
                      </span>
                    ) : (
                      <span className="font-semibold text-emerald-600 dark:text-emerald-400">You&apos;ve unlocked free shipping</span>
                    )}
                  </div>
                  <div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800" role="presentation">
                    <div
                      className="h-full rounded-full bg-emerald-500 transition-[width] duration-500 dark:bg-emerald-400"
                      style={{ width: `${shipProgress}%` }}
                    />
                  </div>
                </div>
              )}

              <div className="flex-1 overflow-y-auto px-6 py-2">
                {items.length === 0 ? (
                  <div className="flex h-full flex-col items-center justify-center gap-4 py-16 text-center">
                    <span className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 text-slate-400 dark:bg-slate-900 dark:text-slate-600">
                      <IconCart className="h-7 w-7" />
                    </span>
                    <div>
                      <p className="text-base font-semibold">Your bag is empty</p>
                      <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">Add something you love to get started.</p>
                    </div>
                    <button
                      type="button"
                      onClick={closeDrawer}
                      className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
                    >
                      Continue shopping
                    </button>
                  </div>
                ) : (
                  <ul className="divide-y divide-slate-200 dark:divide-slate-800">
                    {items.map((item) => (
                      <li key={item.id} className="flex gap-4 py-4">
                        <span
                          aria-hidden="true"
                          className="flex h-16 w-16 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-slate-100 to-slate-200 text-sm font-bold text-slate-400 dark:from-slate-900 dark:to-slate-800 dark:text-slate-600"
                        >
                          {item.name.slice(0, 1)}
                        </span>
                        <div className="flex min-w-0 flex-1 flex-col">
                          <div className="flex items-start justify-between gap-3">
                            <div className="min-w-0">
                              <p className="truncate text-sm font-semibold">{item.name}</p>
                              <p className="truncate text-xs text-slate-500 dark:text-slate-400">{item.variant}</p>
                            </div>
                            <p className="shrink-0 text-sm font-semibold tabular-nums">{formatUSD(item.priceCents * item.qty)}</p>
                          </div>

                          <div className="mt-3 flex items-center justify-between">
                            <div className="inline-flex items-center rounded-lg border border-slate-300 dark:border-slate-700">
                              <button
                                type="button"
                                onClick={() => changeQty(item.id, -1)}
                                disabled={item.qty <= 1}
                                aria-label={`Decrease quantity of ${item.name}`}
                                className="flex h-8 w-8 items-center justify-center rounded-l-lg text-slate-600 transition 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"
                              >
                                <IconMinus className="h-4 w-4" />
                              </button>
                              <span className="w-9 text-center text-sm font-semibold tabular-nums" aria-live="polite" aria-label={`Quantity: ${item.qty}`}>
                                {item.qty}
                              </span>
                              <button
                                type="button"
                                onClick={() => changeQty(item.id, 1)}
                                aria-label={`Increase quantity of ${item.name}`}
                                className="flex h-8 w-8 items-center justify-center rounded-r-lg text-slate-600 transition 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"
                              >
                                <IconPlus className="h-4 w-4" />
                              </button>
                            </div>
                            <button
                              type="button"
                              onClick={() => removeItem(item.id)}
                              aria-label={`Remove ${item.name} from bag`}
                              className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-slate-500 transition hover:bg-rose-50 hover:text-rose-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 dark:text-slate-400 dark:hover:bg-rose-950/40 dark:hover:text-rose-400"
                            >
                              <IconTrash 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">
                  <label className="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm transition hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-slate-700">
                    <input
                      type="checkbox"
                      checked={giftWrap}
                      onChange={(event) => setGiftWrap(event.target.checked)}
                      className="h-4 w-4 rounded border-slate-300 text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800"
                    />
                    <span className="flex-1 font-medium text-slate-700 dark:text-slate-200">Add gift wrap &amp; a handwritten note</span>
                    <span className="text-slate-500 dark:text-slate-400">{formatUSD(GIFT_WRAP_FEE)}</span>
                  </label>

                  <dl className="space-y-2 text-sm">
                    <div className="flex items-center justify-between">
                      <dt className="text-slate-500 dark:text-slate-400">Subtotal</dt>
                      <dd className="font-medium tabular-nums">{formatUSD(subtotal)}</dd>
                    </div>
                    <div className="flex items-center justify-between">
                      <dt className="text-slate-500 dark:text-slate-400">Shipping</dt>
                      <dd className="font-medium tabular-nums">{shipping === 0 ? "Free" : formatUSD(shipping)}</dd>
                    </div>
                    {wrapFee > 0 && (
                      <div className="flex items-center justify-between">
                        <dt className="text-slate-500 dark:text-slate-400">Gift wrap</dt>
                        <dd className="font-medium tabular-nums">{formatUSD(wrapFee)}</dd>
                      </div>
                    )}
                    <div className="flex items-center justify-between border-t border-slate-200 pt-2 text-base dark:border-slate-800">
                      <dt className="font-semibold">Total</dt>
                      <dd className="font-bold tabular-nums" aria-live="polite">{formatUSD(total)}</dd>
                    </div>
                  </dl>

                  <button
                    type="button"
                    className="sdr-sheen relative mt-4 flex w-full items-center justify-center gap-2 overflow-hidden rounded-xl bg-indigo-600 px-5 py-3.5 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 transition 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-950"
                  >
                    <IconLock className="h-4 w-4" />
                    Checkout · {formatUSD(total)}
                  </button>
                  <p className="mt-3 text-center text-xs text-slate-400 dark:text-slate-500">
                    Secure payment · 30-day returns · Carbon-neutral delivery
                  </p>
                </footer>
              )}
            </motion.aside>
          </motion.div>
        )}
      </AnimatePresence>
    </section>
  );
}

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 →