diff --git a/.changeset/cyan-knives-drop.md b/.changeset/cyan-knives-drop.md
new file mode 100644
index 0000000000..09e48dba0a
--- /dev/null
+++ b/.changeset/cyan-knives-drop.md
@@ -0,0 +1,5 @@
+---
+"@sumup-oss/circuit-ui": minor
+---
+
+Set an explicit minimum version for TypeScript of 4.1 or higher. While this is technically a breaking change, v4.1 was released over 4 years ago, so we don't expect this to break anyone's code. Please let us know if this causes you issues.
diff --git a/.changeset/eight-beers-think.md b/.changeset/eight-beers-think.md
new file mode 100644
index 0000000000..fae2e8fa56
--- /dev/null
+++ b/.changeset/eight-beers-think.md
@@ -0,0 +1,5 @@
+---
+"@sumup-oss/circuit-ui": minor
+---
+
+Refactored the NotificationModal component to use the new Modal component under the hood.
diff --git a/.changeset/gold-lemons-report.md b/.changeset/gold-lemons-report.md
new file mode 100644
index 0000000000..1f6368acaf
--- /dev/null
+++ b/.changeset/gold-lemons-report.md
@@ -0,0 +1,5 @@
+---
+"@sumup-oss/design-tokens": minor
+---
+
+Added "::backdrop" to the list of selectors to apply theme custom properties to. See https://developer.chrome.com/blog/css-backdrop-inheritance.
diff --git a/.changeset/grumpy-wombats-talk.md b/.changeset/grumpy-wombats-talk.md
new file mode 100644
index 0000000000..0f4777d5d3
--- /dev/null
+++ b/.changeset/grumpy-wombats-talk.md
@@ -0,0 +1,5 @@
+---
+"@sumup-oss/circuit-ui": minor
+---
+
+Added a new hook `useScrollLock` to disable page scroll on demand.
diff --git a/.changeset/rich-icons-pretend.md b/.changeset/rich-icons-pretend.md
new file mode 100644
index 0000000000..6251bcfb74
--- /dev/null
+++ b/.changeset/rich-icons-pretend.md
@@ -0,0 +1,5 @@
+---
+"@sumup-oss/circuit-ui": minor
+---
+
+Deprecated the `hideCloseButton` prop in the Modal and NotificationModal components. It had no effect.
diff --git a/.changeset/sharp-seals-leave.md b/.changeset/sharp-seals-leave.md
new file mode 100644
index 0000000000..c685adc03d
--- /dev/null
+++ b/.changeset/sharp-seals-leave.md
@@ -0,0 +1,5 @@
+---
+"@sumup-oss/circuit-ui": minor
+---
+
+Added default translations for the Modal and NotificationModal components. The `closeButtonLabel` prop is now optional.
diff --git a/.changeset/wicked-pants-cough.md b/.changeset/wicked-pants-cough.md
new file mode 100644
index 0000000000..d882020047
--- /dev/null
+++ b/.changeset/wicked-pants-cough.md
@@ -0,0 +1,5 @@
+---
+"@sumup-oss/circuit-ui": minor
+---
+
+Refactored the Modal component to use the native `dialog` element. The Modal component can now be rendered directly in your JSX (the older `useModal` hook continues to be supported).
diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml
index d9fcedb65a..56e4de8a4e 100644
--- a/.github/workflows/cr.yml
+++ b/.github/workflows/cr.yml
@@ -1,11 +1,12 @@
 name: Continuous Releases
 on:
-  issue_comment:
-    types: [created]
+  pull_request:
+    types:
+      - labeled
 
 jobs:
   build:
-    if: ${{ github.event.comment.body == '/preview' && github.event.issue.pull_request }}
+    if: ${{ github.event.label.name == 'preview' }}
     runs-on: ubuntu-latest
 
     steps:
@@ -25,4 +26,78 @@ jobs:
         run: npm run build
 
       - name: Publish packages
-        run: npx pkg-pr-new publish './packages/circuit-ui'  './packages/design-tokens'  './packages/icons'
+        run: npx pkg-pr-new publish './packages/circuit-ui'  './packages/design-tokens'  './packages/icons' --json output.json --comment=off
+
+      - name: Post or update comment
+        uses: actions/github-script@v6
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: |
+            const fs = require('fs');
+            const output = JSON.parse(fs.readFileSync('output.json', 'utf8'));
+            console.log(output);
+      
+            const packages = output.packages
+              .map((p) => `- ${p.name}: ${p.url}`)
+              .join('\n');
+      
+            const sha = context.payload.pull_request.head.sha
+            
+            const commitUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha}`;
+      
+            const body = `## 🚀 Your packages were published 
+      
+            ### Published Packages:
+      
+            ${packages}
+      
+            [View Commit](${commitUrl})`;
+      
+            const botCommentIdentifier = '## 🚀 Your packages were published ';
+      
+            async function findBotComment(issueNumber) {
+              if (!issueNumber) return null;
+              const comments = await github.rest.issues.listComments({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                issue_number: issueNumber,
+              });
+              return comments.data.find((comment) =>
+                comment.body.includes(botCommentIdentifier)
+              );
+            }
+      
+            async function createOrUpdateComment(issueNumber) {
+              if (!issueNumber) {
+                console.log('No issue number provided. Cannot post or update comment.');
+                return;
+              }
+      
+              const existingComment = await findBotComment(issueNumber);
+              if (existingComment) {
+                await github.rest.issues.updateComment({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  comment_id: existingComment.id,
+                  body: body,
+                });
+              } else {
+                await github.rest.issues.createComment({
+                  issue_number: issueNumber,
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  body: body,
+                });
+              }
+            }
+            if (context.issue.number) {
+              await createOrUpdateComment(context.issue.number);
+            }
+
+      - name: Delete label
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          PR_NUMBER:
+            ${{ github.event.pull_request.number }}
+        run: |
+          gh pr edit $PR_NUMBER --remove-label "preview"
diff --git a/package-lock.json b/package-lock.json
index 810e2b8ec8..310a9ec248 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41712,7 +41712,8 @@
 				"vite": "^5.4.11"
 			},
 			"engines": {
-				"node": ">=20"
+				"node": ">=20",
+				"typescript": ">=4.1"
 			},
 			"peerDependencies": {
 				"@emotion/is-prop-valid": "^1.2.1",
diff --git a/package.json b/package.json
index 6579f619c1..f2b4cabf2d 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
 		"test:ci": "vitest run --coverage",
 		"lint": "biome check --diagnostic-level=error && foundry run eslint . --ext .js,.jsx,.ts,.tsx",
 		"lint:fix": "biome check --write --diagnostic-level=error && foundry run eslint . --ext .js,.jsx,.ts,.tsx --fix",
-		"lint:ci": "biome ci && foundry run eslint . --ext .js,.jsx,.ts,.tsx --quiet ",
+		"lint:ci": "biome ci --diagnostic-level=error && foundry run eslint . --ext .js,.jsx,.ts,.tsx --quiet ",
 		"lint:css": "foundry run stylelint '**/*.css'",
 		"lint:css:fix": "foundry run stylelint '**/*.css' --fix",
 		"dev": "npm run docs:start",
diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx
index 185eff92b7..9de2f04e69 100644
--- a/packages/circuit-ui/components/DateInput/DateInput.tsx
+++ b/packages/circuit-ui/components/DateInput/DateInput.tsx
@@ -224,6 +224,7 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
     const { floatingStyles, update } = useFloating({
       open,
       placement,
+      strategy: 'fixed',
       middleware: [
         offset(4),
         flip({ padding, fallbackAxisSideDirection: 'start' }),
diff --git a/packages/circuit-ui/components/Modal/Modal.mdx b/packages/circuit-ui/components/Modal/Modal.mdx
index 301a659210..27cb5160b5 100644
--- a/packages/circuit-ui/components/Modal/Modal.mdx
+++ b/packages/circuit-ui/components/Modal/Modal.mdx
@@ -5,58 +5,116 @@ import * as Stories from './Modal.stories';
 
 # Modal
 
-<Status variant="under-review" />
+<Status variant="stable" />
 
-The modal component displays self-contained tasks in a focused window that overlays the page content.
+The modal component displays self-contained tasks in an overlay view, requiring the user to interact with it before returning to the underlying content.
 
 <Story of={Stories.Base} />
 <Props />
 
 ## When to use it
 
-Generally, use the modal component sparingly. Consider displaying more complex tasks and large amounts of information on a separate page instead.
+Generally, use the modal dialog component sparingly. Consider displaying more complex tasks and large amounts of information on a separate page instead.
 
-## Variants
+A modal dialog is a disruptive pattern that interrupts the user's current task. It should only be used when the task requires immediate attention or when the user needs to make a decision before continuing:
 
-<Story of={Stories.Variants} />
+- Confirming a critical action, such as deleting an item or abandoning a task (also see the [NotificationModal](Notification/NotificationModal/Docs) component).
+- Forms or inputs to collect small amounts of data without navigating away from the current page (e.g., login, feedback forms).
+- Focused tasks like editing an item, uploading files, or reviewing details.
 
-### Contextual
+## How to use it
 
-Use this variant when the modal content requires the context of the page underneath to be understood. On small viewports, the modal component opens up from the bottom as a bottom sheet overlay on top of the page content, dimming the uncovered area while giving a visual context of the page underneath. The height of the bottom sheet can be manually adjusted depending on the use case and the amount of content needed to be displayed.
+### Inline (recommended)
 
-### Immersive
+Place your dialog content directly in the `Modal` component:
 
-Use this variant to focus the user's attention on the modal content. On small viewports, the modal component opens up from the bottom as a fullscreen overlay on top of the page content and covers it entirely in favor of an immersive experience.
+```tsx
+import { Modal, Body, Button, Heading } from '@sumup-oss/circuit-ui';
+import { useState } from 'react';
+
+function Component() {
+  const [open, setOpen] = useState(false);
+
+  return (
+    <>
+      <Button onClick={() => setOpen(true)}>Open dialog</Button>
+      <Modal
+        open={open}
+        aria-labelledby="dialog-title"
+        onClose={() => setOpen(false)}
+      >
+        {() => (
+          <>
+            <Heading as="h2" id="dialog-title">
+              Modal title
+            </Heading>
+            <Body>Modal content</Body>
+            <Button>Close dialog</Button>
+          </>
+        )}
+      </Modal>
+    </>
+  );
+}
+```
 
-## Usage in code
+### With the `useModal` hook
 
-First, wrap your application in the `ModalProvider` which keeps track of the open modals, prevents scrolling of the page when a modal is open, and ensures the accessibility of the modal.
+First, wrap your application in the `ModalProvider`:
 
 ```tsx
-// _app.tsx for Next.js or App.js for CRA
 import { ModalProvider } from '@sumup-oss/circuit-ui';
 
