overlaysclient

drawer

a panel that slides in from the side, for things that don't need the whole page.

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add drawer

or by hand: drop these files into components/ui and import from there. drawer leans on CloseButton.tsx and useModalBehavior.ts, so those come along too.

components/ui/Drawer.tsxtsx
"use client";
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { CloseButton } from "./CloseButton";
import { useModalBehavior } from "./useModalBehavior";
interface DrawerProps {
open: boolean;
onClose: () => void;
title?: string;
description?: string;
children?: React.ReactNode;
actions?: React.ReactNode;
side?: "left" | "right";
size?: "sm" | "md" | "lg";
className?: string;
}
const ENTER_MS = 380;
const EXIT_MS = 240;
const sizeMap = {
sm: "max-w-xs",
md: "max-w-md",
lg: "max-w-lg",
} as const;
export function Drawer({
open,
onClose,
title,
description,
children,
actions,
side = "right",
size = "md",
className = "",
}: DrawerProps) {
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const uid = useId();
/* ── mount / unmount ────────────────────────────────── */
useEffect(() => {
if (open) {
setMounted(true);
return;
}
if (mounted) {
setVisible(false);
const t = setTimeout(() => setMounted(false), EXIT_MS);
return () => clearTimeout(t);
}
}, [open, mounted]);
/* ── trigger enter animation after mount ────────────── */
useLayoutEffect(() => {
if (!mounted || !open || visible) return;
void panelRef.current?.getBoundingClientRect();
const id = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(id);
}, [mounted, open, visible]);
/* ── Escape, focus trap + restore, scroll lock ─────── */
useModalBehavior({ open, ready: visible, ref: panelRef, onClose });
if (!mounted) return null;
const isRight = side === "right";
return (
<div className="fixed inset-0 z-90">
<div
aria-hidden
className="absolute inset-0 backdrop-blur-[6px] transition-opacity ease-out"
style={{
background: "var(--scrim)",
opacity: visible ? 1 : 0,
transitionDuration: `${visible ? ENTER_MS : EXIT_MS}ms`,
}}
onClick={onClose}
/>
{/* Floats a few px off the viewport edge so it reads as a panel, not a wall */}
<div
className={[
"absolute inset-y-0 w-full p-2 flex",
isRight ? "right-0 justify-end" : "left-0 justify-start",
].join(" ")}
style={{ pointerEvents: "none" }}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? `${uid}-title` : undefined}
aria-describedby={description ? `${uid}-desc` : undefined}
className={[
"relative h-full w-full rounded-[22px] p-1.5 flex flex-col pointer-events-auto",
sizeMap[size],
className,
]
.filter(Boolean)
.join(" ")}
style={{
background: "var(--panel-bezel)",
boxShadow: "var(--modal-shadow)",
transform: visible
? "translateX(0)"
: isRight
? "translateX(calc(100% + 16px))"
: "translateX(calc(-100% - 16px))",
transition: `transform ${visible ? ENTER_MS : EXIT_MS}ms ${
visible ? "var(--ease-out-soft)" : "cubic-bezier(0.4,0,1,1)"
}`,
}}
>
<div
className="flex flex-col h-full bg-white rounded-[16px] overflow-hidden"
style={{ boxShadow: "var(--panel-face-shadow)" }}
>
<div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4 shrink-0">
<div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
{title && (
<h2
id={`${uid}-title`}
className="text-[16px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug"
>
{title}
</h2>
)}
{description && (
<p id={`${uid}-desc`} className="text-[13px] text-neutral-500 leading-relaxed">
{description}
</p>
)}
</div>
<CloseButton label="Close drawer" onClick={onClose} />
</div>
{children && (
<div className="flex-1 overflow-y-auto scroll-thin px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">
{children}
</div>
)}
{actions && (
<div
className="mt-auto px-5 py-3.5 flex items-center justify-end gap-2 shrink-0"
style={{
background: "linear-gradient(180deg, #fafafa 0%, #f6f6f6 100%)",
boxShadow: "inset 0 1px 0 rgba(17,17,17,0.06)",
}}
>
{actions}
</div>
)}
</div>
</div>
</div>
</div>
);
}
components/ui/CloseButton.tsxtsx
"use client";
import React from "react";
interface CloseButtonProps {
onClick?: () => void;
label?: string;
size?: "sm" | "md";
tone?: "light" | "dark";
className?: string;
}
const sizes = {
sm: { box: "w-6 h-6 rounded-[7px]", icon: 9 },
md: { box: "w-7 h-7 rounded-[8px]", icon: 10 },
};
/* Quiet until hovered, then it rises into a raised face like every other
pressable thing — so it reads as a control, not decoration. */
export function CloseButton({
onClick,
label = "Close",
size = "md",
tone = "light",
className = "",
}: CloseButtonProps) {
const dark = tone === "dark";
const s = sizes[size];
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className={[
"group/close relative shrink-0 flex items-center justify-center cursor-pointer",
"transition-[color,background,scale] duration-150 active:scale-95",
"focus-visible:outline-2 focus-visible:outline-offset-2",
dark
? "text-white/45 hover:text-white focus-visible:outline-white"
: "text-neutral-400 hover:text-neutral-900 focus-visible:outline-neutral-900",
s.box,
className,
].join(" ")}
>
<span
aria-hidden
className={[
"absolute inset-0 rounded-[inherit] opacity-0 group-hover/close:opacity-100 transition-opacity duration-150",
dark
? "[background:var(--tile-dark)] [box-shadow:var(--tile-dark-shadow)]"
: "[background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",
].join(" ")}
/>
<svg
className="relative"
width={s.icon}
height={s.icon}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
);
}
components/ui/useModalBehavior.tstsx
"use client";
import { RefObject, useEffect } from "react";
interface ModalBehaviorOptions {
/** The dialog is logically open */
open: boolean;
/** The dialog is mounted and painted — focus can move into it */
ready: boolean;
ref: RefObject<HTMLElement | null>;
onClose: () => void;
/** Element to focus on open; defaults to the first focusable */
initialFocus?: RefObject<HTMLElement | null>;
}
const FOCUSABLE =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/* Everything a modal surface owes the person using it: Escape closes,
focus is trapped inside, the page behind doesn't scroll, and focus
returns to whatever opened it. */
export function useModalBehavior({ open, ready, ref, onClose, initialFocus }: ModalBehaviorOptions) {
// Escape
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Scroll lock
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Focus trap + restore
useEffect(() => {
if (!open || !ready) return;
const el = ref.current;
if (!el) return;
const opener = document.activeElement as HTMLElement | null;
const target = initialFocus?.current ?? el.querySelector<HTMLElement>(FOCUSABLE);
target?.focus();
const trap = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = el.querySelectorAll<HTMLElement>(FOCUSABLE);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
el.addEventListener("keydown", trap);
return () => {
el.removeEventListener("keydown", trap);
opener?.focus?.();
};
}, [open, ready, ref, initialFocus]);
}

usage

tsx
import { Drawer } from "@blurrow/ui";
tsx
<Drawer open={open} onClose={() => setOpen(false)} title="edit settings">…</Drawer>

examples

left side

props

straight from the source, so they're always up to date.

propsts
interface DrawerProps {
open: boolean;
onClose: () => void;
title?: string;
description?: string;
children?: React.ReactNode;
actions?: React.ReactNode;
side?: "left" | "right";
size?: "sm" | "md" | "lg";
className?: string;
}