Side Drawer Modal
Original · freeright side drawer modal
byWeb InnoventixReact + Tailwind
modalsidedrawermodals
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.jsonmodal-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 quoteSimilar components
Browse all →
Basic Modal
Originalbasic centred modal with overlay

Center Modal
Originalcentred modal with icon and actions

Form Modal
Originalmodal containing a form

Confirm Modal
Originalconfirm/destructive dialog

Alert Modal
Originalalert dialog with single action

Drawer Bottom Modal
Originalbottom sheet drawer

Fullscreen Modal
Originalfullscreen modal

Image Lightbox Modal
Originalimage lightbox modal with nav

Video Modal
Originalvideo player modal

Scroll Modal
Originalmodal with long scrollable body

Steps Modal
Originalmulti-step modal flow

Command Palette Modal
Originalcommand palette modal with search

