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 draweror 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
1"use client";2
3import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";4import { CloseButton } from "./CloseButton";5import { useModalBehavior } from "./useModalBehavior";6
7interface DrawerProps {8 open: boolean;9 onClose: () => void;10 title?: string;11 description?: string;12 children?: React.ReactNode;13 actions?: React.ReactNode;14 side?: "left" | "right";15 size?: "sm" | "md" | "lg";16 className?: string;17}18
19const ENTER_MS = 380;20const EXIT_MS = 240;21
22const sizeMap = {23 sm: "max-w-xs",24 md: "max-w-md",25 lg: "max-w-lg",26} as const;27
28export function Drawer({29 open,30 onClose,31 title,32 description,33 children,34 actions,35 side = "right",36 size = "md",37 className = "",38}: DrawerProps) {39 const [mounted, setMounted] = useState(false);40 const [visible, setVisible] = useState(false);41 const panelRef = useRef<HTMLDivElement>(null);42 const uid = useId();43
44 /* ── mount / unmount ────────────────────────────────── */45 useEffect(() => {46 if (open) {47 setMounted(true);48 return;49 }50 if (mounted) {51 setVisible(false);52 const t = setTimeout(() => setMounted(false), EXIT_MS);53 return () => clearTimeout(t);54 }55 }, [open, mounted]);56
57 /* ── trigger enter animation after mount ────────────── */58 useLayoutEffect(() => {59 if (!mounted || !open || visible) return;60 void panelRef.current?.getBoundingClientRect();61 const id = requestAnimationFrame(() => setVisible(true));62 return () => cancelAnimationFrame(id);63 }, [mounted, open, visible]);64
65 /* ── Escape, focus trap + restore, scroll lock ─────── */66 useModalBehavior({ open, ready: visible, ref: panelRef, onClose });67
68 if (!mounted) return null;69
70 const isRight = side === "right";71
72 return (73 <div className="fixed inset-0 z-90">74 <div75 aria-hidden76 className="absolute inset-0 backdrop-blur-[6px] transition-opacity ease-out"77 style={{78 background: "var(--scrim)",79 opacity: visible ? 1 : 0,80 transitionDuration: `${visible ? ENTER_MS : EXIT_MS}ms`,81 }}82 onClick={onClose}83 />84
85 {/* Floats a few px off the viewport edge so it reads as a panel, not a wall */}86 <div87 className={[88 "absolute inset-y-0 w-full p-2 flex",89 isRight ? "right-0 justify-end" : "left-0 justify-start",90 ].join(" ")}91 style={{ pointerEvents: "none" }}92 >93 <div94 ref={panelRef}95 role="dialog"96 aria-modal="true"97 aria-labelledby={title ? `${uid}-title` : undefined}98 aria-describedby={description ? `${uid}-desc` : undefined}99 className={[100 "relative h-full w-full rounded-[22px] p-1.5 flex flex-col pointer-events-auto",101 sizeMap[size],102 className,103 ]104 .filter(Boolean)105 .join(" ")}106 style={{107 background: "var(--panel-bezel)",108 boxShadow: "var(--modal-shadow)",109 transform: visible110 ? "translateX(0)"111 : isRight112 ? "translateX(calc(100% + 16px))"113 : "translateX(calc(-100% - 16px))",114 transition: `transform ${visible ? ENTER_MS : EXIT_MS}ms ${115 visible ? "var(--ease-out-soft)" : "cubic-bezier(0.4,0,1,1)"116 }`,117 }}118 >119 <div120 className="flex flex-col h-full bg-white rounded-[16px] overflow-hidden"121 style={{ boxShadow: "var(--panel-face-shadow)" }}122 >123 <div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4 shrink-0">124 <div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">125 {title && (126 <h2127 id={`${uid}-title`}128 className="text-[16px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug"129 >130 {title}131 </h2>132 )}133 {description && (134 <p id={`${uid}-desc`} className="text-[13px] text-neutral-500 leading-relaxed">135 {description}136 </p>137 )}138 </div>139 <CloseButton label="Close drawer" onClick={onClose} />140 </div>141
142 {children && (143 <div className="flex-1 overflow-y-auto scroll-thin px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">144 {children}145 </div>146 )}147
148 {actions && (149 <div150 className="mt-auto px-5 py-3.5 flex items-center justify-end gap-2 shrink-0"151 style={{152 background: "linear-gradient(180deg, #fafafa 0%, #f6f6f6 100%)",153 boxShadow: "inset 0 1px 0 rgba(17,17,17,0.06)",154 }}155 >156 {actions}157 </div>158 )}159 </div>160 </div>161 </div>162 </div>163 );164}components/ui/CloseButton.tsxtsx
1"use client";2
3import React from "react";4
5interface CloseButtonProps {6 onClick?: () => void;7 label?: string;8 size?: "sm" | "md";9 tone?: "light" | "dark";10 className?: string;11}12
13const sizes = {14 sm: { box: "w-6 h-6 rounded-[7px]", icon: 9 },15 md: { box: "w-7 h-7 rounded-[8px]", icon: 10 },16};17
18/* Quiet until hovered, then it rises into a raised face like every other19 pressable thing — so it reads as a control, not decoration. */20export function CloseButton({21 onClick,22 label = "Close",23 size = "md",24 tone = "light",25 className = "",26}: CloseButtonProps) {27 const dark = tone === "dark";28 const s = sizes[size];29
30 return (31 <button32 type="button"33 onClick={onClick}34 aria-label={label}35 className={[36 "group/close relative shrink-0 flex items-center justify-center cursor-pointer",37 "transition-[color,background,scale] duration-150 active:scale-95",38 "focus-visible:outline-2 focus-visible:outline-offset-2",39 dark40 ? "text-white/45 hover:text-white focus-visible:outline-white"41 : "text-neutral-400 hover:text-neutral-900 focus-visible:outline-neutral-900",42 s.box,43 className,44 ].join(" ")}45 >46 <span47 aria-hidden48 className={[49 "absolute inset-0 rounded-[inherit] opacity-0 group-hover/close:opacity-100 transition-opacity duration-150",50 dark51 ? "[background:var(--tile-dark)] [box-shadow:var(--tile-dark-shadow)]"52 : "[background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",53 ].join(" ")}54 />55 <svg56 className="relative"57 width={s.icon}58 height={s.icon}59 fill="none"60 stroke="currentColor"61 strokeWidth="2.5"62 viewBox="0 0 24 24"63 >64 <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />65 </svg>66 </button>67 );68}components/ui/useModalBehavior.tstsx
1"use client";2
3import { RefObject, useEffect } from "react";4
5interface ModalBehaviorOptions {6 /** The dialog is logically open */7 open: boolean;8 /** The dialog is mounted and painted — focus can move into it */9 ready: boolean;10 ref: RefObject<HTMLElement | null>;11 onClose: () => void;12 /** Element to focus on open; defaults to the first focusable */13 initialFocus?: RefObject<HTMLElement | null>;14}15
16const FOCUSABLE =17 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';18
19/* Everything a modal surface owes the person using it: Escape closes,20 focus is trapped inside, the page behind doesn't scroll, and focus21 returns to whatever opened it. */22export function useModalBehavior({ open, ready, ref, onClose, initialFocus }: ModalBehaviorOptions) {23 // Escape24 useEffect(() => {25 if (!open) return;26 const onKey = (e: KeyboardEvent) => {27 if (e.key === "Escape") {28 e.stopPropagation();29 onClose();30 }31 };32 document.addEventListener("keydown", onKey);33 return () => document.removeEventListener("keydown", onKey);34 }, [open, onClose]);35
36 // Scroll lock37 useEffect(() => {38 if (!open) return;39 const prev = document.body.style.overflow;40 document.body.style.overflow = "hidden";41 return () => {42 document.body.style.overflow = prev;43 };44 }, [open]);45
46 // Focus trap + restore47 useEffect(() => {48 if (!open || !ready) return;49 const el = ref.current;50 if (!el) return;51
52 const opener = document.activeElement as HTMLElement | null;53 const target = initialFocus?.current ?? el.querySelector<HTMLElement>(FOCUSABLE);54 target?.focus();55
56 const trap = (e: KeyboardEvent) => {57 if (e.key !== "Tab") return;58 const focusable = el.querySelectorAll<HTMLElement>(FOCUSABLE);59 if (focusable.length === 0) return;60 const first = focusable[0];61 const last = focusable[focusable.length - 1];62 if (e.shiftKey && document.activeElement === first) {63 e.preventDefault();64 last.focus();65 } else if (!e.shiftKey && document.activeElement === last) {66 e.preventDefault();67 first.focus();68 }69 };70
71 el.addEventListener("keydown", trap);72 return () => {73 el.removeEventListener("keydown", trap);74 opener?.focus?.();75 };76 }, [open, ready, ref, initialFocus]);77}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;}