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.
registry/behivetech/atoms/modal at v0.4.3, snapshotted 2026-11-10
modal.tsx
1import { type HTMLAttributes, type ReactNode } from "react";2import * as RadixDialog from "@radix-ui/react-dialog";3import { getClassName } from "@behivetech/get-class-name";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 it11// here would pull all 22 icons into every consumer of Modal.12import { CloseIcon } from "@behivetech/icons.close";13import styles from "./modal.module.scss";1415/** `default` is a centered Card panel; `fullscreen` fills the viewport. */16export type ModalSize = "default" | "fullscreen";1718export 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 the30 * viewport with no `Card` panel: a top bar holds the `title` and the close31 * button and `children` fill the rest, for content such as a lightbox.32 * Fullscreen ignores `icon`, `padding` and `headingLevel`, and its title is33 * 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 before40 * 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 content51 * scrolls under the panel's height cap, so a long form never pushes Save off52 * the screen. Ignored by `size="fullscreen"`, which has no panel.53 */54 footer?: ReactNode;55 /** Dialog content */56 children: ReactNode;57}5859/**60 * A `Card` centered in the viewport with a dimmed backdrop that blocks61 * interaction with the rest of the page, built on Radix's `Dialog` for focus62 * trapping, Escape-to-close, and outside-click-to-close. `size="fullscreen"`63 * drops the `Card` and fills the viewport instead. For a panel that docks64 * 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 ...rest79}: 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;9394 const cardTitle = hasHeader ? (95 <RadixDialog.Title asChild>96 <span>{title}</span>97 </RadixDialog.Title>98 ) : undefined;99100 const closeButton = (101 <RadixDialog.Close asChild>102 <Button103 variant="ghost"104 size={isFullscreen ? "lg" : "sm"}105 iconOnly106 aria-label={closeLabel}107 >108 <CloseIcon size={isFullscreen ? "md" : "sm"} />109 </Button>110 </RadixDialog.Close>111 );112113 const cardAction = hasHeader ? closeButton : undefined;114115 const hiddenTitle = !hasHeader && (116 <RadixDialog.Title className={getChildClass("visually-hidden-title")}>117 Dialog118 </RadixDialog.Title>119 );120121 // 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 hiddenTitle131 )}132 {closeButton}133 </div>134 <div className={getChildClass("stage")}>{children}</div>135 </>136 );137138 // With a footer the panel's body scrolls with no padding of its own: the content139 // 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 children147 );148149 const panel = (150 <Card151 className={getChildClass("panel")}152 icon={hasHeader ? icon : undefined}153 title={cardTitle}154 headingLevel={headingLevel}155 action={cardAction}156 padding={hasFooter ? "none" : padding}157 scrollableBody158 >159 {hiddenTitle}160 {content}161 </Card>162 );163164 return (165 <RadixDialog.Root open modal onOpenChange={(open) => !open && onClose()}>166 <RadixDialog.Portal>167 <RadixDialog.Overlay className={getChildClass("backdrop")} />168 <RadixDialog.Content169 {...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 package2// with its own separate CSS — nothing about installing @behivetech/atoms.modal3// tells a consumer they also need @behivetech/atoms.card/styles.css. Pulling4// Card's actual stylesheet in here (not a copy — the real file, by package5// path) means Modal's own bundled CSS is self-sufficient: install/import just6// this package and the panel renders fully styled, no hidden second import7// required. Card uses generateScopedName: "[local]" (unhashed class names),8// so this emits the exact same selectors Card's own build emits — no risk of9// mismatching the classNames the real <Card> component actually renders with.10@use "@behivetech/atoms.card/card.module.scss";1112// Material Design 3 basic dialog: the panel is a Card re-themed through its13// 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);2021 &__backdrop {22 position: fixed;23 inset: 0;24 background: var(--color-overlay);25 z-index: var(--z-modal, 400);26 }2728 &__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;3738 box-shadow: var(--md-sys-elevation-3, var(--shadow-lg));39 width: min(37.5rem, 90vw);40 // dvh follows the iOS Safari toolbars; vh is the fallback.41 max-height: 70vh;42 max-height: 70dvh;43 }4445 // Fullscreen drops the Card panel: the dialog itself fills the viewport on the46 // lowest surface tier, a top bar holds the title and Close, the stage the rest.47 &--fullscreen {48 inset: 0;49 transform: none;50 display: flex;51 flex-direction: column;52 background: var(53 --md-sys-color-surface-container-lowest,54 var(--color-surface-light)55 );56 color: var(--md-sys-color-on-surface, inherit);57 }5859 &__top-bar {60 display: flex;61 flex: none;62 align-items: center;63 justify-content: space-between;64 min-height: 4rem;65 padding-inline: var(--spacing-6) var(--spacing-4);66 }6768 &__top-bar-title {69 font-family: var(--md-sys-typescale-title-medium-font, var(--font-heading));70 font-size: var(--md-sys-typescale-title-medium-size, 1rem);71 font-weight: var(--md-sys-typescale-title-medium-weight, 500);72 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);73 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0);74 }7576 &__stage {77 display: flex;78 flex: 1;79 flex-direction: column;80 min-height: 0;81 }8283 // With a footer the Card's body scrolls with no padding of its own: `__content` carries84 // the padding, and `__footer` sticks to the bottom edge of the scroll area, so the85 // buttons stay in view while a long form scrolls beneath them.86 &--padding-sm {87 .BHT__Modal {88 &__content {89 padding: var(--spacing-3);90 }91 }92 }9394 &--padding-md {95 .BHT__Modal {96 &__content {97 padding: var(--spacing-5);98 }99 }100 }101102 &--padding-lg {103 .BHT__Modal {104 &__content {105 padding: var(--spacing-8);106 }107 }108 }109110 &__footer {111 position: sticky;112 bottom: 0;113 display: flex;114 flex-wrap: wrap;115 align-items: center;116 justify-content: flex-end;117 gap: var(--spacing-2);118 padding: var(--spacing-3) var(--spacing-5);119 background: var(120 --md-sys-color-surface-container-high,121 var(--color-surface-light)122 );123 border-top: 1px solid124 var(--md-sys-color-outline-variant, var(--color-border));125 }126127 &__visually-hidden-title {128 // Fixed px: this is the standard clip idiom for hiding content from sight129 // while leaving it to screen readers, not a size that should scale.130 position: absolute;131 width: 1px;132 height: 1px;133 padding: 0;134 margin: -1px;135 overflow: hidden;136 clip: rect(0, 0, 0, 0);137 white-space: nowrap;138 border: 0;139 }140}
index.ts
1export { Modal } from "./modal.js";2export type { ModalProps } from "./modal.js";
modal.example.tsx
1import { Modal } from "./modal.js";23export const BasicModal = () => (4 <Modal onClose={() => {}} title="Add Block">5 Modal content6 </Modal>7);89export const ModalWithoutTitle = () => (10 <Modal onClose={() => {}}>Modal content, no header</Modal>11);1213export const ModalWithFooter = () => (14 <Modal15 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);