From 9dfba77ec5094c7752e5b73eb49a7e72dced9dcb Mon Sep 17 00:00:00 2001
From: Adi Dahiya <adahiya@palantir.com>
Date: Wed, 24 Jan 2024 08:27:34 -0500
Subject: [PATCH] [core] feat: Overlay2 component (#6656)

---
 packages/core/karma.conf.js                   |   3 +
 packages/core/src/common/errors.ts            |   7 +
 packages/core/src/common/refs.ts              |   6 +-
 packages/core/src/common/utils/domUtils.ts    |   5 +
 packages/core/src/common/utils/jsUtils.ts     |   7 +
 packages/core/src/common/utils/reactUtils.ts  |  53 +-
 packages/core/src/components/alert/alert.tsx  |   2 +-
 packages/core/src/components/components.md    |   1 +
 .../context-menu/contextMenuShared.ts         |   2 +-
 .../core/src/components/dialog/dialog.tsx     |  40 +-
 .../core/src/components/drawer/drawer.tsx     |   7 +-
 packages/core/src/components/index.ts         |  10 +-
 .../core/src/components/overlay/overlay.md    |  25 +-
 .../core/src/components/overlay/overlay.tsx   | 220 +-----
 .../src/components/overlay/overlayProps.ts    | 192 +++++
 .../src/components/overlay/overlayUtils.ts    |  49 ++
 .../core/src/components/overlay2/overlay2.md  | 174 +++++
 .../core/src/components/overlay2/overlay2.tsx | 710 ++++++++++++++++++
 .../core/src/components/popover/popover.tsx   |  31 +-
 .../components/popover/popoverSharedProps.ts  |   4 +-
 .../src/components/toast/overlayToaster.tsx   |  79 +-
 packages/core/src/components/toast/toast.md   | 143 ++--
 packages/core/src/components/toast/toast.tsx  |  58 +-
 packages/core/src/components/toast/toast2.tsx | 109 +++
 .../core/src/components/toast/toastProps.ts   |  55 ++
 packages/core/src/components/toast/toaster.ts |   2 +-
 .../src/hooks/useIsomorphicLayoutEffect.ts    |  21 +
 packages/core/src/hooks/useTimeout.ts         |  48 ++
 .../core/src/legacy/contextMenuLegacy.tsx     |   2 +-
 packages/core/test/dialog/dialogTests.tsx     |   2 +-
 packages/core/test/index.ts                   |   2 +
 packages/core/test/isotest.mjs                |  14 +-
 packages/core/test/overlay/overlayTests.tsx   |   7 +
 .../overlay2/overlay2-test-debugging.scss     |  10 +
 packages/core/test/overlay2/overlay2Tests.tsx | 611 +++++++++++++++
 packages/core/test/popover/popoverTests.tsx   |   8 +-
 .../core/test/toast/overlayToasterTests.tsx   |  12 +-
 packages/core/test/toast/toast2Tests.tsx      | 102 +++
 packages/core/test/toast/toastTests.tsx       |   7 +
 packages/core/test/tooltip/tooltipTests.tsx   |   6 +-
 .../demo-app/src/examples/ToastExample.tsx    |   4 +-
 .../docs-app/src/components/blueprintDocs.tsx |  28 +-
 .../src/examples/core-examples/index.ts       |   3 +-
 .../core-examples/overlay2Example.tsx         | 133 ++++
 .../examples/core-examples/overlayExample.tsx |   7 +
 packages/docs-app/src/styles/_examples.scss   |  12 +
 .../select/src/components/omnibar/omnibar.tsx |   6 +-
 packages/table/test/columnHeaderCellTests.tsx |  39 +-
 packages/table/test/harness.ts                |  11 +-
 packages/table/test/index.ts                  |  60 +-
 50 files changed, 2683 insertions(+), 466 deletions(-)
 create mode 100644 packages/core/src/components/overlay/overlayProps.ts
 create mode 100644 packages/core/src/components/overlay/overlayUtils.ts
 create mode 100644 packages/core/src/components/overlay2/overlay2.md
 create mode 100644 packages/core/src/components/overlay2/overlay2.tsx
 create mode 100644 packages/core/src/components/toast/toast2.tsx
 create mode 100644 packages/core/src/components/toast/toastProps.ts
 create mode 100644 packages/core/src/hooks/useIsomorphicLayoutEffect.ts
 create mode 100644 packages/core/src/hooks/useTimeout.ts
 create mode 100644 packages/core/test/overlay2/overlay2-test-debugging.scss
 create mode 100644 packages/core/test/overlay2/overlay2Tests.tsx
 create mode 100644 packages/core/test/toast/toast2Tests.tsx
 create mode 100644 packages/docs-app/src/examples/core-examples/overlay2Example.tsx

diff --git a/packages/core/karma.conf.js b/packages/core/karma.conf.js
index 7321e4a3ac..118f9e05a2 100644
--- a/packages/core/karma.conf.js
+++ b/packages/core/karma.conf.js
@@ -21,6 +21,9 @@ module.exports = async function (config) {
                 "src/common/abstractComponent*",
                 "src/common/abstractPureComponent*",
                 "src/components/html/html.tsx",
+                // focus mangement is difficult to test, and this function may no longer be required
+                // if we use the react-focus-lock library in Overlay2.
+                "src/components/overlay/overlayUtils.ts",
 
                 // HACKHACK: for karma upgrade only
                 "src/common/refs.ts",
diff --git a/packages/core/src/common/errors.ts b/packages/core/src/common/errors.ts
index bcb88f16c7..0abb9c84d7 100644
--- a/packages/core/src/common/errors.ts
+++ b/packages/core/src/common/errors.ts
@@ -109,3 +109,10 @@ export const DIALOG_WARN_NO_HEADER_CLOSE_BUTTON =
 
 export const DRAWER_ANGLE_POSITIONS_ARE_CASTED =
     ns + ` <Drawer> all angle positions are casted into pure position (TOP, BOTTOM, LEFT or RIGHT)`;
+
+export const OVERLAY_CHILD_REF_AND_REFS_MUTEX =
+    ns + ` <Overlay2> cannot use childRef and childRefs props simultaneously`;
+export const OVERLAY_WITH_MULTIPLE_CHILDREN_REQUIRES_CHILD_REFS =
+    ns + ` <Overlay2> requires childRefs prop when rendering multiple child elements`;
+export const OVERLAY_CHILD_REQUIRES_KEY =
+    ns + ` <Overlay2> requires each child element to have a unique key prop when childRefs is used`;
diff --git a/packages/core/src/common/refs.ts b/packages/core/src/common/refs.ts
index 2b14b6028b..ad4f238621 100644
--- a/packages/core/src/common/refs.ts
+++ b/packages/core/src/common/refs.ts
@@ -53,7 +53,11 @@ export function getRef<T>(ref: T | React.RefObject<T> | null): T | null {
         return null;
     }
 
-    return (ref as React.RefObject<T>).current ?? (ref as T);
+    if (typeof (ref as React.RefObject<T>).current === "undefined") {
+        return ref as T;
+    }
+
+    return (ref as React.RefObject<T>).current;
 }
 
 /**
diff --git a/packages/core/src/common/utils/domUtils.ts b/packages/core/src/common/utils/domUtils.ts
index bb78d568f3..dea1ad826f 100644
--- a/packages/core/src/common/utils/domUtils.ts
+++ b/packages/core/src/common/utils/domUtils.ts
@@ -14,6 +14,11 @@
  * limitations under the License.
  */
 
+/** @returns true if React is running in a client environment, and false if it's in a server */
+export function hasDOMEnvironment(): boolean {
+    return typeof window !== "undefined" && window.document != null;
+}
+
 export function elementIsOrContains(element: HTMLElement, testElement: HTMLElement) {
     return element === testElement || element.contains(testElement);
 }
diff --git a/packages/core/src/common/utils/jsUtils.ts b/packages/core/src/common/utils/jsUtils.ts
index 5126962aeb..b8cba3e827 100644
--- a/packages/core/src/common/utils/jsUtils.ts
+++ b/packages/core/src/common/utils/jsUtils.ts
@@ -79,3 +79,10 @@ export function uniqueId(namespace: string) {
     uniqueCountForNamespace.set(namespace, curCount + 1);
     return `${namespace}-${curCount}`;
 }
+
+/**
+ * @returns `true` if the value is an empty string after trimming whitespace
+ */
+export function isEmptyString(val: any) {
+    return typeof val === "string" && val.trim().length === 0;
+}
diff --git a/packages/core/src/common/utils/reactUtils.ts b/packages/core/src/common/utils/reactUtils.ts
index 86cd7561d9..60b42665b0 100644
--- a/packages/core/src/common/utils/reactUtils.ts
+++ b/packages/core/src/common/utils/reactUtils.ts
@@ -16,6 +16,8 @@
 
 import * as React from "react";
 
+import { isEmptyString } from "./jsUtils";
+
 /**
  * Returns true if `node` is null/undefined, false, empty string, or an array
  * composed of those. If `node` is an array, only one level of the array is
@@ -45,28 +47,44 @@ export function isReactChildrenElementOrElements(
 }
 
 /**
- * Converts a React node to an element: non-empty string or number or
- * `React.Fragment` (React 16.3+) is wrapped in given tag name; empty strings
- * and booleans are discarded.
+ * Converts a React node to an element. Non-empty strings, numbers, and Fragments will be wrapped in given tag name;
+ * empty strings and booleans will be discarded.
+ *
+ * @param child     the React node to convert
+ * @param tagName   the HTML tag name to use when a wrapper element is needed
+ * @param props     additional props to spread onto the element, if any. If the child is a React element and this argument
+ *                  is defined, the child will be cloned and these props will be merged in.
  */
-export function ensureElement(child: React.ReactNode | undefined, tagName: keyof React.JSX.IntrinsicElements = "span") {
-    if (child == null || typeof child === "boolean") {
+export function ensureElement(
+    child: React.ReactNode | undefined,
+    tagName: keyof React.JSX.IntrinsicElements = "span",
+    props: React.HTMLProps<HTMLElement> = {},
+) {
+    if (child == null || typeof child === "boolean" || isEmptyString(child)) {
         return undefined;
-    } else if (typeof child === "string") {
-        // cull whitespace strings
-        return child.trim().length > 0 ? React.createElement(tagName, {}, child) : undefined;
-    } else if (typeof child === "number" || typeof (child as any).type === "symbol" || Array.isArray(child)) {
-        // React.Fragment has a symbol type, ReactNodeArray extends from Array
-        return React.createElement(tagName, {}, child);
+    } else if (
+        typeof child === "string" ||
+        typeof child === "number" ||
+        isReactFragment(child) ||
+        isReactNodeArray(child)
+    ) {
+        // wrap the child element
+        return React.createElement(tagName, props, child);
     } else if (isReactElement(child)) {
-        return child;
+        if (Object.keys(props).length > 0) {
+            // clone the element and merge props
+            return React.cloneElement(child, props);
+        } else {
+            // nothing to do, it's a valid ReactElement
+            return child;
+        }
     } else {
         // child is inferred as {}
         return undefined;
     }
 }
 
-function isReactElement<T = any>(child: React.ReactNode): child is React.ReactElement<T> {
+export function isReactElement<T = any>(child: React.ReactNode): child is React.ReactElement<T> {
     return (
         typeof child === "object" &&
         typeof (child as any).type !== "undefined" &&
@@ -74,6 +92,15 @@ function isReactElement<T = any>(child: React.ReactNode): child is React.ReactEl
     );
 }
 
+function isReactFragment(child: React.ReactNode): child is React.ReactFragment {
+    // bit hacky, but generally works
+    return typeof (child as any).type === "symbol";
+}
+
+function isReactNodeArray(child: React.ReactNode): child is React.ReactNodeArray {
+    return Array.isArray(child);
+}
+
 /**
  * Returns true if the given JSX element matches the given component type.
  *
diff --git a/packages/core/src/components/alert/alert.tsx b/packages/core/src/components/alert/alert.tsx
index a78956a794..aca45e912b 100644
--- a/packages/core/src/components/alert/alert.tsx
+++ b/packages/core/src/components/alert/alert.tsx
@@ -33,7 +33,7 @@ import {
 import { Button } from "../button/buttons";
 import { Dialog } from "../dialog/dialog";
 import { Icon, type IconName } from "../icon/icon";
-import type { OverlayLifecycleProps } from "../overlay/overlay";
+import type { OverlayLifecycleProps } from "../overlay/overlayProps";
 
 export interface AlertProps extends OverlayLifecycleProps, Props {
     /**
diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md
index 48e5cf9ebc..8f3b84e737 100644
--- a/packages/core/src/components/components.md
+++ b/packages/core/src/components/components.md
@@ -56,6 +56,7 @@
 @## Overlays
 
 @page overlay
+@page overlay2
 @page portal
 @page alert
 @page context-menu
diff --git a/packages/core/src/components/context-menu/contextMenuShared.ts b/packages/core/src/components/context-menu/contextMenuShared.ts
index e5ec0bca87..4fb5a0b43c 100644
--- a/packages/core/src/components/context-menu/contextMenuShared.ts
+++ b/packages/core/src/components/context-menu/contextMenuShared.ts
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-import type { OverlayLifecycleProps } from "../overlay/overlay";
+import type { OverlayLifecycleProps } from "../overlay/overlayProps";
 import type { PopoverProps } from "../popover/popover";
 
 export type Offset = {
diff --git a/packages/core/src/components/dialog/dialog.tsx b/packages/core/src/components/dialog/dialog.tsx
index e38979a0c5..1c7347aacb 100644
--- a/packages/core/src/components/dialog/dialog.tsx
+++ b/packages/core/src/components/dialog/dialog.tsx
@@ -19,13 +19,21 @@ import * as React from "react";
 
 import { type IconName, IconSize, SmallCross } from "@blueprintjs/icons";
 
-import { AbstractPureComponent, Classes, DISPLAYNAME_PREFIX, type MaybeElement, type Props } from "../../common";
+import {
+    AbstractPureComponent,
+    Classes,
+    DISPLAYNAME_PREFIX,
+    type MaybeElement,
+    mergeRefs,
+    type Props,
+} from "../../common";
 import * as Errors from "../../common/errors";
 import { uniqueId } from "../../common/utils";
 import { Button } from "../button/buttons";
 import { H6 } from "../html/html";
 import { Icon } from "../icon/icon";
-import { type BackdropProps, Overlay, type OverlayableProps } from "../overlay/overlay";
+import type { BackdropProps, OverlayableProps } from "../overlay/overlayProps";
+import { Overlay2 } from "../overlay2/overlay2";
 
 export interface DialogProps extends OverlayableProps, BackdropProps, Props {
     /** Dialog contents. */
@@ -77,7 +85,7 @@ export interface DialogProps extends OverlayableProps, BackdropProps, Props {
     transitionName?: string;
 
     /**
-     * Ref supplied to the `Classes.DIALOG_CONTAINER` element.
+     * Ref attached to the `Classes.DIALOG_CONTAINER` element.
      */
     containerRef?: React.Ref<HTMLDivElement>;
 
@@ -106,6 +114,8 @@ export class Dialog extends AbstractPureComponent<DialogProps> {
         isOpen: false,
     };
 
+    private childRef = React.createRef<HTMLDivElement>();
+
     private titleId: string;
 
     public static displayName = `${DISPLAYNAME_PREFIX}.Dialog`;
@@ -118,21 +128,31 @@ export class Dialog extends AbstractPureComponent<DialogProps> {
     }
 
     public render() {
+        const { className, children, containerRef, style, title, ...overlayProps } = this.props;
+
         return (
-            <Overlay {...this.props} className={Classes.OVERLAY_SCROLL_CONTAINER} hasBackdrop={true}>
-                <div className={Classes.DIALOG_CONTAINER} ref={this.props.containerRef}>
+            <Overlay2
+                {...overlayProps}
+                className={Classes.OVERLAY_SCROLL_CONTAINER}
+                childRef={this.childRef}
+                hasBackdrop={true}
+            >
+                <div
+                    className={Classes.DIALOG_CONTAINER}
+                    ref={containerRef === undefined ? this.childRef : mergeRefs(containerRef, this.childRef)}
+                >
                     <div
-                        className={classNames(Classes.DIALOG, this.props.className)}
+                        className={classNames(Classes.DIALOG, className)}
                         role="dialog"
-                        aria-labelledby={this.props["aria-labelledby"] || (this.props.title ? this.titleId : undefined)}
+                        aria-labelledby={this.props["aria-labelledby"] || (title ? this.titleId : undefined)}
                         aria-describedby={this.props["aria-describedby"]}
-                        style={this.props.style}
+                        style={style}
                     >
                         {this.maybeRenderHeader()}
-                        {this.props.children}
+                        {children}
                     </div>
                 </div>
-            </Overlay>
+            </Overlay2>
         );
     }
 
diff --git a/packages/core/src/components/drawer/drawer.tsx b/packages/core/src/components/drawer/drawer.tsx
index 47c3257bcc..d2b061df55 100644
--- a/packages/core/src/components/drawer/drawer.tsx
+++ b/packages/core/src/components/drawer/drawer.tsx
@@ -26,7 +26,8 @@ import { DISPLAYNAME_PREFIX, type MaybeElement } from "../../common/props";
 import { Button } from "../button/buttons";
 import { H4 } from "../html/html";
 import { Icon } from "../icon/icon";
-import { type BackdropProps, Overlay, type OverlayableProps } from "../overlay/overlay";
+import type { BackdropProps, OverlayableProps } from "../overlay/overlayProps";
+import { Overlay2 } from "../overlay2/overlay2";
 
 export enum DrawerSize {
     SMALL = "360px",
@@ -134,12 +135,12 @@ export class Drawer extends AbstractPureComponent<DrawerProps> {
         return (
             // N.B. the `OVERLAY_CONTAINER` class is a bit of a misnomer since it is only being used by the Drawer
             // component, but we keep it for backwards compatibility.
-            <Overlay {...overlayProps} className={classNames({ [Classes.OVERLAY_CONTAINER]: hasBackdrop })}>
+            <Overlay2 {...overlayProps} className={classNames({ [Classes.OVERLAY_CONTAINER]: hasBackdrop })}>
                 <div className={classes} style={styleProp}>
                     {this.maybeRenderHeader()}
                     {children}
                 </div>
-            </Overlay>
+            </Overlay2>
         );
     }
 
diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts
index 176e0e8301..7b14377c41 100644
--- a/packages/core/src/components/index.ts
+++ b/packages/core/src/components/index.ts
@@ -70,7 +70,10 @@ export { NavbarGroup, type NavbarGroupProps } from "./navbar/navbarGroup";
 export { NavbarHeading, type NavbarHeadingProps } from "./navbar/navbarHeading";
 export { NonIdealState, type NonIdealStateProps, NonIdealStateIconSize } from "./non-ideal-state/nonIdealState";
 export { OverflowList, type OverflowListProps } from "./overflow-list/overflowList";
-export { Overlay, type OverlayLifecycleProps, type OverlayProps, type OverlayableProps } from "./overlay/overlay";
+// eslint-disable-next-line deprecation/deprecation
+export { Overlay } from "./overlay/overlay";
+export type { OverlayLifecycleProps, OverlayProps, OverlayableProps } from "./overlay/overlayProps";
+export { Overlay2, type Overlay2Props, type OverlayInstance } from "./overlay2/overlay2";
 export { Text, type TextProps } from "./text/text";
 // eslint-disable-next-line deprecation/deprecation
 export { PanelStack, type PanelStackProps } from "./panel-stack/panelStack";
@@ -120,7 +123,10 @@ export { Tag, type TagProps } from "./tag/tag";
 export { TagInput, type TagInputProps, type TagInputAddMethod } from "./tag-input/tagInput";
 export { OverlayToaster, type OverlayToasterCreateOptions } from "./toast/overlayToaster";
 export type { OverlayToasterProps, ToasterPosition } from "./toast/overlayToasterProps";
-export { Toast, type ToastProps } from "./toast/toast";
+// eslint-disable-next-line deprecation/deprecation
+export { Toast } from "./toast/toast";
+export { Toast2 } from "./toast/toast2";
+export type { ToastProps } from "./toast/toastProps";
 export { Toaster, type ToastOptions } from "./toast/toaster";
 export { type TooltipProps, Tooltip } from "./tooltip/tooltip";
 export { Tree, type TreeProps } from "./tree/tree";
diff --git a/packages/core/src/components/overlay/overlay.md b/packages/core/src/components/overlay/overlay.md
index f4249c6812..14df046f11 100644
--- a/packages/core/src/components/overlay/overlay.md
+++ b/packages/core/src/components/overlay/overlay.md
@@ -1,23 +1,36 @@
 @# Overlay
 
-__Overlay__ is a generic low-level component for rendering content _on top of_ its siblings or above the entire
+<div class="@ns-callout @ns-intent-danger @ns-icon-error @ns-callout-has-body-content">
+    <h5 class="@ns-heading">
+
+Deprecated: use [**Overlay2**](#core/components/overlay2)
+
+</h5>
+
+This component is **deprecated since @blueprintjs/core v5.9.0** in favor of the new
+**Overlay2** component which is compatible with React 18 strict mode. You should migrate to the
+new API which will become the standard in a future major version of Blueprint.
+
+</div>
+
+**Overlay** is a generic low-level component for rendering content _on top of_ its siblings or above the entire
 application.
 
-It combines the functionality of the [__Portal__](#core/components/portal) component (which allows React elements to
-escape their current DOM hierarchy) with a [__CSSTransitionGroup__](https://reactcommunity.org/react-transition-group/)
+It combines the functionality of the [**Portal**](#core/components/portal) component (which allows React elements to
+escape their current DOM hierarchy) with a [**CSSTransitionGroup**](https://reactcommunity.org/react-transition-group/)
 (to show elegant enter and leave transitions).
 
 An optional "backdrop" element can be rendered behind the overlaid children to provide modal behavior, whereby the
 overlay prevents interaction with anything behind it.
 
-__Overlay__ is the backbone of all the components listed in the **Overlays** group in the sidebar. Using __Overlay__
+**Overlay** is the backbone of all the components listed in the **Overlays** group in the sidebar. Using **Overlay**
 directly should be rare in your application; it should only be necessary if no existing component meets your needs.
 
 @reactExample OverlayExample
 
 @## Usage
 
-__Overlay__ is a controlled component that renders its children only when `isOpen={true}`. The optional backdrop element
+**Overlay** is a controlled component that renders its children only when `isOpen={true}`. The optional backdrop element
 will be inserted before the children if `hasBackdrop={true}`.
 
 The `onClose` callback prop is invoked when user interaction causes the overlay to close, but your application is
@@ -50,7 +63,7 @@ scrollable, so any overflowing content will be hidden. Fortunately, making an ov
 <Overlay className={Classes.OVERLAY_SCROLL_CONTAINER} />
 ```
 
-Note that the [__Dialog__](https://blueprintjs.com/docs/#core/components/dialog) component applies this CSS class
+Note that the [**Dialog**](https://blueprintjs.com/docs/#core/components/dialog) component applies this CSS class
 automatically.
 
 @## Props interface
diff --git a/packages/core/src/components/overlay/overlay.tsx b/packages/core/src/components/overlay/overlay.tsx
index c28a65697a..4efe4df06a 100644
--- a/packages/core/src/components/overlay/overlay.tsx
+++ b/packages/core/src/components/overlay/overlay.tsx
@@ -14,189 +14,24 @@
  * limitations under the License.
  */
 
+/**
+ * @fileoverview This component is DEPRECATED, and the code is frozen.
+ * All changes & bugfixes should be made to Overlay2 instead.
+ */
+
+/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */
+
 import classNames from "classnames";
 import * as React from "react";
 import { CSSTransition, TransitionGroup } from "react-transition-group";
 
 import { AbstractPureComponent, Classes } from "../../common";
-import { DISPLAYNAME_PREFIX, type HTMLDivProps, type Props } from "../../common/props";
+import { DISPLAYNAME_PREFIX, type HTMLDivProps } from "../../common/props";
 import { getActiveElement, isFunction } from "../../common/utils";
 import { Portal } from "../portal/portal";
 
-export interface OverlayableProps extends OverlayLifecycleProps {
-    /**
-     * Whether the overlay should acquire application focus when it first opens.
-     *
-     * @default true
-     */
-    autoFocus?: boolean;
-
-    /**
-     * Whether pressing the `esc` key should invoke `onClose`.
-     *
-     * @default true
-     */
-    canEscapeKeyClose?: boolean;
-
-    /**
-     * Whether the overlay should prevent focus from leaving itself. That is, if the user attempts
-     * to focus an element outside the overlay and this prop is enabled, then the overlay will
-     * immediately bring focus back to itself. If you are nesting overlay components, either disable
-     * this prop on the "outermost" overlays or mark the nested ones `usePortal={false}`.
-     *
-     * @default true
-     */
-    enforceFocus?: boolean;
-
-    /**
-     * If `true` and `usePortal={true}`, the `Portal` containing the children is created and attached
-     * to the DOM when the overlay is opened for the first time; otherwise this happens when the
-     * component mounts. Lazy mounting provides noticeable performance improvements if you have lots
-     * of overlays at once, such as on each row of a table.
-     *
-     * @default true
-     */
-    lazy?: boolean;
-
-    /**
-     * Whether the application should return focus to the last active element in the
-     * document after this overlay closes.
-     *
-     * @default true
-     */
-    shouldReturnFocusOnClose?: boolean;
-
-    /**
-     * Indicates how long (in milliseconds) the overlay's enter/leave transition takes.
-     * This is used by React `CSSTransition` to know when a transition completes and must match
-     * the duration of the animation in CSS. Only set this prop if you override Blueprint's default
-     * transitions with new transitions of a different length.
-     *
-     * @default 300
-     */
-    transitionDuration?: number;
-
-    /**
-     * Whether the overlay should be wrapped in a `Portal`, which renders its contents in a new
-     * element attached to `portalContainer` prop.
-     *
-     * This prop essentially determines which element is covered by the backdrop: if `false`,
-     * then only its parent is covered; otherwise, the entire page is covered (because the parent
-     * of the `Portal` is the `<body>` itself).
-     *
-     * Set this prop to `false` on nested overlays (such as `Dialog` or `Popover`) to ensure that they
-     * are rendered above their parents.
-     *
-     * @default true
-     */
-    usePortal?: boolean;
-
-    /**
-     * Space-delimited string of class names applied to the `Portal` element if
-     * `usePortal={true}`.
-     */
-    portalClassName?: string;
-
-    /**
-     * The container element into which the overlay renders its contents, when `usePortal` is `true`.
-     * This prop is ignored if `usePortal` is `false`.
-     *
-     * @default document.body
-     */
-    portalContainer?: HTMLElement;
-
-    /**
-     * A list of DOM events which should be stopped from propagating through the Portal.
-     * This prop is ignored if `usePortal` is `false`.
-     *
-     * @deprecated this prop's implementation no longer works in React v17+
-     * @see https://legacy.reactjs.org/docs/portals.html#event-bubbling-through-portals
-     * @see https://github.com/palantir/blueprint/issues/6124
-     * @see https://github.com/palantir/blueprint/issues/6580
-     */
-    portalStopPropagationEvents?: Array<keyof HTMLElementEventMap>;
-
-    /**
-     * A callback that is invoked when user interaction causes the overlay to close, such as
-     * clicking on the overlay or pressing the `esc` key (if enabled).
-     *
-     * Receives the event from the user's interaction, if there was an event (generally either a
-     * mouse or key event). Note that, since this component is controlled by the `isOpen` prop, it
-     * will not actually close itself until that prop becomes `false`.
-     */
-    onClose?: (event: React.SyntheticEvent<HTMLElement>) => void;
-}
-
-export interface OverlayLifecycleProps {
-    /**
-     * Lifecycle method invoked just before the CSS _close_ transition begins on
-     * a child. Receives the DOM element of the child being closed.
-     */
-    onClosing?: (node: HTMLElement) => void;
-
-    /**
-     * Lifecycle method invoked just after the CSS _close_ transition ends but
-     * before the child has been removed from the DOM. Receives the DOM element
-     * of the child being closed.
-     */
-    onClosed?: (node: HTMLElement) => void;
-
-    /**
-     * Lifecycle method invoked just after mounting the child in the DOM but
-     * just before the CSS _open_ transition begins. Receives the DOM element of
-     * the child being opened.
-     */
-    onOpening?: (node: HTMLElement) => void;
-
-    /**
-     * Lifecycle method invoked just after the CSS _open_ transition ends.
-     * Receives the DOM element of the child being opened.
-     */
-    onOpened?: (node: HTMLElement) => void;
-}
-
-export interface BackdropProps {
-    /** CSS class names to apply to backdrop element. */
-    backdropClassName?: string;
-
-    /** HTML props for the backdrop element. */
-    backdropProps?: React.HTMLProps<HTMLDivElement>;
-
-    /**
-     * Whether clicking outside the overlay element (either on backdrop when present or on document)
-     * should invoke `onClose`.
-     *
-     * @default true
-     */
-    canOutsideClickClose?: boolean;
-
-    /**
-     * Whether a container-spanning backdrop element should be rendered behind the contents.
-     * When `false`, users will be able to scroll through and interact with overlaid content.
-     *
-     * @default true
-     */
-    hasBackdrop?: boolean;
-}
-
-export interface OverlayProps extends OverlayableProps, BackdropProps, Props {
-    /** Element to overlay. */
-    children?: React.ReactNode;
-
-    /**
-     * Toggles the visibility of the overlay and its children.
-     * This prop is required because the component is controlled.
-     */
-    isOpen: boolean;
-
-    /**
-     * Name of the transition for internal `CSSTransition`.
-     * Providing your own name here will require defining new CSS transition properties.
-     *
-     * @default Classes.OVERLAY
-     */
-    transitionName?: string;
-}
+import type { OverlayProps } from "./overlayProps";
+import { getKeyboardFocusableElements } from "./overlayUtils";
 
 export interface OverlayState {
     hasEverOpened?: boolean;
@@ -205,6 +40,7 @@ export interface OverlayState {
 /**
  * Overlay component.
  *
+ * @deprecated use `Overlay2` instead
  * @see https://blueprintjs.com/docs/#core/components/overlay
  */
 export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> {
@@ -479,7 +315,7 @@ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> {
             return;
         }
         if (e.shiftKey && e.key === "Tab") {
-            const lastFocusableElement = this.getKeyboardFocusableElements().pop();
+            const lastFocusableElement = getKeyboardFocusableElements(this.containerElement).pop();
             if (lastFocusableElement != null) {
                 lastFocusableElement.focus();
             } else {
@@ -506,7 +342,7 @@ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> {
             this.containerElement.current?.contains(e.relatedTarget as Element) &&
             e.relatedTarget !== this.startFocusTrapElement.current
         ) {
-            const firstFocusableElement = this.getKeyboardFocusableElements().shift();
+            const firstFocusableElement = getKeyboardFocusableElements(this.containerElement).shift();
             // ensure we don't re-focus an already active element by comparing against e.relatedTarget
             if (!this.isAutoFocusing && firstFocusableElement != null && firstFocusableElement !== e.relatedTarget) {
                 firstFocusableElement.focus();
@@ -514,7 +350,7 @@ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> {
                 this.startFocusTrapElement.current?.focus({ preventScroll: true });
             }
         } else {
-            const lastFocusableElement = this.getKeyboardFocusableElements().pop();
+            const lastFocusableElement = getKeyboardFocusableElements(this.containerElement).pop();
             if (lastFocusableElement != null) {
                 lastFocusableElement.focus();
             } else {
@@ -524,34 +360,6 @@ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> {
         }
     };
 
-    private getKeyboardFocusableElements() {
-        const focusableElements: HTMLElement[] =
-            this.containerElement.current !== null
-                ? Array.from(
-                      // Order may not be correct if children elements use tabindex values > 0.
-                      // Selectors derived from this SO question:
-                      // https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
-                      this.containerElement.current.querySelectorAll(
-                          [
-                              'a[href]:not([tabindex="-1"])',
-                              'button:not([disabled]):not([tabindex="-1"])',
-                              'details:not([tabindex="-1"])',
-                              'input:not([disabled]):not([tabindex="-1"])',
-                              'select:not([disabled]):not([tabindex="-1"])',
-                              'textarea:not([disabled]):not([tabindex="-1"])',
-                              '[tabindex]:not([tabindex="-1"])',
-                          ].join(","),
-                      ),
-                  )
-                : [];
-
-        return focusableElements.filter(
-            el =>
-                !el.classList.contains(Classes.OVERLAY_START_FOCUS_TRAP) &&
-                !el.classList.contains(Classes.OVERLAY_END_FOCUS_TRAP),
-        );
-    }
-
     private overlayWillClose() {
         document.removeEventListener("focus", this.handleDocumentFocus, /* useCapture */ true);
         document.removeEventListener("mousedown", this.handleDocumentClick);
diff --git a/packages/core/src/components/overlay/overlayProps.ts b/packages/core/src/components/overlay/overlayProps.ts
new file mode 100644
index 0000000000..4e9d021ffa
--- /dev/null
+++ b/packages/core/src/components/overlay/overlayProps.ts
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 type { Props } from "../../common/props";
+
+export interface OverlayableProps extends OverlayLifecycleProps {
+    /**
+     * Whether the overlay should acquire application focus when it first opens.
+     *
+     * @default true
+     */
+    autoFocus?: boolean;
+
+    /**
+     * Whether pressing the `esc` key should invoke `onClose`.
+     *
+     * @default true
+     */
+    canEscapeKeyClose?: boolean;
+
+    /**
+     * Whether the overlay should prevent focus from leaving itself. That is, if the user attempts
+     * to focus an element outside the overlay and this prop is enabled, then the overlay will
+     * immediately bring focus back to itself. If you are nesting overlay components, either disable
+     * this prop on the "outermost" overlays or mark the nested ones `usePortal={false}`.
+     *
+     * @default true
+     */
+    enforceFocus?: boolean;
+
+    /**
+     * If `true` and `usePortal={true}`, the `Portal` containing the children is created and attached
+     * to the DOM when the overlay is opened for the first time; otherwise this happens when the
+     * component mounts. Lazy mounting provides noticeable performance improvements if you have lots
+     * of overlays at once, such as on each row of a table.
+     *
+     * @default true
+     */
+    lazy?: boolean;
+
+    /**
+     * Whether the application should return focus to the last active element in the
+     * document after this overlay closes.
+     *
+     * @default true
+     */
+    shouldReturnFocusOnClose?: boolean;
+
+    /**
+     * Indicates how long (in milliseconds) the overlay's enter/leave transition takes.
+     * This is used by React `CSSTransition` to know when a transition completes and must match
+     * the duration of the animation in CSS. Only set this prop if you override Blueprint's default
+     * transitions with new transitions of a different length.
+     *
+     * @default 300
+     */
+    transitionDuration?: number;
+
+    /**
+     * Whether the overlay should be wrapped in a `Portal`, which renders its contents in a new
+     * element attached to `portalContainer` prop.
+     *
+     * This prop essentially determines which element is covered by the backdrop: if `false`,
+     * then only its parent is covered; otherwise, the entire page is covered (because the parent
+     * of the `Portal` is the `<body>` itself).
+     *
+     * Set this prop to `false` on nested overlays (such as `Dialog` or `Popover`) to ensure that they
+     * are rendered above their parents.
+     *
+     * @default true
+     */
+    usePortal?: boolean;
+
+    /**
+     * Space-delimited string of class names applied to the `Portal` element if
+     * `usePortal={true}`.
+     */
+    portalClassName?: string;
+
+    /**
+     * The container element into which the overlay renders its contents, when `usePortal` is `true`.
+     * This prop is ignored if `usePortal` is `false`.
+     *
+     * @default document.body
+     */
+    portalContainer?: HTMLElement;
+
+    /**
+     * A list of DOM events which should be stopped from propagating through the Portal.
+     * This prop is ignored if `usePortal` is `false`.
+     *
+     * @deprecated this prop's implementation no longer works in React v17+
+     * @see https://legacy.reactjs.org/docs/portals.html#event-bubbling-through-portals
+     * @see https://github.com/palantir/blueprint/issues/6124
+     * @see https://github.com/palantir/blueprint/issues/6580
+     */
+    portalStopPropagationEvents?: Array<keyof HTMLElementEventMap>;
+
+    /**
+     * A callback that is invoked when user interaction causes the overlay to close, such as
+     * clicking on the overlay or pressing the `esc` key (if enabled).
+     *
+     * Receives the event from the user's interaction, if there was an event (generally either a
+     * mouse or key event). Note that, since this component is controlled by the `isOpen` prop, it
+     * will not actually close itself until that prop becomes `false`.
+     */
+    onClose?: (event: React.SyntheticEvent<HTMLElement>) => void;
+}
+
+export interface OverlayLifecycleProps {
+    /**
+     * Lifecycle method invoked just before the CSS _close_ transition begins on
+     * a child. Receives the DOM element of the child being closed.
+     */
+    onClosing?: (node: HTMLElement) => void;
+
+    /**
+     * Lifecycle method invoked just after the CSS _close_ transition ends but
+     * before the child has been removed from the DOM. Receives the DOM element
+     * of the child being closed.
+     */
+    onClosed?: (node: HTMLElement) => void;
+
+    /**
+     * Lifecycle method invoked just after mounting the child in the DOM but
+     * just before the CSS _open_ transition begins. Receives the DOM element of
+     * the child being opened.
+     */
+    onOpening?: (node: HTMLElement) => void;
+
+    /**
+     * Lifecycle method invoked just after the CSS _open_ transition ends.
+     * Receives the DOM element of the child being opened.
+     */
+    onOpened?: (node: HTMLElement) => void;
+}
+
+export interface BackdropProps {
+    /** CSS class names to apply to backdrop element. */
+    backdropClassName?: string;
+
+    /** HTML props for the backdrop element. */
+    backdropProps?: React.HTMLProps<HTMLDivElement>;
+
+    /**
+     * Whether clicking outside the overlay element (either on backdrop when present or on document)
+     * should invoke `onClose`.
+     *
+     * @default true
+     */
+    canOutsideClickClose?: boolean;
+
+    /**
+     * Whether a container-spanning backdrop element should be rendered behind the contents.
+     * When `false`, users will be able to scroll through and interact with overlaid content.
+     *
+     * @default true
+     */
+    hasBackdrop?: boolean;
+}
+
+export interface OverlayProps extends OverlayableProps, BackdropProps, Props {
+    /** Element to overlay. */
+    children?: React.ReactNode;
+
+    /**
+     * Toggles the visibility of the overlay and its children.
+     * This prop is required because the component is controlled.
+     */
+    isOpen: boolean;
+
+    /**
+     * Name of the transition for internal `CSSTransition`.
+     * Providing your own name here will require defining new CSS transition properties.
+     *
+     * @default Classes.OVERLAY
+     */
+    transitionName?: string;
+}
diff --git a/packages/core/src/components/overlay/overlayUtils.ts b/packages/core/src/components/overlay/overlayUtils.ts
new file mode 100644
index 0000000000..aab21bca9f
--- /dev/null
+++ b/packages/core/src/components/overlay/overlayUtils.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 { OVERLAY_END_FOCUS_TRAP, OVERLAY_START_FOCUS_TRAP } from "../../common/classes";
+import { getRef } from "../../common/refs";
+
+/**
+ * Returns the keyboard-focusable elements inside a given container element, ignoring focus traps
+ * rendered by Overlay/Overlay2.
+ */
+export function getKeyboardFocusableElements(container: HTMLElement | React.RefObject<HTMLElement>): HTMLElement[] {
+    const containerElement = getRef(container);
+    const focusableElements: HTMLElement[] =
+        containerElement != null
+            ? Array.from(
+                  // Order may not be correct if children elements use tabindex values > 0.
+                  // Selectors derived from this SO question:
+                  // https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
+                  containerElement.querySelectorAll(
+                      [
+                          'a[href]:not([tabindex="-1"])',
+                          'button:not([disabled]):not([tabindex="-1"])',
+                          'details:not([tabindex="-1"])',
+                          'input:not([disabled]):not([tabindex="-1"])',
+                          'select:not([disabled]):not([tabindex="-1"])',
+                          'textarea:not([disabled]):not([tabindex="-1"])',
+                          '[tabindex]:not([tabindex="-1"])',
+                      ].join(","),
+                  ),
+              )
+            : [];
+
+    return focusableElements.filter(
+        el => !el.classList.contains(OVERLAY_START_FOCUS_TRAP) && !el.classList.contains(OVERLAY_END_FOCUS_TRAP),
+    );
+}
diff --git a/packages/core/src/components/overlay2/overlay2.md b/packages/core/src/components/overlay2/overlay2.md
new file mode 100644
index 0000000000..58dd6d568d
--- /dev/null
+++ b/packages/core/src/components/overlay2/overlay2.md
@@ -0,0 +1,174 @@
+---
+tag: new
+---
+
+@# Overlay2
+
+<div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content">
+    <h5 class="@ns-heading">
+
+Migrating from [Overlay](#core/components/overlay)?
+
+</h5>
+
+**Overlay2** is a replacement for **Overlay**. It will become the standard API in a future major version of
+Blueprint. You are encouraged to use this new API now for forwards-compatibility. See the full
+[migration guide](https://github.com/palantir/blueprint/wiki/Overlay2-migration) on the wiki.
+
+</div>
+
+**Overlay2** is a generic low-level component for rendering content _on top of_ its siblings or
+above the entire application.
+
+It combines the functionality of the [**Portal**](#core/components/portal) component (which allows
+React elements to escape their current DOM hierarchy) with a
+[**CSSTransitionGroup**](https://reactcommunity.org/react-transition-group/)
+(to show elegant enter and leave transitions).
+
+An optional "backdrop" element can be rendered behind the overlaid children to provide modal
+behavior, whereby the overlay prevents interaction with anything behind it.
+
+**Overlay2** is the backbone of all the components listed in the "Overlays" group in the sidebar.
+Using **Overlay2** directly should be rare in your application; it should only be necessary if no
+existing component meets your needs.
+
+@reactExample Overlay2Example
+
+@## Usage
+
+**Overlay2** is a controlled component that renders its children only when `isOpen={true}`.
+The optional backdrop element will be inserted before the children if `hasBackdrop={true}`.
+
+The `onClose` callback prop is invoked when user interaction causes the overlay to close, but your
+application is responsible for updating the state that actually closes the overlay.
+
+```tsx
+import { useCallback, useState } from "react";
+
+function Example() {
+    const [isOpen, setIsOpen] = useState(false);
+    const toggleOverlay = useCallback(() => setIsOpen(open => !open), [setIsOpen]);
+
+    return (
+        <div>
+            <Button text="Show overlay" onClick={toggleOverlay} />
+            <Overlay2 isOpen={isOpen} onClose={toggleOverlay}>
+                Overlaid contents...
+            </Overlay2>
+        </div>
+    );
+}
+```
+
+@## DOM layout
+
+<div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content">
+    <h5 class="@ns-heading">A note about overlay content positioning</h5>
+
+When rendered inline, content will automatically be set to `position: absolute` to respect
+document flow. Otherwise, content will be set to `position: fixed` to cover the entire viewport.
+
+</div>
+
+Overlays rely on fixed and absolute CSS positioning. By default, an overlay larger than the viewport
+will not be scrollable, so any overflowing content will be hidden. Fortunately, making an overlay
+scrollable is very easy: pass `Classes.OVERLAY_SCROLL_CONTAINER` in the Overlay2 `className` prop,
+and the component will take care of the rest.
+
+```tsx
+<Overlay2 className={Classes.OVERLAY_SCROLL_CONTAINER} />
+```
+
+Note that the [**Dialog**](https://blueprintjs.com/docs/#core/components/dialog) component applies
+this modifier class automatically.
+
+@## Child refs
+
+<div class="@ns-callout @ns-intent-warning @ns-icon-warning-sign @ns-callout-has-body-content">
+    <h5 class="@ns-heading">DOM ref(s) required</h5>
+
+Overlay2 needs to be able to attach DOM refs to its child elements, so the children of this
+component _must be a native DOM element_ or utilize
+[`React.forwardRef()`](https://reactjs.org/docs/forwarding-refs.html) to forward any
+injected ref to the underlying DOM element.
+
+</div>
+
+**Overlay2** utilizes the react-transition-group library to declaratively configure "enter" and
+"exit" transitions for its contents; it does so by individually wrapping its child nodes with
+[**CSSTransition**](https://reactcommunity.org/react-transition-group/css-transition). This
+third-party component requires a DOM ref to its child node in order to work correctly in React 18
+strict mode (where `ReactDOM.findDOMNode()` is not available). **Overlay2** can manage this ref for
+you automatically in some cases, but it requires some user help to handle more advanced use cases:
+
+### Single child with automatic ref
+
+If you provide a _single_ child element to `<Overlay2>` and _do not_ set its `ref` prop, you
+don't need to do anything. The component will generate a child ref and happily pass it along
+to the underlying `<CSSTransition>`.
+
+```tsx
+function Example() {
+    const [isOpen, setIsOpen] = React.useState<boolean>(true);
+    return (
+        <Overlay2 isOpen={isOpen}>
+            <div>Contents</div>
+        </Overlay2>
+    );
+}
+```
+
+### Single child with manual ref
+
+If you provide a _single_ child element to `<Overlay2>` and _do_ set its `ref` prop, you must
+pass the same ref to `<Overlay2 childRef={..}>`.
+
+```tsx
+function Example() {
+    const [isOpen, setIsOpen] = React.useState<boolean>(true);
+    const myRef = React.useRef<HTMLElement>();
+
+    return (
+        <Overlay2 isOpen={isOpen} childRef={myRef}>
+            <div ref={myRef}>Contents</div>
+        </Overlay2>
+    );
+}
+```
+
+### Multiple children
+
+If you provide _multiple_ child elements to `<Overlay2>`, you must enumerate a collection of
+refs for each of those elements and pass those along as a record (keyed by the elements'
+corresponding React `key` values) to `<Overlay2 childRefs={...}>`.
+
+```tsx
+import { uniqueId } from "../utils";
+
+function Example() {
+    const [isOpen, setIsOpen] = React.useState<boolean>(true);
+    const [childRefs, setChildRefs] = React.useState<Record<string, React.RefObject<HTMLDivElement>>>({});
+    const [children, setChildren] = React.useState<Array<{ key: string }>>([]);
+    const addChild = React.useCallback(() => {
+        const newRef = React.createRef<HTMLDivElement>();
+        const newKey = uniqueId();
+        setChildren(oldChildren => [...oldChildren, { key: newKey }]);
+        setChildRefs(oldRefs => ({ ...oldRefs, [newKey]: newRef }));
+    }, []);
+
+    return (
+        <div>
+            <Button onClick={addChild}>Add child</Button>
+            <Overlay2 isOpen={isOpen} childRefs={childRefs}>
+                {children.map(child => (
+                    <div key={child.key} ref={childRefs[child.key]} />
+                ))}
+            </Overlay2>
+        </div>
+    );
+}
+```
+
+@## Props interface
+
+@interface Overlay2Props
diff --git a/packages/core/src/components/overlay2/overlay2.tsx b/packages/core/src/components/overlay2/overlay2.tsx
new file mode 100644
index 0000000000..817b205157
--- /dev/null
+++ b/packages/core/src/components/overlay2/overlay2.tsx
@@ -0,0 +1,710 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 classNames from "classnames";
+import * as React from "react";
+import { CSSTransition, TransitionGroup } from "react-transition-group";
+
+import { Classes } from "../../common";
+import {
+    OVERLAY_CHILD_REF_AND_REFS_MUTEX,
+    OVERLAY_CHILD_REQUIRES_KEY,
+    OVERLAY_WITH_MULTIPLE_CHILDREN_REQUIRES_CHILD_REFS,
+} from "../../common/errors";
+import { DISPLAYNAME_PREFIX, type HTMLDivProps } from "../../common/props";
+import {
+    ensureElement,
+    getActiveElement,
+    getRef,
+    isEmptyString,
+    isNodeEnv,
+    isReactElement,
+    setRef,
+    uniqueId,
+} from "../../common/utils";
+import { hasDOMEnvironment } from "../../common/utils/domUtils";
+import { usePrevious } from "../../hooks/usePrevious";
+import type { OverlayProps } from "../overlay/overlayProps";
+import { getKeyboardFocusableElements } from "../overlay/overlayUtils";
+import { Portal } from "../portal/portal";
+
+// HACKHACK: ugly global state manipulation should move to a proper React context
+const openStack: OverlayInstance[] = [];
+const getLastOpened = () => openStack[openStack.length - 1];
+
+/**
+ * HACKHACK: ugly global state manipulation should move to a proper React context
+ *
+ * @public for testing
+ * @internal
+ */
+export function resetOpenStack() {
+    openStack.splice(0, openStack.length);
+}
+
+/**
+ * Public instance properties & methods for an overlay in the current overlay stack.
+ */
+export interface OverlayInstance {
+    /** Bring document focus inside this eoverlay element. */
+    bringFocusInsideOverlay: () => void;
+
+    /** Reference to the overlay container element which may or may not be in a Portal. */
+    containerElement: React.RefObject<HTMLDivElement>;
+
+    /** Document "focus" event handler which needs to be attached & detached appropriately. */
+    handleDocumentFocus: (e: FocusEvent) => void;
+
+    /** Unique ID for this overlay which helps to identify it across prop changes. */
+    id: string | null;
+
+    /** Subset of props necessary for some overlay stack focus management logic. */
+    props: Pick<OverlayProps, "autoFocus" | "enforceFocus" | "usePortal" | "hasBackdrop">;
+}
+
+export interface Overlay2Props extends OverlayProps, React.RefAttributes<OverlayInstance> {
+    /**
+     * If you provide a single child element to Overlay2 and attach your own `ref` to the node, you must pass the
+     * same value here (otherwise, Overlay2 won't be able to render CSSTransition correctly).
+     *
+     * Mutually exclusive with the `childRefs` prop. This prop is a shorthand for `childRefs={{ [key: string]: ref }}`.
+     */
+    childRef?: React.RefObject<HTMLElement>;
+
+    /**
+     * If you provide a _multiple child elements_ to Overlay2, you must enumerate and generate a
+     * collection of DOM refs to those elements and provide it here. The object's keys must correspond to the child
+     * React element `key` values.
+     *
+     * Mutually exclusive with the `childRef` prop. If you only provide a single child element, consider using
+     * `childRef` instead.
+     */
+    childRefs?: Record<string, React.RefObject<HTMLElement>>;
+}
+
+/**
+ * Overlay2 component.
+ *
+ * @see https://blueprintjs.com/docs/#core/components/overlay2
+ */
+export const Overlay2: React.FC<Overlay2Props> = React.forwardRef<OverlayInstance, Overlay2Props>((props, ref) => {
+    const {
+        autoFocus,
+        backdropClassName,
+        backdropProps,
+        canEscapeKeyClose,
+        canOutsideClickClose,
+        childRef,
+        childRefs,
+        children,
+        className,
+        enforceFocus,
+        hasBackdrop,
+        isOpen,
+        lazy,
+        onClose,
+        onClosed,
+        onClosing,
+        onOpened,
+        onOpening,
+        portalClassName,
+        portalContainer,
+        shouldReturnFocusOnClose,
+        transitionDuration,
+        transitionName,
+        usePortal,
+    } = props;
+
+    useOverlay2Validation(props);
+
+    const [isAutoFocusing, setIsAutoFocusing] = React.useState(false);
+    const [hasEverOpened, setHasEverOpened] = React.useState(false);
+    const lastActiveElementBeforeOpened = React.useRef<Element>(null);
+
+    /** Ref for container element, containing all children and the backdrop */
+    const containerElement = React.useRef<HTMLDivElement>(null);
+
+    /** Ref for backdrop element */
+    const backdropElement = React.useRef<HTMLDivElement>(null);
+
+    /* An empty, keyboard-focusable div at the beginning of the Overlay content */
+    const startFocusTrapElement = React.useRef<HTMLDivElement>(null);
+
+    /* An empty, keyboard-focusable div at the end of the Overlay content */
+    const endFocusTrapElement = React.useRef<HTMLDivElement>(null);
+
+    /**
+     * Locally-generated DOM ref for a singleton child element.
+     * This is only used iff the user does not specify the `childRef` or `childRefs` props.
+     */
+    const localChildRef = React.useRef<HTMLElement>(null);
+
+    const bringFocusInsideOverlay = React.useCallback(() => {
+        // always delay focus manipulation to just before repaint to prevent scroll jumping
+        return requestAnimationFrame(() => {
+            // container element may be undefined between component mounting and Portal rendering
+            // activeElement may be undefined in some rare cases in IE
+            const container = getRef(containerElement);
+            const activeElement = getActiveElement(container);
+
+            if (container == null || activeElement == null || !isOpen) {
+                return;
+            }
+
+            // Overlay2 is guaranteed to be mounted here
+            const isFocusOutsideModal = !container.contains(activeElement);
+            if (isFocusOutsideModal) {
+                getRef(startFocusTrapElement)?.focus({ preventScroll: true });
+                setIsAutoFocusing(false);
+            }
+        });
+    }, [isOpen]);
+
+    /**
+     * When multiple Overlays are open, this event handler is only active for the most recently
+     * opened one to avoid Overlays competing with each other for focus.
+     */
+    const handleDocumentFocus = React.useCallback(
+        (e: FocusEvent) => {
+            // get the actual target even in the Shadow DOM
+            // see https://github.com/palantir/blueprint/issues/4220
+            const eventTarget = e.composed ? e.composedPath()[0] : e.target;
+            const container = getRef(containerElement);
+            if (
+                enforceFocus &&
+                container != null &&
+                eventTarget instanceof Node &&
+                !container.contains(eventTarget as HTMLElement)
+            ) {
+                // prevent default focus behavior (sometimes auto-scrolls the page)
+                e.preventDefault();
+                e.stopImmediatePropagation();
+                bringFocusInsideOverlay();
+            }
+        },
+        [bringFocusInsideOverlay, enforceFocus],
+    );
+
+    const id = useOverlay2ID();
+    const instance = React.useMemo<OverlayInstance>(
+        () => ({
+            bringFocusInsideOverlay,
+            containerElement,
+            handleDocumentFocus,
+            id,
+            props: {
+                autoFocus,
+                enforceFocus,
+                hasBackdrop,
+                usePortal,
+            },
+        }),
+        [autoFocus, bringFocusInsideOverlay, enforceFocus, handleDocumentFocus, hasBackdrop, id, usePortal],
+    );
+
+    React.useEffect(() => {
+        setRef(ref, instance);
+    }, [instance, ref]);
+
+    const handleDocumentClick = React.useCallback(
+        (e: MouseEvent) => {
+            // get the actual target even in the Shadow DOM
+            // see https://github.com/palantir/blueprint/issues/4220
+            const eventTarget = (e.composed ? e.composedPath()[0] : e.target) as HTMLElement;
+
+            const stackIndex = openStack.indexOf(instance);
+            const isClickInThisOverlayOrDescendant = openStack
+                .slice(stackIndex)
+                .some(({ containerElement: containerRef }) => {
+                    // `elem` is the container of backdrop & content, so clicking directly on that container
+                    // should not count as being "inside" the overlay.
+                    const elem = getRef(containerRef);
+                    return elem?.contains(eventTarget) && !elem.isSameNode(eventTarget);
+                });
+
+            if (isOpen && !isClickInThisOverlayOrDescendant && canOutsideClickClose) {
+                // casting to any because this is a native event
+                onClose?.(e as any);
+            }
+        },
+        [canOutsideClickClose, instance, isOpen, onClose],
+    );
+
+    const handleContainerKeyDown = React.useCallback(
+        (e: React.KeyboardEvent<HTMLElement>) => {
+            if (e.key === "Escape" && canEscapeKeyClose) {
+                onClose?.(e);
+                // prevent other overlays from closing
+                e.stopPropagation();
+                // prevent browser-specific escape key behavior (Safari exits fullscreen)
+                e.preventDefault();
+            }
+        },
+        [canEscapeKeyClose, onClose],
+    );
+
+    const overlayWillOpen = React.useCallback(() => {
+        if (openStack.length > 0) {
+            document.removeEventListener("focus", getLastOpened().handleDocumentFocus, /* useCapture */ true);
+        }
+        openStack.push(instance);
+
+        if (autoFocus) {
+            setIsAutoFocusing(true);
+            bringFocusInsideOverlay();
+        }
+
+        if (enforceFocus) {
+            // Focus events do not bubble, but setting useCapture allows us to listen in and execute
+            // our handler before all others
+            document.addEventListener("focus", handleDocumentFocus, /* useCapture */ true);
+        }
+
+        if (canOutsideClickClose && !hasBackdrop) {
+            document.addEventListener("mousedown", handleDocumentClick);
+        }
+
+        if (hasBackdrop && usePortal) {
+            // add a class to the body to prevent scrolling of content below the overlay
+            document.body.classList.add(Classes.OVERLAY_OPEN);
+        }
+
+        setRef(lastActiveElementBeforeOpened, getActiveElement(getRef(containerElement)));
+    }, [
+        instance,
+        autoFocus,
+        enforceFocus,
+        canOutsideClickClose,
+        handleDocumentFocus,
+        hasBackdrop,
+        usePortal,
+        bringFocusInsideOverlay,
+        handleDocumentClick,
+    ]);
+
+    const overlayWillClose = React.useCallback(() => {
+        document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true);
+        document.removeEventListener("mousedown", handleDocumentClick);
+
+        const stackIndex = openStack.findIndex(o =>
+            // fall back to container element ref
+            o.id != null ? o.id === id : o.containerElement.current === containerElement.current,
+        );
+        if (stackIndex !== -1) {
+            openStack.splice(stackIndex, 1);
+            if (openStack.length > 0) {
+                const lastOpenedOverlay = getLastOpened();
+                // Only bring focus back to last overlay if it had autoFocus _and_ enforceFocus enabled.
+                // If `autoFocus={false}`, it's likely that the overlay never received focus in the first place,
+                // so it would be surprising for us to send it there. See https://github.com/palantir/blueprint/issues/4921
+                if (lastOpenedOverlay.props.autoFocus && lastOpenedOverlay.props.enforceFocus) {
+                    lastOpenedOverlay.bringFocusInsideOverlay();
+                    document.addEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true);
+                }
+            }
+
+            if (openStack.filter(o => o.props.usePortal && o.props.hasBackdrop).length === 0) {
+                document.body.classList.remove(Classes.OVERLAY_OPEN);
+            }
+        }
+    }, [handleDocumentClick, handleDocumentFocus, id]);
+
+    const prevIsOpen = usePrevious(isOpen);
+    React.useEffect(() => {
+        if (isOpen) {
+            setHasEverOpened(true);
+        }
+
+        if (!prevIsOpen && isOpen) {
+            // just opened
+            overlayWillOpen();
+        }
+
+        if (prevIsOpen && !isOpen) {
+            // just closed
+            overlayWillClose();
+        }
+    }, [isOpen, overlayWillOpen, overlayWillClose, prevIsOpen]);
+
+    // run once on unmount
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    React.useEffect(() => overlayWillClose, []);
+
+    const handleTransitionExited = React.useCallback(
+        (node: HTMLElement) => {
+            const lastActiveElement = getRef(lastActiveElementBeforeOpened);
+            if (shouldReturnFocusOnClose && lastActiveElement instanceof HTMLElement) {
+                lastActiveElement.focus();
+            }
+            onClosed?.(node);
+        },
+        [onClosed, shouldReturnFocusOnClose],
+    );
+
+    // N.B. CSSTransition requires this callback to be defined, even if it's unused.
+    const handleTransitionAddEnd = React.useCallback(() => {
+        // no-op
+    }, []);
+
+    /**
+     * Gets the relevant DOM ref for a child element using the `childRef` or `childRefs` props (if possible).
+     * This ref is necessary for `CSSTransition` to work in React 18 without relying on `ReactDOM.findDOMNode`.
+     *
+     * Returns `undefined` if the user did not specify either of those props. In those cases, we use the ref we
+     * have locally generated and expect that the user _did not_ specify their own `ref` on the child element
+     * (it will get clobbered / overriden).
+     *
+     * @see https://reactcommunity.org/react-transition-group/css-transition
+     */
+    const getUserChildRef = React.useCallback(
+        (child: React.ReactNode) => {
+            if (childRef != null) {
+                return childRef;
+            } else if (childRefs != null) {
+                const key = (child as React.ReactElement).key;
+                if (key == null) {
+                    if (!isNodeEnv("production")) {
+                        console.error(OVERLAY_CHILD_REQUIRES_KEY);
+                    }
+                    return undefined;
+                }
+                return childRefs[key];
+            }
+            return undefined;
+        },
+        [childRef, childRefs],
+    );
+
+    const maybeRenderChild = React.useCallback(
+        (child: React.ReactNode | undefined) => {
+            if (child == null || isEmptyString(child)) {
+                return null;
+            }
+
+            // decorate the child with a few injected props
+            const userChildRef = getUserChildRef(child);
+            const childProps = isReactElement(child) ? child.props : {};
+            // if the child is a string, number, or fragment, it will be wrapped in a <span> element
+            const decoratedChild = ensureElement(child, "span", {
+                className: classNames(childProps.className, Classes.OVERLAY_CONTENT),
+                // IMPORTANT: only inject our ref if the user didn't specify childRef or childRefs already. Otherwise,
+                // we risk clobbering the user's ref (which we cannot inspect here while cloning/decorating the child).
+                ref: userChildRef === undefined ? localChildRef : undefined,
+                tabIndex: enforceFocus || autoFocus ? 0 : undefined,
+            });
+            const resolvedChildRef = userChildRef ?? localChildRef;
+
+            return (
+                <CSSTransition
+                    addEndListener={handleTransitionAddEnd}
+                    classNames={transitionName}
+                    // HACKHACK: CSSTransition types are slightly incompatible with React types here.
+                    // React prefers `| null` but not `| undefined` for the ref value, while
+                    // CSSTransition _demands_ that `| undefined` be part of the element type.
+                    nodeRef={resolvedChildRef as React.RefObject<HTMLElement | undefined>}
+                    onEntered={getLifecycleCallbackWithChildRef(onOpened, resolvedChildRef)}
+                    onEntering={getLifecycleCallbackWithChildRef(onOpening, resolvedChildRef)}
+                    onExited={getLifecycleCallbackWithChildRef(handleTransitionExited, resolvedChildRef)}
+                    onExiting={getLifecycleCallbackWithChildRef(onClosing, resolvedChildRef)}
+                    timeout={transitionDuration}
+                >
+                    {decoratedChild}
+                </CSSTransition>
+            );
+        },
+        [
+            autoFocus,
+            enforceFocus,
+            getUserChildRef,
+            handleTransitionAddEnd,
+            handleTransitionExited,
+            onClosing,
+            onOpened,
+            onOpening,
+            transitionDuration,
+            transitionName,
+        ],
+    );
+
+    const handleBackdropMouseDown = React.useCallback(
+        (e: React.MouseEvent<HTMLDivElement>) => {
+            if (canOutsideClickClose) {
+                onClose?.(e);
+            }
+            if (enforceFocus) {
+                bringFocusInsideOverlay();
+            }
+            backdropProps?.onMouseDown?.(e);
+        },
+        [backdropProps, bringFocusInsideOverlay, canOutsideClickClose, enforceFocus, onClose],
+    );
+
+    const renderDummyElement = React.useCallback(
+        (key: string, dummyElementProps: HTMLDivProps & { ref?: React.Ref<HTMLDivElement> }) => (
+            <CSSTransition
+                addEndListener={handleTransitionAddEnd}
+                classNames={transitionName}
+                key={key}
+                nodeRef={dummyElementProps.ref}
+                timeout={transitionDuration}
+                unmountOnExit={true}
+            >
+                <div tabIndex={0} {...dummyElementProps} />
+            </CSSTransition>
+        ),
+        [handleTransitionAddEnd, transitionDuration, transitionName],
+    );
+
+    /**
+     * Ensures repeatedly pressing shift+tab keeps focus inside the Overlay. Moves focus to
+     * the `endFocusTrapElement` or the first keyboard-focusable element in the Overlay (excluding
+     * the `startFocusTrapElement`), depending on whether the element losing focus is inside the
+     * Overlay.
+     */
+    const handleStartFocusTrapElementFocus = React.useCallback(
+        (e: React.FocusEvent<HTMLDivElement>) => {
+            if (!enforceFocus || isAutoFocusing) {
+                return;
+            }
+            // e.relatedTarget will not be defined if this was a programmatic focus event, as is the
+            // case when we call this.bringFocusInsideOverlay() after a user clicked on the backdrop.
+            // Otherwise, we're handling a user interaction, and we should wrap around to the last
+            // element in this transition group.
+            const container = getRef(containerElement);
+            const endFocusTrap = getRef(endFocusTrapElement);
+            if (
+                e.relatedTarget != null &&
+                container?.contains(e.relatedTarget as Element) &&
+                e.relatedTarget !== endFocusTrap
+            ) {
+                endFocusTrap?.focus({ preventScroll: true });
+            }
+        },
+        [enforceFocus, isAutoFocusing],
+    );
+
+    /**
+     * Wrap around to the end of the dialog if `enforceFocus` is enabled.
+     */
+    const handleStartFocusTrapElementKeyDown = React.useCallback(
+        (e: React.KeyboardEvent<HTMLDivElement>) => {
+            if (!enforceFocus) {
+                return;
+            }
+            if (e.shiftKey && e.key === "Tab") {
+                const lastFocusableElement = getKeyboardFocusableElements(containerElement).pop();
+                if (lastFocusableElement != null) {
+                    lastFocusableElement.focus();
+                } else {
+                    getRef(endFocusTrapElement)?.focus({ preventScroll: true });
+                }
+            }
+        },
+        [enforceFocus],
+    );
+
+    /**
+     * Ensures repeatedly pressing tab keeps focus inside the Overlay. Moves focus to the
+     * `startFocusTrapElement` or the last keyboard-focusable element in the Overlay (excluding the
+     * `startFocusTrapElement`), depending on whether the element losing focus is inside the
+     * Overlay.
+     */
+    const handleEndFocusTrapElementFocus = React.useCallback(
+        (e: React.FocusEvent<HTMLDivElement>) => {
+            // No need for this.props.enforceFocus check here because this element is only rendered
+            // when that prop is true.
+            // During user interactions, e.relatedTarget will be defined, and we should wrap around to the
+            // "start focus trap" element.
+            // Otherwise, we're handling a programmatic focus event, which can only happen after a user
+            // presses shift+tab from the first focusable element in the overlay.
+            const startFocusTrap = getRef(startFocusTrapElement);
+            if (
+                e.relatedTarget != null &&
+                getRef(containerElement)?.contains(e.relatedTarget as Element) &&
+                e.relatedTarget !== startFocusTrap
+            ) {
+                const firstFocusableElement = getKeyboardFocusableElements(containerElement).shift();
+                // ensure we don't re-focus an already active element by comparing against e.relatedTarget
+                if (!isAutoFocusing && firstFocusableElement != null && firstFocusableElement !== e.relatedTarget) {
+                    firstFocusableElement.focus();
+                } else {
+                    startFocusTrap?.focus({ preventScroll: true });
+                }
+            } else {
+                const lastFocusableElement = getKeyboardFocusableElements(containerElement).pop();
+                if (lastFocusableElement != null) {
+                    lastFocusableElement.focus();
+                } else {
+                    // Keeps focus within Overlay even if there are no keyboard-focusable children
+                    startFocusTrap?.focus({ preventScroll: true });
+                }
+            }
+        },
+        [isAutoFocusing],
+    );
+
+    const maybeBackdrop = React.useMemo(
+        () =>
+            hasBackdrop && isOpen ? (
+                <CSSTransition
+                    classNames={transitionName}
+                    key="__backdrop"
+                    nodeRef={backdropElement}
+                    timeout={transitionDuration}
+                    addEndListener={handleTransitionAddEnd}
+                >
+                    <div
+                        {...backdropProps}
+                        className={classNames(Classes.OVERLAY_BACKDROP, backdropClassName, backdropProps?.className)}
+                        onMouseDown={handleBackdropMouseDown}
+                        ref={backdropElement}
+                    />
+                </CSSTransition>
+            ) : null,
+        [
+            backdropClassName,
+            backdropProps,
+            handleBackdropMouseDown,
+            handleTransitionAddEnd,
+            hasBackdrop,
+            isOpen,
+            transitionDuration,
+            transitionName,
+        ],
+    );
+
+    // no reason to render anything at all if we're being truly lazy
+    if (lazy && !hasEverOpened) {
+        return null;
+    }
+
+    // TransitionGroup types require single array of children; does not support nested arrays.
+    // So we must collapse backdrop and children into one array, and every item must be wrapped in a
+    // Transition element (no ReactText allowed).
+    const childrenWithTransitions = isOpen ? React.Children.map(children, maybeRenderChild) ?? [] : [];
+
+    // const maybeBackdrop = maybeRenderBackdrop();
+    if (maybeBackdrop !== null) {
+        childrenWithTransitions.unshift(maybeBackdrop);
+    }
+    if (isOpen && (autoFocus || enforceFocus) && childrenWithTransitions.length > 0) {
+        childrenWithTransitions.unshift(
+            renderDummyElement("__start", {
+                className: Classes.OVERLAY_START_FOCUS_TRAP,
+                onFocus: handleStartFocusTrapElementFocus,
+                onKeyDown: handleStartFocusTrapElementKeyDown,
+                ref: startFocusTrapElement,
+            }),
+        );
+        if (enforceFocus) {
+            childrenWithTransitions.push(
+                renderDummyElement("__end", {
+                    className: Classes.OVERLAY_END_FOCUS_TRAP,
+                    onFocus: handleEndFocusTrapElementFocus,
+                    ref: endFocusTrapElement,
+                }),
+            );
+        }
+    }
+
+    const transitionGroup = (
+        <div
+            aria-live="polite"
+            className={classNames(
+                Classes.OVERLAY,
+                {
+                    [Classes.OVERLAY_OPEN]: isOpen,
+                    [Classes.OVERLAY_INLINE]: !usePortal,
+                },
+                className,
+            )}
+            onKeyDown={handleContainerKeyDown}
+            ref={containerElement}
+        >
+            <TransitionGroup appear={true} component={null}>
+                {childrenWithTransitions}
+            </TransitionGroup>
+        </div>
+    );
+
+    if (usePortal) {
+        return (
+            <Portal className={portalClassName} container={portalContainer}>
+                {transitionGroup}
+            </Portal>
+        );
+    } else {
+        return transitionGroup;
+    }
+});
+Overlay2.defaultProps = {
+    autoFocus: true,
+    backdropProps: {},
+    canEscapeKeyClose: true,
+    canOutsideClickClose: true,
+    enforceFocus: true,
+    hasBackdrop: true,
+    isOpen: false,
+    lazy: hasDOMEnvironment(),
+    shouldReturnFocusOnClose: true,
+    transitionDuration: 300,
+    transitionName: Classes.OVERLAY,
+    usePortal: true,
+};
+Overlay2.displayName = `${DISPLAYNAME_PREFIX}.Overlay2`;
+
+function useOverlay2Validation({ childRef, childRefs, children }: Overlay2Props) {
+    const numChildren = React.Children.count(children);
+    React.useEffect(() => {
+        if (isNodeEnv("production")) {
+            return;
+        }
+
+        if (childRef != null && childRefs != null) {
+            console.error(OVERLAY_CHILD_REF_AND_REFS_MUTEX);
+        }
+
+        if (numChildren > 1 && childRefs == null) {
+            console.error(OVERLAY_WITH_MULTIPLE_CHILDREN_REQUIRES_CHILD_REFS);
+        }
+    }, [childRef, childRefs, numChildren]);
+}
+
+/**
+ * Generates a unique ID for a given Overlay which persists across the component's lifecycle.
+ *
+ * N.B. unmounted overlays will have a `null` ID.
+ */
+function useOverlay2ID(): string | null {
+    const [id, setId] = React.useState<string | null>(null);
+    React.useEffect(() => {
+        setId(uniqueId(Overlay2.displayName!));
+    }, []);
+    return id;
+}
+
+// N.B. the `onExiting` callback is not provided with the `node` argument as suggested in CSSTransition types since
+// we are using the `nodeRef` prop, so we must inject it dynamically.
+function getLifecycleCallbackWithChildRef(
+    callback: ((node: HTMLElement) => void) | undefined,
+    childRef: React.RefObject<HTMLElement> | undefined,
+) {
+    return () => {
+        if (childRef?.current != null) {
+            callback?.(childRef.current);
+        }
+    };
+}
diff --git a/packages/core/src/components/popover/popover.tsx b/packages/core/src/components/popover/popover.tsx
index a5c4f56a3c..7d5831b295 100644
--- a/packages/core/src/components/popover/popover.tsx
+++ b/packages/core/src/components/popover/popover.tsx
@@ -36,7 +36,7 @@ import {
     Utils,
 } from "../../common";
 import * as Errors from "../../common/errors";
-import { Overlay } from "../overlay/overlay";
+import { Overlay2 } from "../overlay2/overlay2";
 import { ResizeSensor } from "../resize-sensor/resizeSensor";
 // eslint-disable-next-line import/no-cycle
 import { Tooltip } from "../tooltip/tooltip";
@@ -205,6 +205,11 @@ export class Popover<
      */
     public targetRef = React.createRef<HTMLElement>();
 
+    /**
+     * Overlay transition container element ref.
+     */
+    private transitionContainerElement = React.createRef<HTMLDivElement>();
+
     private cancelOpenTimeout?: () => void;
 
     // a flag that lets us detect mouse movement between the target and popover,
@@ -351,7 +356,7 @@ export class Popover<
             targetTagName = "div";
         }
 
-        // react-popper has a wide type for this ref, but we can narrow it based on the source
+        // N.B. react-popper has a wide type for this ref, but we can narrow it based on the source,
         // see https://github.com/floating-ui/react-popper/blob/beac280d61082852c4efc302be902911ce2d424c/src/Reference.js#L17
         const ref = mergeRefs(popperChildRef as React.RefCallback<HTMLElement>, this.targetRef);
 
@@ -498,12 +503,13 @@ export class Popover<
               : this.props.shouldReturnFocusOnClose;
 
         return (
-            <Overlay
+            <Overlay2
                 autoFocus={autoFocus ?? defaultAutoFocus}
                 backdropClassName={Classes.POPOVER_BACKDROP}
                 backdropProps={backdropProps}
                 canEscapeKeyClose={canEscapeKeyClose}
                 canOutsideClickClose={interactionKind === PopoverInteractionKind.CLICK}
+                childRef={this.transitionContainerElement}
                 enforceFocus={enforceFocus}
                 hasBackdrop={hasBackdrop}
                 isOpen={isOpen}
@@ -522,7 +528,16 @@ export class Popover<
                 portalStopPropagationEvents={this.props.portalStopPropagationEvents}
                 shouldReturnFocusOnClose={shouldReturnFocusOnClose}
             >
-                <div className={Classes.POPOVER_TRANSITION_CONTAINER} ref={popperProps.ref} style={popperProps.style}>
+                <div
+                    className={Classes.POPOVER_TRANSITION_CONTAINER}
+                    // We need to attach a ref that notifies both react-popper and our Popover component about the DOM
+                    // element inside the Overlay2. We cannot re-use `PopperChildrenProps.ref` because Overlay2 only
+                    // accepts a ref object (not a callback) due to a CSSTransition API limitation.
+                    // N.B. react-popper has a wide type for this ref, but we can narrow it based on the source,
+                    // see https://github.com/floating-ui/react-popper/blob/beac280d61082852c4efc302be902911ce2d424c/src/Popper.js#L94
+                    ref={mergeRefs(popperProps.ref as React.RefCallback<HTMLElement>, this.transitionContainerElement)}
+                    style={popperProps.style}
+                >
                     <ResizeSensor onResize={this.reposition}>
                         <div
                             className={popoverClasses}
@@ -537,7 +552,7 @@ export class Popover<
                         </div>
                     </ResizeSensor>
                 </div>
-            </Overlay>
+            </Overlay2>
         );
     };
 
@@ -661,9 +676,11 @@ export class Popover<
     private handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => {
         this.isMouseInTargetOrPopover = false;
 
-        // wait until the event queue is flushed, because we want to leave the
+        // Wait until the event queue is flushed, because we want to leave the
         // popover open if the mouse entered the popover immediately after
-        // leaving the target (or vice versa).
+        // leaving the target (or vice versa). Make sure to persist the event since
+        // we need to access `nativeEvent` in `this.setOpenState()`.
+        e.persist();
         this.setTimeout(() => {
             if (this.isMouseInTargetOrPopover) {
                 return;
diff --git a/packages/core/src/components/popover/popoverSharedProps.ts b/packages/core/src/components/popover/popoverSharedProps.ts
index 24fcac63a2..b161e5bcb4 100644
--- a/packages/core/src/components/popover/popoverSharedProps.ts
+++ b/packages/core/src/components/popover/popoverSharedProps.ts
@@ -19,7 +19,7 @@ import type * as React from "react";
 import type { StrictModifier } from "react-popper";
 
 import type { Props } from "../../common";
-import type { OverlayableProps } from "../overlay/overlay";
+import type { OverlayableProps } from "../overlay/overlayProps";
 
 import type { PopoverPosition } from "./popoverPosition";
 
@@ -221,7 +221,7 @@ export interface PopoverSharedProps<TProps extends DefaultPopoverTargetHTMLProps
     openOnTargetFocus?: boolean;
 
     /**
-     * Ref supplied to the `Classes.POPOVER` element.
+     * DOM ref attached to the `Classes.POPOVER` element.
      */
     popoverRef?: React.Ref<HTMLElement>;
 
diff --git a/packages/core/src/components/toast/overlayToaster.tsx b/packages/core/src/components/toast/overlayToaster.tsx
index 33b3baa0d0..d2681f719b 100644
--- a/packages/core/src/components/toast/overlayToaster.tsx
+++ b/packages/core/src/components/toast/overlayToaster.tsx
@@ -26,15 +26,18 @@ import {
     TOASTER_WARN_INLINE,
 } from "../../common/errors";
 import { DISPLAYNAME_PREFIX } from "../../common/props";
-import { isNodeEnv } from "../../common/utils";
-import { Overlay } from "../overlay/overlay";
+import { isElementOfType, isNodeEnv } from "../../common/utils";
+import { Overlay2 } from "../overlay2/overlay2";
 
 import type { OverlayToasterProps } from "./overlayToasterProps";
-import { Toast, type ToastProps } from "./toast";
+import { Toast } from "./toast";
+import { Toast2 } from "./toast2";
 import type { Toaster, ToastOptions } from "./toaster";
+import type { ToastProps } from "./toastProps";
 
 export interface OverlayToasterState {
     toasts: ToastOptions[];
+    toastRefs: Record<string, React.RefObject<HTMLElement>>;
 }
 
 export interface OverlayToasterCreateOptions {
@@ -142,45 +145,57 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O
     }
 
     public state: OverlayToasterState = {
+        toastRefs: {},
         toasts: [],
     };
 
     // auto-incrementing identifier for un-keyed toasts
     private toastId = 0;
 
+    private toastRefs: Record<string, React.RefObject<HTMLElement>> = {};
+
+    /** Compute a new collection of toast refs (usually after updating toasts) */
+    private getToastRefs = (toasts: ToastOptions[]) => {
+        return toasts.reduce<typeof this.toastRefs>((refs, toast) => {
+            refs[toast.key!] = React.createRef<HTMLElement>();
+            return refs;
+        }, {});
+    };
+
     public show(props: ToastProps, key?: string) {
         if (this.props.maxToasts) {
             // check if active number of toasts are at the maxToasts limit
             this.dismissIfAtLimit();
         }
         const options = this.createToastOptions(props, key);
-        if (key === undefined || this.isNewToastKey(key)) {
-            this.setState(prevState => ({
-                toasts: [options, ...prevState.toasts],
-            }));
-        } else {
-            this.setState(prevState => ({
-                toasts: prevState.toasts.map(t => (t.key === key ? options : t)),
-            }));
-        }
+        this.setState(prevState => {
+            const toasts =
+                key === undefined || this.isNewToastKey(key)
+                    ? // prepend a new toast
+                      [options, ...prevState.toasts]
+                    : // update a specific toast
+                      prevState.toasts.map(t => (t.key === key ? options : t));
+            return { toasts, toastRefs: this.getToastRefs(toasts) };
+        });
         return options.key;
     }
 
     public dismiss(key: string, timeoutExpired = false) {
-        this.setState(({ toasts }) => ({
-            toasts: toasts.filter(t => {
+        this.setState(prevState => {
+            const toasts = prevState.toasts.filter(t => {
                 const matchesKey = t.key === key;
                 if (matchesKey) {
                     t.onDismiss?.(timeoutExpired);
                 }
                 return !matchesKey;
-            }),
-        }));
+            });
+            return { toasts, toastRefs: this.getToastRefs(toasts) };
+        });
     }
 
     public clear() {
         this.state.toasts.forEach(t => t.onDismiss?.(false));
-        this.setState({ toasts: [] });
+        this.setState({ toasts: [], toastRefs: {} });
     }
 
     public getToasts() {
@@ -190,11 +205,12 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O
     public render() {
         const classes = classNames(Classes.TOAST_CONTAINER, this.getPositionClasses(), this.props.className);
         return (
-            <Overlay
+            <Overlay2
                 autoFocus={this.props.autoFocus}
                 canEscapeKeyClose={this.props.canEscapeKeyClear}
                 canOutsideClickClose={false}
                 className={classes}
+                childRefs={this.toastRefs}
                 enforceFocus={false}
                 hasBackdrop={false}
                 isOpen={this.state.toasts.length > 0 || this.props.children != null}
@@ -206,8 +222,8 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O
                 usePortal={this.props.usePortal}
             >
                 {this.state.toasts.map(this.renderToast, this)}
-                {this.props.children}
-            </Overlay>
+                {this.renderChildren()}
+            </Overlay2>
         );
     }
 
@@ -218,6 +234,27 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O
         }
     }
 
+    /**
+     * If provided `Toast` children, automaticaly upgrade them to `Toast2` elements so that `Overlay` can inject
+     * refs into them for use by `CSSTransition`. This is a bit hacky but ensures backwards compatibility for
+     * `OverlayToaster`. It should be an uncommon code path in most applications, since we expect most usage to
+     * occur via the imperative toaster APIs.
+     *
+     * We can remove this indirection once `Toast2` fully replaces `Toast` in a future major version.
+     *
+     * TODO(@adidahiya): Blueprint v6.0
+     */
+    private renderChildren() {
+        return React.Children.map(this.props.children, child => {
+            // eslint-disable-next-line deprecation/deprecation
+            if (isElementOfType(child, Toast)) {
+                return <Toast2 {...child.props} />;
+            } else {
+                return child;
+            }
+        });
+    }
+
     private isNewToastKey(key: string) {
         return this.state.toasts.every(toast => toast.key !== key);
     }
@@ -230,7 +267,7 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O
     }
 
     private renderToast = (toast: ToastOptions) => {
-        return <Toast {...toast} onDismiss={this.getDismissHandler(toast)} />;
+        return <Toast2 {...toast} onDismiss={this.getDismissHandler(toast)} />;
     };
 
     private createToastOptions(props: ToastProps, key = `toast-${this.toastId++}`) {
diff --git a/packages/core/src/components/toast/toast.md b/packages/core/src/components/toast/toast.md
index 60b96d9d28..edc30ff763 100644
--- a/packages/core/src/components/toast/toast.md
+++ b/packages/core/src/components/toast/toast.md
@@ -1,9 +1,9 @@
 @# Toast
 
-A __Toast__ is a lightweight, ephemeral notice from an application in direct response to a user's action.
+A toast is a lightweight, ephemeral notice from an application in direct response to a user's action.
 
-__Toasts__ can be configured to appear at either the top or the bottom of an application window, and it is possible to
-have more than one toast onscreen at a time.
+**Toasts** can be configured to appear at either the top or the bottom of an application window.
+It is possible to show more than one toast on-screen at a time.
 
 @reactExample ToastExample
 
@@ -11,44 +11,52 @@ have more than one toast onscreen at a time.
 
 @### Toast
 
-__Toasts__ have a built-in timeout of five seconds. Users can also dismiss them manually by clicking the &times; button.
-Hovering the cursor over a toast prevents it from disappearing. When the cursor leaves the toast, the toast's timeout restarts.
-Similarly, focusing the toast (for example, by hitting the `tab` key) halts the timeout, and blurring restarts the timeout.
+**Toasts** have a built-in timeout of five seconds. Users can also dismiss them manually by clicking
+the &times; button. overing the cursor over a toast prevents it from disappearing. When the cursor
+leaves the toast, the toast's timeout restarts. Similarly, focussing the toast DOM element (for
+example, by hitting the `tab` key) halts the timeout, and blurring restarts the timeout.
 
-You can add one additional action button to a toast. You might use this to provide an undo button, for example.
+You may add one additional action button to a toast. You might use this to provide an undo button,
+for example.
 
-You can also apply the same visual intent styles to `Toast`s that you can to [`Button`s](#core/components/button.css).
+You may also apply the same visual intents to **Toasts** as other core components like
+[**Buttons**](#core/components/button.css).
 
 @interface ToastProps
 
 @### OverlayToaster
 
-The __OverlayToaster__ component (previously named __Toaster__) is a stateful container for a single list of toasts.
-Internally, it uses [__Overlay__](#core/components/overlay) to manage children and transitions. It can be vertically
-aligned along the top or bottom edge of its container (new toasts will slide in from that edge) and
-horizontally aligned along the left edge, center, or right edge of its container.
+The **OverlayToaster** component (previously named **Toaster**) is a stateful container for a single
+list of toasts. Internally, it uses [**Overlay2**](#core/components/overlay2) to manage children and
+transitions. It can be vertically aligned along the top or bottom edge of its container (new toasts
+will slide in from that edge) and horizontally aligned along the left edge, center, or right edge
+of its container.
 
-There are three ways to use __OverlayToaster__:
+There are three ways to use **OverlayToaster**:
+
+1. **Recommended**: use the `OverlayToaster.createAsync()` static method to create a new `Toaster` instance:
 
-1. __Recommended__: use the `OverlayToaster.createAsync()` static method to create a new `Toaster` instance:
     ```ts
     const myToaster: Toaster = await OverlayToaster.createAsync({ position: "bottom" });
     myToaster.show({ ...toastOptions });
     ```
 
-    We recommend calling `OverlayToaster.createAsync` once in your application and [sharing the created instance](#core/components/toast.example) throughout your application.
+    We recommend calling `OverlayToaster.createAsync` once in your application and
+    [sharing the generated instance](#core/components/toast.example) throughout your application.
 
-    A synchronous `OverlayToaster.create()` static method is also available, but will be phased out since React 18+ no longer synchronously renders components to the DOM.
+    A synchronous `OverlayToaster.create()` static method is also available, but will be phased out
+    since React 18+ no longer synchronously renders components to the DOM.
 
     ```ts
     const myToaster: Toaster = OverlayToaster.create({ position: "bottom" });
     myToaster.show({ ...toastOptions });
     ```
-2. Render an `<OverlayToaster>` with `<Toast>` children:
+
+2. Render an `<OverlayToaster>` with `<Toast2>` children:
     ```ts
     render(
         <OverlayToaster>
-            <Toast {...toastOptions} />
+            <Toast2 {...toastOptions} />
         </OverlayToaster>,
         targetElement,
     );
@@ -68,28 +76,28 @@ There are three ways to use __OverlayToaster__:
 <div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content">
     <h5 class="@ns-heading">Working with multiple toasters</h5>
 
-You can have multiple toasters in a single application, but you must ensure that each has a unique `position` to
-prevent overlap.
+You can have multiple toasters in a single application, but you must ensure that each has a unique
+`position` to prevent overlap.
 
 </div>
 
 <div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content">
     <h5 class="@ns-heading">Toaster focus</h5>
 
-__OverlayToaster__ always disables Overlay's `enforceFocus` behavior (meaning that you're not blocked
+**OverlayToaster** always disables Overlay2's `enforceFocus` behavior (meaning that you're not blocked
 from accessing other parts of the application while a toast is active), and by default also
 disables `autoFocus` (meaning that focus will not switch to a toast when it appears). You can
 enable `autoFocus` for an individual `OverlayToaster` via a prop, if desired.
 
 </div>
 
-
 @interface OverlayToasterProps
 
 @## Static usage
 
-__OverlayToaster__ provides the static `createAsync` method that returns a new `Toaster`, rendered into an
-element attached to `<body>`. A toaster instance has a collection of methods to show and hide toasts in its given container.
+**OverlayToaster** provides the static `createAsync` method that returns a new `Toaster`, rendered
+into an element attached to `<body>`. A toaster instance has a collection of methods to show and
+hide toasts in its given container.
 
 ```ts
 OverlayToaster.createAsync(props?: OverlayToasterProps, options?: OverlayToasterCreateOptions): Promise<Toaster>;
@@ -98,15 +106,16 @@ OverlayToaster.createAsync(props?: OverlayToasterProps, options?: OverlayToaster
 @interface OverlayToasterCreateOptions
 
 The toaster will be rendered into a new element appended to the given `container`.
-The `container` determines which element toasts are positioned relative to; the default value of `<body>` allows them to use the entire viewport.
+The `container` determines which element toasts are positioned relative to; the default value of
+`<body>` allows them to use the entire viewport.
 
-The return type is `Promise<Toaster>`, which is a minimal interface that exposes only the instance methods detailed
-below. It can be thought of as `OverlayToaster` minus the `React.Component` methods, because the `OverlayToaster` should
-not be treated as a normal React component.
+The return type is `Promise<Toaster>`, which is a minimal interface that exposes only the instance
+methods detailed below. It can be thought of as `OverlayToaster` minus the `React.Component` methods,
+because the `OverlayToaster` should not be treated as a normal React component.
 
-A promise is returned as React components cannot be rendered synchronously after React version 18. If this makes
-`Toaster` usage difficult outside of a function that's not `async`, it's still possible to attach `.then()` handlers to
-the returned toaster.
+A promise is returned as React components cannot be rendered synchronously after React version 18.
+If this makes `Toaster` usage difficult outside of a function that's not `async`, it's still
+possible to attach `.then()` handlers to the returned toaster.
 
 ```ts
 function synchronousFn() {
@@ -115,8 +124,8 @@ function synchronousFn() {
 }
 ```
 
-Note that `OverlayToaster.createAsync()` will throw an error if invoked inside a component lifecycle method, as
-`ReactDOM.render()` will return `null` resulting in an inaccessible toaster instance.
+Note that `OverlayToaster.createAsync()` will throw an error if invoked inside a component lifecycle
+method, as `ReactDOM.render()` will return `null` resulting in an inaccessible toaster instance.
 
 <div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content">
     <h5 class="@ns-heading">Beware of memory leaks</h5>
@@ -167,7 +176,7 @@ export class App extends React.PureComponent {
         // create toasts in response to interactions.
         // in most cases, it's enough to simply create and forget (thanks to timeout).
         (await AppToaster).show({ message: "Toasted." });
-    }
+    };
 }
 ```
 
@@ -177,7 +186,9 @@ The example below uses the `OverlayToaster.createAsync()` static method. Clickin
 
 #### React 18
 
-To maintain backwards compatibility with React 16 and 17, `OverlayToaster.createAsync` uses `ReactDOM.render` out of the box. This triggers a [console warning on React 18](https://react.dev/blog/2022/03/08/react-18-upgrade-guide#updates-to-client-rendering-apis). A future major version of Blueprint will drop support for React versions before 18 and switch the default rendering function from `ReactDOM.render` to `createRoot`.
+To maintain backwards compatibility with React 16 and 17, `OverlayToaster.createAsync` uses `ReactDOM.render` out of the box. This triggers a [console warning on React 18](https://react.dev/blog/2022/03/08/react-18-upgrade-guide#updates-to-client-rendering-apis).
+A future major version of Blueprint will drop support for React versions before 18 and switch the
+default rendering function from `ReactDOM.render` to `createRoot`.
 
 If you're using React 18, we recommend passing in a custom `domRenderer` function.
 
@@ -191,44 +202,44 @@ const toaster = await OverlayToaster.createAsync(toasterProps, {
     domRenderer: (toaster, containerElement) => createRoot(containerElement).render(toaster),
 });
 
-toaster.show({ message: "Hello React 18!" })
+toaster.show({ message: "Hello React 18!" });
 ```
 
-
 @## React component usage
 
-Render the `<OverlayToaster>` component like any other element and supply `<Toast>` elements as `children`. You can
-optionally attach a `ref` handler to access the instance methods, but we strongly recommend using the
-[`OverlayToaster.create` static method](#core/components/toast.static-usage) documented above instead. Note that
-`children` and `ref` can be used together, but `children` will always appear _after_ toasts created with
-`ref.show()`.
+Render the `<OverlayToaster>` component like any other element and supply `<Toast2>` elements as
+`children`. You can optionally attach a `ref` handler to access the instance methods, but we
+strongly recommend using the [`OverlayToaster.create` static method](#core/components/toast.static-usage)
+documented above instead. Note that `children` and `ref` can be used together, but `children` will
+always appear _after_ toasts created with `ref.show()`.
 
 ```tsx
-import { Button, OverlayToaster, Position, Toast, Toaster } from "@blueprintjs/core";
+import { Button, OverlayToaster, Position, Toast2, ToastOptions } from "@blueprintjs/core";
 import * as React from "react";
 
-class MyComponent extends React.PureComponent {
-    public state = { toasts: [ /* ToastProps[] */ ] }
-
-    private toaster: Toaster;
-    private refHandlers = {
-        toaster: (ref: Toaster) => this.toaster = ref,
-    };
-
-    public render() {
-        return (
-            <div>
-                <Button onClick={this.addToast} text="Procure toast" />
-                <OverlayToaster position={Position.TOP_RIGHT} ref={this.refHandlers.toaster}>
-                    {/* "Toasted!" will appear here after clicking button. */}
-                    {this.state.toasts.map(toast => <Toast {...toast} />)}
-                </OverlayToaster>
-            </div>
-        )
-    }
-
-    private addToast = () => {
-        this.toaster.show({ message: "Toasted!" });
-    }
+function MyComponent() {
+    const [toasts, setToasts] = React.useState<ToastOptions[]>([]);
+    const toaster = React.useRef<OverlayToaster>(null);
+
+    const addToastViaRef = React.useCallback(() => {
+        toaster.current?.show({ message: "Toasted!" });
+    }, []);
+
+    const addToastLocally = React.useCallback(() => {
+        setToasts(t => [...t, { key: "toasted", message: "Toasted!" }]);
+    }, []);
+
+    return (
+        <div>
+            <Button onClick={addToastViaRef} text="Procure toast remotely" />
+            <Button onClick={addToastLocally} text="Procure toast locally" />
+            <OverlayToaster position={Position.TOP_RIGHT} ref={toaster}>
+                {/* "Toasted!" will appear here after clicking button. */}
+                {toasts.map(toast => (
+                    <Toast2 key={toast.key} {...toast} />
+                ))}
+            </OverlayToaster>
+        </div>
+    );
 }
 ```
diff --git a/packages/core/src/components/toast/toast.tsx b/packages/core/src/components/toast/toast.tsx
index 1a95768408..c517936b44 100644
--- a/packages/core/src/components/toast/toast.tsx
+++ b/packages/core/src/components/toast/toast.tsx
@@ -14,64 +14,30 @@
  * limitations under the License.
  */
 
+/**
+ * @fileoverview This component is DEPRECATED, and the code is frozen.
+ * All changes & bugfixes should be made to Toast2 instead.
+ */
+
+/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */
+
 import classNames from "classnames";
 import * as React from "react";
 
 import { Cross } from "@blueprintjs/icons";
 
 import { AbstractPureComponent, Classes } from "../../common";
-import {
-    type ActionProps,
-    DISPLAYNAME_PREFIX,
-    type IntentProps,
-    type LinkProps,
-    type MaybeElement,
-    type Props,
-} from "../../common/props";
+import { DISPLAYNAME_PREFIX } from "../../common/props";
 import { ButtonGroup } from "../button/buttonGroup";
 import { AnchorButton, Button } from "../button/buttons";
-import { Icon, type IconName } from "../icon/icon";
-
-export interface ToastProps extends Props, IntentProps {
-    /**
-     * Action rendered as a minimal `AnchorButton`. The toast is dismissed automatically when the
-     * user clicks the action button. Note that the `intent` prop is ignored (the action button
-     * cannot have its own intent color that might conflict with the toast's intent). Omit this
-     * prop to omit the action button.
-     */
-    action?: ActionProps & LinkProps;
-
-    /** Name of a Blueprint UI icon (or an icon element) to render before the message. */
-    icon?: IconName | MaybeElement;
-
-    /**
-     * Whether to show the close button in the toast.
-     *
-     * @default true
-     */
-    isCloseButtonShown?: boolean;
-
-    /** Message to display in the body of the toast. */
-    message: React.ReactNode;
-
-    /**
-     * Callback invoked when the toast is dismissed, either by the user or by the timeout.
-     * The value of the argument indicates whether the toast was closed because the timeout expired.
-     */
-    onDismiss?: (didTimeoutExpire: boolean) => void;
-
-    /**
-     * Milliseconds to wait before automatically dismissing toast.
-     * Providing a value less than or equal to 0 will disable the timeout (this is discouraged).
-     *
-     * @default 5000
-     */
-    timeout?: number;
-}
+import { Icon } from "../icon/icon";
+
+import type { ToastProps } from "./toastProps";
 
 /**
  * Toast component.
  *
+ * @deprecated use `Toast2` instead, which forwards DOM refs and is thus compatible with `Overlay2`.
  * @see https://blueprintjs.com/docs/#core/components/toast
  */
 export class Toast extends AbstractPureComponent<ToastProps> {
diff --git a/packages/core/src/components/toast/toast2.tsx b/packages/core/src/components/toast/toast2.tsx
new file mode 100644
index 0000000000..d1c08212ca
--- /dev/null
+++ b/packages/core/src/components/toast/toast2.tsx
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 classNames from "classnames";
+import * as React from "react";
+
+import { Cross } from "@blueprintjs/icons";
+
+import { Classes } from "../../common";
+import { DISPLAYNAME_PREFIX } from "../../common/props";
+import { useTimeout } from "../../hooks/useTimeout";
+import { ButtonGroup } from "../button/buttonGroup";
+import { AnchorButton, Button } from "../button/buttons";
+import { Icon } from "../icon/icon";
+
+import type { ToastProps } from "./toastProps";
+
+/**
+ * Toast2 component.
+ *
+ * Compared to the deprecated `Toast` component, this is a function component which forwards DOM
+ * refs and is thus compatible with `Overlay2`.
+ *
+ * @see https://blueprintjs.com/docs/#core/components/toast2
+ */
+export const Toast2 = React.forwardRef<HTMLDivElement, ToastProps>((props, ref) => {
+    const { action, className, icon, intent, isCloseButtonShown, message, onDismiss, timeout } = props;
+
+    const [isTimeoutStarted, setIsTimeoutStarted] = React.useState(false);
+    const startTimeout = React.useCallback(() => setIsTimeoutStarted(true), []);
+    const clearTimeout = React.useCallback(() => setIsTimeoutStarted(false), []);
+
+    // timeout is triggered & cancelled by updating `isTimeoutStarted` state
+    useTimeout(
+        () => {
+            triggerDismiss(true);
+        },
+        isTimeoutStarted && timeout !== undefined ? timeout : null,
+    );
+
+    // start timeout on mount or change, cancel on unmount
+    React.useEffect(() => {
+        if (timeout != null && timeout > 0) {
+            startTimeout();
+        } else {
+            clearTimeout();
+        }
+        return clearTimeout;
+    }, [clearTimeout, startTimeout, timeout]);
+
+    const triggerDismiss = React.useCallback(
+        (didTimeoutExpire: boolean) => {
+            clearTimeout();
+            onDismiss?.(didTimeoutExpire);
+        },
+        [clearTimeout, onDismiss],
+    );
+
+    const handleCloseClick = React.useCallback(() => triggerDismiss(false), [triggerDismiss]);
+
+    const handleActionClick = React.useCallback(
+        (e: React.MouseEvent<HTMLElement>) => {
+            action?.onClick?.(e);
+            triggerDismiss(false);
+        },
+        [action, triggerDismiss],
+    );
+
+    return (
+        <div
+            className={classNames(Classes.TOAST, Classes.intentClass(intent), className)}
+            onBlur={startTimeout}
+            onFocus={clearTimeout}
+            onMouseEnter={clearTimeout}
+            onMouseLeave={startTimeout}
+            ref={ref}
+            tabIndex={0}
+        >
+            <Icon icon={icon} />
+            <span className={Classes.TOAST_MESSAGE} role="alert">
+                {message}
+            </span>
+            <ButtonGroup minimal={true}>
+                {action && <AnchorButton {...action} intent={undefined} onClick={handleActionClick} />}
+                {isCloseButtonShown && <Button aria-label="Close" icon={<Cross />} onClick={handleCloseClick} />}
+            </ButtonGroup>
+        </div>
+    );
+});
+Toast2.defaultProps = {
+    className: "",
+    isCloseButtonShown: true,
+    message: "",
+    timeout: 5000,
+};
+Toast2.displayName = `${DISPLAYNAME_PREFIX}.Toast2`;
diff --git a/packages/core/src/components/toast/toastProps.ts b/packages/core/src/components/toast/toastProps.ts
new file mode 100644
index 0000000000..63cc02577d
--- /dev/null
+++ b/packages/core/src/components/toast/toastProps.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 type { ActionProps, IntentProps, LinkProps, MaybeElement, Props } from "../../common/props";
+import type { IconName } from "../icon/icon";
+
+export interface ToastProps extends Props, IntentProps {
+    /**
+     * Action rendered as a minimal `AnchorButton`. The toast is dismissed automatically when the
+     * user clicks the action button. Note that the `intent` prop is ignored (the action button
+     * cannot have its own intent color that might conflict with the toast's intent). Omit this
+     * prop to omit the action button.
+     */
+    action?: ActionProps & LinkProps;
+
+    /** Name of a Blueprint UI icon (or an icon element) to render before the message. */
+    icon?: IconName | MaybeElement;
+
+    /**
+     * Whether to show the close button in the toast.
+     *
+     * @default true
+     */
+    isCloseButtonShown?: boolean;
+
+    /** Message to display in the body of the toast. */
+    message: React.ReactNode;
+
+    /**
+     * Callback invoked when the toast is dismissed, either by the user or by the timeout.
+     * The value of the argument indicates whether the toast was closed because the timeout expired.
+     */
+    onDismiss?: (didTimeoutExpire: boolean) => void;
+
+    /**
+     * Milliseconds to wait before automatically dismissing toast.
+     * Providing a value less than or equal to 0 will disable the timeout (this is discouraged).
+     *
+     * @default 5000
+     */
+    timeout?: number;
+}
diff --git a/packages/core/src/components/toast/toaster.ts b/packages/core/src/components/toast/toaster.ts
index 45c609d355..e0d46d9a0d 100644
--- a/packages/core/src/components/toast/toaster.ts
+++ b/packages/core/src/components/toast/toaster.ts
@@ -16,7 +16,7 @@
 
 import { OverlayToaster } from "./overlayToaster";
 import type { OverlayToasterProps } from "./overlayToasterProps";
-import type { ToastProps } from "./toast";
+import type { ToastProps } from "./toastProps";
 
 export type ToastOptions = ToastProps & { key: string };
 /** Instance methods available on a toaster component instance. */
diff --git a/packages/core/src/hooks/useIsomorphicLayoutEffect.ts b/packages/core/src/hooks/useIsomorphicLayoutEffect.ts
new file mode 100644
index 0000000000..a52a793e64
--- /dev/null
+++ b/packages/core/src/hooks/useIsomorphicLayoutEffect.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 * as React from "react";
+
+import { hasDOMEnvironment } from "../common/utils/domUtils";
+
+export const useIsomorphicLayoutEffect = hasDOMEnvironment() ? React.useLayoutEffect : React.useEffect;
diff --git a/packages/core/src/hooks/useTimeout.ts b/packages/core/src/hooks/useTimeout.ts
new file mode 100644
index 0000000000..7bb7e6487c
--- /dev/null
+++ b/packages/core/src/hooks/useTimeout.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 * as React from "react";
+
+import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
+
+/**
+ * React hook wrapper for setTimeout(), adapted from usehooks-ts.
+ * The provided callback is invoked after the specified delay in milliseconds.
+ * If the delay is null or the component is unmounted, any pending timeout is cleared.
+ *
+ * @see https://usehooks-ts.com/react-hook/use-timeout
+ */
+export function useTimeout(callback: () => void, delay: number | null) {
+    const savedCallback = React.useRef(callback);
+
+    // remember the latest callback if it changes
+    useIsomorphicLayoutEffect(() => {
+        savedCallback.current = callback;
+    }, [callback]);
+
+    // set up the timeout
+    React.useEffect(() => {
+        // Don't schedule if no delay is specified.
+        // Note: 0 is a valid value for delay.
+        if (!delay && delay !== 0) {
+            return;
+        }
+
+        const id = setTimeout(() => savedCallback.current(), delay);
+
+        return () => clearTimeout(id);
+    }, [delay]);
+}
diff --git a/packages/core/src/legacy/contextMenuLegacy.tsx b/packages/core/src/legacy/contextMenuLegacy.tsx
index f943937c34..6e6c83b787 100644
--- a/packages/core/src/legacy/contextMenuLegacy.tsx
+++ b/packages/core/src/legacy/contextMenuLegacy.tsx
@@ -26,7 +26,7 @@ import * as React from "react";
 import * as ReactDOM from "react-dom";
 
 import { AbstractPureComponent, Classes } from "../common";
-import type { OverlayLifecycleProps } from "../components/overlay/overlay";
+import type { OverlayLifecycleProps } from "../components/overlay/overlayProps";
 import { Popover } from "../components/popover/popover";
 
 export interface Offset {
diff --git a/packages/core/test/dialog/dialogTests.tsx b/packages/core/test/dialog/dialogTests.tsx
index 2ec8ef5bee..b18de8168b 100644
--- a/packages/core/test/dialog/dialogTests.tsx
+++ b/packages/core/test/dialog/dialogTests.tsx
@@ -193,7 +193,7 @@ describe("<Dialog>", () => {
             setTimeout(() => {
                 assert.isTrue(containerRef.current?.classList.contains(Classes.DIALOG_CONTAINER));
                 done();
-            }, 0);
+            });
         });
     });
 
diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts
index 9cbec22898..cd904db70d 100644
--- a/packages/core/test/index.ts
+++ b/packages/core/test/index.ts
@@ -58,6 +58,7 @@ import "./multistep-dialog/multistepDialogTests";
 import "./non-ideal-state/nonIdealStateTests";
 import "./overflow-list/overflowListTests";
 import "./overlay/overlayTests";
+import "./overlay2/overlay2Tests";
 import "./panel-stack/panelStackTests";
 import "./panel-stack2/panelStack2Tests";
 import "./popover/popoverTests";
@@ -78,6 +79,7 @@ import "./tag/tagTests";
 import "./text/textTests";
 import "./toast/overlayToasterTests";
 import "./toast/toastTests";
+import "./toast/toast2Tests";
 import "./toast/toasterTests";
 import "./tooltip/tooltipTests";
 import "./tree/treeTests";
diff --git a/packages/core/test/isotest.mjs b/packages/core/test/isotest.mjs
index e5916fc94c..e9309c7c8b 100644
--- a/packages/core/test/isotest.mjs
+++ b/packages/core/test/isotest.mjs
@@ -30,7 +30,7 @@ describe("@blueprintjs/core isomorphic rendering", () => {
         Core,
         {
             Alert: {
-                props: { isOpen: true, usePortal: false },
+                props: { isOpen: true, lazy: false, usePortal: false },
             },
             Breadcrumbs: {
                 props: { items: [] },
@@ -39,10 +39,10 @@ describe("@blueprintjs/core isomorphic rendering", () => {
                 props: { children: React.createElement("div"), content: React.createElement("div") },
             },
             Dialog: {
-                props: { isOpen: true, usePortal: false },
+                props: { isOpen: true, lazy: false, usePortal: false },
             },
             Drawer: {
-                props: { isOpen: true, usePortal: false },
+                props: { isOpen: true, lazy: false, usePortal: false },
             },
             Hotkey: {
                 props: EXAMPLE_HOTKEY_CONFIG,
@@ -54,6 +54,7 @@ describe("@blueprintjs/core isomorphic rendering", () => {
                 props: {
                     hotkeys: [EXAMPLE_HOTKEY_CONFIG],
                     isOpen: true,
+                    lazy: false,
                     usePortal: false,
                 },
             },
@@ -71,7 +72,7 @@ describe("@blueprintjs/core isomorphic rendering", () => {
                 props: { icon: "build" },
             },
             MultistepDialog: {
-                props: { isOpen: true, usePortal: false },
+                props: { isOpen: true, lazy: false, usePortal: false },
                 children: React.createElement(Core.DialogStep, {
                     key: 1,
                     id: 1,
@@ -88,9 +89,12 @@ describe("@blueprintjs/core isomorphic rendering", () => {
             Overlay: {
                 props: { lazy: false, usePortal: false },
             },
+            Overlay2: {
+                props: { lazy: false, usePortal: false },
+            },
             OverlayToaster: {
                 props: { usePortal: false },
-                children: React.createElement(Core.Toast, { message: "Toast" }),
+                children: React.createElement(Core.Toast2, { message: "Toast" }),
             },
             PanelStack: {
                 props: {
diff --git a/packages/core/test/overlay/overlayTests.tsx b/packages/core/test/overlay/overlayTests.tsx
index 446de2fd92..d80ce37c4f 100644
--- a/packages/core/test/overlay/overlayTests.tsx
+++ b/packages/core/test/overlay/overlayTests.tsx
@@ -14,6 +14,13 @@
  * limitations under the License.
  */
 
+/**
+ * @fileoverview This component is DEPRECATED, and the code is frozen.
+ * All changes & bugfixes should be made to Overlay2 instead.
+ */
+
+/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */
+
 import { assert } from "chai";
 import { mount, type ReactWrapper, shallow } from "enzyme";
 import * as React from "react";
diff --git a/packages/core/test/overlay2/overlay2-test-debugging.scss b/packages/core/test/overlay2/overlay2-test-debugging.scss
new file mode 100644
index 0000000000..ec47250b9c
--- /dev/null
+++ b/packages/core/test/overlay2/overlay2-test-debugging.scss
@@ -0,0 +1,10 @@
+// Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+@import "@blueprintjs/colors/lib/scss/colors";
+@import "@blueprintjs/core/src/common/variables";
+
+body {
+  min-width: 500px;
+  min-height: 500px;
+}
diff --git a/packages/core/test/overlay2/overlay2Tests.tsx b/packages/core/test/overlay2/overlay2Tests.tsx
new file mode 100644
index 0000000000..323e76a87d
--- /dev/null
+++ b/packages/core/test/overlay2/overlay2Tests.tsx
@@ -0,0 +1,611 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 { assert } from "chai";
+import { mount, type ReactWrapper } from "enzyme";
+import * as React from "react";
+import { spy } from "sinon";
+
+import { dispatchMouseEvent } from "@blueprintjs/test-commons";
+
+import { Classes, Overlay2, type OverlayInstance, type OverlayProps, Portal, Utils } from "../../src";
+import { resetOpenStack } from "../../src/components/overlay2/overlay2";
+import { findInPortal } from "../utils";
+
+import "./overlay2-test-debugging.scss";
+
+const BACKDROP_SELECTOR = `.${Classes.OVERLAY_BACKDROP}`;
+
+/*
+ * IMPORTANT NOTE: It is critical that every <Overlay2> wrapper be unmounted after the test, to avoid
+ * polluting the DOM with leftover overlay elements. This was the cause of the Overlay test flakes of
+ * late 2017/early 2018 and was resolved by ensuring that every wrapper is unmounted.
+ *
+ * The `wrapper` variable below and the `mountWrapper` method should be used for full enzyme mounts.
+ * For shallow mounts, be sure to call `shallowWrapper.unmount()` after the assertions.
+ */
+describe("<Overlay2>", () => {
+    let wrapper: ReactWrapper<OverlayProps, any>;
+    let isMounted = false;
+    const testsContainerElement = document.createElement("div");
+    document.documentElement.appendChild(testsContainerElement);
+
+    /**
+     * Mount the `content` into `testsContainerElement` and assign to local `wrapper` variable.
+     * Use this method in this suite instead of Enzyme's `mount` method.
+     */
+    function mountWrapper(content: React.JSX.Element) {
+        wrapper = mount(content, { attachTo: testsContainerElement });
+        isMounted = true;
+        return wrapper;
+    }
+
+    afterEach(() => {
+        if (isMounted) {
+            // clean up wrapper after each test, if it was used
+            wrapper?.unmount();
+            wrapper?.detach();
+            isMounted = false;
+        }
+    });
+
+    after(() => {
+        document.documentElement.removeChild(testsContainerElement);
+    });
+
+    it("renders its content correctly", () => {
+        const overlay = mountWrapper(
+            <Overlay2 isOpen={true} usePortal={false}>
+                {createOverlayContents()}
+            </Overlay2>,
+        );
+        assert.lengthOf(overlay.find("strong"), 1);
+        assert.lengthOf(overlay.find(BACKDROP_SELECTOR), 1);
+    });
+
+    it("renders contents to specified container correctly", () => {
+        const CLASS_TO_TEST = "bp-test-content";
+        const container = document.createElement("div");
+        document.body.appendChild(container);
+        mountWrapper(
+            <Overlay2 isOpen={true} portalContainer={container}>
+                <p className={CLASS_TO_TEST}>test</p>
+            </Overlay2>,
+        );
+        assert.lengthOf(container.getElementsByClassName(CLASS_TO_TEST), 1);
+        document.body.removeChild(container);
+    });
+
+    it("sets aria-live", () => {
+        // Using an open Overlay2 because an initially closed Overlay2 will not render anything to the
+        // DOM
+        mountWrapper(<Overlay2 className="aria-test" isOpen={true} usePortal={false} />);
+        const overlayElement = document.querySelector(".aria-test");
+        assert.exists(overlayElement);
+        // Element#ariaLive not supported in Firefox or IE
+        assert.equal(overlayElement?.getAttribute("aria-live"), "polite");
+    });
+
+    it("portalClassName appears on Portal", () => {
+        const CLASS_TO_TEST = "bp-test-content";
+        mountWrapper(
+            <Overlay2 isOpen={true} portalClassName={CLASS_TO_TEST}>
+                <p>test</p>
+            </Overlay2>,
+        );
+        // search document for portal container element.
+        assert.isDefined(document.querySelector(`.${Classes.PORTAL}.${CLASS_TO_TEST}`));
+    });
+
+    it("renders Portal after first opened", () => {
+        mountWrapper(<Overlay2 isOpen={false}>{createOverlayContents()}</Overlay2>);
+        assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal");
+        wrapper.setProps({ isOpen: true }).update();
+        assert.lengthOf(wrapper.find(Portal), 1, "expected Portal");
+    });
+
+    it("supports non-element children", () => {
+        assert.doesNotThrow(() => {
+            mountWrapper(
+                <Overlay2 isOpen={true} usePortal={false}>
+                    {null} {undefined}
+                </Overlay2>,
+            );
+        });
+    });
+
+    it("hasBackdrop=false does not render backdrop", () => {
+        const overlay = mountWrapper(
+            <Overlay2 hasBackdrop={false} isOpen={true} usePortal={false}>
+                {createOverlayContents()}
+            </Overlay2>,
+        );
+        assert.lengthOf(overlay.find("strong"), 1);
+        assert.lengthOf(overlay.find(BACKDROP_SELECTOR), 0);
+    });
+
+    it("renders portal attached to body when not inline after first opened", () => {
+        mountWrapper(<Overlay2 isOpen={false}>{createOverlayContents()}</Overlay2>);
+        assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal");
+        wrapper.setProps({ isOpen: true }).update();
+        assert.lengthOf(wrapper.find(Portal), 1, "expected Portal");
+    });
+
+    describe("onClose", () => {
+        it("invoked on backdrop mousedown when canOutsideClickClose=true", () => {
+            const onClose = spy();
+            const overlay = mountWrapper(
+                <Overlay2 canOutsideClickClose={true} isOpen={true} onClose={onClose} usePortal={false}>
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+            overlay.find(BACKDROP_SELECTOR).simulate("mousedown");
+            assert.isTrue(onClose.calledOnce);
+        });
+
+        it("not invoked on backdrop mousedown when canOutsideClickClose=false", () => {
+            const onClose = spy();
+            const overlay = mountWrapper(
+                <Overlay2 canOutsideClickClose={false} isOpen={true} onClose={onClose} usePortal={false}>
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+            overlay.find(BACKDROP_SELECTOR).simulate("mousedown");
+            assert.isTrue(onClose.notCalled);
+        });
+
+        it("invoked on document mousedown when hasBackdrop=false", () => {
+            const onClose = spy();
+            // mounting cuz we need document events + lifecycle
+            mountWrapper(
+                <Overlay2 hasBackdrop={false} isOpen={true} onClose={onClose} usePortal={false}>
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+
+            dispatchMouseEvent(document.documentElement, "mousedown");
+            assert.isTrue(onClose.calledOnce);
+        });
+
+        it("not invoked on document mousedown when hasBackdrop=false and canOutsideClickClose=false", () => {
+            const onClose = spy();
+            mountWrapper(
+                <Overlay2
+                    canOutsideClickClose={false}
+                    hasBackdrop={false}
+                    isOpen={true}
+                    onClose={onClose}
+                    usePortal={false}
+                >
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+
+            dispatchMouseEvent(document.documentElement, "mousedown");
+            assert.isTrue(onClose.notCalled);
+        });
+
+        it("not invoked on click of a nested overlay", () => {
+            const onClose = spy();
+            mountWrapper(
+                <Overlay2 isOpen={true} onClose={onClose}>
+                    <div id="outer-element">
+                        {createOverlayContents()}
+                        <Overlay2 isOpen={true}>
+                            <div id="inner-element">{createOverlayContents()}</div>
+                        </Overlay2>
+                    </div>
+                </Overlay2>,
+            );
+            // this hackery is necessary for React 15 support, where Portals break trees.
+            findInPortal(findInPortal(wrapper, "#outer-element"), "#inner-element").simulate("mousedown");
+            assert.isTrue(onClose.notCalled);
+        });
+
+        it("invoked on escape key", () => {
+            const onClose = spy();
+            mountWrapper(
+                <Overlay2 isOpen={true} onClose={onClose} usePortal={false}>
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+            wrapper.simulate("keydown", { key: "Escape" });
+            assert.isTrue(onClose.calledOnce);
+        });
+
+        it("not invoked on escape key when canEscapeKeyClose=false", () => {
+            const onClose = spy();
+            const overlay = mountWrapper(
+                <Overlay2 canEscapeKeyClose={false} isOpen={true} onClose={onClose} usePortal={false}>
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+            overlay.simulate("keydown", { key: "Escape" });
+            assert.isTrue(onClose.notCalled);
+        });
+
+        it("renders portal attached to body when not inline", () => {
+            const overlay = mountWrapper(
+                <Overlay2 isOpen={true} usePortal={true}>
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+            const portal = overlay.find(Portal);
+            assert.isTrue(portal.exists(), "missing Portal");
+            assert.lengthOf(portal.find("strong"), 1, "missing h1");
+        });
+    });
+
+    describe("Focus management", () => {
+        const overlayClassName = "test-overlay";
+
+        it("brings focus to overlay if autoFocus=true", done => {
+            mountWrapper(
+                <Overlay2 className={overlayClassName} autoFocus={true} isOpen={true} usePortal={true}>
+                    <input type="text" />
+                </Overlay2>,
+            );
+            assertFocusIsInOverlayWithTimeout(done);
+        });
+
+        it("does not bring focus to overlay if autoFocus=false and enforceFocus=false", done => {
+            mountWrapper(
+                <div>
+                    <button>something outside overlay for browser to focus on</button>
+                    <Overlay2
+                        className={overlayClassName}
+                        autoFocus={false}
+                        enforceFocus={false}
+                        isOpen={true}
+                        usePortal={true}
+                    >
+                        <input type="text" />
+                    </Overlay2>
+                </div>,
+            );
+            assertFocusWithTimeout("body", done);
+        });
+
+        // React implements autoFocus itself so our `[autofocus]` logic never fires.
+        // Still, worth testing we can control where the focus goes.
+        it("autoFocus element inside overlay gets the focus", done => {
+            mountWrapper(
+                <Overlay2 className={overlayClassName} isOpen={true} usePortal={true}>
+                    <input autoFocus={true} type="text" />
+                </Overlay2>,
+            );
+            assertFocusWithTimeout("input", done);
+        });
+
+        it("returns focus to overlay if enforceFocus=true", done => {
+            const buttonRef = React.createRef<HTMLButtonElement>();
+            const inputRef = React.createRef<HTMLInputElement>();
+            mountWrapper(
+                <div>
+                    <button ref={buttonRef} />
+                    <Overlay2 className={overlayClassName} enforceFocus={true} isOpen={true} usePortal={true}>
+                        <div>
+                            <input autoFocus={true} ref={inputRef} />
+                        </div>
+                    </Overlay2>
+                </div>,
+            );
+            assert.strictEqual(document.activeElement, inputRef.current);
+            buttonRef.current?.focus();
+            assertFocusIsInOverlayWithTimeout(done);
+        });
+
+        it("returns focus to overlay after clicking the backdrop if enforceFocus=true", done => {
+            mountWrapper(
+                <Overlay2
+                    className={overlayClassName}
+                    enforceFocus={true}
+                    canOutsideClickClose={false}
+                    isOpen={true}
+                    usePortal={false}
+                >
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+            wrapper.find(BACKDROP_SELECTOR).simulate("mousedown");
+            assertFocusIsInOverlayWithTimeout(done);
+        });
+
+        it("returns focus to overlay after clicking an outside element if enforceFocus=true", done => {
+            mountWrapper(
+                <div>
+                    <Overlay2
+                        enforceFocus={true}
+                        canOutsideClickClose={false}
+                        className={overlayClassName}
+                        isOpen={true}
+                        usePortal={false}
+                        hasBackdrop={false}
+                    >
+                        {createOverlayContents()}
+                    </Overlay2>
+                    <button id="buttonId" />
+                </div>,
+            );
+            wrapper.find("#buttonId").simulate("click");
+            assertFocusIsInOverlayWithTimeout(done);
+        });
+
+        it("does not result in maximum call stack if two overlays open with enforceFocus=true", () => {
+            const instanceRef = React.createRef<OverlayInstance>();
+            const anotherContainer = document.createElement("div");
+            document.documentElement.appendChild(anotherContainer);
+            const temporaryWrapper = mount(
+                <Overlay2
+                    className={overlayClassName}
+                    enforceFocus={true}
+                    isOpen={true}
+                    usePortal={false}
+                    ref={instanceRef}
+                >
+                    <input type="text" />
+                </Overlay2>,
+                { attachTo: anotherContainer },
+            );
+
+            mountWrapper(
+                <Overlay2 className={overlayClassName} enforceFocus={true} isOpen={false} usePortal={false}>
+                    <input id="inputId" type="text" />
+                </Overlay2>,
+            );
+
+            assert.isNotNull(instanceRef.current, "ref should be set");
+
+            wrapper.setProps({ isOpen: true }).update();
+            // potentially triggers infinite recursion if both overlays try to bring focus back to themselves
+            wrapper.find("#inputId").simulate("click").update();
+            // previous test suites for Overlay spied on bringFocusInsideOverlay and asserted it was called once here,
+            // but that is more difficult to test with function components and breaches the abstraction of Overlay2.
+
+            temporaryWrapper.unmount();
+            document.documentElement.removeChild(anotherContainer);
+        });
+
+        it("does not return focus to overlay if enforceFocus=false", done => {
+            let buttonRef: HTMLElement | null;
+            const focusBtnAndAssert = () => {
+                buttonRef?.focus();
+                assert.strictEqual(buttonRef, document.activeElement);
+                done();
+            };
+
+            mountWrapper(
+                <div>
+                    <button ref={ref => (buttonRef = ref)} />
+                    <Overlay2 className={overlayClassName} enforceFocus={false} isOpen={true} usePortal={true}>
+                        <div>
+                            <input ref={ref => ref && setTimeout(focusBtnAndAssert)} />
+                        </div>
+                    </Overlay2>
+                </div>,
+            );
+        });
+
+        it("doesn't focus overlay if focus is already inside overlay", done => {
+            let textarea: HTMLTextAreaElement | null;
+            mountWrapper(
+                <Overlay2 className={overlayClassName} isOpen={true} usePortal={true}>
+                    <div>
+                        <textarea ref={ref => (textarea = ref)} />
+                    </div>
+                </Overlay2>,
+            );
+            textarea!.focus();
+            assertFocusWithTimeout("textarea", done);
+        });
+
+        it("does not focus overlay when closed", done => {
+            mountWrapper(
+                <div>
+                    <button ref={ref => ref && ref.focus()} />
+                    <Overlay2 className={overlayClassName} isOpen={false} usePortal={true} />
+                </div>,
+            );
+            assertFocusWithTimeout("button", done);
+        });
+
+        it("does not crash while trying to return focus to overlay if user clicks outside the document", () => {
+            mountWrapper(
+                <Overlay2
+                    className={overlayClassName}
+                    enforceFocus={true}
+                    canOutsideClickClose={false}
+                    isOpen={true}
+                    usePortal={false}
+                >
+                    {createOverlayContents()}
+                </Overlay2>,
+            );
+
+            // this is a fairly custom / nonstandard event dispatch, trying to simulate what happens in some browsers when a user clicks
+            // on the browser toolbar (outside the document), but a focus event is still dispatched to document
+            // see https://github.com/palantir/blueprint/issues/3928
+            const event = new FocusEvent("focus");
+            Object.defineProperty(event, "target", { value: window });
+
+            try {
+                document.dispatchEvent(event);
+            } catch (e) {
+                assert.fail("threw uncaught error");
+            }
+        });
+
+        function assertFocusWithTimeout(selector: string | (() => void), done: Mocha.Done) {
+            // the behavior being tested relies on requestAnimationFrame.
+            // setTimeout for a few frames later to let things settle (to reduce flakes).
+            setTimeout(() => {
+                wrapper.update();
+                if (Utils.isFunction(selector)) {
+                    selector();
+                } else {
+                    assert.strictEqual(document.querySelector(selector), document.activeElement);
+                }
+                done();
+            }, 40);
+        }
+
+        function assertFocusIsInOverlayWithTimeout(done: Mocha.Done) {
+            assertFocusWithTimeout(() => {
+                const overlayElement = document.querySelector(`.${overlayClassName}`);
+                assert.isTrue(overlayElement?.contains(document.activeElement));
+            }, done);
+        }
+    });
+
+    describe("Background scrolling", () => {
+        beforeEach(() => {
+            // force-reset Overlay2 stack state between tests
+            resetOpenStack();
+            document.body.classList.remove(Classes.OVERLAY_OPEN);
+        });
+
+        describe("upon mount", () => {
+            it("disables document scrolling by default", done => {
+                wrapper = mountWrapper(renderBackdropOverlay());
+                assertBodyScrollingDisabled(true, done);
+            });
+
+            it("disables document scrolling if hasBackdrop=true and usePortal=true", done => {
+                wrapper = mountWrapper(renderBackdropOverlay(true, true));
+                assertBodyScrollingDisabled(true, done);
+            });
+
+            it("does not disable document scrolling if hasBackdrop=true and usePortal=false", done => {
+                wrapper = mountWrapper(renderBackdropOverlay(true, false));
+                assertBodyScrollingDisabled(false, done);
+            });
+
+            it("does not disable document scrolling if hasBackdrop=false and usePortal=true", done => {
+                wrapper = mountWrapper(renderBackdropOverlay(false, true));
+                assertBodyScrollingDisabled(false, done);
+            });
+
+            it("does not disable document scrolling if hasBackdrop=false and usePortal=false", done => {
+                wrapper = mountWrapper(renderBackdropOverlay(false, false));
+                assertBodyScrollingDisabled(false, done);
+            });
+        });
+
+        describe("after closing", () => {
+            it("restores body scrolling", done => {
+                const handleClosed = () => {
+                    assert.isFalse(
+                        wrapper.getDOMNode().classList.contains(Classes.OVERLAY_OPEN),
+                        `expected overlay element to not have ${Classes.OVERLAY_OPEN} class`,
+                    );
+                    assertBodyScrollingDisabled(false, done);
+                };
+
+                wrapper = mountWrapper(
+                    <Overlay2 isOpen={true} usePortal={true} onClosed={handleClosed} transitionDuration={0}>
+                        {createOverlayContents()}
+                    </Overlay2>,
+                );
+                wrapper.setProps({ isOpen: false });
+            });
+        });
+
+        describe("after unmount", () => {
+            it("keeps scrolling disabled if some overlay with hasBackdrop=true exists", done => {
+                const otherOverlayWithBackdrop = mount(renderBackdropOverlay(true));
+                wrapper = mountWrapper(renderBackdropOverlay(true));
+                otherOverlayWithBackdrop.unmount();
+
+                assertBodyScrollingDisabled(true, done);
+            });
+
+            it("doesn't keep scrolling disabled if no overlay exists with hasBackdrop=true", done => {
+                const otherOverlayWithBackdrop = mount(renderBackdropOverlay(true));
+                wrapper = mountWrapper(renderBackdropOverlay(false));
+                otherOverlayWithBackdrop.unmount();
+
+                assertBodyScrollingDisabled(false, done);
+            });
+        });
+
+        function renderBackdropOverlay(hasBackdrop?: boolean, usePortal?: boolean) {
+            return (
+                <Overlay2 hasBackdrop={hasBackdrop} isOpen={true} usePortal={usePortal}>
+                    <div>Some overlay content</div>
+                </Overlay2>
+            );
+        }
+
+        function assertBodyScrollingDisabled(disabled: boolean, done: Mocha.Done) {
+            // wait for the DOM to settle before checking body classes
+            setTimeout(() => {
+                const hasClass = document.body.classList.contains(Classes.OVERLAY_OPEN);
+                assert.equal(
+                    hasClass,
+                    disabled,
+                    `expected <body> element to ${disabled ? "have" : "not have"} ${Classes.OVERLAY_OPEN} class`,
+                );
+                done();
+            });
+        }
+    });
+
+    it("lifecycle methods called as expected", done => {
+        // these lifecycles are passed directly to CSSTransition from react-transition-group
+        // so we do not need to test these extensively. one integration test should do.
+        const onClosed = spy();
+        const onClosing = spy();
+        const onOpened = spy();
+        const onOpening = spy();
+        wrapper = mountWrapper(
+            <Overlay2
+                {...{ onClosed, onClosing, onOpened, onOpening }}
+                isOpen={true}
+                usePortal={false}
+                // transition duration shorter than timeout below to ensure it's done
+                transitionDuration={8}
+            >
+                {createOverlayContents()}
+            </Overlay2>,
+        );
+        assert.isTrue(onOpening.calledOnce, "onOpening");
+        assert.isFalse(onOpened.calledOnce, "onOpened not called yet");
+
+        setTimeout(() => {
+            // on*ed called after transition completes
+            assert.isTrue(onOpened.calledOnce, "onOpened");
+
+            wrapper.setProps({ isOpen: false });
+            // on*ing called immediately when prop changes
+            assert.isTrue(onClosing.calledOnce, "onClosing");
+            assert.isFalse(onClosed.calledOnce, "onClosed not called yet");
+
+            setTimeout(() => {
+                assert.isTrue(onClosed.calledOnce, "onOpened");
+                done();
+            }, 10);
+        }, 10);
+    });
+
+    let index = 0;
+    function createOverlayContents() {
+        return (
+            <strong id={`overlay-${index++}`} tabIndex={0}>
+                Overlay2 content!
+            </strong>
+        );
+    }
+});
diff --git a/packages/core/test/popover/popoverTests.tsx b/packages/core/test/popover/popoverTests.tsx
index 7917cfcb3d..9d78021015 100644
--- a/packages/core/test/popover/popoverTests.tsx
+++ b/packages/core/test/popover/popoverTests.tsx
@@ -23,7 +23,7 @@ import { dispatchMouseEvent } from "@blueprintjs/test-commons";
 
 import { Classes } from "../../src/common";
 import * as Errors from "../../src/common/errors";
-import { Button, Overlay, Portal } from "../../src/components";
+import { Button, Overlay2, Portal } from "../../src/components";
 import {
     Popover,
     PopoverInteractionKind,
@@ -112,11 +112,11 @@ describe("<Popover>", () => {
                     <Button />
                 </Popover>,
             );
-            assert.isFalse(popover.find(Overlay).exists(), "not open for undefined content");
+            assert.isFalse(popover.find(Overlay2).exists(), "not open for undefined content");
             assert.equal(warnSpy.callCount, 1);
 
             popover.setProps({ content: "    " });
-            assert.isFalse(popover.find(Overlay).exists(), "not open for white-space string content");
+            assert.isFalse(popover.find(Overlay2).exists(), "not open for white-space string content");
             assert.equal(warnSpy.callCount, 2);
         });
 
@@ -921,7 +921,7 @@ describe("<Popover>", () => {
             return wrapper!;
         };
         wrapper.assertIsOpen = (isOpen = true, index = 0) => {
-            const overlay = wrapper!.find(Overlay).at(index);
+            const overlay = wrapper!.find(Overlay2).at(index);
             assert.equal(overlay.prop("isOpen"), isOpen, "PopoverWrapper#assertIsOpen()");
             return wrapper!;
         };
diff --git a/packages/core/test/toast/overlayToasterTests.tsx b/packages/core/test/toast/overlayToasterTests.tsx
index f8b8f1e7e9..843045f29f 100644
--- a/packages/core/test/toast/overlayToasterTests.tsx
+++ b/packages/core/test/toast/overlayToasterTests.tsx
@@ -92,12 +92,20 @@ describe("OverlayToaster", () => {
                 );
             });
 
-            it("show() renders toast immediately", () => {
+            it("show() renders toast on next tick", done => {
                 toaster.show({
                     message: "Hello world",
                 });
                 assert.lengthOf(toaster.getToasts(), 1, "expected 1 toast");
-                assert.isNotNull(document.querySelector(`.${Classes.TOAST_CONTAINER}.${Classes.OVERLAY_OPEN}`));
+
+                // setState needs a tick to flush DOM updates
+                setTimeout(() => {
+                    assert.isNotNull(
+                        document.querySelector(`.${Classes.TOAST_CONTAINER}.${Classes.OVERLAY_OPEN}`),
+                        "expected toast container element to have 'overlay open' class name",
+                    );
+                    done();
+                });
             });
 
             it("multiple show()s renders them all", () => {
diff --git a/packages/core/test/toast/toast2Tests.tsx b/packages/core/test/toast/toast2Tests.tsx
new file mode 100644
index 0000000000..adab602202
--- /dev/null
+++ b/packages/core/test/toast/toast2Tests.tsx
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * 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 { assert } from "chai";
+import { mount, shallow } from "enzyme";
+import * as React from "react";
+import { type SinonSpy, spy } from "sinon";
+
+import { AnchorButton, Button, Toast2 } from "../../src";
+
+describe("<Toast2>", () => {
+    it("renders only dismiss button by default", () => {
+        const { action, dismiss } = wrap(<Toast2 message="Hello World" />);
+        assert.lengthOf(action, 0);
+        assert.lengthOf(dismiss, 1);
+    });
+
+    it("clicking dismiss button triggers onDismiss callback with `false`", () => {
+        const handleDismiss = spy();
+        wrap(<Toast2 message="Hello" onDismiss={handleDismiss} />).dismiss.simulate("click");
+        assert.isTrue(handleDismiss.calledOnce, "onDismiss not called once");
+        assert.isTrue(handleDismiss.calledWith(false), "onDismiss not called with false");
+    });
+
+    it("renders action button when action string prop provided", () => {
+        // pluralize cuz now there are two buttons
+        const { action } = wrap(<Toast2 action={{ text: "Undo" }} message="hello world" />);
+        assert.lengthOf(action, 1);
+        assert.equal(action.prop("text"), "Undo");
+    });
+
+    it("clicking action button triggers onClick callback", () => {
+        const onClick = spy();
+        wrap(<Toast2 action={{ onClick, text: "Undo" }} message="Hello" />).action.simulate("click");
+        assert.isTrue(onClick.calledOnce, "action onClick not called once");
+    });
+
+    it("clicking action button also triggers onDismiss callback with `false`", () => {
+        const handleDismiss = spy();
+        wrap(<Toast2 action={{ text: "Undo" }} message="Hello" onDismiss={handleDismiss} />).action.simulate("click");
+        assert.isTrue(handleDismiss.calledOnce, "onDismiss not called once");
+        assert.isTrue(handleDismiss.calledWith(false), "onDismiss not called with false");
+    });
+
+    function wrap(toast: React.JSX.Element) {
+        const root = shallow(toast);
+        return {
+            action: root.find(AnchorButton),
+            dismiss: root.find(Button),
+            root,
+        };
+    }
+
+    describe("timeout", () => {
+        let handleDismiss: SinonSpy;
+        beforeEach(() => (handleDismiss = spy()));
+
+        it("calls onDismiss automatically after timeout expires with `true`", done => {
+            // mounting for lifecycle methods to start timeout
+            mount(<Toast2 message="Hello" onDismiss={handleDismiss} timeout={20} />);
+            setTimeout(() => {
+                assert.isTrue(handleDismiss.calledOnce, "onDismiss not called once");
+                assert.isTrue(handleDismiss.firstCall.args[0], "onDismiss not called with `true`");
+                done();
+            }, 20);
+        });
+
+        it("updating with timeout={0} cancels timeout", done => {
+            mount(<Toast2 message="Hello" onDismiss={handleDismiss} timeout={20} />).setProps({
+                timeout: 0,
+            });
+            setTimeout(() => {
+                assert.isTrue(handleDismiss.notCalled, "onDismiss was called");
+                done();
+            }, 20);
+        });
+
+        it("updating timeout={0} with timeout={X} starts timeout", done => {
+            mount(<Toast2 message="Hello" onDismiss={handleDismiss} timeout={0} />).setProps({
+                timeout: 20,
+            });
+            setTimeout(() => {
+                assert.isTrue(handleDismiss.calledOnce, "onDismiss not called once");
+                assert.isTrue(handleDismiss.firstCall.args[0], "onDismiss not called with `true`");
+                done();
+            }, 20);
+        });
+    });
+});
diff --git a/packages/core/test/toast/toastTests.tsx b/packages/core/test/toast/toastTests.tsx
index 4ac35c157a..4b748f2096 100644
--- a/packages/core/test/toast/toastTests.tsx
+++ b/packages/core/test/toast/toastTests.tsx
@@ -14,6 +14,13 @@
  * limitations under the License.
  */
 
+/**
+ * @fileoverview This component is DEPRECATED, and the code is frozen.
+ * All changes & bugfixes should be made to Toast2 instead.
+ */
+
+/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */
+
 import { assert } from "chai";
 import { mount, shallow } from "enzyme";
 import * as React from "react";
diff --git a/packages/core/test/tooltip/tooltipTests.tsx b/packages/core/test/tooltip/tooltipTests.tsx
index 1c9384eac1..64ed778c73 100644
--- a/packages/core/test/tooltip/tooltipTests.tsx
+++ b/packages/core/test/tooltip/tooltipTests.tsx
@@ -20,7 +20,7 @@ import * as React from "react";
 import { spy, stub } from "sinon";
 
 import { Classes } from "../../src/common";
-import { Button, Overlay } from "../../src/components";
+import { Button, Overlay2 } from "../../src/components";
 import { Popover } from "../../src/components/popover/popover";
 import { Tooltip, type TooltipProps } from "../../src/components/tooltip/tooltip";
 
@@ -106,7 +106,7 @@ describe("<Tooltip>", () => {
 
             function assertDisabledPopover(content: string) {
                 tooltip.setProps({ content });
-                assert.isFalse(tooltip.find(Overlay).exists(), `"${content}"`);
+                assert.isFalse(tooltip.find(Overlay2).exists(), `"${content}"`);
                 assert.isTrue(warnSpy.called, "spy not called");
                 warnSpy.resetHistory();
             }
@@ -139,7 +139,7 @@ describe("<Tooltip>", () => {
         it("empty content disables Popover and warns", () => {
             const warnSpy = stub(console, "warn");
             const tooltip = renderTooltip({ content: "", isOpen: true });
-            assert.isFalse(tooltip.find(Overlay).exists());
+            assert.isFalse(tooltip.find(Overlay2).exists());
             assert.isTrue(warnSpy.called);
             warnSpy.restore();
         });
diff --git a/packages/demo-app/src/examples/ToastExample.tsx b/packages/demo-app/src/examples/ToastExample.tsx
index 683d3dfa81..1fa71da156 100644
--- a/packages/demo-app/src/examples/ToastExample.tsx
+++ b/packages/demo-app/src/examples/ToastExample.tsx
@@ -16,7 +16,7 @@
 
 import * as React from "react";
 
-import { Intent, Toast } from "@blueprintjs/core";
+import { Intent, Toast2 } from "@blueprintjs/core";
 
 import { ExampleCard } from "./ExampleCard";
 
@@ -30,7 +30,7 @@ export class ToastExample extends React.PureComponent {
             <div className="example-row">
                 <ExampleCard label="Toast">
                     {Object.values(Intent).map(intent => (
-                        <Toast
+                        <Toast2
                             key={`${intent}-toast`}
                             intent={intent as Intent}
                             message="This is a toast message"
diff --git a/packages/docs-app/src/components/blueprintDocs.tsx b/packages/docs-app/src/components/blueprintDocs.tsx
index 46845fa332..03c9cfb456 100644
--- a/packages/docs-app/src/components/blueprintDocs.tsx
+++ b/packages/docs-app/src/components/blueprintDocs.tsx
@@ -18,7 +18,7 @@ import { type HeadingNode, isPageNode, type PageData, type TsDocBase } from "@do
 import classNames from "classnames";
 import * as React from "react";
 
-import { AnchorButton, Classes, HotkeysProvider, type Intent, Tag } from "@blueprintjs/core";
+import { AnchorButton, Classes, HotkeysProvider, type Intent, PortalProvider, Tag } from "@blueprintjs/core";
 import type { DocsCompleteData } from "@blueprintjs/docs-data";
 import {
     Banner,
@@ -100,18 +100,20 @@ export class BlueprintDocs extends React.Component<BlueprintDocsProps, { themeNa
         );
         return (
             <HotkeysProvider>
-                <Documentation
-                    {...this.props}
-                    className={this.state.themeName}
-                    banner={banner}
-                    footer={footer}
-                    header={header}
-                    navigatorExclude={isNavSection}
-                    onComponentUpdate={this.handleComponentUpdate}
-                    renderNavMenuItem={this.renderNavMenuItem}
-                    renderPageActions={this.renderPageActions}
-                    renderViewSourceLinkText={this.renderViewSourceLinkText}
-                />
+                <PortalProvider>
+                    <Documentation
+                        {...this.props}
+                        className={this.state.themeName}
+                        banner={banner}
+                        footer={footer}
+                        header={header}
+                        navigatorExclude={isNavSection}
+                        onComponentUpdate={this.handleComponentUpdate}
+                        renderNavMenuItem={this.renderNavMenuItem}
+                        renderPageActions={this.renderPageActions}
+                        renderViewSourceLinkText={this.renderViewSourceLinkText}
+                    />
+                </PortalProvider>
             </HotkeysProvider>
         );
     }
diff --git a/packages/docs-app/src/examples/core-examples/index.ts b/packages/docs-app/src/examples/core-examples/index.ts
index 62e8d571fd..bec09829d7 100644
--- a/packages/docs-app/src/examples/core-examples/index.ts
+++ b/packages/docs-app/src/examples/core-examples/index.ts
@@ -54,7 +54,8 @@ export * from "./numericInputBasicExample";
 export * from "./numericInputExtendedExample";
 export * from "./nonIdealStateExample";
 export * from "./overflowListExample";
-export * from "./overlayExample";
+export { OverlayExample } from "./overlayExample";
+export { Overlay2Example } from "./overlay2Example";
 export { PanelStackExample } from "./panelStackExample";
 export { PanelStack2Example } from "./panelStack2Example";
 export * from "./popoverDismissExample";
diff --git a/packages/docs-app/src/examples/core-examples/overlay2Example.tsx b/packages/docs-app/src/examples/core-examples/overlay2Example.tsx
new file mode 100644
index 0000000000..4cef759510
--- /dev/null
+++ b/packages/docs-app/src/examples/core-examples/overlay2Example.tsx
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ * 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 classNames from "classnames";
+import * as React from "react";
+
+import { Button, Classes, Code, H3, H5, Intent, Overlay2, Switch } from "@blueprintjs/core";
+import { Example, type ExampleProps, handleBooleanChange } from "@blueprintjs/docs-theme";
+
+import type { BlueprintExampleData } from "../../tags/types";
+
+const OVERLAY_EXAMPLE_CLASS = "docs-overlay-example-transition";
+const OVERLAY_TALL_CLASS = "docs-overlay-example-tall";
+
+export const Overlay2Example: React.FC<ExampleProps<BlueprintExampleData>> = props => {
+    const [autoFocus, setAutoFocus] = React.useState(true);
+    const [canEscapeKeyClose, setCanEscapeKeyClose] = React.useState(true);
+    const [canOutsideClickClose, setCanOutsideClickClose] = React.useState(true);
+    const [enforceFocus, setEnforceFocus] = React.useState(true);
+    const [hasBackdrop, setHasBackdrop] = React.useState(true);
+    const [isOpen, setIsOpen] = React.useState(false);
+    const [usePortal, setUsePortal] = React.useState(true);
+    const [useTallContent, setUseTallContent] = React.useState(false);
+
+    const buttonRef = React.useRef<HTMLButtonElement>(null);
+
+    const handleOpen = React.useCallback(() => setIsOpen(true), [setIsOpen]);
+
+    const handleClose = React.useCallback(() => {
+        setIsOpen(false);
+        setUseTallContent(false);
+    }, [setIsOpen, setUseTallContent]);
+
+    const focusButton = React.useCallback(() => buttonRef.current?.focus(), [buttonRef]);
+
+    const toggleScrollButton = React.useCallback(() => setUseTallContent(use => !use), [setUseTallContent]);
+
+    const classes = classNames(Classes.CARD, Classes.ELEVATION_4, OVERLAY_EXAMPLE_CLASS, props.data.themeName, {
+        [OVERLAY_TALL_CLASS]: useTallContent,
+    });
+
+    const options = (
+        <>
+            <H5>Props</H5>
+            <Switch checked={autoFocus} label="Auto focus" onChange={handleBooleanChange(setAutoFocus)} />
+            <Switch checked={enforceFocus} label="Enforce focus" onChange={handleBooleanChange(setEnforceFocus)} />
+            <Switch checked={usePortal} onChange={handleBooleanChange(setUsePortal)}>
+                Use <Code>Portal</Code>
+            </Switch>
+            <Switch
+                checked={canOutsideClickClose}
+                label="Click outside to close"
+                onChange={handleBooleanChange(setCanOutsideClickClose)}
+            />
+            <Switch
+                checked={canEscapeKeyClose}
+                label="Escape key to close"
+                onChange={handleBooleanChange(setCanEscapeKeyClose)}
+            />
+            <Switch checked={hasBackdrop} label="Has backdrop" onChange={handleBooleanChange(setHasBackdrop)} />
+        </>
+    );
+
+    return (
+        <Example options={options} {...props}>
+            <React.StrictMode>
+                <Button ref={buttonRef} onClick={handleOpen} text="Show overlay" />
+                <Overlay2
+                    onClose={handleClose}
+                    className={Classes.OVERLAY_SCROLL_CONTAINER}
+                    {...{
+                        autoFocus,
+                        canEscapeKeyClose,
+                        canOutsideClickClose,
+                        enforceFocus,
+                        hasBackdrop,
+                        isOpen,
+                        usePortal,
+                    }}
+                >
+                    <div className={classes}>
+                        <H3>I'm an Overlay!</H3>
+                        <p>
+                            This is a simple container with some inline styles to position it on the screen. Its CSS
+                            transitions are customized for this example only to demonstrate how easily custom
+                            transitions can be implemented.
+                        </p>
+                        <p>
+                            Click the "Focus button" below to transfer focus to the "Show overlay" trigger button
+                            outside of this overlay. If persistent focus is enabled, focus will be constrained to the
+                            overlay. Use the <Code>tab</Code> key to move to the next focusable element to illustrate
+                            this effect.
+                        </p>
+                        <p>
+                            Click the "Make me scroll" button below to make this overlay's content really tall, which
+                            will make the overlay's container (but not the page) scrollable
+                        </p>
+                        <br />
+                        <div className={Classes.DIALOG_FOOTER_ACTIONS}>
+                            <Button intent={Intent.DANGER} onClick={handleClose} style={{ margin: "" }}>
+                                Close
+                            </Button>
+                            <Button onClick={focusButton} style={{ margin: "" }}>
+                                Focus button
+                            </Button>
+                            <Button
+                                onClick={toggleScrollButton}
+                                icon="double-chevron-down"
+                                rightIcon="double-chevron-down"
+                                active={useTallContent}
+                                style={{ margin: "" }}
+                            >
+                                Make me scroll
+                            </Button>
+                        </div>
+                    </div>
+                </Overlay2>
+            </React.StrictMode>
+        </Example>
+    );
+};
diff --git a/packages/docs-app/src/examples/core-examples/overlayExample.tsx b/packages/docs-app/src/examples/core-examples/overlayExample.tsx
index cb9f271073..9f78df4d5e 100644
--- a/packages/docs-app/src/examples/core-examples/overlayExample.tsx
+++ b/packages/docs-app/src/examples/core-examples/overlayExample.tsx
@@ -13,6 +13,13 @@
  * limitations under the License.
  */
 
+/**
+ * @fileoverview This component is DEPRECATED, and the code is frozen.
+ * All changes & bugfixes should be made to Overlay2 instead.
+ */
+
+/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */
+
 import classNames from "classnames";
 import * as React from "react";
 
diff --git a/packages/docs-app/src/styles/_examples.scss b/packages/docs-app/src/styles/_examples.scss
index ec556620b9..04a9a53c75 100644
--- a/packages/docs-app/src/styles/_examples.scss
+++ b/packages/docs-app/src/styles/_examples.scss
@@ -320,6 +320,13 @@ $docs-hotkey-piano-height: 510px;
   }
 }
 
+#{example("Overlay")},
+#{example("Overlay2")} {
+  .docs-example {
+    position: relative;
+  }
+}
+
 // prettier-ignore
 .docs-overlay-example-transition {
   $overlay-example-width: $pt-grid-size * 40;
@@ -350,6 +357,11 @@ $docs-hotkey-piano-height: 510px;
 
   top: 0;
   width: $overlay-example-width;
+
+  .#{$ns}-overlay-inline & {
+    left: calc(50% - #{$overlay-example-width * 0.5});
+    margin: 20px 0;
+  }
 }
 
 .docs-overlay-example-tall {
diff --git a/packages/select/src/components/omnibar/omnibar.tsx b/packages/select/src/components/omnibar/omnibar.tsx
index 3fd0a7ff41..8491a76b8d 100644
--- a/packages/select/src/components/omnibar/omnibar.tsx
+++ b/packages/select/src/components/omnibar/omnibar.tsx
@@ -17,7 +17,7 @@
 import classNames from "classnames";
 import * as React from "react";
 
-import { DISPLAYNAME_PREFIX, InputGroup, type InputGroupProps, Overlay, type OverlayProps } from "@blueprintjs/core";
+import { DISPLAYNAME_PREFIX, InputGroup, type InputGroupProps, Overlay2, type OverlayProps } from "@blueprintjs/core";
 import { Search } from "@blueprintjs/icons";
 
 import { Classes, type ListItemsProps } from "../../common";
@@ -89,7 +89,7 @@ export class Omnibar<T> extends React.PureComponent<OmnibarProps<T>> {
         const handlers = isOpen ? { onKeyDown: handleKeyDown, onKeyUp: handleKeyUp } : {};
 
         return (
-            <Overlay
+            <Overlay2
                 hasBackdrop={true}
                 {...overlayProps}
                 isOpen={isOpen}
@@ -108,7 +108,7 @@ export class Omnibar<T> extends React.PureComponent<OmnibarProps<T>> {
                     />
                     {listProps.itemList}
                 </div>
-            </Overlay>
+            </Overlay2>
         );
     };
 
diff --git a/packages/table/test/columnHeaderCellTests.tsx b/packages/table/test/columnHeaderCellTests.tsx
index ab2cdf6208..66ea3abd78 100644
--- a/packages/table/test/columnHeaderCellTests.tsx
+++ b/packages/table/test/columnHeaderCellTests.tsx
@@ -89,21 +89,25 @@ describe("<ColumnHeaderCell>", () => {
             expect(text).to.equal("Header of 2");
         });
 
-        it("renders custom menu items with a menuRenderer callback", () => {
+        it("renders custom menu items with a menuRenderer callback", done => {
             const columnHeaderCellRenderer = (columnIndex: number) => (
                 <ColumnHeaderCell name={`COL-${columnIndex}`} menuRenderer={renderMenu} />
             );
             const table = harness.mount(createTableOfSize(3, 2, { columnHeaderCellRenderer }));
             expectMenuToOpen(table);
 
-            // attempt to click one of the menu items
-            ElementHarness.document().find('[data-icon="export"]')!.mouse("click");
-            expect(menuClickSpy.called).to.be.true;
+            // popovers need a tick to render contents after they open
+            setTimeout(() => {
+                // attempt to click one of the menu items
+                ElementHarness.document().find('[data-icon="export"]')!.mouse("click");
+                expect(menuClickSpy.called, "expected menu item click handler to be called").to.be.true;
+                done();
+            });
         });
 
-        it("custom menu supports popover props", () => {
+        it("custom menu supports popover props", done => {
             const expectedMenuPopoverProps = {
-                placement: "top" as const,
+                placement: "right-start" as const,
                 popoverClassName: "test-popover-class",
             };
             const columnHeaderCellRenderer = (columnIndex: number) => (
@@ -115,10 +119,20 @@ describe("<ColumnHeaderCell>", () => {
             );
             const table = harness.mount(createTableOfSize(3, 2, { columnHeaderCellRenderer }));
             expectMenuToOpen(table);
-            const popover = ElementHarness.document().find(`.${CoreClasses.POPOVER}`);
-            expect(popover.hasClass(expectedMenuPopoverProps.popoverClassName)).to.be.true;
-            expect(popover.hasClass(`${CoreClasses.POPOVER_CONTENT_PLACEMENT}-${expectedMenuPopoverProps.placement}`))
-                .to.be.true;
+
+            // popovers need a tick to render contents after they open
+            setTimeout(() => {
+                const popover = ElementHarness.document().find(`.${CoreClasses.POPOVER}`);
+                expect(
+                    popover.hasClass(expectedMenuPopoverProps.popoverClassName),
+                    `expected popover element to have ${expectedMenuPopoverProps.popoverClassName} class`,
+                ).to.be.true;
+                expect(
+                    popover.hasClass(`${CoreClasses.POPOVER_CONTENT_PLACEMENT}-right`),
+                    `expected popover element to have '${expectedMenuPopoverProps.placement}' placement classes applied`,
+                ).to.be.true;
+                done();
+            });
         });
 
         it("renders loading state properly", () => {
@@ -146,7 +160,10 @@ describe("<ColumnHeaderCell>", () => {
             table.find(`.${Classes.TABLE_COLUMN_HEADERS}`)!.mouse("mousemove");
             const target = table.find(`.${Classes.TABLE_TH_MENU}.${CoreClasses.POPOVER_TARGET}`)!;
             target.mouse("click");
-            expect(target.hasClass(CoreClasses.POPOVER_OPEN)).to.be.true;
+            expect(
+                target.hasClass(CoreClasses.POPOVER_OPEN),
+                "expected th menu popover target element to have 'popover open' indicator class",
+            ).to.be.true;
         }
     });
 
diff --git a/packages/table/test/harness.ts b/packages/table/test/harness.ts
index 085889163c..62b709ee88 100644
--- a/packages/table/test/harness.ts
+++ b/packages/table/test/harness.ts
@@ -16,9 +16,11 @@
 
 /* eslint-disable  max-classes-per-file */
 
-import type * as React from "react";
+import * as React from "react";
 import * as ReactDOM from "react-dom";
 
+import { HotkeysProvider } from "@blueprintjs/core";
+
 export type MouseEventType = "click" | "mousedown" | "mouseup" | "mousemove" | "mouseenter" | "mouseleave";
 export type KeyboardEventType = "keypress" | "keydown" | "keyup";
 
@@ -208,11 +210,12 @@ export class ReactHarness {
 
     constructor() {
         this.container = document.createElement("div");
-        document.documentElement.appendChild(this.container);
+        document.body.appendChild(this.container);
     }
 
     public mount(component: React.ReactElement<any>) {
-        ReactDOM.render(component, this.container);
+        // wrap in a <HotkeysProvider> to avoid console warnings
+        ReactDOM.render(React.createElement(HotkeysProvider, { children: component }), this.container);
         return new ElementHarness(this.container);
     }
 
@@ -221,7 +224,7 @@ export class ReactHarness {
     }
 
     public destroy() {
-        document.documentElement.removeChild(this.container);
+        document.body.removeChild(this.container);
         // @ts-ignore
         delete this.container;
     }
diff --git a/packages/table/test/index.ts b/packages/table/test/index.ts
index 4b7c4af81f..9056e24de3 100644
--- a/packages/table/test/index.ts
+++ b/packages/table/test/index.ts
@@ -16,34 +16,34 @@
 
 import "@blueprintjs/test-commons/bootstrap";
 
-import "./batcherTests.tsx";
-import "./cellTests.tsx";
-import "./clipboardTests.ts";
-import "./columnHeaderCellTests.tsx";
-import "./columnTests.tsx";
+import "./batcherTests";
+import "./cellTests";
+import "./clipboardTests";
+import "./columnHeaderCellTests";
+import "./columnTests";
 import "./common/internal";
-import "./editableCell2Tests.tsx";
-import "./editableCellTests.tsx";
-import "./editableNameTests.tsx";
-import "./formats/jsonFormatTests.tsx";
-import "./formats/truncatedFormatTests.tsx";
-import "./gridTests.ts";
-import "./guidesTests.tsx";
-import "./loadableContentTests.tsx";
-import "./loadingOptionsTests.tsx";
-import "./locatorTests.tsx";
-import "./menusTests.tsx";
-import "./quadrants/tableQuadrantStackTests.tsx";
-import "./quadrants/tableQuadrantTests.tsx";
-import "./rectTests.ts";
-import "./regionsTests.ts";
-import "./reorderableTests.tsx";
-import "./resizableTests.tsx";
-import "./rowHeaderCellTests.tsx";
-import "./selectableTests.tsx";
-import "./selectionTests.tsx";
-import "./table2Tests.tsx";
-import "./tableBody2Tests.tsx";
-import "./tableBodyTests.tsx";
-import "./tableTests.tsx";
-import "./utilsTests.ts";
+import "./editableCell2Tests";
+import "./editableCellTests";
+import "./editableNameTests";
+import "./formats/jsonFormatTests";
+import "./formats/truncatedFormatTests";
+import "./gridTests";
+import "./guidesTests";
+import "./loadableContentTests";
+import "./loadingOptionsTests";
+import "./locatorTests";
+import "./menusTests";
+import "./quadrants/tableQuadrantStackTests";
+import "./quadrants/tableQuadrantTests";
+import "./rectTests";
+import "./regionsTests";
+import "./reorderableTests";
+import "./resizableTests";
+import "./rowHeaderCellTests";
+import "./selectableTests";
+import "./selectionTests";
+import "./table2Tests";
+import "./tableBody2Tests";
+import "./tableBodyTests";
+import "./tableTests";
+import "./utilsTests";