Disclosure & OverlaysDialog
A modal sheet over a scrim — compound pieces on the Radix dialog primitive.
The thinking
A dialog is the page stopping to ask you something, so I built it as a sheet of the page and not floating machinery — the whole component optimizes for the interruption feeling like the page's own doing. That splits the work cleanly in two. The parts that must never fail — focus trapping, escape, returning focus to where you were — stay Radix's job, and I refuse to re-implement any of them; the part I actually own is making that contract visible, which is why the scrim and the sheet move on two separate logics and opening reads as one surface arriving, not two elements animating near each other. Everything I added is manners. The machinery is borrowed on purpose.
Interface design
The sheet is
bg-card with a hairline border-border-faint — the same surface vocabulary as a card resting on the page, not a popover's separate chrome, because a dialog is the page talking and should look like it. Width is w-full max-w-md: enough for a small form, never enough to read as a page. Inside, one grid with gap-4 spaces header, body and footer, the title sits at font-head text-base font-medium and the description drops to text-sm text-muted-foreground — the question loud, the context quiet. The footer is flex-col-reverse sm:flex-row sm:justify-end, so one DOM order puts the primary action rightmost on desktop and on top when the buttons stack on a phone. The close is an X in text-muted-foreground that only reaches foreground on hover — it's an exit, not an invitation.Interaction
Two layers, two motions. The scrim is pure opacity — it never moves, it only exists more or less — while the sheet pops:
animate-pop-in fades in while scaling up from 0.96, both layers on the house ease-swift curve, cubic-bezier(0.23, 0.88, 0.26, 0.92). Both enter in 200ms and leave in 150, because a close is a decision already made — escape, a scrim click and the X all mean get me out, and an overlay that exits slower than it arrived is arguing with you. The trick underneath is property separation: centering is the standalone [translate:-50%_-50%] while the pop keyframes animate the standalone scale property, so the two compose for the animation's whole duration instead of one wiping the other out. Focus trapping and return are Radix's and I don't fight them — the motion just makes that contract visible.Sound design
A dialog is a door: open lands on the fifth, close resolves back to the root. The pair is owned by the root's
onOpenChange rather than any button, so escape, a scrim click and the X all sound exactly the same — the sound tracks the state, not the gesture. The trigger and close mark themselves data-silent, because the SoundLayer's capturing listener taps anything with an interactive role, and without the opt-out its tap would stack on top of the pair.Empathy
The parts a dialog can hurt someone with are delegated to Radix: focus is trapped inside while it's open, escape closes it, and closing returns focus to the trigger that opened it. The title and description render the Radix primitives, so assistive tech announces the sheet with its actual question and context instead of an anonymous box. The icon-only close carries an
sr-only Close label and shows a focus-visible:ring-1 for keyboards. Reduced motion is honored on both layers — motion-reduce:animate-none on the scrim and the sheet — so the dialog simply appears and disappears, and nothing about it depends on the animation. Long text is handled at the type level, text-balance on the title and text-pretty on the description, so an awkward question still wraps evenly. One honest gap: there's no max-height and no internal scroll, so content taller than the viewport runs past the edges. My position is that a dialog asking that much should have been a page — but that's a stance, not handling."use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "@phosphor-icons/react/dist/ssr";
import { cn } from "@/lib/utils";
import { useSound } from "@/hooks/useSound";
import { SOUNDS } from "@/lib/sounds";
// the dialog owns its pair: open lands on the fifth, close resolves
// back to the root. wiring it to onOpenChange means esc, scrim click
// and the X all sound the same — the trigger and close are data-silent
// so the layer's tap never stacks on top.
const Dialog = ({
onOpenChange,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root>) => {
const [playOpen] = useSound(SOUNDS.open);
const [playClose] = useSound(SOUNDS.close);
return (
<DialogPrimitive.Root
onOpenChange={(open) => {
(open ? playOpen : playClose)();
onOpenChange?.(open);
}}
{...props}
/>
);
};
Dialog.displayName = "Dialog";
const DialogTrigger = ({
ref,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger> & {
ref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Trigger>>;
}) => <DialogPrimitive.Trigger ref={ref} data-silent {...props} />;
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
const DialogClose = ({
ref,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> & {
ref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Close>>;
}) => <DialogPrimitive.Close ref={ref} data-silent {...props} />;
DialogClose.displayName = DialogPrimitive.Close.displayName;
const DialogPortal = DialogPrimitive.Portal;
// the scrim is pure opacity — it never moves, it only exists more or less
const DialogOverlay = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
ref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Overlay>>;
}) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-scrim data-[state=open]:animate-overlay-in data-[state=closed]:animate-overlay-out motion-reduce:animate-none",
className
)}
{...props}
/>
);
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
// centering uses the standalone `translate` property, not a transform —
// the pop keyframes own `transform: scale(...)`, and the two compose
// instead of the keyframe wiping the centering out
const DialogContent = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
ref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Content>>;
}) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-md gap-4 rounded-lg border border-border-faint bg-card p-6 text-card-foreground outline-none [translate:-50%_-50%] data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
className
)}
{...props}
>
{children}
<DialogClose className="absolute right-4 top-4 rounded-sm text-muted-foreground transition-colors duration-150 ease-swift hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</DialogPrimitive.Content>
</DialogPortal>
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
ref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Title>>;
}) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"font-head text-base font-medium text-balance",
className
)}
{...props}
/>
);
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
ref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Description>>;
}) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground text-pretty", className)}
{...props}
/>
);
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
};