A `Card` centered in the viewport with a dimmed backdrop that blocks interaction with the rest of the page, built on [Radix `Dialog`](https://www.radix-ui.com/primitives/docs/components/dialog) for focus trapping, Escape-to-close, and outside-click-to-close. It has no opinion on what goes inside the panel — an optional `title` renders a header with a close button, otherwise just the `children` are shown.

pnpm add @behivetech/atoms.modal

You are viewing Modal v0.4.1, snapshotted 2026-11-09. The latest is v0.4.3. Go to the latest →

registry/behivetech/atoms/modal at v0.4.1, snapshotted 2026-11-09

modal.tsx
1import { type HTMLAttributes, type ReactNode } from "react";
2import * as RadixDialog from "@radix-ui/react-dialog";
3import { getClassName } from "@bht-component-registry/class-names";
4import {
5 Card,
6 type CardPadding,
7 type CardHeadingLevel,
8} from "@behivetech/atoms.card";
9import { Button } from "@behivetech/atoms.button";
10// The glyph, not the set: `<Icon name>` is a runtime lookup, so importing it
11// here would pull all 22 icons into every consumer of Modal.
12import { CloseIcon } from "@behivetech/atoms.icon";
13import styles from "./modal.module.scss";
14
15/** `default` is a centered Card panel; `fullscreen` fills the viewport. */
16export type ModalSize = "default" | "fullscreen";
17
18export interface ModalProps extends Omit<
19 HTMLAttributes<HTMLDivElement>,
20 "title"
21> {
22 /** Additional class names to merge with the component root element */
23 className?: string;
24 /** Called when the modal is dismissed (backdrop click, Escape, or the close button) */
25 onClose: () => void;
26 /** Optional header title; omit to render no header and no close button */
27 title?: ReactNode;
28 /**
29 * `default` is a `Card` panel centered in the viewport. `fullscreen` fills the
30 * viewport with no `Card` panel: a top bar holds the `title` and the close
31 * button and `children` fill the rest, for content such as a lightbox.
32 * Fullscreen ignores `icon`, `padding` and `headingLevel`, and its title is
33 * not a heading. The close button is always there, with or without a title.
34 */
35 size?: ModalSize;
36 /** Accessible name of the close button, for translation; defaults to "Close" */
37 closeLabel?: string;
38 /**
39 * Called when focus is about to return to the element that was focused before
40 * the modal opened; call `event.preventDefault()` to move focus elsewhere.
41 */
42 onCloseAutoFocus?: (event: Event) => void;
43 /** Optional icon rendered before the title in the header; ignored if `title` is omitted */
44 icon?: ReactNode;
45 /** Internal padding of the panel body — same values as `Card`'s `padding` prop */
46 padding?: CardPadding;
47 /** Which heading tag wraps `title` — same values as `Card`'s `headingLevel` prop */
48 headingLevel?: CardHeadingLevel;
49 /**
50 * Action buttons kept below the content. They stay visible while the content
51 * scrolls under the panel's height cap, so a long form never pushes Save off
52 * the screen. Ignored by `size="fullscreen"`, which has no panel.
53 */
54 footer?: ReactNode;
55 /** Dialog content */
56 children: ReactNode;
57}
58
59/**
60 * A `Card` centered in the viewport with a dimmed backdrop that blocks
61 * interaction with the rest of the page, built on Radix's `Dialog` for focus
62 * trapping, Escape-to-close, and outside-click-to-close. `size="fullscreen"`
63 * drops the `Card` and fills the viewport instead. For a panel that docks
64 * to an edge of the viewport instead, use `Drawer`.
65 */
66export const Modal = ({
67 className,
68 onClose,
69 title,
70 icon,
71 padding,
72 headingLevel,
73 size = "default",
74 closeLabel = "Close",
75 onCloseAutoFocus,
76 footer,
77 children,
78 ...rest
79}: ModalProps) => {
80 const isFullscreen = size === "fullscreen";
81 const hasFooter = footer !== undefined && footer !== null && footer !== false;
82 const [rootClass, getChildClass] = getClassName({
83 className,
84 rootClass: "BHT__Modal",
85 modifiers: {
86 fullscreen: isFullscreen,
87 // The body padding moves from the Card to the content when a footer is shown.
88 [`padding-${padding ?? "none"}`]: hasFooter,
89 },
90 styles,
91 });
92 const hasHeader = title !== undefined && title !== null && title !== false;
93
94 const cardTitle = hasHeader ? (
95 <RadixDialog.Title asChild>
96 <span>{title}</span>
97 </RadixDialog.Title>
98 ) : undefined;
99
100 const closeButton = (
101 <RadixDialog.Close asChild>
102 <Button
103 variant="ghost"
104 size={isFullscreen ? "lg" : "sm"}
105 iconOnly
106 aria-label={closeLabel}
107 >
108 <CloseIcon size={isFullscreen ? "md" : "sm"} />
109 </Button>
110 </RadixDialog.Close>
111 );
112
113 const cardAction = hasHeader ? closeButton : undefined;
114
115 const hiddenTitle = !hasHeader && (
116 <RadixDialog.Title className={getChildClass("visually-hidden-title")}>
117 Dialog
118 </RadixDialog.Title>
119 );
120
121 // A fullscreen modal has no backdrop to tap, so Close is there with or without a title.
122 const fullscreenContent = (
123 <>
124 <div className={getChildClass("top-bar")}>
125 {hasHeader ? (
126 <RadixDialog.Title asChild>
127 <span className={getChildClass("top-bar-title")}>{title}</span>
128 </RadixDialog.Title>
129 ) : (
130 hiddenTitle
131 )}
132 {closeButton}
133 </div>
134 <div className={getChildClass("stage")}>{children}</div>
135 </>
136 );
137
138 // With a footer the panel's body scrolls with no padding of its own: the content
139 // carries the padding, and the footer sticks to the bottom edge of the scroll area.
140 const content = hasFooter ? (
141 <>
142 <div className={getChildClass("content")}>{children}</div>
143 <div className={getChildClass("footer")}>{footer}</div>
144 </>
145 ) : (
146 children
147 );
148
149 const panel = (
150 <Card
151 className={getChildClass("panel")}
152 icon={hasHeader ? icon : undefined}
153 title={cardTitle}
154 headingLevel={headingLevel}
155 action={cardAction}
156 padding={hasFooter ? "none" : padding}
157 scrollableBody
158 >
159 {hiddenTitle}
160 {content}
161 </Card>
162 );
163
164 return (
165 <RadixDialog.Root open modal onOpenChange={(open) => !open && onClose()}>
166 <RadixDialog.Portal>
167 <RadixDialog.Overlay className={getChildClass("backdrop")} />
168 <RadixDialog.Content
169 {...rest}
170 className={rootClass}
171 onCloseAutoFocus={onCloseAutoFocus}
172 >
173 {isFullscreen ? fullscreenContent : panel}
174 </RadixDialog.Content>
175 </RadixDialog.Portal>
176 </RadixDialog.Root>
177 );
178};
modal.module.scss
1// Modal renders its panel as a Card, but Card is a separate published package
2// with its own separate CSS — nothing about installing @behivetech/atoms.modal
3// tells a consumer they also need @behivetech/atoms.card/styles.css. Pulling
4// Card's actual stylesheet in here (not a copy — the real file, by package
5// path) means Modal's own bundled CSS is self-sufficient: install/import just
6// this package and the panel renders fully styled, no hidden second import
7// required. Card uses generateScopedName: "[local]" (unhashed class names),
8// so this emits the exact same selectors Card's own build emits — no risk of
9// mismatching the classNames the real <Card> component actually renders with.
10@use "@behivetech/atoms.card/card.module.scss";
11
12// Material Design 3 basic dialog: the panel is a Card re-themed through its
13// custom properties, never by targeting Card's classes.
14.BHT__Modal {
15 position: fixed;
16 top: 50%;
17 left: 50%;
18 transform: translate(-50%, -50%);
19 z-index: var(--z-modal, 400);
20
21 &__backdrop {
22 position: fixed;
23 inset: 0;
24 background: var(--color-overlay);
25 z-index: var(--z-modal, 400);
26 }
27
28 &__panel {
29 /* Card reads these custom properties — see Card's README */
30 --card-background: var(
31 --md-sys-color-surface-container-high,
32 var(--color-surface-light)
33 );
34 --card-radius: var(--md-sys-shape-corner-extra-large, 28px);
35 // MD3 dialogs separate from the page by tone and elevation, not an outline.
36 --card-border: none;
37
38 box-shadow: var(--md-sys-elevation-3, var(--shadow-lg));
39 width: min(37.5rem, 90vw);
40 max-height: 70vh;
41 }
42
43 // Fullscreen drops the Card panel: the dialog itself fills the viewport on the
44 // lowest surface tier, a top bar holds the title and Close, the stage the rest.
45 &--fullscreen {
46 inset: 0;
47 transform: none;
48 display: flex;
49 flex-direction: column;
50 background: var(
51 --md-sys-color-surface-container-lowest,
52 var(--color-surface-light)
53 );
54 color: var(--md-sys-color-on-surface, inherit);
55 }
56
57 &__top-bar {
58 display: flex;
59 flex: none;
60 align-items: center;
61 justify-content: space-between;
62 min-height: 4rem;
63 padding-inline: var(--spacing-6) var(--spacing-4);
64 }
65
66 &__top-bar-title {
67 font-family: var(--md-sys-typescale-title-medium-font, var(--font-heading));
68 font-size: var(--md-sys-typescale-title-medium-size, 1rem);
69 font-weight: var(--md-sys-typescale-title-medium-weight, 500);
70 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);
71 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0);
72 }
73
74 &__stage {
75 display: flex;
76 flex: 1;
77 flex-direction: column;
78 min-height: 0;
79 }
80
81 // With a footer the Card's body scrolls with no padding of its own: `__content` carries
82 // the padding, and `__footer` sticks to the bottom edge of the scroll area, so the
83 // buttons stay in view while a long form scrolls beneath them.
84 &--padding-sm {
85 .BHT__Modal {
86 &__content {
87 padding: var(--spacing-3);
88 }
89 }
90 }
91
92 &--padding-md {
93 .BHT__Modal {
94 &__content {
95 padding: var(--spacing-5);
96 }
97 }
98 }
99
100 &--padding-lg {
101 .BHT__Modal {
102 &__content {
103 padding: var(--spacing-8);
104 }
105 }
106 }
107
108 &__footer {
109 position: sticky;
110 bottom: 0;
111 display: flex;
112 flex-wrap: wrap;
113 align-items: center;
114 justify-content: flex-end;
115 gap: var(--spacing-2);
116 padding: var(--spacing-3) var(--spacing-5);
117 background: var(
118 --md-sys-color-surface-container-high,
119 var(--color-surface-light)
120 );
121 border-top: 1px solid
122 var(--md-sys-color-outline-variant, var(--color-border));
123 }
124
125 &__visually-hidden-title {
126 // Fixed px: this is the standard clip idiom for hiding content from sight
127 // while leaving it to screen readers, not a size that should scale.
128 position: absolute;
129 width: 1px;
130 height: 1px;
131 padding: 0;
132 margin: -1px;
133 overflow: hidden;
134 clip: rect(0, 0, 0, 0);
135 white-space: nowrap;
136 border: 0;
137 }
138}
index.ts
1export { Modal } from "./modal.js";
2export type { ModalProps } from "./modal.js";
modal.example.tsx
1import { Modal } from "./modal.js";
2
3export const BasicModal = () => (
4 <Modal onClose={() => {}} title="Add Block">
5 Modal content
6 </Modal>
7);
8
9export const ModalWithoutTitle = () => (
10 <Modal onClose={() => {}}>Modal content, no header</Modal>
11);
12
13export const ModalWithFooter = () => (
14 <Modal
15 onClose={() => {}}
16 title="Gallery image"
17 padding="md"
18 footer={
19 <>
20 <button type="button">Cancel</button>
21 <button type="button">Save</button>
22 </>
23 }
24 >
25 Content that scrolls while the footer buttons stay in view.
26 </Modal>
27);