Componentfeedback1.49 KB gzipZero dependencies
Dialog
Accessible modal dialog with focus trap and scale animation.
Preview
Live Preview
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add dialogOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzconst [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogHeader>
<DialogTitle>Title</DialogTitle>
<DialogDescription>Description.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setOpen(false)}>Got it</Button>
</DialogFooter>
</Dialog>Usage guidance
Use Dialog when a ticket stub surface needs Accessible modal dialog with focus trap and scale animation. Keep props minimal and prefer composition with sibling stubs.
Avoid Dialog for long form flows or dense data grids where a dedicated layout block fits better.
Source code
dialog.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
const FOCUSABLE = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
export type DialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
/** Dual-mode: false renders instantly with no motion. Default true. */
animated?: boolean;
};
export function Dialog({ open, onOpenChange, children, animated = true }: DialogProps) {
const contentRef = React.useRef<HTMLDivElement>(null);
const restoreRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (!open) return;
restoreRef.current = document.activeElement as HTMLElement | null;
const content = contentRef.current;
const focusables = content?.querySelectorAll<HTMLElement>(FOCUSABLE);
(focusables?.[0] ?? content)?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onOpenChange(false);
return;
}
if (event.key !== "Tab" || !content) return;
const items = Array.from(content.querySelectorAll<HTMLElement>(FOCUSABLE));
if (items.length === 0) return;
const first = items[0]!;
const last = items[items.length - 1]!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
restoreRef.current?.focus();
};
}, [open, onOpenChange]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<div
aria-hidden
onClick={() => onOpenChange(false)}
className={cn("absolute inset-0 bg-black/50 backdrop-blur-[2px]", animated && "animate-[fade-in_0.2s_ease-out_both]")}
/>
<div
ref={contentRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className={cn(
"absolute left-1/2 top-1/2 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border-[1.5px] border-foreground bg-card p-6 shadow-lg outline-1 outline-dashed outline-offset-[-7px] focus:outline-none",
animated && "animate-[scale-in_0.2s_ease-out_both] motion-reduce:animate-none"
)}
>
{children}
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("mb-4 flex flex-col gap-1.5 text-start", className)} {...props} />;
}
export function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h2 className={cn("text-lg font-semibold tracking-tight", className)} {...props} />;
}
export function DialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />;
}
Props
| Prop | Type | Description |
|---|---|---|
| open | boolean | Whether the dialog is open (controlled). |
| onOpenChange | (open: boolean) => void | Called when open state should change. |
Pairs well with
Alert DialogConfirm/cancel modal with destructive variant for irreversible actions, Escape/backdrop close, and animated content.ConfirmDialogSelf-contained confirm dialog with focus trap and stamp entrance for confirm button in destructive mode.SheetSlide-over drawer panel with focus trap and scroll lock.DrawerBottom slide-up drawer with pull handle, backdrop blur, and focus trap.