Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import React, { useState } from 'react';

import { Button, ButtonVariant } from '../Button';
Copy link
Copy Markdown
Contributor

@georgewrmarshall georgewrmarshall Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I just merged #1034 so ButtonVariant is a shared const, so per .cursor/rules/component-architecture.md it should come from @metamask/design-system-shared rather than through a sibling component export.


import { ModalOverlay } from './ModalOverlay';
import type { ModalOverlayProps } from './ModalOverlay.types';
import README from './README.mdx';

const meta: Meta<ModalOverlayProps> = {
title: 'React Components/ModalOverlay',
component: ModalOverlay,
parameters: {
docs: {
page: README,
},
},
argTypes: {
className: {
control: 'text',
description:
'Optional prop for additional CSS classes to be applied to the ModalOverlay component',
},
onClick: {
action: 'onClick',
description:
'Optional click handler. Useful when used directly without Modal context to dismiss content rendered above the overlay.',
},
},
};

export default meta;

type Story = StoryObj<ModalOverlayProps>;

export const Default: Story = {
args: {},
render: (args) => <ModalOverlay {...args} />,
};

export const OnClick: Story = {
render: (args) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button variant={ButtonVariant.Primary} onClick={() => setIsOpen(true)}>
Show modal overlay
</Button>
{isOpen && <ModalOverlay {...args} onClick={() => setIsOpen(false)} />}
</>
);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React, { createRef } from 'react';

import { ModalOverlay } from './ModalOverlay';

