diff --git a/src/components/ButtonGroup/ButtonGroup-v2.module.css b/src/components/ButtonGroup/ButtonGroup-v2.module.css
new file mode 100644
index 000000000..630c4e430
--- /dev/null
+++ b/src/components/ButtonGroup/ButtonGroup-v2.module.css
@@ -0,0 +1,28 @@
+/*------------------------------------*\
+ # BUTTON GROUP
+\*------------------------------------*/
+
+/**
+ * A group of buttons displayed in an organized fashion.
+ */
+.button-group {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+
+ gap: 0.5rem;
+}
+
+.button-group--horizontal {
+ flex-direction: row-reverse;
+}
+
+.button-group--vertical {
+ flex-direction: column;
+ align-content: center;
+}
+
+.button-group--horizontal-progressive {
+ flex-direction: row-reverse;
+ justify-content: space-between;
+}
\ No newline at end of file
diff --git a/src/components/ButtonGroup/ButtonGroup-v2.stories.tsx b/src/components/ButtonGroup/ButtonGroup-v2.stories.tsx
new file mode 100644
index 000000000..6d4701170
--- /dev/null
+++ b/src/components/ButtonGroup/ButtonGroup-v2.stories.tsx
@@ -0,0 +1,60 @@
+import type { StoryObj, Meta } from '@storybook/react';
+import React from 'react';
+
+import { ButtonGroup } from './ButtonGroup-v2';
+import { ButtonV2 as Button } from '../Button';
+
+export default {
+ title: 'Components/V2/ButtonGroup',
+ component: ButtonGroup,
+ args: {
+ orientation: 'horizontal',
+ children: (
+ <>
+
+
+ >
+ ),
+ },
+ argTypes: {
+ children: {
+ control: {
+ type: null,
+ },
+ },
+ },
+ parameters: {
+ badges: ['intro-1.0', 'current-2.0'],
+ },
+ decorators: [(Story) =>
{Story()}
],
+} as Meta;
+
+type Args = React.ComponentProps;
+
+export const Default: StoryObj = {};
+
+export const Vertical: StoryObj = {
+ args: {
+ buttonLayout: 'vertical',
+ },
+};
+
+export const HorizontalProgressive: StoryObj = {
+ args: {
+ buttonLayout: 'horizontal-progressive',
+ },
+};
+
+export const WithFiveButtons: StoryObj = {
+ args: {
+ children: (
+ <>
+
+
+
+
+
+ >
+ ),
+ },
+};
diff --git a/src/components/ButtonGroup/ButtonGroup-v2.tsx b/src/components/ButtonGroup/ButtonGroup-v2.tsx
new file mode 100644
index 000000000..679010ae0
--- /dev/null
+++ b/src/components/ButtonGroup/ButtonGroup-v2.tsx
@@ -0,0 +1,43 @@
+import clsx from 'clsx';
+import type { ReactNode } from 'react';
+import React from 'react';
+
+import styles from './ButtonGroup-v2.module.css';
+
+type ButtonGroupProps = {
+ // Component API
+ /**
+ * The buttons. Should not be wrapped in another element – we just want the buttons.
+ */
+ children: ReactNode;
+ /**
+ * Additional classnames passed in for styling.
+ *
+ * This will be applied to the container we're placing around the buttons.
+ */
+ className?: string;
+ // Design API
+ /**
+ * Whether the buttons should be laid out horizontally or stacked vertically (along with relative button position).
+ */
+ buttonLayout?: 'horizontal' | 'vertical' | 'horizontal-progressive';
+};
+
+/**
+ * `import {ButtonGroup} from "@chanzuckerberg/eds";`
+ *
+ * A container for buttons grouped together horizontally or vertically.
+ */
+export function ButtonGroup({
+ children,
+ className,
+ buttonLayout = 'horizontal',
+}: ButtonGroupProps) {
+ const componentClassName = clsx(
+ styles['button-group'],
+ buttonLayout && styles[`button-group--${buttonLayout}`],
+ className,
+ );
+
+ return
{children}
;
+}
diff --git a/src/components/ButtonGroup/index.ts b/src/components/ButtonGroup/index.ts
index 6c6477b12..02bd9b97a 100644
--- a/src/components/ButtonGroup/index.ts
+++ b/src/components/ButtonGroup/index.ts
@@ -1 +1,2 @@
export { ButtonGroup as default } from './ButtonGroup';
+export { ButtonGroup as ButtonGroupV2 } from './ButtonGroup-v2';
diff --git a/src/components/Modal/Modal-v2.module.css b/src/components/Modal/Modal-v2.module.css
new file mode 100755
index 000000000..95badd40a
--- /dev/null
+++ b/src/components/Modal/Modal-v2.module.css
@@ -0,0 +1,212 @@
+@import '../../design-tokens/mixins.css';
+
+/*------------------------------------*\
+ # MODAL
+\*------------------------------------*/
+
+/**
+ * The modal wrapper and overlay which takes up the entire screen.
+ */
+.modal,
+.modal__overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100vh; /* TODO-AH: make sure this doesn't spill out of the container */
+ width: 100%;
+}
+
+/**
+ * The inverted background of the modal to provide contrast with the actual modal.
+ */
+.modal__overlay {
+ /* TODO-AH: opacity of color based on 50% */
+ background-color: var(--eds-theme-color-background-utility-overlay-low-emphasis);
+ opacity: 0.5;
+}
+
+/**
+ * The modal container which positions the modal in the center of the screen.
+ */
+.modal {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+
+ /**
+ * Ensures modal is above other components. This is not a design token for now since we need to align on
+ * z-indeces across the system
+ */
+ z-index: 1050;
+}
+
+/**
+ * Modal transition animations.
+ */
+.modal__transition--enter {
+ transition: opacity var(--eds-anim-fade-long) var(--eds-anim-ease);
+ @media (prefers-reduced-motion) {
+ transition: none;
+ }
+}
+
+.modal__transition--enterFrom {
+ opacity: 0;
+}
+
+.modal__transition--enterTo {
+ opacity: 1;
+}
+
+.modal__transition--leave {
+ transition: opacity var(--eds-anim-fade-long) var(--eds-anim-ease);
+ @media (prefers-reduced-motion) {
+ transition: none;
+ }
+}
+
+.modal__transition--leaveFrom {
+ opacity: 1;
+}
+
+.modal__transition--leaveTo {
+ opacity: 0;
+}
+
+/**
+ * The content of the modal, which can wrap header, body, and footer.
+ */
+.modal__content {
+ position: relative;
+ height: 43.125rem;
+ max-height: 90vh;
+ overflow: hidden;
+
+ /**
+ * This transparent border is for Window High Contrast Mode, which removes all
+ * background colors but makes borders 100% opacity black. Without this, the
+ * modal would have no clear boundary.
+ */
+ border: var(--eds-theme-form-border-width) transparent var(--eds-theme-color-background-utility-container);
+
+ display: flex;
+ flex-direction: column;
+
+ width: 22.5rem;
+
+ background-color: var(--eds-theme-color-background-utility-container);
+}
+
+/**
+ * The medium modal size used for the md modal.
+ * Also used for the lg modal size for when the screen size is smaller than 75rem.
+ */
+.modal__content--md {
+ @media all and (min-width: $eds-bp-md) {
+ width: 42rem;
+ }
+}
+
+/**
+ * The large modal size used for the lg/default modal.
+ */
+.modal__content--lg {
+ @media all and (min-width: $eds-bp-xl) {
+ width: 64rem;
+ --modal-horizontal-padding: 4rem;
+ }
+}
+
+/**
+ * Allows scrolling of the modal content except for sticky footer.
+ * This functionality is our intended scroll behavior but consuming teams can
+ * style the body content as they wish to handle overflow in various ways.
+ */
+.modal__content--scrollable {
+ overflow: auto;
+}
+
+/**
+ * The modal close button.
+ * TODO-AH: this should be a `Button`
+ */
+.modal__close-button {
+ border: 0;
+ background-color: transparent;
+
+ position: absolute;
+ top: 0;
+ right: 0;
+
+ width: 3rem;
+ height: 3rem;
+
+ cursor: pointer;
+
+ z-index: 1;
+
+ color: var(--eds-theme-color-text-utility-default-secondary);
+}
+
+/**
+ * The modal close icon that resides in the close button.
+ */
+.modal__close-icon {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+}
+
+/*------------------------------------*\
+ # MODAL BODY
+\*------------------------------------*/
+
+/**
+ * The body of the modal
+ */
+ .modal-body {
+ flex: 1;
+ padding: 0 2rem;
+}
+
+/*------------------------------------*\
+ # MODAL FOOTER
+\*------------------------------------*/
+
+/**
+ * Footer for the modal.
+ */
+ .modal-footer {
+ width: 100%;
+ z-index: 1000;
+
+ padding: 1.5rem 2rem;
+
+ background-color: var(--eds-theme-color-background-utility-container);
+}
+
+.modal-footer--sticky {
+ position: sticky;
+ bottom: 0;
+
+ /* TODO-AH: bring in scrollwrapper to handle show/hide of visible elevation (and which box shadow to apply) */
+ box-shadow: var(--eds-box-shadow-xl);
+}
+
+/*------------------------------------*\
+ # MODAL HEADER
+\*------------------------------------*/
+
+/**
+ * Header for the modal.
+ */
+ .modal-header {
+ width: 100%;
+
+ padding: 1.5rem 2rem;
+}
+
+.modal-sub-title {
+ color: var(--eds-theme-color-text-utility-default-secondary);
+}
\ No newline at end of file
diff --git a/src/components/Modal/Modal-v2.stories.tsx b/src/components/Modal/Modal-v2.stories.tsx
new file mode 100644
index 000000000..6d15715d3
--- /dev/null
+++ b/src/components/Modal/Modal-v2.stories.tsx
@@ -0,0 +1,478 @@
+import type { StoryObj, Meta } from '@storybook/react';
+import React from 'react';
+import { useState } from 'react';
+
+import { Modal, ModalContent } from './Modal-v2';
+import { Heading, Text } from '../../';
+import { chromaticViewports, storybookViewports } from '../../util/viewports';
+import { ButtonV2 as Button } from '../Button';
+import { ButtonGroupV2 as ButtonGroup } from '../ButtonGroup';
+import { TooltipV2 as Tooltip } from '../Tooltip';
+
+export default {
+ title: 'Components/V2/Modal',
+ component: Modal,
+ parameters: {
+ // The modal is initially closed for most of these stories,
+ // which renders testing it for visual regressions unhelpful.
+ chromatic: { disableSnapshot: true },
+ badges: ['intro-1.0', 'current-2.0'],
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ // For some reason, storybook is not able to pick up the doc.s automatically. Adding manually.
+ children: {
+ control: {
+ type: null,
+ },
+ description:
+ 'Contains the sub-components for a Modal, including `.Header` , `.Title` , `.Body` , `.Footer` , `.Stepper`',
+ },
+ open: {
+ type: 'boolean',
+ description: 'Whether or not the modal is visible.',
+ },
+ hideCloseButton: {
+ description:
+ 'Hides the close button in the top right of the modal. **Default is `false`**.',
+ type: 'boolean',
+ },
+ isScrollable: {
+ description:
+ 'Toggles scrollable variant of the modal. If modal is scrollable, footer is not, and vice versa.',
+ type: 'boolean',
+ },
+ },
+ decorators: [(Story) =>
{Story()}
],
+} as Meta;
+
+type Args = React.ComponentProps;
+type Story = StoryObj;
+type InteractiveArgs = Omit;
+
+/**
+ * Wrapper for triggering `Modal` with a button
+ * @param args
+ * @returns
+ */
+function InteractiveExample(args: InteractiveArgs) {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+
+ setOpen(false)} open={open} />
+ >
+ );
+}
+
+/**
+ * Clicking on a trigger item (in this case `Button`) will cause a modal to open.
+ *
+ * **Note**: this only works from certain screens in Storybook. If it doesn't work as expected, view from the
+ * "docs" sub-page.
+ */
+export const Default: StoryObj = {
+ render: (args) => (
+
+
+ Modal Title
+ Modal Sub-title
+
+
+
Modal Content
+
+
+
+ {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */}
+
+
+
+
+
+
+
+ ),
+};
+
+/**
+ * Modals can contain long, scrollable text. This is not recommended, however.
+ */
+export const WithLongTextScrollable: StoryObj = {
+ args: {
+ isScrollable: true,
+ },
+ render: (args) => (
+
+
+ Modal Title
+ Modal Sub-title
+
+
+ Title text
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc aliquam
+ diam quis dolor maximus, non tincidunt lacus facilisis. Praesent ac
+ vestibulum diam. Sed ac orci fringilla, ullamcorper quam nec,
+ elementum turpis. Orci varius natoque penatibus et magnis dis
+ parturient montes, nascetur ridiculus mus. Curabitur et vulputate leo.
+ Phasellus convallis ante at augue iaculis, quis consectetur dolor
+ placerat. Nulla ornare malesuada elit eu faucibus. Mauris ultricies
+ tincidunt eleifend. Aliquam erat volutpat. Morbi nec ipsum sed sem
+ facilisis tristique nec et ligula. Vivamus id feugiat sapien.
+
+ Title text
+
+ Nam ac tincidunt arcu. Nam non metus sem. Morbi eleifend metus vel
+ venenatis accumsan. Vestibulum pharetra, ante quis sollicitudin
+ aliquam, orci ex pretium ipsum, congue rhoncus dui orci sed velit.
+ Cras ac leo vel massa rutrum auctor eget sed orci. Aenean id nisi
+ consectetur, dapibus tellus ut, finibus metus. Integer tristique est
+ vitae lectus suscipit, ut vulputate est fringilla. Donec pharetra
+ facilisis erat at venenatis. Etiam faucibus dignissim leo eget congue.
+ Sed vehicula imperdiet neque id gravida. Proin volutpat tortor quis
+ quam molestie, faucibus condimentum ante sagittis. Suspendisse sit
+ amet luctus tellus. Suspendisse a sapien hendrerit eros dictum
+ faucibus. Vivamus pretium vel sem faucibus tristique. Integer iaculis
+ pellentesque nunc ac pellentesque.
+
+ Title text
+
+ Integer mollis, urna eget sollicitudin laoreet, nunc elit facilisis
+ urna, ut finibus est mi eu quam. Nam ac venenatis massa. Vestibulum
+ suscipit ac ligula venenatis scelerisque. Fusce rutrum nulla lectus,
+ sed dignissim ipsum faucibus sodales. Suspendisse id aliquet quam.
+ Maecenas facilisis mauris dolor, id accumsan ex vehicula in. In nisl
+ ligula, fringilla in enim nec, lobortis sollicitudin purus. Aliquam
+ vehicula euismod enim quis finibus. Fusce ornare tortor malesuada,
+ consequat magna quis, porta dolor. Donec quis nisl ac sem dictum
+ semper quis vel turpis. Proin sed leo in ante rhoncus pellentesque a
+ eu urna. Phasellus consequat lectus et hendrerit luctus. Lorem ipsum
+ dolor sit amet, consectetur adipiscing elit.
+
+ Title text
+
+ Suspendisse vitae eros elit. Maecenas id urna tempus, tempus turpis
+ id, blandit turpis. Fusce augue quam, pellentesque et suscipit
+ consectetur, pharetra in mi. Suspendisse non ultricies purus. Integer
+ dignissim condimentum sem ac porta. Vivamus viverra congue massa,
+ vitae fermentum est scelerisque et. Duis a urna vitae odio semper
+ dictum a sed nisl. In fringilla hendrerit massa, at luctus arcu
+ tincidunt nec. Donec gravida, mauris sit amet porta lobortis, justo
+ sem vehicula ipsum, at pretium sem leo sed libero. Morbi tristique
+ rhoncus suscipit. Nullam at malesuada sapien. Nam in egestas tellus.
+ Nulla quis metus dui. Suspendisse sit amet nisi at lectus ultricies
+ egestas.
+
+ Title text
+
+ Integer pulvinar felis sit amet dignissim fermentum. Nulla sodales
+ enim mi, varius feugiat sapien congue eget. Morbi vitae ipsum non
+ ligula eleifend molestie. Aenean bibendum tortor sapien, quis volutpat
+ ante ultricies id. Morbi varius dolor ac ante posuere, sit amet
+ tincidunt lectus pulvinar. Proin id efficitur neque. Nullam vel
+ feugiat dui. Curabitur imperdiet lacinia eros, ac iaculis odio.
+ Praesent quis pretium sapien, quis posuere lectus. Proin eleifend
+ purus nec massa aliquam commodo. Quisque auctor suscipit ex sed
+ tristique. Sed eget ultrices est. Suspendisse nunc justo, dapibus at
+ eros ac, rutrum vestibulum est.
+
+
+
+
+ {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */}
+
+
+
+
+
+
+
+ ),
+};
+
+/**
+ * The default modal experience's Content area (the modal itself). The following stories show how the modal
+ * will render in various contexts and with various props set.
+ *
+ * When viewing code, This will use `` directly, to demonstrate composition. When building `Modal`s, use
+ * `Modal.Title` instead.
+ *
+ * **NOTE**: The order of the buttons puts the primary to the far bottom and right of the modal, per
+ * macOS conventions and style guide.
+ */
+export const ContentDefault: Story = {
+ render: (args) => (
+
+
+
+
+ {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */}
+
+
+
+
+
+
+ >
+ ),
+ hideCloseButton: false,
+ open: true,
+ },
+ parameters: {
+ // This story shows the modal content by default, for visual regression testing purposes.
+ chromatic: { disableSnapshot: false },
+ },
+};
+
+/**
+ * `Modal` provides `size`, which allows control over the natural width of the modal. This does not affect the contents
+ * of the modal.
+ */
+export const Medium: Story = {
+ ...ContentDefault,
+ args: {
+ ...ContentDefault.args,
+ size: 'md',
+ },
+};
+
+/**
+ * `Modal` also allows for `small`.
+ */
+export const Small: Story = {
+ ...ContentDefault,
+ args: {
+ ...ContentDefault.args,
+ size: 'sm',
+ },
+};
+
+/**
+ * Buttons can have a vertical alignment in the footer. For this, use `ButtonGroup` with the `buttonLayout` = `vertical`.
+ * Make sure to match the Button width style by using `isFullWidth`.
+ */
+export const LayoutVertical: Story = {
+ ...ContentDefault,
+ args: {
+ ...ContentDefault.args,
+ size: 'md',
+ children: (
+ <>
+
+ Modal Title
+ Modal Sub-title
+
+
+
Modal Content
+
+
+
+ {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */}
+
+
+
+
+
+
+ >
+ ),
+ },
+ render: (args) => (
+
+
+ {}}
+ />
+
+ ),
+};
+
+/**
+ * Buttons can have a vertical alignment in the footer. For this, use `ButtonGroup` with the `buttonLayout` = `vertical`.
+ * Make sure to match the Button width style by using `isFullWidth`.
+ */
+export const LayoutVerticalWithTertiary: Story = {
+ ...ContentDefault,
+ args: {
+ ...ContentDefault.args,
+ size: 'md',
+ children: (
+ <>
+
+ Modal Title
+ Modal Sub-title
+
+
+
Modal Content
+
+
+
+ {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */}
+
+
+
+
+
+
+ >
+ ),
+ },
+ render: (args) => (
+
+
+ {}}
+ />
+
+ ),
+};
+
+/**
+ * Modals can have destructive behavior. Use the 'critical' button variant.
+ */
+export const WithCriticalButton: Story = {
+ ...ContentDefault,
+ args: {
+ ...ContentDefault.args,
+ size: 'md',
+ children: (
+ <>
+
+ Modal Title
+ Modal Sub-title
+
+
+
Modal Content
+
+
+
+ {/* This has to be manually tested since Tooltip tests are flaky in Chromatic */}
+
+
+
+
+
+ >
+ ),
+ },
+ render: (args) => (
+
+
+ {}}
+ />
+
+ ),
+};
+
+/**
+ * This shows the responsive layout in a mobile viewport
+ */
+export const Mobile: Story = {
+ ...ContentDefault,
+ parameters: {
+ ...ContentDefault.parameters,
+ viewport: {
+ defaultViewport: 'googlePixel2',
+ },
+ chromatic: {
+ disableSnapshot: false,
+ viewports: [chromaticViewports.googlePixel2],
+ },
+ },
+};
+
+/**
+ * This shows the responsive layout in a mobile (landscape) viewport
+ */
+export const MobileLandscape: Story = {
+ ...ContentDefault,
+ parameters: {
+ ...ContentDefault.parameters,
+ viewport: {
+ defaultViewport: 'mobilelandscape',
+ viewports: {
+ mobilelandscape: {
+ name: 'Mobile Landscape',
+ styles: {
+ width: '896px',
+ height: '414px',
+ },
+ },
+ },
+ /**
+ * Chromatic sets viewport height to 900px, hence won't snap as necessary
+ */
+ chromatic: { disableSnapshot: true },
+ },
+ },
+};
+
+/**
+ * This shows the responsive layout in a tablet viewport
+ */
+export const Tablet: Story = {
+ ...ContentDefault,
+ parameters: {
+ ...ContentDefault.parameters,
+ viewport: {
+ defaultViewport: 'ipadMini',
+ viewports: {
+ mobilelandscape: storybookViewports.ipadMini,
+ },
+ },
+ chromatic: {
+ disableSnapshot: false,
+ viewports: [chromaticViewports.ipadMini],
+ },
+ },
+};
diff --git a/src/components/Modal/Modal-v2.tsx b/src/components/Modal/Modal-v2.tsx
new file mode 100644
index 000000000..72be4dbd8
--- /dev/null
+++ b/src/components/Modal/Modal-v2.tsx
@@ -0,0 +1,434 @@
+import { Dialog, Transition } from '@headlessui/react';
+import clsx from 'clsx';
+import type { MutableRefObject, ReactNode } from 'react';
+import React from 'react';
+
+import type { ExtractProps } from '../../util/utility-types';
+import type { Size } from '../../util/variant-types';
+import Heading from '../Heading';
+import { Icon } from '../Icon/Icon';
+import Text from '../Text';
+
+import styles from './Modal-v2.module.css';
+
+type ModalContentProps = {
+ // Component API
+ /**
+ * Optional aria-label for the modal.
+ *
+ * If undefined, the headingText of the Modal.Header will be used.
+ * If there is no Modal.Header, an aria-label is required.
+ */
+ 'aria-label'?: string;
+ /**
+ * Additional classnames passed in for styling.
+ */
+ className?: string;
+ /**
+ * Contents of the modal.
+ */
+ children: ReactNode;
+ /**
+ * Hides the close button in the top right of the modal.
+ *
+ * **Default is `false`**.
+ */
+ hideCloseButton?: boolean;
+ /**
+ * A ref to an element that should receive focus when the modal first opens.
+ *
+ * If undefined, the first focusable element (usually the close button) will be used.
+ *
+ * ```
+ * const inputFieldRef = useRef();
+ *
+ *
+ * ...
+ *
+ *
+ * ```
+ */
+ initialFocus?: MutableRefObject;
+ /**
+ * Toggles scrollable variant of the modal. If modal is scrollable, footer is not, and vice versa.
+ * Also adds border and shadow to the footer indicate sticky status and tabindex to body for keyboard scrolling.
+ * Prop should be dependent on whether content overflows at the mobile level.
+ * Tabindex for keyboard scroll is on the body, however, due to focus outline
+ * not having high contrast on the brand header and being overlapped by the footer.
+ *
+ * **Default is `false`**.
+ */
+ isScrollable?: boolean;
+ /**
+ * Method called when the close button is clicked. Use this to hide the modal.
+ * This should be used to also reset the `open` state.
+ * @see https://headlessui.com/react/dialog
+ *
+ * This is required even if you don't have a close button so the ESC key can close the modal.
+ *
+ * ```
+ * const [isOpen, setIsOpen] = useState(true);
+ * // ....
+ *
+ * setIsOpen(false)}>
+ * ...
+ *
+ * ```
+ */
+ onClose: () => void;
+ // Design API
+ /**
+ * Max size of the modal. Defaults to 'lg'.
+ * Will still break responsively.
+ *
+ * **Default is `"lg"`**.
+ */
+ size?: Extract;
+};
+
+type ModalProps = ModalContentProps & {
+ /**
+ * Whether or not the modal is visible. Recommend using `useState` to set this
+ * variable instead of a boolean literal, to avoid component control issues.
+ * @see https://headlessui.com/react/dialog
+ *
+ * ```
+ * const [isOpen, setIsOpen] = useState(true);
+ * // ....
+ *
+ *
+ * ...
+ *
+ * ```
+ */
+ open: boolean;
+ /**
+ * Additional classnames passed in for the modal container.
+ */
+ modalContainerClassName?: string;
+};
+
+type ModalTitleProps = ExtractProps & {
+ // Component API
+ /**
+ * Contents for the modal title.
+ */
+ children: ReactNode;
+ /**
+ * CSS class names that can be appended to the component.
+ */
+ className?: string;
+};
+
+type ModalSubTitleProps = ExtractProps & {
+ // Component API
+ /**
+ * Contents for the modal title.
+ */
+ children: ReactNode;
+ /**
+ * CSS class names that can be appended to the component.
+ */
+ className?: string;
+};
+
+type ModalBodyProps = {
+ // Component API
+ /**
+ * Child node(s) that can be nested inside component. `Modal.Header`,
+ * `Modal.Body`, and `Model.Footer` are the only permissible children of the Modal.
+ */
+ children: ReactNode;
+ /**
+ * CSS class names that can be appended to the component.
+ */
+ className?: string;
+ // Design API
+ /**
+ * Toggles focusable variant of the modal. Used to attach a tabIndex for keyboard scrolling
+ * and focus indicator outline.
+ * Scrolling functionality exists on Modal since the header also needs to scroll.
+ * Defaults to false since modal default is not scrollable.
+ */
+ isFocusable?: boolean;
+};
+
+type ModalHeaderProps = {
+ // Component API
+ /**
+ * Child node(s) to place inside the Modal header.
+ * Should include the
+ */
+ children: ReactNode;
+ /**
+ * CSS class names that can be appended to the component.
+ */
+ className?: string;
+ // Design API
+};
+
+type ModalFooterProps = {
+ // Component API
+ /**
+ * Child node(s) to place inside the Modal footer.
+ */
+ children: ReactNode;
+ /**
+ * CSS class names that can be appended to the component.
+ */
+ className?: string;
+ // Design API
+ /**
+ * Toggles sticky variant of the footer. If modal is scrollable, footer is sticky.
+ * Also adds border and shadow to indicate sticky status.
+ * Defaults to false since modal default is not scrollable.
+ */
+ isSticky?: boolean;
+};
+
+type Context = {
+ isScrollable?: boolean;
+};
+
+const ModalContext = React.createContext({});
+
+function childrenHaveModalTitle(children?: ReactNode): boolean {
+ const childrenArray = React.Children.toArray(children);
+ return childrenArray.some((child) => {
+ if (typeof child === 'string' || typeof child === 'number') {
+ return false;
+ } else if (
+ 'props' in child &&
+ child.type &&
+ typeof child.type !== 'string' &&
+ (child.type?.name === 'ModalTitle' || child.type?.name === 'Modal.Title')
+ ) {
+ return true;
+ } else if ('props' in child && child.props.children) {
+ return childrenHaveModalTitle(child.props.children);
+ }
+ return false;
+ });
+}
+
+/**
+ * The actual modal, without the dark overlay behind it.
+ *
+ * This is only exported for testing purposes; please do not import and use this directly.
+ */
+export const ModalContent = (props: ModalContentProps) => {
+ const {
+ children,
+ className,
+ hideCloseButton = false,
+ isScrollable,
+ onClose,
+ size = 'lg',
+ ...other
+ } = props;
+
+ const componentClassName = clsx(
+ styles['modal__content'],
+ isScrollable && styles['modal__content--scrollable'],
+ (size === 'md' || size === 'lg') && styles['modal__content--md'],
+ size === 'lg' && styles['modal__content--lg'],
+ className,
+ );
+
+ const closeIconClassName = clsx(styles['modal__close-icon']);
+
+ return (
+
+
+ {!hideCloseButton && (
+
+ )}
+
+ {children}
+
+
+ );
+};
+
+/**
+ * `import {Modal} from "@chanzuckerberg/eds";`
+ *
+ * EDS Wrapper for the HeadlessUI Dialog component
+ * @see https://headlessui.dev/react/dialog
+ *
+ * NOTE: You must have at least one focusable element in the modal contents, for keyboard
+ * accessibility reasons. (The close button counts as a focusable element.) Use `initialFocus`
+ * to choose a different element.
+ *
+ */
+export const Modal = (props: ModalProps) => {
+ const {
+ 'aria-label': ariaLabel,
+ initialFocus,
+ modalContainerClassName,
+ onClose,
+ open,
+ ...rest
+ } = props;
+ const { children } = rest;
+
+ if (process.env.NODE_ENV !== 'production') {
+ const hasModalTitle = childrenHaveModalTitle(children);
+ if (!hasModalTitle && !ariaLabel) {
+ throw new Error(
+ "You must use the Modal.Title helper component or pass in an aria-label when using the Modal. The Modal uses the Modal.Title to describe the modal to screen readers using aria-labelledby. If you're not using the Modal.Title component, you can pass in an aria-label instead.",
+ );
+ }
+ }
+
+ const componentClassName = clsx(styles['modal'], modalContainerClassName);
+
+ return (
+
+
+
+ );
+};
+
+/**
+ * Component defines the body of the modal.
+ */
+const ModalBody = ({
+ children,
+ className,
+ isFocusable,
+ ...other
+}: ModalBodyProps) => (
+