Modal
Accessible dialog on Radix Dialog: colored title band (primary or danger), close button, scrollable body, footer action bar. Re-applies the DA scope inside the portal. Stepped drop-in entrance.
brutalist/ui/modalSource
"use client";
import type { ReactNode } from "react";
import { Dialog } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ModalProps {
/** Element that opens the modal (rendered with Radix `asChild`, so pass a single focusable element). */
trigger?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
title: string;
description?: string;
/** Mono label above the title, e.g. "Danger zone". */
kicker?: string;
children?: ReactNode;
/** Action buttons, right-aligned. Wrap a button in <ModalClose asChild> to close on click. */
footer?: ReactNode;
tone?: "surface" | "danger";
size?: "sm" | "md" | "lg";
/**
* DA scope re-applied inside the portal (the modal renders at the end of <body>,
* outside your `data-da` wrapper).
*/
da?: string;
className?: string;
}
const SIZES = { sm: "max-w-sm", md: "max-w-lg", lg: "max-w-2xl" } as const;
export const ModalClose = Dialog.Close;
export function Modal({
trigger,
open,
defaultOpen,
onOpenChange,
title,
description,
kicker,
children,
footer,
tone = "surface",
size = "md",
da = "brutalist",
className,
}: ModalProps) {
return (
<Dialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
{trigger && <Dialog.Trigger asChild>{trigger}</Dialog.Trigger>}
<Dialog.Portal>
<Dialog.Overlay
data-da={da}
className="fixed inset-0 z-50 bg-da-overlay"
/>
<Dialog.Content
data-da={da}
className={cn(
"da-stroke fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col bg-da-surface text-da-surface-fg shadow-da-lg outline-none",
// Brutalist entrance: a short stepped drop (no fade), skipped with reduced motion.
"transition-[translate] duration-(--da-duration-slow) ease-da-emphasized starting:-translate-y-[calc(50%+12px)] motion-reduce:transition-none",
SIZES[size],
className,
)}
>
<div className={cn("da-stroke-b flex items-start gap-4 p-5 sm:p-6", tone === "danger" ? "bg-da-danger text-da-danger-fg" : "bg-da-primary text-da-primary-fg")}>
<div className="min-w-0 flex-1">
{kicker && <p className="font-da-mono text-[11px] font-bold tracking-da-label uppercase">{kicker}</p>}
<Dialog.Title className="mt-1 font-da-display text-2xl leading-tight tracking-da-display uppercase">{title}</Dialog.Title>
</div>
<Dialog.Close
aria-label="Close"
className="da-focus da-transition da-stroke grid size-9 shrink-0 place-items-center bg-da-surface text-da-surface-fg shadow-da-sm hover:translate-x-[3px] hover:translate-y-[3px] hover:shadow-none"
>
<X aria-hidden className="size-5" strokeWidth={2.5} />
</Dialog.Close>
</div>
<div className="overflow-y-auto p-5 sm:p-6">
{description ? (
<Dialog.Description className="text-base leading-relaxed text-da-muted-fg">{description}</Dialog.Description>
) : (
<Dialog.Description className="sr-only">{title}</Dialog.Description>
)}
{children && <div className={cn(description && "mt-5")}>{children}</div>}
</div>
{footer && <div className="da-stroke-t flex flex-col-reverse gap-3 bg-da-surface-2 p-5 sm:flex-row sm:justify-end sm:p-6">{footer}</div>}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export default Modal;
modules/brutalist/ui/modal/index.tsx
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | string | — | Dialog title (Radix Dialog.Title). |
| description | string | — | Body text (Dialog.Description). Falls back to a visually hidden description. |
| kicker | string | — | Mono label above the title. |
| trigger | ReactNode | — | Single focusable element that opens the modal. |
| open / defaultOpen / onOpenChange | boolean / boolean / (open: boolean) => void | — | Controlled or uncontrolled state (Radix API). |
| children | ReactNode | — | Extra body content. |
| footer | ReactNode | — | Actions; wrap buttons in <ModalClose asChild> to close on click. |
| tone | "surface" | "danger" | "surface" | Title band color (primary or danger). |
| size | "sm" | "md" | "lg" | "md" | Max width. |
| da | string | "brutalist" | DA scope applied to the portal content (change it when re-theming). |
Other modal variants in Brutalist
Confirm Dialog
Destructive confirmation (Radix AlertDialog) over a hazard-striped overlay: red 'Danger zone' band, consequences list, and a type-the-name field that arms the delete button; async pending state.
Drawer
Side sheet (Radix Dialog) sliding in from the right or left in stepped motion: yellow title band with close square, scrollable body for forms, ruled footer actions.
Onboarding Modal
Multi-step onboarding dialog: a colored visual panel (giant step number by default) next to title, description, segmented progress bar and Back/Next buttons; resets on open.
Modal in other art directions
Bottom Sheet
Mobile-style bottom sheet (Radix Dialog): heavily frosted panel sliding up with a grab handle, title and description; becomes a floating centered card on wider screens.
Dropdown Menu
Frosted dropdown menu (Radix DropdownMenu) built from an items array: labels, icons, shortcuts, separators, checkbox items, nested submenus and a danger item.
Modal
Frosted glass sheet dialog (Radix): 40px backdrop blur, optional icon bubble, rise-and-pop entrance with overshoot, blurred overlay, round close button and footer actions.
Share Dialog
Glass share dialog: invite-by-email field with role select, people-with-access list, a general access switch (invited only / anyone with link) and a copy-link button with confirmation.
Command Palette
⌘K command palette (Radix Dialog): search input, results filtered as you type and grouped (actions, incidents, schedules), icons and shortcuts, arrow-key highlight, Enter to run, footer hints.
Modal
Radix-based dialog with fade + subtle scale entrance, optional icon, title/description, scrollable body and a tinted footer action bar. Re-applies the DA scope inside the portal.
Popover Card
Anchored popover (Radix Popover) with arrow, optional title + close button and any content — quick forms (snooze), definitions or details. Collision-aware placement.
Sheet
Side sheet (Radix Dialog) sliding in from the right or left over a dimmed overlay: title and description header with close button, scrollable body and footer actions.
Command Dialog
Command palette (Radix Dialog + listbox): a square panel near the top with a large borderless search, grouped results under mono headings, arrow-key navigation with an inverted active row and shortcut hints.
Drawer
Right side panel (Radix Dialog) sliding in over a dimmed page: mono top bar with label and “Close”, large title, scrollable body with ruled rows and a sticky footer.
Lightbox
Full-screen image viewer (Radix Dialog) on ink (stays dark in dark mode): the image frame centered, mono “03 / 04” counter and caption, Prev/Next text buttons and arrow-key navigation, thumbnails strip.
Modal
Square dialog (Radix): a mono label + text “Close” in the top bar, a full-ink rule, a large title, body and right-aligned actions; opens with a quick fade and 8px rise.
Confirm Dialog
Destructive confirmation (Radix AlertDialog): red warning icon, title, consequence text, optional “type VOID to confirm” guard, cancel + red confirm.
Modal
Corporate dialog (Radix): white card with header (title, description, close), divided scrollable body and a tinted footer bar for actions; quick fade + slight scale.
Payment Modal
Pay-invoice dialog: amount summary, card/bank method radio cards, method-specific fields, secure note and a pay button that switches to a success state.
Side Sheet
Detail drawer (Radix Dialog) sliding in from the right: full-height bordered panel with sticky header, scrollable body and footer actions — for record details.
Bottom Sheet
Bottom sheet (Radix Dialog): slides up from the bottom with a grab handle and very rounded top corners; becomes a centered card from sm up.
Confirm Dialog
Gentle confirmation (Radix AlertDialog): a wilting-leaf illustration in a clay circle, serif question, kind consequence text, a sage “keep” action and a quiet clay confirm.
Modal
Soft dialog (Radix): large-radius cream card that rises gently with a slight overshoot, serif title, round close button and pill actions.
Welcome Modal
Onboarding dialog: an illustrated tinted header (sprout growing with each step), serif title and copy, leaf-shaped step dots, Back/Next pills and “Let’s begin” on the last step.
Celebrate Modal
“Week complete!” celebration dialog: pastel confetti shapes burst from the center, trophy emoji, friendly copy, three pastel stat tiles and one pill button.
Confirm Sheet
Destructive confirmation shown as a bottom sheet on mobile and a centered card on desktop: big emoji in a red-tint circle, gentle copy, red confirm with pending state and a soft cancel.
Modal
Soft modal (Radix Dialog): big rounded white card popping in with a small bounce, pastel emoji circle, round close button, body and pill footer actions.
Popover Menu
Rounded dropdown menu (Radix DropdownMenu) with emoji items, keyboard hints, soft highlight, separators and a red danger item; pops in with a bounce.