describe('ModalOverlay', () => {
it('renders without crashing', () => {
render(<ModalOverlay data-testid="modal-overlay" />);
expect(screen.getByTestId('modal-overlay')).toBeInTheDocument();
});

it('applies overlay positioning, z-index, and motion-safe fade-in classes', () => {
render(<ModalOverlay data-testid="modal-overlay" />);
expect(screen.getByTestId('modal-overlay')).toHaveClass(
'fixed',
'inset-0',
'z-[1050]',
'motion-safe:animate-fade-in',
);
});

it('applies the overlay-default background color', () => {
render(<ModalOverlay data-testid="modal-overlay" />);
expect(screen.getByTestId('modal-overlay')).toHaveClass(
'bg-overlay-default',
);
});

it('marks the overlay as decorative for assistive tech', () => {
render(<ModalOverlay data-testid="modal-overlay" />);
expect(screen.getByTestId('modal-overlay')).toHaveAttribute(
'aria-hidden',
'true',
);
});

it('merges custom className alongside default classes', () => {
render(<ModalOverlay data-testid="modal-overlay" className="opacity-50" />);
const overlay = screen.getByTestId('modal-overlay');
expect(overlay).toHaveClass('opacity-50');
expect(overlay).toHaveClass('fixed', 'inset-0');
});

it('fires the onClick handler when clicked', () => {
const handleClick = jest.fn();
render(<ModalOverlay data-testid="modal-overlay" onClick={handleClick} />);
fireEvent.click(screen.getByTestId('modal-overlay'));
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('forwards ref to the underlying element', () => {
const ref = createRef<HTMLDivElement>();
render(<ModalOverlay ref={ref} data-testid="modal-overlay" />);
expect(ref.current).toBe(screen.getByTestId('modal-overlay'));
});

it('forwards arbitrary HTML attributes to the underlying element', () => {
render(
<ModalOverlay
data-testid="modal-overlay"
id="overlay"
role="presentation"
/>,
);
const overlay = screen.getByTestId('modal-overlay');
expect(overlay).toHaveAttribute('id', 'overlay');
expect(overlay).toHaveAttribute('role', 'presentation');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { forwardRef } from 'react';

import { twMerge } from '../../utils/tw-merge';
import { Box, BoxBackgroundColor } from '../Box';

import type { ModalOverlayProps } from './ModalOverlay.types';

export const ModalOverlay = forwardRef<HTMLDivElement, ModalOverlayProps>(
({ className, ...props }, ref) => (
<Box
ref={ref}
backgroundColor={BoxBackgroundColor.OverlayDefault}
aria-hidden="true"
className={twMerge(
'fixed inset-0 z-[1050] motion-safe:animate-fade-in',
className,
)}
{...props}
/>
),
);

ModalOverlay.displayName = 'ModalOverlay';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ComponentProps } from 'react';

export type ModalOverlayProps = ComponentProps<'div'> & {
/**
* Optional prop for additional CSS classes to be applied to the ModalOverlay component.
* These classes will be merged with the component's default classes using twMerge.
*/
className?: string;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Controls, Canvas } from '@storybook/addon-docs/blocks';

import * as ModalOverlayStories from './ModalOverlay.stories';

# ModalOverlay

`ModalOverlay` is a transparent backdrop that covers the entire viewport. Use it to dim and isolate the page behind a modal or dialog. The component is purely presentational; pair it with `ModalContent` and the rest of the modal stack when building a full modal experience.

```tsx
import { ModalOverlay } from '@metamask/design-system-react';

<ModalOverlay onClick={handleClose} />;
```

<Canvas of={ModalOverlayStories.Default} />

## Props

### `onClick`

Optional click handler invoked when the overlay is clicked. Useful for closing the modal when the user clicks outside its content. Not necessary when used inside `Modal` with `closeOnClickOutside` enabled.

<table>
<thead>
<tr>
<th align="left">TYPE</th>
<th align="left">REQUIRED</th>
<th align="left">DEFAULT</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left">
<code>(event: MouseEvent&lt;HTMLDivElement&gt;) =&gt; void</code>
</td>
<td align="left">No</td>
<td align="left">
<code>undefined</code>
</td>
</tr>
</tbody>
</table>

<Canvas of={ModalOverlayStories.OnClick} />

### `className`

Use the `className` prop to add custom CSS classes to the component. These classes will be merged with the component's default classes using `twMerge`, allowing you to:

- Add new styles that don't exist in the default component
- Override the component's default styles when needed

<table>
<thead>
<tr>
<th align="left">TYPE</th>
<th align="left">REQUIRED</th>
<th align="left">DEFAULT</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left">
<code>string</code>
</td>
<td align="left">No</td>
<td align="left">
<code>undefined</code>
</td>
</tr>
</tbody>
</table>

## Component API

<Controls of={ModalOverlayStories.Default} />

## Migration Guide

Migrating from `ui/components/component-library/modal-overlay` in MetaMask Extension? See the [ModalOverlay Migration Guide](../../../MIGRATION.md#modaloverlay-component) for prop mappings, before/after examples, and breaking changes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: This README links to ../../../MIGRATION.md#modaloverlay-component, but packages/design-system-react/MIGRATION.md does not have a ModalOverlay section yet. Since this PR is part of the extension migration flow, can we add the ModalOverlay migration entry before merging so consumers have the actual prop mapping / before-after examples?


## References

[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ModalOverlay } from './ModalOverlay';
export type { ModalOverlayProps } from './ModalOverlay.types';
3 changes: 3 additions & 0 deletions packages/design-system-react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export type { JazziconProps } from './temp-components/Jazzicon';
export { Maskicon } from './temp-components/Maskicon';
export type { MaskiconProps } from './temp-components/Maskicon';

export { ModalOverlay } from './ModalOverlay';
export type { ModalOverlayProps } from './ModalOverlay';

export { Text } from './Text';
export {
TextVariant,
Expand Down
11 changes: 11 additions & 0 deletions packages/design-system-tailwind-preset/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ const tailwindConfig: Config = {
}),
...typography,
boxShadow: shadows,
keyframes: {
'fade-in': {
from: { opacity: '0' },
to: { opacity: '1' },
},
},
animation: {
// Duration matches `AnimationDuration.Regularly` from `@metamask/design-tokens`.
// Inlined to avoid a workspace dependency cycle (design-tokens → design-system-react → tailwind-preset).
'fade-in': 'fade-in 300ms linear forwards',
},
},
},
plugins: [
Expand Down
Loading