overlaysclient
modal
a dialog that springs in over the page. escape closes it and focus goes back where it was.
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 modalor by hand: drop these files into components/ui and import from there. modal leans on CloseButton.tsx and useModalBehavior.ts, so those come along too.
components/ui/Modal.tsxtsx
1"use client";2
3import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";4import { CloseButton } from "./CloseButton";5import { useModalBehavior } from "./useModalBehavior";6
7interface ModalProps {8 open: boolean;9 onClose: () => void;10 title?: string;11 description?: string;12 children?: React.ReactNode;13 actions?: React.ReactNode;14 className?: string;15}16
17const CLOSE_MS = 240;18
19export function Modal({20 open,21 onClose,22 title,23 description,24 children,25 actions,26 className = "",27}: ModalProps) {28 const [mounted, setMounted] = useState(false);29 const [visible, setVisible] = useState(false);30 const dialogRef = useRef<HTMLDivElement>(null);31 const uid = useId();32
33 useEffect(() => {34 if (open) {35 setMounted(true);36 return;37 }38 if (mounted) {39 setVisible(false);40 const t = setTimeout(() => setMounted(false), CLOSE_MS);41 return () => clearTimeout(t);42 }43 }, [open, mounted]);44
45 useLayoutEffect(() => {46 if (!mounted || !open || visible) return;47 void dialogRef.current?.getBoundingClientRect();48 const id = requestAnimationFrame(() => setVisible(true));49 return () => cancelAnimationFrame(id);50 }, [mounted, open, visible]);51
52 // Escape, focus trap + restore, scroll lock53 useModalBehavior({ open, ready: visible, ref: dialogRef, onClose });54
55 if (!mounted) return null;56
57 return (58 <div className="fixed inset-0 z-90 flex items-center justify-center p-4">59 <div60 aria-hidden61 className={[62 "absolute inset-0 backdrop-blur-[6px]",63 "transition-opacity ease-out",64 visible ? "opacity-100 duration-300" : "opacity-0 duration-200",65 ].join(" ")}66 style={{ background: "var(--scrim)" }}67 onClick={onClose}68 />69 <div70 ref={dialogRef}71 role="dialog"72 aria-modal="true"73 aria-labelledby={title ? `${uid}-title` : undefined}74 aria-describedby={description ? `${uid}-desc` : undefined}75 className={[76 "relative z-10 w-full max-w-lg rounded-[22px] p-1.5",77 "transition-[opacity,transform,scale,translate] ease-[var(--ease-spring)]",78 visible79 ? "opacity-100 scale-100 translate-y-0 duration-300"80 : "opacity-0 scale-[0.94] translate-y-3 duration-200",81 className,82 ]83 .filter(Boolean)84 .join(" ")}85 style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}86 >87 <div88 className="bg-white rounded-[16px] overflow-hidden"89 style={{ boxShadow: "var(--panel-face-shadow)" }}90 >91 <div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4">92 <div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">93 {title && (94 <h295 id={`${uid}-title`}96 className="text-[16px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug"97 >98 {title}99 </h2>100 )}101 {description && (102 <p id={`${uid}-desc`} className="text-[13px] text-neutral-500 leading-relaxed">103 {description}104 </p>105 )}106 </div>107 <CloseButton label="Close dialog" onClick={onClose} />108 </div>109
110 {children && <div className="px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">{children}</div>}111
112 {/* Footer sits in the bezel tone so actions read as a separate tray */}113 {actions && (114 <div115 className="px-5 py-3.5 flex items-center justify-end gap-2"116 style={{117 background: "linear-gradient(180deg, #fafafa 0%, #f6f6f6 100%)",118 boxShadow: "inset 0 1px 0 rgba(17,17,17,0.06)",119 }}120 >121 {actions}122 </div>123 )}124 </div>125 </div>126 </div>127 );128}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 { Modal } from "@blurrow/ui";tsx
<Modal open={open} onClose={() => setOpen(false)} title="delete post?" actions={actions} />examples
with content
props
straight from the source, so they're always up to date.
propsts
interface ModalProps { open: boolean; onClose: () => void; title?: string; description?: string; children?: React.ReactNode; actions?: React.ReactNode; className?: string;}