"use client";
import * as React from "react";
import { animate, motion, useDragControls, useMotionValue, useTransform, type PanInfo } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface SideDrawerProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called when the drawer asks to open or close (drag, Escape, backdrop, close button). */
onOpenChange?: (open: boolean) => void;
/** Drawer title. Labels the dialog. */
title: React.ReactNode;
/** Supporting text under the title. Describes the dialog. */
description?: React.ReactNode;
/** Pinned footer, e.g. actions. The body scrolls between header and footer. */
footer?: React.ReactNode;
/** Edge the drawer slides in from. */
side?: "left" | "right";
/**
* "overlay": modal (top layer, backdrop, inert page, scroll lock).
* "push": non-modal panel inside the nearest positioned container that shifts `pushRef` aside.
*/
mode?: "overlay" | "push";
/** In push mode, the element that shifts aside while the drawer is open. */
pushRef?: React.RefObject<HTMLElement | null>;
/** Panel width in px (never wider than the viewport / container minus 48px). */
width?: number;
/** Allow closing by dragging the panel away and by clicking the backdrop. Escape and the close button always work. */
dismissible?: boolean;
/** Backdrop opacity when fully open (overlay mode). */
backdropOpacity?: number;
/** Spring for opening, closing and settling after a drag. */
spring?: Spring;
/** Accent for focus rings and the drag grip. */
accent?: string;
/** Accessible name of the close button. */
closeLabel?: string;
/** Classes added to the panel. */
className?: string;
/** Inline styles merged onto the panel. */
style?: React.CSSProperties;
children?: React.ReactNode;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
const SHADOW_ROOM = 32; // extra px travelled when closing so the panel shadow leaves the screen too
/**
* A side panel on a native <dialog>. Overlay mode opens it modally (top layer, inert page, backdrop,
* body scroll lock); push mode opens it non-modally inside a container and slides `pushRef` aside.
* Drag the panel towards its edge to dismiss: release velocity is projected to decide. Focus moves in
* on open and returns on close; with reduced motion the panel fades instead of sliding.
*/
export function SideDrawer({
open,
defaultOpen = false,
onOpenChange,
title,
description,
footer,
side = "right",
mode = "overlay",
pushRef,
width = 380,
dismissible = true,
backdropOpacity = 0.4,
spring = { stiffness: 380, damping: 38, mass: 1 },
accent = "#FF7448",
closeLabel = "Close",
className = "",
style,
children,
}: SideDrawerProps) {
const uid = React.useId();
const titleId = `${uid}-title`;
const descriptionId = `${uid}-description`;
const reduceMotion = usePrefersReducedMotion();
const dialogRef = React.useRef<HTMLDialogElement>(null);
const panelRef = React.useRef<HTMLDivElement>(null);
const returnFocusRef = React.useRef<HTMLElement | null>(null);
const releaseVelocityRef = React.useRef(0);
const mountedRef = React.useRef(false);
const dragControls = useDragControls();
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isOpen = open ?? internalOpen;
const modal = mode === "overlay";
const direction = side === "right" ? 1 : -1; // sign of x that moves the panel off screen
const setOpen = (next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
};
/* ------------------------------ motion values ------------------------------ */
// x: drag / slide offset from the open position. fade: 0 when closed, 1 when shown (also the
// reduced-motion animation). panelWidth: measured, so the maths respects the max-width clamp.
const x = useMotionValue(0);
const fade = useMotionValue(0);
const panelWidth = useMotionValue(width);
const progress = useTransform([x, fade, panelWidth], ([xv, f, w]: number[]) =>
w > 0 ? f * (1 - clamp(Math.abs(xv) / w, 0, 1)) : f,
);
const backdrop = useTransform(progress, (p) => p * backdropOpacity);
// Push mode: slide the provided content by the drawer's visible width (instant under reduced motion).
const writePush = React.useEffectEvent((target: HTMLElement, p: number) => {
const w = panelWidth.get();
const amount = reduceMotion ? (fade.get() > 0.001 ? 1 - clamp(Math.abs(x.get()) / w, 0, 1) : 0) : p;
target.style.setProperty("translate", amount > 0.0005 ? `${(-direction * amount * w).toFixed(2)}px 0` : "");
});
React.useEffect(() => {
const target = pushRef?.current;
if (!target || modal) return;
const unsubscribe = progress.on("change", (p) => writePush(target, p));
return () => {
unsubscribe();
target.style.removeProperty("translate");
};
}, [pushRef, modal, progress]);
const transition = (velocity = 0) => ({ type: "spring" as const, ...spring, velocity });
const fadeTransition = { duration: 0.18, ease: "easeOut" as const };
const closedX = () => direction * (panelWidth.get() + SHADOW_ROOM);
/* --------------------------------- effects --------------------------------- */
const animateIn = React.useEffectEvent((fromClosed: boolean) => {
const panel = panelRef.current;
if (panel) panelWidth.set(panel.offsetWidth);
if (reduceMotion) {
x.set(0);
return animate(fade, 1, fadeTransition);
}
fade.set(1);
if (fromClosed) x.set(closedX());
return animate(x, 0, transition());
});
const animateOut = React.useEffectEvent(() => {
const velocity = releaseVelocityRef.current;
releaseVelocityRef.current = 0;
if (reduceMotion) return animate(fade, 0, fadeTransition);
return animate(x, closedX(), transition(velocity));
});
const focusInside = React.useEffectEvent((dialog: HTMLDialogElement) => {
if (dialog.contains(document.activeElement)) return;
const target =
dialog.querySelector<HTMLElement>("[autofocus]") ??
dialog.querySelector<HTMLElement>("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])");
target?.focus({ preventScroll: true });
});
// Drive the native dialog from state: open → show / showModal and slide in; close → slide out, then close().
React.useEffect(() => {
const dialog = dialogRef.current;
const initial = !mountedRef.current;
mountedRef.current = true;
if (!dialog) return;
if (isOpen) {
if (dialog.open) {
// Re-opened while the exit animation was still running.
const controls = animateIn(false);
return () => controls.stop();
}
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
if (modal) {
dialog.showModal();
} else if (initial) {
// Open on first render (defaultOpen): show it without taking focus from the page.
dialog.setAttribute("open", "");
} else {
dialog.show();
focusInside(dialog);
}
const controls = animateIn(true);
return () => controls.stop();
}
if (dialog.open) {
let cancelled = false;
const controls = animateOut();
controls.then(() => {
if (cancelled) return;
fade.set(0);
dialog.close();
const returnTo = returnFocusRef.current;
returnFocusRef.current = null;
if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });
});
return () => {
cancelled = true;
controls.stop();
};
}
}, [isOpen, modal, fade]);
// Keep the measured width current (window resize, width prop changes).
React.useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
const ro = new ResizeObserver(() => {
if (panel.offsetWidth > 0) panelWidth.set(panel.offsetWidth);
});
ro.observe(panel);
return () => ro.disconnect();
}, [panelWidth]);
// Overlay mode locks body scroll while open, compensating for the scrollbar.
React.useEffect(() => {
if (!isOpen || !modal) return;
const html = document.documentElement;
const body = document.body;
const scrollbar = window.innerWidth - html.clientWidth;
const prev = { overflow: html.style.overflow, paddingRight: body.style.paddingRight };
html.style.overflow = "hidden";
if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`;
return () => {
html.style.overflow = prev.overflow;
body.style.paddingRight = prev.paddingRight;
};
}, [isOpen, modal]);
/* ---------------------------------- drag ---------------------------------- */
const startDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dismissible || event.button !== 0) return;
const target = event.target as HTMLElement;
if (target.closest("button, a, input, select, textarea, label, [role=slider], [contenteditable]")) return;
// Touch and pen drag from anywhere (vertical pans still scroll the body); a mouse drags from the
// header or the grip so text in the body stays selectable.
if (event.pointerType === "mouse" && !target.closest("[data-drawer-handle]")) return;
dragControls.start(event);
};
const handleDragEnd = (_: PointerEvent, info: PanInfo) => {
const w = panelWidth.get();
const offset = x.get() * direction; // > 0 means towards the closed edge
const velocity = info.velocity.x * direction;
const projected = offset + velocity * 0.2;
if (dismissible && (projected > w * 0.5 || (velocity > 900 && offset > 12))) {
releaseVelocityRef.current = info.velocity.x;
setOpen(false);
return;
}
animate(x, 0, reduceMotion ? { duration: 0 } : transition(info.velocity.x));
};
/* --------------------------------- render ---------------------------------- */
const focusRing =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sd-accent)]";
const edge = side === "right" ? "right-0" : "left-0";
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onCancel={(event) => {
// Escape: keep React state the source of truth so the exit animation runs.
event.preventDefault();
setOpen(false);
}}
onKeyDown={(event) => {
// Non-modal dialogs do not receive "cancel" on Escape in every browser.
if (event.key === "Escape" && !modal && !event.defaultPrevented) {
event.preventDefault();
setOpen(false);
}
}}
onClose={() => {
// Closed by something other than our state (e.g. a form with method="dialog").
if (isOpen) setOpen(false);
}}
className={
modal
? "fixed inset-0 m-0 h-full max-h-none w-full max-w-none overflow-clip border-0 bg-transparent p-0 backdrop:bg-transparent"
: // Covers the container without blocking it; only the panel takes pointer events.
"pointer-events-none absolute inset-0 z-20 m-0 h-full max-h-none w-full max-w-none overflow-clip border-0 bg-transparent p-0"
}
style={{ ["--sd-accent" as string]: accent }}
>
{modal && (
<motion.div
aria-hidden="true"
className="absolute inset-0 bg-zinc-950"
style={{ opacity: backdrop }}
onClick={() => {
if (dismissible) setOpen(false);
}}
/>
)}
<motion.div
ref={panelRef}
drag={dismissible ? "x" : false}
dragControls={dragControls}
dragListener={false}
dragMomentum={false}
dragConstraints={side === "right" ? { left: 0 } : { right: 0 }}
dragElastic={side === "right" ? { left: 0.04, right: 1 } : { left: 1, right: 0.04 }}
onDragEnd={handleDragEnd}
onPointerDown={startDrag}
className={`pointer-events-auto absolute inset-y-0 ${edge} flex touch-pan-y flex-col bg-white text-zinc-900 outline-hidden dark:bg-zinc-900 dark:text-zinc-100 ${
side === "right"
? "border-l shadow-[-24px_0_60px_-32px_rgba(0,0,0,0.35)] dark:shadow-[-24px_0_60px_-32px_rgba(0,0,0,0.9)]"
: "border-r shadow-[24px_0_60px_-32px_rgba(0,0,0,0.35)] dark:shadow-[24px_0_60px_-32px_rgba(0,0,0,0.9)]"
} border-zinc-900/10 dark:border-white/10 ${className}`}
style={{
x,
opacity: reduceMotion ? fade : 1,
width: `min(${width}px, calc(100${modal ? "vw" : "%"} - 3rem))`,
...style,
}}
>
{/* Grip on the inner edge: a drag affordance for pointers. */}
{dismissible && (
<div
aria-hidden="true"
data-drawer-handle=""
className={`group absolute inset-y-0 z-10 flex w-4 cursor-grab items-center justify-center active:cursor-grabbing ${
side === "right" ? "-left-2" : "-right-2"
}`}
>
<span className="h-10 w-1 rounded-full bg-zinc-900/15 transition-colors group-hover:bg-[var(--sd-accent)] dark:bg-white/20" />
</div>
)}
{/* Opaque strip that fills the gap if a spring overshoots past the open position. */}
<div
aria-hidden="true"
className={`absolute inset-y-0 w-16 bg-white dark:bg-zinc-900 ${side === "right" ? "left-full" : "right-full"}`}
/>
<header
data-drawer-handle=""
className={`flex shrink-0 items-start gap-4 border-b border-zinc-900/[0.08] px-5 pb-4 pt-5 dark:border-white/[0.08] ${dismissible ? "cursor-grab select-none active:cursor-grabbing" : ""}`}
>
<div className="min-w-0 flex-1">
<h2 id={titleId} className="text-base font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">
{title}
</h2>
{description && (
<p id={descriptionId} className="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">
{description}
</p>
)}
</div>
<button
type="button"
aria-label={closeLabel}
onClick={() => setOpen(false)}
className={`-mr-1.5 -mt-1 flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-zinc-500 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white ${focusRing}`}
>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path d="m4 4 8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4">{children}</div>
{footer && (
<footer className="shrink-0 border-t border-zinc-900/[0.08] bg-zinc-50/80 px-5 py-3.5 dark:border-white/[0.08] dark:bg-white/[0.02]">
{footer}
</footer>
)}
</motion.div>
</dialog>
);
}