Drawer Right Sidebar
Original · freeslide-in right drawer panel
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"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'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 & 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
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 Sidebar
Originalbasic app sidebar navigation

Collapsible Sidebar
Originalcollapsible sidebar sections
Icons Sidebar
Originalicon sidebar with labels on hover

Drawer Left Sidebar
Originalslide-in left drawer with overlay

Nested Sidebar
Originalsidebar with expandable nested items

Mini Sidebar
Originalmini rail that expands on hover

Dark Sidebar
Originaldark themed dashboard sidebar

Sections Sidebar
Originalsidebar with grouped sections and headers

Mobile Sidebar
Originalmobile hamburger slide menu

Floating Sidebar
Originalfloating rounded sidebar

Rail Sidebar
Originalnarrow icon rail with tooltips

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

