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 modal

or 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
"use client";
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { CloseButton } from "./CloseButton";
import { useModalBehavior } from "./useModalBehavior";
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
description?: string;
children?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}
const CLOSE_MS = 240;
export function Modal({
open,
onClose,
title,
description,
children,
actions,
className = "",
}: ModalProps) {
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
const uid = useId();
useEffect(() => {
if (open) {
setMounted(true);
return;
}
if (mounted) {
setVisible(false);
const t = setTimeout(() => setMounted(false), CLOSE_MS);
return () => clearTimeout(t);
}
}, [open, mounted]);
useLayoutEffect(() => {
if (!mounted || !open || visible) return;
void dialogRef.current?.getBoundingClientRect();
const id = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(id);
}, [mounted, open, visible]);
// Escape, focus trap + restore, scroll lock
useModalBehavior({ open, ready: visible, ref: dialogRef, onClose });
if (!mounted) return null;
return (
<div className="fixed inset-0 z-90 flex items-center justify-center p-4">
<div
aria-hidden
className={[
"absolute inset-0 backdrop-blur-[6px]",
"transition-opacity ease-out",
visible ? "opacity-100 duration-300" : "opacity-0 duration-200",
].join(" ")}
style={{ background: "var(--scrim)" }}
onClick={onClose}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? `${uid}-title` : undefined}
aria-describedby={description ? `${uid}-desc` : undefined}
className={[
"relative z-10 w-full max-w-lg rounded-[22px] p-1.5",
"transition-[opacity,transform,scale,translate] ease-[var(--ease-spring)]",
visible
? "opacity-100 scale-100 translate-y-0 duration-300"
: "opacity-0 scale-[0.94] translate-y-3 duration-200",
className,
]
.filter(Boolean)
.join(" ")}
style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}
>
<div
className="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">
<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 dialog" onClick={onClose} />
</div>
{children && <div className="px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">{children}</div>}
{/* Footer sits in the bezel tone so actions read as a separate tray */}
{actions && (
<div
className="px-5 py-3.5 flex items-center justify-end gap-2"
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>
);
}
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 { Modal } from "@blurrow/ui";
tsx
<Modal open={open} onClose={() => setOpen(false)} title="delete post?" actions={actions} />

examples

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;
}