-export default function App() {
+export function App() {
   return <ModalProvider>{/* Your content here... */}</ModalProvider>;
 }
 ```
 
-Then, use the `useModal` hook to open a modal from a component:
+Then, use the `useModal` hook to open a dialog from a component:
 
 ```tsx
-import { useModal, Button, Body } from '@sumup-oss/circuit-ui';
+import { useModal, Heading, Button, Body } from '@sumup-oss/circuit-ui';
 
 export function SayHello({ name }) {
   const { setModal } = useModal();
 
   const handleClick = () => {
     setModal({
-      children: <Body>Hello {name}</Body>,
+      children: (
+        <>
+          <Heading as="h2" id="dialog-title">
+            Modal title
+          </Heading>
+          <Body>Modal content</Body>
+          <Button>Close dialog</Button>
+        </>
+      ),
+      'aria-labelledby': 'dialog-title',
       variant: 'immersive',
-      closeButtonLabel: 'Close modal',
     });
   };
 
   return <Button onClick={handleClick}>Say hello</Button>;
 }
 ```
+
+## Immersive
+
+Use the `immersive` variant to focus the user's attention on the dialog content. On small viewports, the dialog component opens up from the bottom as a fullscreen overlay on top of the page content and covers it entirely in favor of an immersive experience.
+
+<Story of={Stories.Immersive} />
+
+## Related components
+
+For cases displaying simple text content but requiring immediate or critical action(s) from the user, consider using the [NotificationModal](Notification/NotificationModal/Docs) component.
+
+## Accessibility
+
+This component is built using the native `dialog` HTML element and follows the [WAI-ARIA Modal Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/). It is fully accessible and supports keyboard navigation and screen readers. In browsers where `dialog` is not supported, it uses [dialog-polyfill](https://github.com/GoogleChrome/dialog-polyfill).
+
+It is important to ensure that the dialog is appropriately labeled and that the user can easily navigate and interact with it using a keyboard or screen reader.
+If your dialog content has a title, make sure to provide an `aria-labelledby` attribute with the ID of the title element to the `Modal` component. Otherwise, provide an `aria-label` attribute with a descriptive label.
+
+If your dialog displays a complex flow with multiple screens (for example: a complex form with multiple steps), make sure to programmatically set focus to the title upon landing on every step to convey to the user their evolution within your flow.
+
+If your content contains interactive elements, the component will focus the first interactive element when the dialog opens by default. However, if you wish to focus a different element, you can provide the `initialFocusRef` prop with the ref of the element you want to focus. It is generally recommended to focus the least destructive element, such as a close or a cancel button. If the element you want to focus is not interactive, don't forget to give it a tabindex with a negative value to enable its focus.
diff --git a/packages/circuit-ui/components/Modal/Modal.module.css b/packages/circuit-ui/components/Modal/Modal.module.css
index c034e4e2e4..aa32636896 100644
--- a/packages/circuit-ui/components/Modal/Modal.module.css
+++ b/packages/circuit-ui/components/Modal/Modal.module.css
@@ -1,15 +1,44 @@
 .base {
+  --dialog-animation-duration: 0.3s;
+
   position: fixed;
+  max-height: 90vh;
+  padding: 0;
+  margin: auto;
+  overflow-y: auto;
+  pointer-events: none;
+  visibility: hidden;
   background-color: var(--cui-bg-elevated);
+  border: none;
   outline: none;
+
+  /* Firefox does not support animating the backdrop property.
+   As a workaround, we used the box-shadow on the dialog element as a fake backdrop,
+   which gets animated along with the dialog element itself.
+   https://stackoverflow.com/questions/75313685/animating-dialog-backdrop-in-firefox
+   */
+  box-shadow: 0 0 0 100vmax var(--cui-bg-overlay);
+  animation: fade-out var(--dialog-animation-duration) forwards;
+}
+
+.base.show {
+  pointer-events: auto;
+  animation: fade-in var(--dialog-animation-duration) forwards;
+}
+
+.content {
+  position: relative;
+  max-height: 90vh;
+  overflow-y: scroll;
 }
 
 .base::after {
-  position: fixed;
+  position: absolute;
   right: 0;
-  bottom: 0;
+  bottom: env(safe-area-inset-bottom);
   left: 0;
   display: block;
+  pointer-events: none;
   content: "";
   background: linear-gradient(
     color-mix(in sRGB, var(--cui-bg-elevated) 0%, transparent),
@@ -18,157 +47,148 @@
   );
 }
 
-@media (max-width: 479px) {
-  .base {
-    right: 0;
-    bottom: 0;
-    left: 0;
-    transition: transform var(--cui-transitions-default);
-    transform: translateY(100%);
-  }
+/* Close button */
+.base .close {
+  position: absolute;
+  z-index: var(--cui-z-index-absolute);
+}
 
-  .base::after {
-    height: var(--cui-spacings-mega);
-  }
+/* Native Backdrop */
+.base::backdrop {
+  background: transparent;
+}
+
+/* Polyfill Backdrop */
+.base + .backdrop {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  background: transparent;
 }
 
 @media (min-width: 480px) {
   .base {
-    top: 50%;
-    left: 50%;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    min-width: 480px;
+    max-width: 50vw;
+    max-height: 90vh;
     border-radius: var(--cui-border-radius-mega);
-    opacity: 0;
-    transition: opacity var(--cui-transitions-slow);
-    transform: translate(-50%, -50%);
   }
 
-  .base::after {
-    height: var(--cui-spacings-giga);
-    border-bottom-right-radius: var(--cui-border-radius-mega);
-    border-bottom-left-radius: var(--cui-border-radius-mega);
+  .base .content {
+    padding: var(--cui-spacings-giga);
+    padding-bottom: calc(
+      env(safe-area-inset-bottom) + var(--cui-spacings-giga)
+    );
   }
-}
 
-/* Variants */
-
-@media (max-width: 479px) {
-  .contextual {
-    border-top-left-radius: var(--cui-border-radius-mega);
-    border-top-right-radius: var(--cui-border-radius-mega);
+  .base::after {
+    height: var(--cui-spacings-giga);
   }
 
-  .immersive {
-    top: 0;
+  .base .close {
+    top: var(--cui-spacings-byte);
+    right: var(--cui-spacings-byte);
   }
 }
 
 @media (max-width: 479px) {
-  .open {
-    transform: translateY(0);
+  .base {
+    top: unset;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    width: 100%;
+    max-width: 100%;
+    border-radius: var(--cui-border-radius-mega) var(--cui-border-radius-mega) 0
+      0;
+    animation: slide-out var(--dialog-animation-duration) forwards;
   }
-}
 
-@media (min-width: 480px) {
-  .open {
-    opacity: 1;
+  .base.show {
+    animation: slide-in var(--dialog-animation-duration) forwards;
   }
-}
 
-@media (max-width: 479px) {
-  .closed {
-    transform: translateY(100%);
+  .immersive {
+    height: 100%;
+    max-height: unset;
+    border: none;
+    border-radius: unset;
   }
-}
 
-@media (min-width: 480px) {
-  .closed {
-    opacity: 0;
+  .base .content {
+    padding: var(--cui-spacings-mega);
+    padding-bottom: calc(
+      env(safe-area-inset-bottom) + var(--cui-spacings-mega)
+    );
+    -webkit-overflow-scrolling: touch;
   }
-}
-
-/* Overlay */
-
-.overlay {
-  position: fixed;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  z-index: var(--cui-z-index-modal);
-  background: var(--cui-bg-overlay);
-  opacity: 0;
-  transition: opacity var(--cui-transitions-default);
-}
 
-@media (min-width: 480px) {
-  .overlay {
-    transition: opacity var(--cui-transitions-slow);
+  .base::after {
+    height: var(--cui-spacings-mega);
   }
-}
 
-.overlay-open {
-  opacity: 1;
+  .base .close {
+    top: var(--cui-spacings-bit);
+    right: var(--cui-spacings-bit);
+  }
 }
 
-.overlay-closed {
-  opacity: 0;
-}
+/* Animations */
 
-/* Content */
+@keyframes fade-in {
+  from {
+    visibility: hidden;
+    opacity: 0;
+  }
 
-.content {
-  overflow-y: auto;
+  to {
+    visibility: visible;
+    opacity: 1;
+  }
 }
 
-@media (max-width: 479px) {
-  .content {
-    width: 100vw;
-    padding: var(--cui-spacings-mega);
-    padding-bottom: calc(
-      env(safe-area-inset-bottom) + var(--cui-spacings-mega)
-    );
-    -webkit-overflow-scrolling: touch;
+@keyframes fade-out {
+  from {
+    visibility: visible;
+    opacity: 1;
   }
-}
 
-@media (min-width: 480px) {
-  .content {
-    min-width: 480px;
-    max-width: 90vw;
-    max-height: 90vh;
-    padding: var(--cui-spacings-giga);
-    padding-bottom: calc(
-      env(safe-area-inset-bottom) + var(--cui-spacings-giga)
-    );
+  to {
+    visibility: hidden;
+    opacity: 0;
   }
 }
 
-/* Variants */
-
-@media (max-width: 479px) {
-  .contextual .content {
-    max-height: calc(100vh - var(--cui-spacings-giga));
+@keyframes slide-in {
+  from {
+    visibility: hidden;
+    opacity: 0;
+    transform: translateY(100%);
   }
-}
 
-/* iOS viewport bug fix: https://allthingssmitty.com/2020/05/11/css-fix-for-100vh-in-mobile-webkit/ */
-@supports (max-height: -webkit-fill-available) {
-  @media (max-width: 479px) {
-    .contextual .content {
-      max-height: 80vh;
-    }
+  to {
+    visibility: visible;
+    opacity: 1;
+    transform: translateY(0);
   }
 }
 
-@media (max-width: 479px) {
-  .immersive .content {
-    height: 100%;
+@keyframes slide-out {
+  from {
+    visibility: visible;
+    opacity: 1;
+    transform: translateY(0);
   }
-}
 
-.base .close {
-  position: absolute;
-  top: var(--cui-spacings-byte);
-  right: var(--cui-spacings-byte);
-  z-index: var(--cui-z-index-absolute);
+  to {
+    visibility: hidden;
+    opacity: 0;
+    transform: translateY(100%);
+  }
 }
diff --git a/packages/circuit-ui/components/Modal/Modal.spec.tsx b/packages/circuit-ui/components/Modal/Modal.spec.tsx
index 10942bf450..593ed3d452 100644
--- a/packages/circuit-ui/components/Modal/Modal.spec.tsx
+++ b/packages/circuit-ui/components/Modal/Modal.spec.tsx
@@ -1,5 +1,5 @@
 /**
- * Copyright 2019, SumUp Ltd.
+ * Copyright 2024, SumUp Ltd.
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -13,57 +13,234 @@
  * limitations under the License.
  */
 
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
+import { createRef } from 'react';
 
 import {
   render,
-  userEvent,
+  screen,
   axe,
+  userEvent,
   waitFor,
-  screen,
+  act,
 } from '../../util/test-utils.js';
 
-import { Modal, type ModalProps } from './Modal.js';
+import { ANIMATION_DURATION, Modal } from './Modal.js';
 
 describe('Modal', () => {
-  const defaultModal: ModalProps = {
-    variant: 'immersive',
-    isOpen: true,
-    closeButtonLabel: 'Close modal',
+  const props = {
     onClose: vi.fn(),
-    // eslint-disable-next-line react/prop-types, react/display-name
-    children: <p data-testid="children">Hello world!</p>,
-    // Silences the warning about the missing app element.
-    // In user land, the modal is always rendered by the ModalProvider,
-    // which takes care of setting the app element.
-    // http://reactcommunity.org/react-modal/accessibility/#app-element
-    ariaHideApp: false,
+    open: false,
+    closeButtonLabel: 'Close',
+    children: 'Modal dialog content',
   };
+  let originalHTMLDialogElement: typeof window.HTMLDialogElement;
+
+  beforeEach(() => {
+    originalHTMLDialogElement = window.HTMLDialogElement;
+    vi.clearAllMocks();
+    vi.useFakeTimers({ shouldAdvanceTime: true });
+  });
+
+  afterEach(() => {
+    vi.runOnlyPendingTimers();
+    vi.useRealTimers();
+    vi.resetAllMocks();
+    Object.defineProperty(window, 'HTMLDialogElement', {
+      writable: true,
+      value: originalHTMLDialogElement,
+    });
+  });
+
+  it('should forward a ref', () => {
+    const ref = createRef<HTMLDialogElement>();
+    render(<Modal {...props} ref={ref} />);
+    const dialog = screen.getByRole('dialog', { hidden: true });
+    expect(ref.current).toBe(dialog);
+  });
 
-  it('should render the modal', async () => {
-    render(<Modal {...defaultModal} />);
+  it('should merge a custom class name with the default ones', () => {
+    const className = 'foo';
+    render(<Modal {...props} className={className} />);
+    // eslint-disable-next-line testing-library/no-container
+    const dialog = screen.getByRole('dialog', { hidden: true });
+    expect(dialog?.className).toContain(className);
+  });
+
+  it('should render in closed state by default', () => {
+    render(<Modal {...props} />);
+    // eslint-disable-next-line testing-library/no-container
+    const dialog = screen.getByRole('dialog', { hidden: true });
+    expect(dialog).not.toBeVisible();
+  });
 
-    await waitFor(() => {
-      expect(screen.getByRole('dialog')).toBeVisible();
+  it('should open the dialog when the open prop becomes truthy', () => {
+    const { rerender } = render(<Modal {...props} />);
+    // eslint-disable-next-line testing-library/no-container
+    const dialog = screen.getByRole('dialog', {
+      hidden: true,
     });
+    vi.spyOn(dialog, 'showModal');
+    rerender(<Modal {...props} open />);
+    expect(dialog.showModal).toHaveBeenCalledOnce();
+    expect(dialog).toBeVisible();
   });
 
-  it('should call the onClose callback', async () => {
-    render(<Modal {...defaultModal} />);
+  it('should close the dialog when the open prop becomes falsy', () => {
+    const { rerender } = render(<Modal {...props} open />);
+    // eslint-disable-next-line testing-library/no-container
+    const dialog = screen.getByRole('dialog', {
+      hidden: true,
+    });
+    vi.spyOn(dialog, 'close');
+    rerender(<Modal {...props} />);
+    act(() => {
+      vi.advanceTimersByTime(ANIMATION_DURATION);
+    });
+    expect(dialog.close).toHaveBeenCalledOnce();
+    expect(dialog).not.toBeVisible();
+  });
 
-    await userEvent.click(screen.getByRole('button'));
+  it('should close the dialog when the component is unmounted', async () => {
+    const { unmount } = render(<Modal {...props} open />);
+    // eslint-disable-next-line testing-library/no-container
+    const dialog = screen.getByRole('dialog', {
+      hidden: true,
+    });
+    vi.spyOn(dialog, 'close');
+    unmount();
+    expect(dialog.close).toHaveBeenCalledOnce();
+    expect(dialog).not.toBeVisible();
+  });
+
+  describe('when the dialog is closed', () => {
+    it('should not render its children', () => {
+      render(<Modal {...props} />);
+      const children = screen.queryByText('Modal dialog content');
+
+      expect(children).not.toBeInTheDocument();
+    });
 
-    expect(defaultModal.onClose).toHaveBeenCalled();
+    it('should do nothing when pressing the Escape key', async () => {
+      render(<Modal {...props} />);
+      await userEvent.keyboard('{Escape}');
+      expect(props.onClose).not.toHaveBeenCalled();
+    });
   });
 
-  it('should render the children render prop', () => {
-    render(<Modal {...defaultModal} />);
-    expect(screen.getByTestId('children')).toHaveTextContent('Hello world!');
+  describe('when the dialog is open', () => {
+    it('should render its children', () => {
+      render(<Modal {...props} open />);
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
+      expect(screen.getByText('Modal dialog content')).toBeVisible();
+    });
+
+    it('should not close modal on backdrop click if preventClose is true', async () => {
+      render(<Modal {...props} open preventClose />);
+      // eslint-disable-next-line testing-library/no-container
+      const dialog = screen.getByRole('dialog', { hidden: true });
+      await userEvent.click(dialog);
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
+      expect(props.onClose).not.toHaveBeenCalled();
+      expect(dialog).toBeVisible();
+    });
+
+    it('should close the dialog when pressing the backdrop', async () => {
+      render(<Modal {...props} open />);
+      const dialog = screen.getByRole('dialog', { hidden: true });
+      await userEvent.click(screen.getByRole('dialog', { hidden: true }));
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
+      expect(props.onClose).toHaveBeenCalledOnce();
+      expect(dialog).not.toBeVisible();
+    });
+
+    it('should close the dialog when the close button is clicked', async () => {
+      render(<Modal {...props} open />);
+      const dialog = screen.getByRole('dialog', { hidden: true });
+      await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
+      expect(props.onClose).toHaveBeenCalledOnce();
+      expect(dialog).not.toBeVisible();
+    });
+
+    it('should remove animation classes when closed when polyfill is used', async () => {
+      Object.defineProperty(window, 'HTMLDialogElement', {
+        writable: true,
+        value: undefined,
+      });
+
+      render(<Modal {...props} open />);
+      const dialog = screen.getByRole('dialog', { hidden: true });
+
+      const backdrop = document.getElementsByClassName('backdrop')[0];
+      expect(backdrop.classList.toString()).toContain('backdrop-visible');
+      await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+      expect(backdrop.classList.toString()).not.toContain('backdrop-visible');
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
+
+      expect(props.onClose).toHaveBeenCalledOnce();
+      expect(dialog).not.toBeVisible();
+    });
   });
 
-  it('should meet accessibility guidelines', async () => {
-    const { container } = render(<Modal {...defaultModal} />);
-    const actual = await axe(container);
-    expect(actual).toHaveNoViolations();
+  describe('accessibility', () => {
+    it('should have no accessibility violations', async () => {
+      const { container } = render(<Modal {...props} open />);
+      const actual = await axe(container);
+      expect(actual).toHaveNoViolations();
+    });
+
+    it('should focus the close button when opened', () => {
+      render(<Modal {...props} open />);
+      expect(screen.getByRole('button', { name: /Close/i })).toHaveFocus();
+    });
+
+    it('should focus the first interactive element when opened', async () => {
+      render(
+        <Modal {...props} open>
+          {() => (
+            <button type="button" name="btn">
+              Button
+            </button>
+          )}
+        </Modal>,
+      );
+      const closeButton = screen.getByRole('button', { name: /Button/i });
+
+      await waitFor(() => expect(closeButton).toHaveFocus());
+    });
+
+    it('should focus a given element when provided', async () => {
+      const ref = createRef<HTMLButtonElement>();
+      render(
+        <Modal {...props} open initialFocusRef={ref}>
+          {() => (
+            <div>
+              <button type="button" name="btn">
+                Button
+              </button>
+              <button ref={ref} type="button" name="btn">
+                Special button
+              </button>
+            </div>
+          )}
+        </Modal>,
+      );
+      const spacialButton = screen.getByRole('button', {
+        name: /Special button/i,
+      });
+
+      await waitFor(() => expect(spacialButton).toHaveFocus());
+    });
   });
 });
diff --git a/packages/circuit-ui/components/Modal/Modal.stories.tsx b/packages/circuit-ui/components/Modal/Modal.stories.tsx
index 89ea759ad0..dc1f3463a7 100644
--- a/packages/circuit-ui/components/Modal/Modal.stories.tsx
+++ b/packages/circuit-ui/components/Modal/Modal.stories.tsx
@@ -13,35 +13,33 @@
  * limitations under the License.
  */
 
-import type { Decorator } from '@storybook/react';
-import { Fragment } from 'react';
+import { Fragment, useState } from 'react';
 import { screen, userEvent, within } from '@storybook/test';
+import type { Decorator } from '@storybook/react';
 
-import {
-  FullViewport,
-  Stack,
-} from '../../../../.storybook/components/index.js';
 import { modes } from '../../../../.storybook/modes.js';
-import { Button } from '../Button/index.js';
 import { Headline } from '../Headline/index.js';
 import { Body } from '../Body/index.js';
-import { Image } from '../Image/index.js';
-import { ModalProvider } from '../ModalContext/index.js';
+import { Button } from '../Button/index.js';
+import { FullViewport } from '../../../../.storybook/components/index.js';
+
+import { ModalProvider } from './ModalContext.js';
 
-import { useModal, Modal, type ModalProps } from './Modal.js';
+import { Modal, type ModalProps, useModal } from './index.js';
 
 export default {
   title: 'Components/Modal',
   component: Modal,
-  subcomponents: { ModalProvider },
-  tags: ['status:under-review'],
-  parameters: {
-    chromatic: {
-      modes: {
-        mobile: modes.smallMobile,
-        desktop: modes.desktop,
-      },
+  tags: ['status:stable'],
+  chromatic: {
+    modes: {
+      mobile: modes.smallMobile,
+      desktop: modes.desktop,
     },
+    pauseAnimationAtEnd: true,
+  },
+  parameters: {
+    layout: 'padded',
   },
   decorators: [
     (Story) => (
@@ -54,10 +52,10 @@ export default {
 
 const defaultModalChildren = () => (
   <Fragment>
-    <Headline as="h2" size="s" style={{ marginBottom: '1rem' }}>
+    <Headline id="title" as="h2" size="s" style={{ marginBottom: '1rem' }}>
       Hello World!
     </Headline>
-    <Body>I am a modal.</Body>
+    <Body id="description">I am a modal dialog.</Body>
   </Fragment>
 );
 
@@ -75,61 +73,39 @@ const openModal = async ({
   await screen.findByRole('dialog');
 };
 
-export const Base = (modal: ModalProps) => {
-  const ComponentWithModal = () => {
-    const { setModal } = useModal();
-
-    return (
-      <Button type="button" onClick={() => setModal(modal)}>
-        Open modal
-      </Button>
-    );
-  };
-  return (
-    <ModalProvider>
-      <ComponentWithModal />
-    </ModalProvider>
-  );
-};
-
-Base.args = {
-  children: defaultModalChildren,
+const baseArgs: ModalProps = {
+  open: false,
+  onClose: () => {},
+  'aria-labelledby': 'title',
+  'aria-describedby': 'description',
   variant: 'contextual',
-  closeButtonLabel: 'Close modal',
+  closeButtonLabel: 'Close',
+  children: defaultModalChildren,
 };
-Base.play = openModal;
-
-export const Variants = (modal: ModalProps) => {
-  const ComponentWithModal = ({ variant }: Pick<ModalProps, 'variant'>) => {
-    const { setModal } = useModal();
 
-    return (
-      <Button type="button" onClick={() => setModal({ ...modal, variant })}>
-        Open {variant} modal
-      </Button>
-    );
-  };
+export const Base = (modal: ModalProps) => {
+  const [modalOpen, setModalOpen] = useState(false);
   return (
-    <ModalProvider>
-      <Stack>
-        <ComponentWithModal variant="contextual" />
-        <ComponentWithModal variant="immersive" />
-      </Stack>
-    </ModalProvider>
+    <>
+      <Button
+        type="button"
+        onClick={() => {
+          setModalOpen(true);
+        }}
+      >
+        Open modal
+      </Button>
+      <Modal {...modal} open={modalOpen} onClose={() => setModalOpen(false)} />
+    </>
   );
 };
+Base.args = baseArgs;
+Base.play = openModal;
 
-Variants.args = {
-  children: defaultModalChildren,
-  closeButtonLabel: 'Close modal',
-};
-Variants.parameters = {
-  chromatic: { disableSnapshot: true },
-};
-
-export const PreventClose = (modal: ModalProps) => {
+export const WithUseModal = (modal: ModalProps) => {
   const ComponentWithModal = () => {
     const { setModal } = useModal();
+
     return (
       <Button type="button" onClick={() => setModal(modal)}>
         Open modal
@@ -143,82 +119,88 @@ export const PreventClose = (modal: ModalProps) => {
     </ModalProvider>
   );
 };
-
-PreventClose.args = {
-  children: ({ onClose }: { onClose: ModalProps['onClose'] }) => (
-    <Fragment>
-      <Headline as="h2" size="s" style={{ marginBottom: '1rem' }}>
-        Complete the action
-      </Headline>
-      <Body style={{ marginBottom: '1rem' }}>
-        Users have to complete the action inside the modal to close it. The
-        close button is hidden and clicking outside the modal or pressing the
-        escape key does not close the modal either.
-      </Body>
-      <Button variant="primary" onClick={onClose}>
-        Close modal
-      </Button>
-    </Fragment>
-  ),
-  variant: 'immersive',
-  preventClose: true,
+WithUseModal.args = {
+  children: defaultModalChildren,
+  closeButtonLabel: 'Close modal',
 };
-PreventClose.play = openModal;
+WithUseModal.play = openModal;
 
 export const InitiallyOpen = (modal: ModalProps) => {
   const initialModal = { id: 'initial', component: Modal, ...modal };
+
   return (
     <ModalProvider initialState={[initialModal]}>
       <div />
     </ModalProvider>
   );
 };
-
 InitiallyOpen.args = {
   children: defaultModalChildren,
   variant: 'contextual',
   closeButtonLabel: 'Close modal',
 };
+InitiallyOpen.parameters = {
+  chromatic: { disableSnapshot: true },
+};
 
-export const CustomStyles = (modal: ModalProps) => {
-  const ComponentWithModal = () => {
-    const { setModal } = useModal();
-    return (
-      <Button type="button" onClick={() => setModal(modal)}>
+export const Immersive = () => {
+  const [modalOpen, setModalOpen] = useState(false);
+  return (
+    <>
+      <Button
+        type="button"
+        onClick={() => {
+          setModalOpen(true);
+        }}
+      >
         Open modal
       </Button>
-    );
-  };
-
-  return (
-    <ModalProvider>
-      <ComponentWithModal />
-    </ModalProvider>
+      <Modal
+        open={modalOpen}
+        onClose={() => setModalOpen(false)}
+        closeButtonLabel="Close"
+        aria-labelledby="title"
+        aria-describedby="description"
+        variant="immersive"
+      >
+        {defaultModalChildren}
+      </Modal>
+    </>
   );
 };
+Immersive.chromatic = {
+  modes: {
+    mobile: modes.smallMobile,
+  },
+};
+Immersive.play = openModal;
 
-CustomStyles.args = {
-  children: () => (
-    <Fragment>
-      <Image
-        src="/images/illustration-waves.jpg"
-        alt=""
-        style={{
-          borderTopLeftRadius: 'var(--cui-border-radius-mega)',
-          borderTopRightRadius: 'var(--cui-border-radius-mega)',
+export const PreventClose = () => {
+  const [modalOpen, setModalOpen] = useState(false);
+  return (
+    <>
+      <Button
+        type="button"
+        onClick={() => {
+          setModalOpen(true);
         }}
-      />
-      <Headline as="h2" size="s" style={{ margin: '1rem' }}>
-        Custom styles
-      </Headline>
-      <Body style={{ margin: '1rem' }}>
-        Custom styles can be applied using the <code>className</code> or{' '}
-        <code>style</code> props.
-      </Body>
-    </Fragment>
-  ),
-  style: { padding: '0' },
-  variant: 'contextual',
-  closeButtonLabel: 'Close modal',
+      >
+        Open modal
+      </Button>
+      <Modal
+        open={modalOpen}
+        preventClose
+        onClose={() => setModalOpen(false)}
+        closeButtonLabel="Close"
+        aria-labelledby="title"
+        aria-describedby="description"
+      >
+        {defaultModalChildren}
+      </Modal>
+    </>
+  );
 };
-CustomStyles.play = openModal;
+PreventClose.parameters = {
+  chromatic: { disableSnapshot: true },
+};
+PreventClose.play = openModal;
diff --git a/packages/circuit-ui/components/Modal/Modal.tsx b/packages/circuit-ui/components/Modal/Modal.tsx
index 5608fe0232..5b0856ccc6 100644
--- a/packages/circuit-ui/components/Modal/Modal.tsx
+++ b/packages/circuit-ui/components/Modal/Modal.tsx
@@ -15,139 +15,292 @@
 
 'use client';
 
-import type { HTMLAttributes, ReactNode } from 'react';
-import ReactModal from 'react-modal';
-
-import { isFunction } from '../../util/type-check.js';
 import {
-  createUseModal,
-  type ModalComponent,
-  type BaseModalProps,
-} from '../ModalContext/index.js';
+  forwardRef,
+  type HTMLAttributes,
+  type ReactNode,
+  type RefObject,
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useRef,
+} from 'react';
+
 import { CloseButton } from '../CloseButton/index.js';
-import { StackContext } from '../StackContext/index.js';
-import {
-  AccessibilityError,
-  isSufficientlyLabelled,
-} from '../../util/errors.js';
+import dialogPolyfill from '../../vendor/dialog-polyfill/index.js';
+import { applyMultipleRefs } from '../../util/refs.js';
 import { clsx } from '../../styles/clsx.js';
+import type { ClickEvent } from '../../types/events.js';
+import { isEscape } from '../../util/key-codes.js';
+import { useI18n } from '../../hooks/useI18n/useI18n.js';
+import { deprecate } from '../../util/logger.js';
+import type { Locale } from '../../util/i18n.js';
+import { useScrollLock } from '../../hooks/useScrollLock/useScrollLock.js';
 
 import classes from './Modal.module.css';
+import { getFirstFocusableElement } from './ModalService.js';
+import { translations } from './translations/index.js';
 
-const TRANSITION_DURATION = 300;
-
-type PreventCloseProps =
-  | {
-      /**
-       * Text label for the close button for screen readers.
-       * Important for accessibility.
-       */
-      closeButtonLabel?: never;
-      /**
-       * Prevent users from closing the modal by clicking/tapping the overlay or
-       * pressing the escape key. Default `false`.
-       */
-      preventClose: boolean;
-    }
-  | {
-      closeButtonLabel: string;
-      preventClose?: never;
-    };
+type DataAttribute = `data-${string}`;
+export interface ModalProps
+  extends Omit<HTMLAttributes<HTMLDialogElement>, 'children'> {
+  /**
+   * Whether the modal dialog is open or not.
+   */
+  open: boolean;
+  /**
+   * Callback when the modal dialog is closed.
+   */
+  onClose?: () => void;
+  /**
+   * a ReactNode or a function that returns the content of the modal dialog.
+   */
+  children?:
+    | ReactNode
+    | (({ onClose }: { onClose?: ModalProps['onClose'] }) => ReactNode);
+  /**
+   * Text label for the close button for screen readers.
+   * Important for accessibility.
+   */
+  closeButtonLabel?: string;
+  /**
+   * Use the `immersive` variant to focus the user's attention on the dialog content.
+   * default: 'contextual'
+   * */
+  variant?: 'contextual' | 'immersive';
+  /**
+   * Prevent users from closing the modal by clicking/tapping the overlay or
+   * pressing the escape key. Default `false`.
+   */
+  preventClose?: boolean;
+  /**
+   * Enables focusing a particular element in the dialog content and override default behavior
+   */
+  initialFocusRef?: RefObject<HTMLElement>;
+  /**
+   * One or more [IETF BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag)
+   * locale identifiers such as `'de-DE'` or `['GB', 'en-US']`.
+   * When passing an array, the first supported locale is used.
+   * Defaults to `navigator.language` in supported environments.
+   */
+  locale?: Locale;
+  /**
+   * @deprecated This prop was passed to `react-modal` and is no longer relevant.
+   * Use the `preventClose` prop instead. Also see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/dialog_role#required_javascript_features
+   */
+  hideCloseButton?: boolean;
+  [key: DataAttribute]: string | undefined;
+}
 
-export type ModalProps = BaseModalProps &
-  PreventCloseProps & {
-    /**
-     * The modal content. Use a render function when you need access to the
-     * `onClose` function.
-     */
-    children:
-      | ReactNode
-      | (({ onClose }: Pick<BaseModalProps, 'onClose'>) => ReactNode);
-    /**
-     * Use the `contextual` variant when the modal content requires the context
-     * of the page underneath to be understood, otherwise, use the `immersive`
-     * variant to focus the user's attention.
-     */
-    variant: 'contextual' | 'immersive';
-    /**
-     * Custom styles for the modal wrapper element.
-     */
-    className?: HTMLAttributes<HTMLDivElement>['className'];
-    /**
-     * Custom styles for the modal wrapper element.
-     */
-    style?: HTMLAttributes<HTMLDivElement>['style'];
-  };
+export const ANIMATION_DURATION = 300;
 
-/**
- * The modal component displays self-contained tasks in a focused window that
- * overlays the page content.
- * Built on top of [`react-modal`](https://reactcommunity.org/react-modal/).
- */
-export const Modal: ModalComponent<ModalProps> = ({
-  children,
-  onClose,
-  variant = 'contextual',
-  preventClose = false,
-  closeButtonLabel,
-  className,
-  style,
-  ...props
-}) => {
-  if (
-    process.env.NODE_ENV !== 'production' &&
-    process.env.NODE_ENV !== 'test' &&
-    !preventClose &&
-    !isSufficientlyLabelled(closeButtonLabel)
-  ) {
-    throw new AccessibilityError(
-      'Modal',
-      "The `closeButtonLabel` prop is missing or invalid. Pass it in `setModal`, or pass `preventClose` if you intend to hide the Modal's close button.",
-    );
+export const Modal = forwardRef<HTMLDialogElement, ModalProps>((props, ref) => {
+  const {
+    open,
+    onClose,
+    locale,
+    closeButtonLabel,
+    variant = 'contextual',
+    children,
+    className,
+    preventClose,
+    initialFocusRef,
+    hideCloseButton,
+    ...rest
+  } = useI18n(props, translations);
+  const dialogRef = useRef<HTMLDialogElement>(null);
+  const lastFocusedElementRef = useRef<HTMLElement | null>(null);
+
+  if (process.env.NODE_ENV !== 'production') {
+    if (hideCloseButton) {
+      deprecate(
+        'Modal',
+        'The `hideCloseButton` prop has been deprecated. Use the `preventClose` prop instead.',
+      );
+    }
   }
 
-  const reactModalProps = {
-    className: {
-      base: clsx(classes.base, classes[variant]),
-      afterOpen: classes.open,
-      beforeClose: classes.closed,
+  // eslint-disable-next-line compat/compat
+  const hasNativeDialog = window.HTMLDialogElement !== undefined;
+
+  useScrollLock(open);
+
+  useLayoutEffect(
+    () => () => {
+      if (dialogRef?.current?.open) {
+        dialogRef?.current?.close();
+      }
     },
-    overlayClassName: {
-      base: classes.overlay,
-      afterOpen: classes['overlay-open'],
-      beforeClose: classes['overlay-closed'],
+    [],
+  );
+
+  // set initial focus on the modal dialog content
+  useEffect(() => {
+    const dialogElement = dialogRef.current;
+    let timeoutId: NodeJS.Timeout;
+    if (open && dialogElement) {
+      timeoutId = setTimeout(() => {
+        if (initialFocusRef?.current) {
+          initialFocusRef?.current?.focus();
+        } else {
+          getFirstFocusableElement(dialogElement).focus();
+        }
+      }, ANIMATION_DURATION);
+    }
+    return () => {
+      clearTimeout(timeoutId);
+    };
+  }, [open, initialFocusRef?.current]);
+
+  const handleDialogClose = useCallback(() => {
+    const dialogElement = dialogRef.current;
+    if (!dialogElement) {
+      return;
+    }
+    // restore focus to the last focused element
+    if (lastFocusedElementRef.current) {
+      setTimeout(
+        () => lastFocusedElementRef.current?.focus(),
+        ANIMATION_DURATION,
+      );
+    }
+    // trigger closing animation
+    dialogElement.classList.remove(classes.show);
+    if (!hasNativeDialog) {
+      (dialogElement.nextSibling as HTMLDivElement).classList.remove(
+        classes['backdrop-visible'],
+      );
+    }
+    // trigger closing of the dialog after animation
+    setTimeout(() => {
+      if (dialogElement.open) {
+        dialogElement.close();
+      }
+    }, ANIMATION_DURATION);
+  }, [hasNativeDialog]);
+
+  // intercept and prevent polyfill modal closing if preventClose is true
+  const onPolyfillDialogKeydown = useCallback((event: KeyboardEvent) => {
+    if (isEscape(event)) {
+      event.preventDefault();
+      event.stopPropagation();
+    }
+  }, []);
+
+  const onPolyfillBackdropClick = useCallback(
+    (event: MouseEvent) => {
+      if (preventClose) {
+        event.preventDefault();
+      }
     },
-    onRequestClose: onClose,
-    closeTimeoutMS: TRANSITION_DURATION,
-    shouldCloseOnOverlayClick: !preventClose,
-    shouldCloseOnEsc: !preventClose,
-    /**
-     * react-modal relies on document.activeElement to return focus after the modal is closed.
-     * Safari and Firefox don't set it properly on button click (see https://github.com/reactjs/react-modal/issues/858 and https://github.com/reactjs/react-modal/issues/389).
-     * Returning the focus to document.body or to the focus-root can cause unwanted page scroll.
-     * Preventing scroll on focus would provide better UX for mouse users and shouldn't cause any side effects for assistive technology users.
-     */
-    preventScroll: true,
-    ...props,
+    [preventClose],
+  );
+
+  useEffect(() => {
+    const dialogElement = dialogRef.current;
+    if (!dialogElement) {
+      return undefined;
+    }
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore The package is bundled incorrectly
+    dialogPolyfill.registerDialog(dialogElement);
+    if (preventClose) {
+      dialogElement.addEventListener('keydown', onPolyfillDialogKeydown);
+    }
+    if (onClose) {
+      dialogElement.addEventListener('close', onClose);
+    }
+
+    return () => {
+      if (onClose) {
+        dialogElement.removeEventListener('close', onClose);
+      }
+      if (!hasNativeDialog && dialogElement.nextSibling) {
+        (dialogElement.nextSibling as HTMLDivElement).removeEventListener(
+          'click',
+          onPolyfillBackdropClick,
+        );
+        dialogElement.removeEventListener('keydown', onPolyfillDialogKeydown);
+      }
+    };
+  }, [
+    onClose,
+    onPolyfillBackdropClick,
+    preventClose,
+    hasNativeDialog,
+    onPolyfillDialogKeydown,
+  ]);
+
+  useEffect(() => {
+    const dialogElement = dialogRef.current;
+
+    if (!dialogElement) {
+      return;
+    }
+    if (open) {
+      if (document.activeElement instanceof HTMLElement) {
+        lastFocusedElementRef.current = document.activeElement;
+      }
+      if (!dialogElement.open) {
+        dialogElement.showModal();
+        if (!hasNativeDialog) {
+          // use the polyfill backdrop
+          (dialogElement.nextSibling as HTMLDivElement).classList.add(
+            classes['backdrop-visible'],
+            classes.backdrop,
+          );
+          // intercept and prevent modal closing if preventClose is true
+          (dialogElement.nextSibling as HTMLDivElement).addEventListener(
+            'click',
+            onPolyfillBackdropClick,
+          );
+        }
+
+        // trigger show animation
+        dialogElement.classList.add(classes.show);
+      }
+    } else if (dialogElement.open) {
+      handleDialogClose();
+    }
+  }, [open, handleDialogClose, hasNativeDialog, onPolyfillBackdropClick]);
+
+  const onDialogClick = (
+    event: ClickEvent<HTMLDialogElement> | ClickEvent<HTMLDivElement>,
+  ) => {
+    // the dialog content covers the whole dialog element
+    // leaving the backdrop element as the only clickable area
+    // that can trigger an onClick event
+    if (event.target === event.currentTarget && !preventClose) {
+      handleDialogClose();
+    }
   };
 
   return (
-    <StackContext.Provider value={'var(--cui-z-index-modal)'}>
-      <ReactModal {...reactModalProps}>
-        <div className={clsx(classes.content, className)} style={style}>
-          {!preventClose && closeButtonLabel && (
-            <CloseButton onClick={onClose} className={classes.close}>
-              {closeButtonLabel}
-            </CloseButton>
-          )}
-
-          {isFunction(children) ? children({ onClose }) : children}
-        </div>
-      </ReactModal>
-    </StackContext.Provider>
+    <>
+      {/* eslint-disable-next-line  jsx-a11y/no-noninteractive-element-interactions */}
+      <dialog
+        onClick={onDialogClick}
+        ref={applyMultipleRefs(ref, dialogRef)}
+        className={clsx(
+          classes.base,
+          variant === 'immersive' && classes.immersive,
+          className,
+        )}
+        {...rest}
+      >
+        <CloseButton onClick={handleDialogClose} className={classes.close}>
+          {closeButtonLabel}
+        </CloseButton>
+        {open && (
+          <div className={classes.content}>
+            {typeof children === 'function'
+              ? children?.({ onClose })
+              : children}
+          </div>
+        )}
+      </dialog>
+    </>
   );
-};
-
-Modal.TRANSITION_DURATION = TRANSITION_DURATION;
+});
 
-export const useModal = createUseModal(Modal);
+Modal.displayName = 'Modal';
diff --git a/packages/circuit-ui/components/Modal/ModalContext.spec.tsx b/packages/circuit-ui/components/Modal/ModalContext.spec.tsx
new file mode 100644
index 0000000000..5e2eb4cc11
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/ModalContext.spec.tsx
@@ -0,0 +1,115 @@
+/**
+ * Copyright 2021, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useContext } from 'react';
+
+import { render, act, screen, userEvent } from '../../util/test-utils.js';
+
+import { ModalProvider, ModalContext } from './ModalContext.js';
+import { ANIMATION_DURATION, type ModalProps } from './Modal.js';
+
+const Modal = ({ onClose, open }: ModalProps) => (
+  <dialog aria-label="Modal" open={open} data-testid="dummy-dialog">
+    <button onClick={onClose} type="button">
+      Close
+    </button>
+  </dialog>
+);
+
+describe('ModalContext', () => {
+  beforeEach(() => {
+    vi.useFakeTimers({ shouldAdvanceTime: true });
+  });
+  afterEach(() => {
+    vi.runOnlyPendingTimers();
+    vi.useRealTimers();
+    vi.clearAllMocks();
+  });
+
+  describe('ModalProvider', () => {
+    const onClose = vi.fn();
+    const modal = {
+      id: 'initial',
+      open: true,
+      component: Modal,
+      onClose,
+      children: () => <p>Modal content</p>,
+      closeButtonLabel: 'Close',
+    };
+    const initialState = [modal];
+
+    it('should render the initial modals', () => {
+      render(
+        <ModalProvider initialState={initialState}>
+          <div />
+        </ModalProvider>,
+      );
+
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
+
+      expect(screen.getByTestId('dummy-dialog')).toBeVisible();
+    });
+
+    it('should open and close a modal when the context functions are called', async () => {
+      const Trigger = () => {
+        const { setModal, removeModal } = useContext(ModalContext);
+        return (
+          <>
+            <button onClick={() => setModal(modal)}>Open modal</button>
+            <button onClick={() => removeModal(modal)}>Close modal</button>
+          </>
+        );
+      };
+
+      render(
+        <ModalProvider>
+          <Trigger />
+        </ModalProvider>,
+      );
+
+      await userEvent.click(screen.getByRole('button', { name: 'Open modal' }));
+
+      expect(screen.getByRole('dialog')).toBeVisible();
+
+      await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+
+      act(() => {
+        vi.runAllTimers();
+      });
+
+      expect(screen.queryByTestId('dummy-dialog')).not.toBeInTheDocument();
+    });
+
+    it('should close the modal when the onClose method is called', async () => {
+      render(
+        <ModalProvider initialState={initialState}>
+          <div />
+        </ModalProvider>,
+      );
+
+      const closeButton = screen.queryByRole('button') as HTMLButtonElement;
+      await userEvent.click(closeButton);
+      act(() => {
+        vi.runAllTimers();
+      });
+
+      expect(screen.queryByTestId('dummy-dialog')).not.toBeInTheDocument();
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/packages/circuit-ui/components/Modal/ModalContext.tsx b/packages/circuit-ui/components/Modal/ModalContext.tsx
new file mode 100644
index 0000000000..d3d4521557
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/ModalContext.tsx
@@ -0,0 +1,114 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use client';
+
+import { createContext, type ReactNode, useCallback, useMemo } from 'react';
+
+import { useStack } from '../../hooks/useStack/index.js';
+
+import { ANIMATION_DURATION, type ModalProps } from './Modal.js';
+import type { ModalDialogComponent } from './createUseModal.js';
+
+export type SetModalArgs<T> = Omit<T, 'open'>;
+
+// keep initial state compatible with the old version of this component
+export type ModalState<T extends ModalProps> = SetModalArgs<T> & {
+  component: ModalDialogComponent<T>;
+  id: string | number;
+  open: boolean;
+};
+
+type ModalContextValue<T extends ModalProps> = {
+  setModal: (modal: ModalState<T>) => void;
+  removeModal: (modal: ModalState<T>) => void;
+};
+export interface ModalProviderProps<T extends ModalProps> {
+  /**
+   * The ModalProvider should wrap your entire application.
+   */
+  children: ReactNode;
+  /**
+   * An array of modals that should be displayed immediately, e.g. on page load.
+   */
+  initialState?: ModalState<T>[];
+}
+
+// TODO replace any
+export const ModalContext = createContext<ModalContextValue<any>>({
+  setModal: () => {},
+  removeModal: () => {},
+});
+
+export function ModalProvider<T extends ModalProps>({
+  children,
+  initialState,
+  ...defaultModalProps
+}: ModalProviderProps<T>) {
+  const [modals, dispatch] = useStack<ModalState<T>>(
+    initialState?.map((modal) => ({ ...modal, open: true })),
+  );
+
+  const setModal = useCallback(
+    (modal: ModalState<T>) => {
+      dispatch({ type: 'push', item: modal });
+    },
+    [dispatch],
+  );
+
+  const removeModal = useCallback(
+    (modal: ModalState<T>) => {
+      if (modal.onClose) {
+        modal.onClose();
+      }
+      dispatch({
+        type: 'update',
+        item: modal,
+      });
+      dispatch({
+        type: 'remove',
+        id: modal.id,
+        transition: {
+          duration: ANIMATION_DURATION,
+        },
+      });
+    },
+    [dispatch],
+  );
+
+  const context = useMemo(
+    () => ({ setModal, removeModal }),
+    [setModal, removeModal],
+  );
+
+  return (
+    <ModalContext.Provider value={context}>
+      {children}
+      {modals.map((modal) => {
+        const { id, open, component: Component, ...modalProps } = modal;
+        return (
+          // @ts-expect-error type will either be ModalProps or NotificationProps
+          <Component
+            {...defaultModalProps}
+            {...modalProps}
+            key={id}
+            open={open}
+            onClose={() => removeModal(modal)}
+          />
+        );
+      })}
+    </ModalContext.Provider>
+  );
+}
diff --git a/packages/circuit-ui/components/Modal/ModalService.spec.tsx b/packages/circuit-ui/components/Modal/ModalService.spec.tsx
new file mode 100644
index 0000000000..495798f97f
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/ModalService.spec.tsx
@@ -0,0 +1,88 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { getKeyboardFocusableElements } from './ModalService.js';
+
+describe('DialogService', () => {
+  describe('getKeyboardFocusableElements', () => {
+    it('should return empty array if element is empty', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+
+      const result = getKeyboardFocusableElements(document.body);
+      expect(result).toEqual([]);
+    });
+
+    it('should not return an a tag without href', () => {
+      const a = document.createElement('a');
+      document.body.appendChild(a);
+      const result = getKeyboardFocusableElements(document.body);
+      expect(result).toEqual([]);
+    });
+
+    it('should not return a disabled element', () => {
+      const input = document.createElement('input');
+      input.setAttribute('disabled', 'true');
+      document.body.appendChild(input);
+      const result = getKeyboardFocusableElements(document.body);
+      expect(result).toEqual([]);
+    });
+
+    it('should not return an element with aria-hidden', () => {
+      const input = document.createElement('input');
+      input.setAttribute('aria-hidden', 'true');
+      document.body.appendChild(input);
+      const result = getKeyboardFocusableElements(document.body);
+      expect(result).toEqual([]);
+    });
+
+    it('should return an array of focusable elements', () => {
+      const container = document.createElement('div');
+      container.setAttribute('tabindex', '0');
+      const button = document.createElement('button');
+      const input = document.createElement('input');
+      const a = document.createElement('a');
+      a.setAttribute('href', 'showSignature(xyz)');
+      const textarea = document.createElement('textarea');
+      const select = document.createElement('select');
+      const details = document.createElement('details');
+
+      document.body.append(
+        container,
+        button,
+        input,
+        a,
+        textarea,
+        select,
+        details,
+      );
+
+      const result = getKeyboardFocusableElements(document.body);
+      expect(result).toEqual(
+        expect.arrayContaining([
+          button,
+          input,
+          a,
+          textarea,
+          select,
+          details,
+          container,
+        ]),
+      );
+    });
+  });
+});
diff --git a/packages/circuit-ui/components/Modal/ModalService.ts b/packages/circuit-ui/components/Modal/ModalService.ts
new file mode 100644
index 0000000000..7c2a5ecc45
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/ModalService.ts
@@ -0,0 +1,39 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export function getKeyboardFocusableElements(
+  element: HTMLElement,
+): HTMLElement[] {
+  return [
+    ...element.querySelectorAll(
+      'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])',
+    ),
+  ].filter(
+    (el) =>
+      !el.hasAttribute('disabled') &&
+      !el.hasAttribute('aria-disabled') &&
+      !el.getAttribute('aria-hidden'),
+  ) as HTMLElement[];
+}
+
+export function getFirstFocusableElement(
+  dialog: HTMLDialogElement,
+): HTMLElement {
+  const focusableElements = getKeyboardFocusableElements(dialog);
+  // if there is only one focusable element (the close button), focus it
+  return focusableElements.length === 1
+    ? focusableElements[0]
+    : focusableElements[1];
+}
diff --git a/packages/circuit-ui/components/Modal/createUseModal.spec.tsx b/packages/circuit-ui/components/Modal/createUseModal.spec.tsx
new file mode 100644
index 0000000000..7ef53700e9
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/createUseModal.spec.tsx
@@ -0,0 +1,74 @@
+/**
+ * Copyright 2021, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it, vi } from 'vitest';
+import type { PropsWithChildren } from 'react';
+
+import { renderHook, act } from '../../util/test-utils.js';
+
+import { createUseModal } from './createUseModal.js';
+import { ModalContext } from './ModalContext.js';
+import { Modal, type ModalProps } from './Modal.js';
+
+const ModalComponent = (props: ModalProps) => <Modal {...props} />;
+
+const props = {
+  onClose: vi.fn(),
+  open: false,
+  closeButtonLabel: 'Close',
+  children: vi.fn(() => <div data-testid="children">Modal dialog content</div>),
+};
+
+describe('createUseModal', () => {
+  const useModal = createUseModal(ModalComponent);
+
+  const setModal = vi.fn();
+  const removeModal = vi.fn();
+
+  const wrapper = ({ children }: PropsWithChildren) => (
+    <ModalContext.Provider value={{ setModal, removeModal }}>
+      {children}
+    </ModalContext.Provider>
+  );
+
+  it('should add the modal when setModal is called', () => {
+    const { result } = renderHook(() => useModal(), { wrapper });
+
+    act(() => {
+      result.current.setModal(props);
+    });
+
+    const expected = expect.objectContaining({
+      component: expect.any(Function),
+      id: expect.any(String),
+    });
+    expect(setModal).toHaveBeenCalledWith(expected);
+  });
+
+  it('should remove the modal when removeModal is called', () => {
+    const { result } = renderHook(() => useModal(), { wrapper });
+
+    act(() => {
+      result.current.setModal(props);
+    });
+
+    act(() => {
+      result.current.removeModal();
+    });
+
+    const expected = expect.any(Object);
+    expect(removeModal).toHaveBeenCalledWith(expected);
+  });
+});
diff --git a/packages/circuit-ui/components/Modal/createUseModal.ts b/packages/circuit-ui/components/Modal/createUseModal.ts
new file mode 100644
index 0000000000..2a566451f2
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/createUseModal.ts
@@ -0,0 +1,62 @@
+/**
+ * Copyright 2021, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use client';
+
+import { useContext, useCallback, useRef, useId, type ReactNode } from 'react';
+
+import { ModalContext, type SetModalArgs } from './ModalContext.js';
+import type { ModalProps } from './Modal.js';
+
+export type ModalDialogComponent<TProps extends ModalProps = ModalProps> = (
+  props: TProps,
+) => ReactNode;
+
+export function createUseModal<T extends ModalProps>(
+  component: ModalDialogComponent<T>,
+) {
+  return (): {
+    setModal: (props: SetModalArgs<T>) => void;
+    removeModal: () => void;
+  } => {
+    const id = useId();
+    const modalRef = useRef<SetModalArgs<T> | null>(null);
+    const context = useContext(ModalContext);
+
+    // biome-ignore lint/correctness/useExhaustiveDependencies: The `component` never changes
+    const setModal = useCallback(
+      (props: SetModalArgs<T>): void => {
+        modalRef.current = props;
+        context.setModal({ ...props, id, component, open: true });
+      },
+      [context, id],
+    );
+
+    // biome-ignore lint/correctness/useExhaustiveDependencies: The `component` never changes
+    const removeModal = useCallback((): void => {
+      if (modalRef.current) {
+        context.removeModal({
+          ...modalRef.current,
+          id,
+          component,
+          open: false,
+        });
+        modalRef.current = null;
+      }
+    }, [context, id]);
+
+    return { setModal, removeModal };
+  };
+}
diff --git a/packages/circuit-ui/components/Modal/index.ts b/packages/circuit-ui/components/Modal/index.ts
index f9a6bb6201..3e721d7c1c 100644
--- a/packages/circuit-ui/components/Modal/index.ts
+++ b/packages/circuit-ui/components/Modal/index.ts
@@ -13,6 +13,9 @@
  * limitations under the License.
  */
 
-export { useModal } from './Modal.js';
+import { createUseModal } from './createUseModal.js';
+import { Modal } from './Modal.js';
 
+export { Modal } from './Modal.js';
 export type { ModalProps } from './Modal.js';
+export const useModal = createUseModal(Modal);
diff --git a/packages/circuit-ui/components/Modal/translations/bg-BG.json b/packages/circuit-ui/components/Modal/translations/bg-BG.json
new file mode 100644
index 0000000000..ddf333d07f
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/bg-BG.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Затвори"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/cs-CZ.json b/packages/circuit-ui/components/Modal/translations/cs-CZ.json
new file mode 100644
index 0000000000..78f6b38afd
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/cs-CZ.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Zavřít"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/da-DK.json b/packages/circuit-ui/components/Modal/translations/da-DK.json
new file mode 100644
index 0000000000..12c5d6f111
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/da-DK.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Luk"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/de-AT.json b/packages/circuit-ui/components/Modal/translations/de-AT.json
new file mode 100644
index 0000000000..b6226360c4
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/de-AT.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Schließen"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/de-CH.json b/packages/circuit-ui/components/Modal/translations/de-CH.json
new file mode 100644
index 0000000000..ddcc840f48
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/de-CH.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Schliessen"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/de-DE.json b/packages/circuit-ui/components/Modal/translations/de-DE.json
new file mode 100644
index 0000000000..b6226360c4
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/de-DE.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Schließen"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/de-LU.json b/packages/circuit-ui/components/Modal/translations/de-LU.json
new file mode 100644
index 0000000000..b6226360c4
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/de-LU.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Schließen"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/el-CY.json b/packages/circuit-ui/components/Modal/translations/el-CY.json
new file mode 100644
index 0000000000..33d5e74c1d
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/el-CY.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Κλείσιμο"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/el-GR.json b/packages/circuit-ui/components/Modal/translations/el-GR.json
new file mode 100644
index 0000000000..33d5e74c1d
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/el-GR.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Κλείσιμο"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/en-AU.json b/packages/circuit-ui/components/Modal/translations/en-AU.json
new file mode 100644
index 0000000000..5fb33ad8f3
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/en-AU.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Close"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/en-GB.json b/packages/circuit-ui/components/Modal/translations/en-GB.json
new file mode 100644
index 0000000000..5fb33ad8f3
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/en-GB.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Close"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/en-IE.json b/packages/circuit-ui/components/Modal/translations/en-IE.json
new file mode 100644
index 0000000000..5fb33ad8f3
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/en-IE.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Close"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/en-MT.json b/packages/circuit-ui/components/Modal/translations/en-MT.json
new file mode 100644
index 0000000000..5fb33ad8f3
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/en-MT.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Close"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/en-US.json b/packages/circuit-ui/components/Modal/translations/en-US.json
new file mode 100644
index 0000000000..5fb33ad8f3
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/en-US.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Close"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/es-CL.json b/packages/circuit-ui/components/Modal/translations/es-CL.json
new file mode 100644
index 0000000000..b46dc02916
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/es-CL.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Cerrar"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/es-CO.json b/packages/circuit-ui/components/Modal/translations/es-CO.json
new file mode 100644
index 0000000000..b46dc02916
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/es-CO.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Cerrar"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/es-ES.json b/packages/circuit-ui/components/Modal/translations/es-ES.json
new file mode 100644
index 0000000000..b46dc02916
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/es-ES.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Cerrar"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/es-MX.json b/packages/circuit-ui/components/Modal/translations/es-MX.json
new file mode 100644
index 0000000000..b46dc02916
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/es-MX.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Cerrar"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/es-PE.json b/packages/circuit-ui/components/Modal/translations/es-PE.json
new file mode 100644
index 0000000000..b46dc02916
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/es-PE.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Cerrar"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/es-US.json b/packages/circuit-ui/components/Modal/translations/es-US.json
new file mode 100644
index 0000000000..b46dc02916
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/es-US.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Cerrar"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/et-EE.json b/packages/circuit-ui/components/Modal/translations/et-EE.json
new file mode 100644
index 0000000000..a9d5010617
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/et-EE.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Sulge"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/fi-FI.json b/packages/circuit-ui/components/Modal/translations/fi-FI.json
new file mode 100644
index 0000000000..10d8058a44
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/fi-FI.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Sulje"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/fr-BE.json b/packages/circuit-ui/components/Modal/translations/fr-BE.json
new file mode 100644
index 0000000000..b071a84d05
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/fr-BE.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Fermer"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/fr-CH.json b/packages/circuit-ui/components/Modal/translations/fr-CH.json
new file mode 100644
index 0000000000..b071a84d05
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/fr-CH.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Fermer"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/fr-FR.json b/packages/circuit-ui/components/Modal/translations/fr-FR.json
new file mode 100644
index 0000000000..b071a84d05
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/fr-FR.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Fermer"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/fr-LU.json b/packages/circuit-ui/components/Modal/translations/fr-LU.json
new file mode 100644
index 0000000000..b071a84d05
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/fr-LU.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Fermer"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/hr-HR.json b/packages/circuit-ui/components/Modal/translations/hr-HR.json
new file mode 100644
index 0000000000..58c4e9bbb8
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/hr-HR.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Zatvori"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/hu-HU.json b/packages/circuit-ui/components/Modal/translations/hu-HU.json
new file mode 100644
index 0000000000..f1f68c32e6
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/hu-HU.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Bezárás"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/index.ts b/packages/circuit-ui/components/Modal/translations/index.ts
new file mode 100644
index 0000000000..d0df2ae94b
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/index.ts
@@ -0,0 +1,26 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { transformModulesToTranslations } from '../../../util/i18n.js';
+
+export const translations = transformModulesToTranslations<
+  typeof import('./en-US.json')
+>(
+  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+  // @ts-ignore import.meta.glob is supported by Vite
+  import.meta.glob('./*.json', {
+    eager: true,
+  }),
+);
diff --git a/packages/circuit-ui/components/Modal/translations/it-CH.json b/packages/circuit-ui/components/Modal/translations/it-CH.json
new file mode 100644
index 0000000000..73c3efb158
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/it-CH.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Chiudi"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/it-IT.json b/packages/circuit-ui/components/Modal/translations/it-IT.json
new file mode 100644
index 0000000000..73c3efb158
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/it-IT.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Chiudi"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/lt-LT.json b/packages/circuit-ui/components/Modal/translations/lt-LT.json
new file mode 100644
index 0000000000..b55cf74468
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/lt-LT.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Uždaryti"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/lv-LV.json b/packages/circuit-ui/components/Modal/translations/lv-LV.json
new file mode 100644
index 0000000000..486fc0ee7a
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/lv-LV.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Aizvērt"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/nb-NO.json b/packages/circuit-ui/components/Modal/translations/nb-NO.json
new file mode 100644
index 0000000000..72f78d9c6a
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/nb-NO.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Lukk"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/nl-BE.json b/packages/circuit-ui/components/Modal/translations/nl-BE.json
new file mode 100644
index 0000000000..538d49ed54
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/nl-BE.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Sluit"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/nl-NL.json b/packages/circuit-ui/components/Modal/translations/nl-NL.json
new file mode 100644
index 0000000000..f2a9127e10
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/nl-NL.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Sluiten"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/pl-PL.json b/packages/circuit-ui/components/Modal/translations/pl-PL.json
new file mode 100644
index 0000000000..a1cb3d0112
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/pl-PL.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Zamknij"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/pt-BR.json b/packages/circuit-ui/components/Modal/translations/pt-BR.json
new file mode 100644
index 0000000000..12ce2fa58f
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/pt-BR.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Fechar"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/pt-PT.json b/packages/circuit-ui/components/Modal/translations/pt-PT.json
new file mode 100644
index 0000000000..12ce2fa58f
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/pt-PT.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Fechar"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/ro-RO.json b/packages/circuit-ui/components/Modal/translations/ro-RO.json
new file mode 100644
index 0000000000..58d8225420
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/ro-RO.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "ÃŽnchide"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/sk-SK.json b/packages/circuit-ui/components/Modal/translations/sk-SK.json
new file mode 100644
index 0000000000..73e63be91a
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/sk-SK.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Zatvoriť"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/sl-SI.json b/packages/circuit-ui/components/Modal/translations/sl-SI.json
new file mode 100644
index 0000000000..364b505566
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/sl-SI.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Zapri"
+}
diff --git a/packages/circuit-ui/components/Modal/translations/sv-SE.json b/packages/circuit-ui/components/Modal/translations/sv-SE.json
new file mode 100644
index 0000000000..2d332d9d2a
--- /dev/null
+++ b/packages/circuit-ui/components/Modal/translations/sv-SE.json
@@ -0,0 +1,3 @@
+{
+  "closeButtonLabel": "Stäng"
+}
diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.mdx b/packages/circuit-ui/components/NotificationModal/NotificationModal.mdx
index 3184b435cd..58ac3fe00a 100644
--- a/packages/circuit-ui/components/NotificationModal/NotificationModal.mdx
+++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.mdx
@@ -1,5 +1,5 @@
-import { Meta, Status, Props, Story } from '../../../../.storybook/components';
-import * as Stories from './NotificationModal.stories';
+import { Meta, Status, Props, Story } from "../../../../.storybook/components";
+import * as Stories from "./NotificationModal.stories";
 
 <Meta of={Stories} />
 
@@ -7,7 +7,7 @@ import * as Stories from './NotificationModal.stories';
 
 <Status variant="stable" />
 
-The notification modal component communicates critical information while blocking everything else on the page, and needs the user's attention or action to proceed.
+The notification modal component communicates critical information while blocking everything else on the page, and needs the user's attention or action to proceed. It is built atop the [Modal](Components/Modal/Docs) component.
 
 <Story of={Stories.Base} />
 <Props />
@@ -17,6 +17,87 @@ The notification modal component communicates critical information while blockin
 - For information that needs a user's immediate attention.
 - To request confirmation before performing a destructive action.
 
+## How to use it
+
+### Inline (recommended)
+
+Place your NotificationModal directly in your JSX:
+
+```jsx
+import { NotificationModal, Button } from "@sumup-oss/circuit-ui";
+import { useState } from "react";
+
+const [open, setOpen] = useState(false);
+
+return (
+  <>
+    <Button onClick={() => setOpen(true)}>Open dialog</Button>
+    <NotificationModal
+      open={open}
+      closeButtonLabel="Close"
+      onClose={() => setOpen(false)}
+      headline="It's time to update your browser"
+      actions={{
+        primary: {
+          children: "Update now",
+          onClick: update(),
+        },
+        secondary: {
+          children: "Not now",
+          onClick: () => setOpen(false),
+        },
+      }}
+    ></NotificationModal>
+  </>
+);
+```
+
+### with the `useModal` hook
+
+First, wrap your application in the `ModalProvider`:
+
+```tsx
+import { ModalProvider } from "@sumup-oss/circuit-ui";
+
+export default function App() {
+  return <ModalProvider>{/* Your content here... */}</ModalProvider>;
+}
+```
+
+Then, use the `useNotificationModal` hook to open a NotificationModal from a component:
+
+```tsx
+import { useNotificationModal, Heading, Button } from "@sumup-oss/circuit-ui";
+
+export function SayHello({ name }) {
+  const { setModal, removeModal } = useNotificationModal();
+
+  const handleClick = () => {
+    setModal({
+      image: {
+        src: "/images/illustration-update.svg",
+        alt: "",
+      },
+      headline: "It's time to update your browser",
+      body: "You'll soon need a more up-to-date browser to continue using SumUp.",
+      actions: {
+        primary: {
+          children: "Update now",
+          onClick: update(),
+        },
+        secondary: {
+          children: "Not now",
+          onClick: removeModal(),
+        },
+      },
+      closeButtonLabel: "Close",
+    });
+  };
+
+  return <Button onClick={handleClick}>Say hello</Button>;
+}
+```
+
 ## Usage guidelines
 
 - Use a concise headline to communicate the message.
diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.module.css b/packages/circuit-ui/components/NotificationModal/NotificationModal.module.css
index 7fb794efd8..928e04d691 100644
--- a/packages/circuit-ui/components/NotificationModal/NotificationModal.module.css
+++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.module.css
@@ -1,19 +1,9 @@
 .base {
-  position: fixed;
-  top: 50%;
-  left: 50%;
   width: calc(100vw - var(--cui-spacings-peta) * 2);
+  min-width: unset;
   max-width: 420px;
   max-height: calc(100vh - var(--cui-spacings-mega) * 2);
-  padding: var(--cui-spacings-giga);
-  overflow-y: auto;
   text-align: center;
-  background-color: var(--cui-bg-elevated);
-  border-radius: var(--cui-border-radius-mega);
-  outline: none;
-  opacity: 0;
-  transition: opacity var(--cui-transitions-slow);
-  transform: translate(-50%, -50%);
 }
 
 @media (max-width: 479px) {
@@ -22,50 +12,8 @@
   }
 }
 
-/* Overlay */
-
-.overlay {
-  position: fixed;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  z-index: var(--cui-z-index-modal);
-  background: var(--cui-bg-overlay);
-  opacity: 0;
-  transition: opacity var(--cui-transitions-slow);
-}
-
-@media (min-width: 480px) {
-  .overlay {
-    -webkit-overflow-scrolling: touch;
-    overflow-y: auto;
-  }
-}
-
-.open {
-  opacity: 1;
-}
-
-.closed {
-  opacity: 0;
-}
-
 /* Child elements */
 
-.base .close {
-  position: absolute;
-  top: var(--cui-spacings-byte);
-  right: var(--cui-spacings-byte);
-}
-
-@media (min-width: 480px) {
-  .base .close {
-    top: var(--cui-spacings-mega);
-    right: var(--cui-spacings-mega);
-  }
-}
-
 .base .image {
   max-width: 232px;
   height: 120px;
diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.spec.tsx b/packages/circuit-ui/components/NotificationModal/NotificationModal.spec.tsx
index 19c2c86300..ac684b2fbc 100644
--- a/packages/circuit-ui/components/NotificationModal/NotificationModal.spec.tsx
+++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.spec.tsx
@@ -13,10 +13,11 @@
  * limitations under the License.
  */
 
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import { Plus } from '@sumup-oss/icons';
 
-import { axe, render, userEvent, screen } from '../../util/test-utils.js';
+import { axe, render, userEvent, screen, act } from '../../util/test-utils.js';
+import { ANIMATION_DURATION } from '../Modal/Modal.js';
 
 import {
   NotificationModal,
@@ -24,11 +25,21 @@ import {
 } from './NotificationModal.js';
 
 describe('NotificationModal', () => {
+  beforeEach(() => {
+    vi.useFakeTimers({ shouldAdvanceTime: true });
+  });
+
+  afterEach(() => {
+    vi.runOnlyPendingTimers();
+    vi.useRealTimers();
+    vi.clearAllMocks();
+  });
+
   const renderNotificationModal = (props: NotificationModalProps) =>
     render(<NotificationModal {...props} />);
 
   const baseNotificationModal = {
-    isOpen: true,
+    open: true,
     closeButtonLabel: 'Close modal',
     onClose: vi.fn(),
     image: {
@@ -47,7 +58,6 @@ describe('NotificationModal', () => {
         onClick: vi.fn(),
       },
     },
-    ariaHideApp: false,
   } as const;
 
   it('should render with an SVG', () => {
@@ -57,6 +67,9 @@ describe('NotificationModal', () => {
       image: { svg: Plus, alt },
     };
     renderNotificationModal(props);
+    act(() => {
+      vi.advanceTimersByTime(ANIMATION_DURATION);
+    });
 
     const svg = screen.getByRole('img');
 
@@ -74,6 +87,10 @@ describe('NotificationModal', () => {
   it('should render the modal', async () => {
     renderNotificationModal(baseNotificationModal);
 
+    act(() => {
+      vi.advanceTimersByTime(ANIMATION_DURATION);
+    });
+
     const modalEl = await screen.findByRole('dialog');
 
     expect(modalEl).toBeVisible();
@@ -82,27 +99,27 @@ describe('NotificationModal', () => {
   describe('business logic', () => {
     it('should close the modal when clicking the close button', async () => {
       renderNotificationModal(baseNotificationModal);
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
 
       const closeButton = await screen.findByRole('button', {
         name: baseNotificationModal.closeButtonLabel,
       });
 
       await userEvent.click(closeButton);
-
-      expect(baseNotificationModal.onClose).toHaveBeenCalled();
-    });
-
-    it('should close the modal when clicking outside', async () => {
-      renderNotificationModal(baseNotificationModal);
-
-      await userEvent.click(document.body);
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
 
       expect(baseNotificationModal.onClose).toHaveBeenCalled();
     });
 
     it('should perform an action and close the modal when clicking an action button', async () => {
       renderNotificationModal(baseNotificationModal);
-
+      act(() => {
+        vi.advanceTimersByTime(ANIMATION_DURATION);
+      });
       const actionButton = await screen.findByRole('button', {
         name: baseNotificationModal.actions.primary.children,
       });
diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.stories.tsx b/packages/circuit-ui/components/NotificationModal/NotificationModal.stories.tsx
index ca33e29a6b..66cbfcd7d8 100644
--- a/packages/circuit-ui/components/NotificationModal/NotificationModal.stories.tsx
+++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.stories.tsx
@@ -16,10 +16,12 @@
 import type { Decorator } from '@storybook/react';
 import { action } from '@storybook/addon-actions';
 import { screen, userEvent, within } from '@storybook/test';
+import { useState } from 'react';
 
 import { FullViewport } from '../../../../.storybook/components/index.js';
-import { ModalProvider } from '../ModalContext/index.js';
+import { ModalProvider } from '../Modal/ModalContext.js';
 import { Button } from '../Button/index.js';
+import { modes } from '../../../../.storybook/modes.js';
 
 import {
   NotificationModal,
@@ -31,6 +33,13 @@ export default {
   title: 'Notification/NotificationModal',
   component: NotificationModal,
   tags: ['status:stable'],
+  chromatic: {
+    modes: {
+      mobile: modes.smallMobile,
+      desktop: modes.desktop,
+    },
+    pauseAnimationAtEnd: true,
+  },
   parameters: {
     layout: 'padded',
   },
@@ -43,16 +52,14 @@ export default {
   ] as Decorator[],
 };
 
-export const Base = (modal: NotificationModalProps) => {
-  const ComponentWithModal = () => {
-    const { setModal } = useNotificationModal();
+export const Base = (args: Omit<NotificationModalProps, 'open'>) => {
+  const [open, setOpen] = useState(false);
 
-    return <Button onClick={() => setModal(modal)}>Open modal</Button>;
-  };
   return (
-    <ModalProvider>
-      <ComponentWithModal />
-    </ModalProvider>
+    <>
+      <Button onClick={() => setOpen(true)}>Open modal</Button>
+      <NotificationModal open={open} {...args} onClose={() => setOpen(false)} />
+    </>
   );
 };
 
@@ -75,6 +82,51 @@ Base.args = {
   },
   closeButtonLabel: 'Close',
 };
+
+export const WithUseNotificationModal = () => {
+  const ComponentWithModal = () => {
+    const { setModal } = useNotificationModal();
+
+    return (
+      <Button
+        onClick={() =>
+          setModal({
+            image: {
+              src: '/images/illustration-update.svg',
+              alt: '',
+            },
+            headline: "It's time to update your browser",
+            body: "You'll soon need a more up-to-date browser to continue using SumUp.",
+            actions: {
+              primary: {
+                children: 'Update now',
+                onClick: action('primary'),
+              },
+              secondary: {
+                children: 'Not now',
+                onClick: action('secondary'),
+              },
+            },
+            'data-selector': 'test',
+            closeButtonLabel: 'Close',
+          })
+        }
+      >
+        Open modal
+      </Button>
+    );
+  };
+  return (
+    <ModalProvider>
+      <ComponentWithModal />
+    </ModalProvider>
+  );
+};
+
+WithUseNotificationModal.parameters = {
+  chromatic: { disableSnapshot: true },
+};
+
 Base.play = async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => {
   const canvas = within(canvasElement);
   const button = canvas.getByRole('button', {
diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx b/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx
index 9127440b71..837cf3fef7 100644
--- a/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx
+++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx
@@ -15,64 +15,42 @@
 
 'use client';
 
-import type { FC, ReactNode, SVGProps } from 'react';
-import ReactModal from 'react-modal';
+import { type FC, type ReactNode, type SVGProps, useId, useRef } from 'react';
 
 import type { ClickEvent } from '../../types/events.js';
-import type { ModalComponent, BaseModalProps } from '../ModalContext/index.js';
 import { Image, type ImageProps } from '../Image/index.js';
 import { Headline } from '../Headline/index.js';
 import { Body } from '../Body/index.js';
 import type { ButtonProps } from '../Button/index.js';
 import { ButtonGroup, type ButtonGroupProps } from '../ButtonGroup/index.js';
-import { CloseButton } from '../CloseButton/index.js';
+import { Modal, type ModalProps } from '../Modal/index.js';
+import { clsx } from '../../styles/clsx.js';
 import { CircuitError } from '../../util/errors.js';
 
 import classes from './NotificationModal.module.css';
 
-const TRANSITION_DURATION = 200;
-
-type PreventCloseProps =
-  | {
-      /**
-       * Text label for the close button for screen readers.
-       * Important for accessibility.
-       */
-      closeButtonLabel?: never;
-      /**
-       * Prevent users from closing the modal by clicking/tapping the overlay or
-       * pressing the escape key. Default `false`.
-       */
-      preventClose: boolean;
-    }
-  | {
-      closeButtonLabel: string;
-      preventClose?: never;
-    };
-
-export type NotificationModalProps = BaseModalProps &
-  PreventCloseProps & {
-    /**
-     * An optional image to illustrate the notification. Supports either
-     * passing an image source to `image.src` or an SVG component to
-     * `image.svg`. Pass an empty string as alt text if the image is
-     * [decorative](https://www.w3.org/WAI/tutorials/images/decorative/),
-     * or a localized description if the image is [informative](https://www.w3.org/WAI/tutorials/images/informative/).
-     */
-    image?: ImageProps | { svg: FC<SVGProps<SVGSVGElement>>; alt: string };
-    /**
-     * The notification's headline.
-     */
-    headline: string;
-    /**
-     * Optional body copy for notification details.
-     */
-    body?: string | ReactNode;
-    /**
-     * Action buttons to allow users to act on the notification.
-     */
-    actions: ButtonGroupProps['actions'];
-  };
+export type NotificationModalProps = Omit<ModalProps, 'children'> & {
+  /**
+   * An optional image to illustrate the notification. Supports either
+   * passing an image source to `image.src` or an SVG component to
+   * `image.svg`. Pass an empty string as alt text if the image is
+   * [decorative](https://www.w3.org/WAI/tutorials/images/decorative/),
+   * or a localized description if the image is [informative](https://www.w3.org/WAI/tutorials/images/informative/).
+   */
+  image?: ImageProps | { svg: FC<SVGProps<SVGSVGElement>>; alt: string };
+  /**
+   * The notification's headline.
+   */
+  headline: string;
+  /**
+   * Optional body copy for notification details.
+   */
+  body?: string | ReactNode;
+  /**
+   * Action buttons to allow users to act on the notification.
+   */
+  actions: ButtonGroupProps['actions'];
+};
 
 function NotificationImage({ image }: Pick<NotificationModalProps, 'image'>) {
   if (!image) {
@@ -96,11 +74,7 @@ function NotificationImage({ image }: Pick<NotificationModalProps, 'image'>) {
   return <Image {...image} className={classes.image} />;
 }
 
-/**
- * Circuit UI's wrapper component for ReactModal.
- * http://reactcommunity.org/react-modal/accessibility/#aria
- */
-export const NotificationModal: ModalComponent<NotificationModalProps> = ({
+export const NotificationModal = ({
   image,
   headline,
   body,
@@ -110,7 +84,7 @@ export const NotificationModal: ModalComponent<NotificationModalProps> = ({
   preventClose = false,
   className,
   ...props
-}) => {
+}: NotificationModalProps) => {
   if (process.env.NODE_ENV !== 'production' && className) {
     throw new CircuitError(
       'NotificationModal',
@@ -118,60 +92,58 @@ export const NotificationModal: ModalComponent<NotificationModalProps> = ({
     );
   }
 
-  const reactModalProps = {
-    className: {
-      base: classes.base,
-      afterOpen: classes.open,
-      beforeClose: classes.closed,
-    },
-    overlayClassName: {
-      base: classes.overlay,
-      afterOpen: classes.open,
-      beforeClose: classes.closed,
-    },
-    onRequestClose: onClose,
-    closeTimeoutMS: TRANSITION_DURATION,
-    shouldCloseOnOverlayClick: !preventClose,
-    shouldCloseOnEsc: !preventClose,
+  const headlineId = useId();
+  const initialFocusRef = useRef<HTMLButtonElement>(null);
+  const dialogProps = {
+    className: clsx(className, classes.base),
+    closeButtonLabel,
+    'aria-labelledby': headlineId,
+    preventClose,
+    onClose,
+    initialFocusRef,
     ...props,
   };
 
   function wrapOnClick(onClick?: ButtonProps['onClick']) {
     return (event: ClickEvent) => {
-      onClose?.(event);
+      onClose?.();
       onClick?.(event);
     };
   }
 
   return (
-    <ReactModal {...reactModalProps}>
-      {!preventClose && closeButtonLabel && (
-        <CloseButton onClick={onClose} className={classes.close}>
-          {closeButtonLabel}
-        </CloseButton>
+    <Modal {...dialogProps}>
+      {() => (
+        <>
+          <NotificationImage image={image} />
+          <Headline
+            as="h2"
+            size="s"
+            id={headlineId}
+            className={classes.headline}
+          >
+            {headline}
+          </Headline>
+          {body && <Body>{body}</Body>}
+          {actions && (
+            <ButtonGroup
+              actions={{
+                primary: {
+                  ...actions.primary,
+                  onClick: wrapOnClick(actions.primary.onClick),
+                },
+                secondary: actions.secondary && {
+                  ...actions.secondary,
+                  // @ts-expect-error ref is a valid prop on a button
+                  ref: initialFocusRef,
+                  onClick: wrapOnClick(actions.secondary.onClick),
+                },
+              }}
+              className={classes.buttons}
+            />
+          )}
+        </>
       )}
-      <NotificationImage image={image} />
-      <Headline as="h2" size="s" className={classes.headline}>
-        {headline}
-      </Headline>
-      {body && <Body>{body}</Body>}
-      {actions && (
-        <ButtonGroup
-          actions={{
-            primary: {
-              ...actions.primary,
-              onClick: wrapOnClick(actions.primary.onClick),
-            },
-            secondary: actions.secondary && {
-              ...actions.secondary,
-              onClick: wrapOnClick(actions.secondary.onClick),
-            },
-          }}
-          className={classes.buttons}
-        />
-      )}
-    </ReactModal>
+    </Modal>
   );
 };
-
-NotificationModal.TRANSITION_DURATION = TRANSITION_DURATION;
diff --git a/packages/circuit-ui/components/NotificationModal/index.ts b/packages/circuit-ui/components/NotificationModal/index.ts
index bff7a7de8a..1f830ea54d 100644
--- a/packages/circuit-ui/components/NotificationModal/index.ts
+++ b/packages/circuit-ui/components/NotificationModal/index.ts
@@ -16,3 +16,4 @@
 export { useNotificationModal } from './useNotificationModal.js';
 
 export type { NotificationModalProps } from './NotificationModal.js';
+export { NotificationModal } from './NotificationModal.js';
diff --git a/packages/circuit-ui/components/NotificationModal/useNotificationModal.ts b/packages/circuit-ui/components/NotificationModal/useNotificationModal.ts
index cc7ab32477..f702e137ed 100644
--- a/packages/circuit-ui/components/NotificationModal/useNotificationModal.ts
+++ b/packages/circuit-ui/components/NotificationModal/useNotificationModal.ts
@@ -13,8 +13,12 @@
  * limitations under the License.
  */
 
-import { createUseModal } from '../ModalContext/index.js';
+import { createUseModal } from '../Modal/createUseModal.js';
 
-import { NotificationModal } from './NotificationModal.js';
+import {
+  NotificationModal,
+  type NotificationModalProps,
+} from './NotificationModal.js';
 
-export const useNotificationModal = createUseModal(NotificationModal);
+export const useNotificationModal =
+  createUseModal<NotificationModalProps>(NotificationModal);
diff --git a/packages/circuit-ui/hooks/useScrollLock/useScrollLock.mdx b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.mdx
new file mode 100644
index 0000000000..2f5e67bb31
--- /dev/null
+++ b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.mdx
@@ -0,0 +1,28 @@
+import { Meta, Status, Story } from "../../../../.storybook/components";
+import * as Stories from "./useScrollLock.stories";
+
+<Meta of={Stories} />
+
+# useScrollLock()
+
+<Status variant="stable" />
+
+Disables scrolling on the body element when the given argument is true.
+
+<div
+  style={{
+    maxHeight: "50vh",
+    overflowY: "scroll",
+  }}
+>
+  <Story of={Stories.ForDocs} />
+</div>
+
+```ts
+function useScrollLock(isLocked: boolean): void;
+```
+
+## Usage
+
+Use this hook to prevent scrolling on the body element when a modal or other overlay is open.
+This pattern prevents users from scrolling the background content while interacting with a modal or other overlay.
diff --git a/packages/circuit-ui/hooks/useScrollLock/useScrollLock.spec.ts b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.spec.ts
new file mode 100644
index 0000000000..2a9733a97e
--- /dev/null
+++ b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.spec.ts
@@ -0,0 +1,76 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { renderHook } from '../../util/test-utils.js';
+
+import { useScrollLock } from './useScrollLock.js';
+
+describe('useScrollLock', () => {
+  Object.defineProperty(window, 'scrollTo', {
+    value: vi.fn(),
+    writable: true,
+  });
+
+  Object.defineProperty(window, 'scrollY', { value: 1, writable: true });
+
+  beforeEach(() => {
+    document.body.style.position = '';
+    document.body.style.top = '';
+    window.scrollY = 1;
+  });
+
+  it('locks the scroll when `isLocked` is true', () => {
+    window.scrollY = 100;
+    const { rerender } = renderHook(({ isLocked }) => useScrollLock(isLocked), {
+      initialProps: { isLocked: false },
+    });
+
+    rerender({ isLocked: true });
+
+    expect(document.body.style.position).toBe('fixed');
+    expect(document.body.style.top).toBe('-100px');
+  });
+
+  it('unlocks the scroll when `isLocked` is false', () => {
+    window.scrollY = 100;
+
+    const { rerender } = renderHook(({ isLocked }) => useScrollLock(isLocked), {
+      initialProps: { isLocked: true },
+    });
+
+    rerender({ isLocked: false });
+
+    expect(document.body.style.position).toBe('');
+    expect(document.body.style.top).toBe('');
+    expect(window.scrollTo).toHaveBeenCalledWith(0, 100);
+  });
+
+  it('unlocks the scroll when unmounted', () => {
+    window.scrollY = 100;
+
+    const { unmount } = renderHook(() => useScrollLock(true), {
+      initialProps: { isLocked: true },
+    });
+    expect(document.body.style.position).toBe('fixed');
+
+    unmount();
+
+    expect(document.body.style.position).toBe('');
+    expect(document.body.style.top).toBe('');
+    expect(window.scrollTo).toHaveBeenCalledWith(0, 100);
+  });
+});
diff --git a/packages/circuit-ui/hooks/useScrollLock/useScrollLock.stories.tsx b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.stories.tsx
new file mode 100644
index 0000000000..edc7ec5015
--- /dev/null
+++ b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.stories.tsx
@@ -0,0 +1,61 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useState } from 'react';
+
+import { Button } from '../../components/Button/index.js';
+import { Stack } from '../../../../.storybook/components/index.js';
+
+import { useScrollLock } from './useScrollLock.js';
+
+export default {
+  title: 'Hooks/useScrollLock',
+  parameters: {
+    chromatic: {
+      layout: 'padded',
+      disableSnapshot: true,
+    },
+  },
+  tags: ['status:stable'],
+};
+
+export const Base = ({ height = '150vh' }) => {
+  const [disableScroll, setDisableScroll] = useState(false);
+
+  const toggleScroll = () => {
+    setDisableScroll((prev) => !prev);
+  };
+  useScrollLock(disableScroll);
+
+  return (
+    <div
+      style={{
+        height,
+        width: '50vw',
+        backgroundColor: 'lightgray',
+        padding: 'var(--cui-spacings-giga)',
+      }}
+    >
+      <Stack>
+        <Button onClick={toggleScroll}>
+          {disableScroll ? 'Enable scroll' : 'Disable Scroll'} on this page
+        </Button>
+      </Stack>
+    </div>
+  );
+};
+
+export const ForDocs = () => Base({ height: 'unset' });
+ForDocs.tags = ['!dev']; // hide story, used for docs only.
diff --git a/packages/circuit-ui/hooks/useScrollLock/useScrollLock.ts b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.ts
new file mode 100644
index 0000000000..003c087178
--- /dev/null
+++ b/packages/circuit-ui/hooks/useScrollLock/useScrollLock.ts
@@ -0,0 +1,49 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useCallback, useEffect, useRef } from 'react';
+
+export const useScrollLock = (isLocked: boolean): void => {
+  const scrollValue = useRef<string>();
+
+  const restoreScroll = useCallback(() => {
+    // restore scroll to page
+    const { body } = document;
+    const scrollY = body.style.top;
+    body.style.position = '';
+    body.style.top = '';
+    body.style.width = '';
+    window.scrollTo(0, Number.parseInt(scrollY || '0', 10) * -1);
+  }, []);
+  useEffect(() => {
+    if (isLocked) {
+      scrollValue.current = `${window.scrollY}px`;
+      const scrollY = scrollValue.current;
+      const { body } = document;
+      const bodyWidth = body.offsetWidth;
+      // when position is set to fixed, the body element is taken out of
+      // the normal document flow and this may cause it to change size.
+      // To prevent this, we set the width of the body to its current width.
+      body.style.position = 'fixed';
+      body.style.width = `${bodyWidth}px`;
+      body.style.top = `-${scrollY}`;
+    } else {
+      restoreScroll();
+    }
+    return () => {
+      restoreScroll();
+    };
+  }, [isLocked, restoreScroll]);
+};
diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts
index f654e1ab4a..738a98fa4d 100644
--- a/packages/circuit-ui/index.ts
+++ b/packages/circuit-ui/index.ts
@@ -155,12 +155,14 @@ export type {
   PopoverProps,
   PopoverItemProps,
 } from './components/Popover/index.js';
-export { ModalProvider } from './components/ModalContext/index.js';
-export type { ModalProviderProps } from './components/ModalContext/index.js';
+export { ModalProvider } from './components/Modal/ModalContext.js';
+export type { ModalProviderProps } from './components/Modal/ModalContext.js';
 export { useModal } from './components/Modal/index.js';
 export type { ModalProps } from './components/Modal/index.js';
+export { Modal } from './components/Modal/index.js';
 export { useNotificationModal } from './components/NotificationModal/index.js';
 export type { NotificationModalProps } from './components/NotificationModal/index.js';
+export { NotificationModal } from './components/NotificationModal/index.js';
 export { ListItem } from './components/ListItem/index.js';
 export type { ListItemProps } from './components/ListItem/index.js';
 export { ListItemGroup } from './components/ListItemGroup/index.js';
@@ -220,3 +222,4 @@ export { useFocusList } from './hooks/useFocusList/index.js';
 export { useCollapsible } from './hooks/useCollapsible/index.js';
 export { useSwipe } from './hooks/useSwipe/index.js';
 export { useMedia } from './hooks/useMedia/index.js';
+export { useScrollLock } from './hooks/useScrollLock/useScrollLock.js';
diff --git a/packages/circuit-ui/package.json b/packages/circuit-ui/package.json
index 5ec712887c..bb8d8ac0a3 100644
--- a/packages/circuit-ui/package.json
+++ b/packages/circuit-ui/package.json
@@ -97,6 +97,7 @@
     "temporal-polyfill": "0.2.x"
   },
   "engines": {
-    "node": ">=20"
+    "node": ">=20",
+    "typescript": ">=4.1"
   }
 }
diff --git a/packages/design-tokens/scripts/build.ts b/packages/design-tokens/scripts/build.ts
index a1dcdc8d8c..3ea2f6afea 100755
--- a/packages/design-tokens/scripts/build.ts
+++ b/packages/design-tokens/scripts/build.ts
@@ -48,7 +48,7 @@ function main(): void {
       {
         type: 'tokens',
         tokens: [...light, ...shared],
-        selectors: [':root'],
+        selectors: [':root, ::backdrop'],
         colorScheme: 'light',
       },
     ],
@@ -56,7 +56,7 @@ function main(): void {
       {
         type: 'tokens',
         tokens: [...dark, ...shared],
-        selectors: [':root'],
+        selectors: [':root, ::backdrop'],
         colorScheme: 'dark',
       },
     ],
@@ -80,13 +80,13 @@ function main(): void {
       {
         type: 'tokens',
         tokens: [...light, ...shared],
-        selectors: [':root'],
+        selectors: [':root, ::backdrop'],
         colorScheme: 'light',
       },
       {
         type: 'tokens',
         tokens: dark,
-        selectors: ['@media (prefers-color-scheme: dark)', ':root'],
+        selectors: ['@media (prefers-color-scheme: dark)', ':root, ::backdrop'],
         colorScheme: 'dark',
       },
       {