diff --git a/addons/web/static/src/js/core/dialog.js b/addons/web/static/src/js/core/dialog.js
index bb522992c2f481c9b77ac1194a50385723343ea2..6e9620421c3918e08d654b984205d27d5bec95d6 100644
--- a/addons/web/static/src/js/core/dialog.js
+++ b/addons/web/static/src/js/core/dialog.js
@@ -4,6 +4,7 @@ odoo.define('web.Dialog', function (require) {
 var core = require('web.core');
 var dom = require('web.dom');
 var Widget = require('web.Widget');
+const OwlDialog = require('web.OwlDialog');
 
 var QWeb = core.qweb;
 var _t = core._t;
@@ -205,6 +206,9 @@ var Dialog = Widget.extend({
             if (options && options.shouldFocusButtons) {
                 self._onFocusControlButton();
             }
+
+            // Notifies OwlDialog to adjust focus/active properties on owl dialogs
+            OwlDialog.display(self);
         });
 
         return self;
@@ -236,6 +240,9 @@ var Dialog = Widget.extend({
             return;
         }
 
+        // Notifies OwlDialog to adjust focus/active properties on owl dialogs
+        OwlDialog.hide(this);
+
         // Triggers the onForceClose event if the callback is defined
         if (this.onForceClose) {
             this.onForceClose();
diff --git a/addons/web/static/src/js/core/owl_dialog.js b/addons/web/static/src/js/core/owl_dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..a6ee3c7b1d8a6bf194f93fad7fd39cd663176cca
--- /dev/null
+++ b/addons/web/static/src/js/core/owl_dialog.js
@@ -0,0 +1,259 @@
+odoo.define('web.OwlDialog', function (require) {
+    "use strict";
+
+    const { useExternalListener } = require('web.custom_hooks');
+
+    const { Component, hooks, misc } = owl;
+    const { Portal } = misc;
+    const { useRef } = hooks;
+    const SIZE_CLASSES = {
+        'extra-large': 'modal-xl',
+        'large': 'modal-lg',
+        'small': 'modal-sm',
+    };
+
+    /**
+     * Dialog (owl version)
+     *
+     * Represents a bootstrap-styled dialog handled with pure JS. Its implementation
+     * is roughly the same as the legacy dialog, the only exception being the buttons.
+     * @extends Component
+     **/
+    class Dialog extends Component {
+        /**
+         * @param {Object} [props]
+         * @param {(boolean|string)} [props.backdrop='static'] The kind of modal backdrop
+         *      to use (see Bootstrap documentation).
+         * @param {string} [props.contentClass] Class to add to the dialog
+         * @param {boolean} [props.fullscreen=false] Whether the dialog should be
+         *      open in fullscreen mode (the main usecase is mobile).
+         * @param {boolean} [props.renderFooter=true] Whether the dialog footer
+         *      should be rendered.
+         * @param {boolean} [props.renderHeader=true] Whether the dialog header
+         *      should be rendered.
+         * @param {string} [props.size='large'] 'extra-large', 'large', 'medium'
+         *      or 'small'.
+         * @param {string} [props.subtitle='']
+         * @param {string} [props.title='Odoo']
+         * @param {boolean} [props.technical=true] If set to false, the modal will have
+         *      the standard frontend style (use this for non-editor frontend features).
+         */
+        constructor() {
+            super(...arguments);
+
+            this.modalRef = useRef('modal');
+            this.footerRef = useRef('modal-footer');
+
+            useExternalListener(window, 'keydown', this._onKeydown);
+        }
+
+        mounted() {
+            this.constructor.display(this);
+
+            this.env.bus.on('close_dialogs', this, this._close);
+
+            if (this.props.renderFooter) {
+                // Set up main button : will first look for an element with the
+                // 'btn-primary' class, then a 'btn' class, then the first button
+                // element.
+                let mainButton = this.footerRef.el.querySelector('.btn.btn-primary');
+                if (!mainButton) {
+                    mainButton = this.footerRef.el.querySelector('.btn');
+                }
+                if (!mainButton) {
+                    mainButton = this.footerRef.el.querySelector('button');
+                }
+                if (mainButton) {
+                    this.mainButton = mainButton;
+                    this.mainButton.addEventListener('keydown', this._onMainButtonKeydown.bind(this));
+                    this.mainButton.focus();
+                }
+            }
+
+            this._removeTooltips();
+        }
+
+        async willUnmount() {
+            this.env.bus.off('close_dialogs', this, this._close);
+
+            this._removeTooltips();
+
+            this.constructor.hide(this);
+        }
+
+        //--------------------------------------------------------------------------
+        // Getters
+        //--------------------------------------------------------------------------
+
+        /**
+         * @returns {string}
+         */
+        get size() {
+            return SIZE_CLASSES[this.props.size];
+        }
+
+        //--------------------------------------------------------------------------
+        // Private
+        //--------------------------------------------------------------------------
+
+        /**
+         * Send an event signaling that the dialog must be closed.
+         * @private
+         */
+        _close() {
+            this.trigger('dialog_closed');
+        }
+
+        /**
+         * Remove any existing tooltip present in the DOM.
+         * @private
+         */
+        _removeTooltips() {
+            for (const tooltip of document.querySelectorAll('.tooltip')) {
+                tooltip.remove(); // remove open tooltip if any to prevent them staying when modal is opened
+            }
+        }
+
+        //--------------------------------------------------------------------------
+        // Handlers
+        //--------------------------------------------------------------------------
+
+        /**
+         * @private
+         */
+        _onBackdropClick() {
+            if (this.props.backdrop === 'static') {
+                if (this.mainButton) {
+                    this.mainButton.focus();
+                }
+            } else {
+                this._close();
+            }
+        }
+
+        /**
+         * @private
+         */
+        _onFocus() {
+            if (this.mainButton) {
+                this.mainButton.focus();
+            }
+        }
+
+        /**
+         * Manage the TAB key on the main button. If the focus is on a primary
+         * button and the user tries to tab to go to the next button : a tooltip
+         * will be displayed.
+         * @private
+         * @param {KeyboardEvent} ev
+         */
+        _onMainButtonKeydown(ev) {
+            if (ev.key === 'Tab' && !ev.shiftKey) {
+                ev.preventDefault();
+                $(this.mainButton)
+                    .tooltip({
+                        delay: { show: 200, hide: 0 },
+                        title: () => this.env.qweb.render('DialogButton.tooltip', {
+                            title: this.mainButton.innerText.toUpperCase(),
+                        }),
+                        trigger: 'manual',
+                    })
+                    .tooltip('show');
+            }
+        }
+
+        /**
+         * @private
+         * @param {KeyboardEvent} ev
+         */
+        _onKeydown(ev) {
+            if (
+                ev.key === 'Escape' &&
+                !['INPUT', 'TEXTAREA'].includes(ev.target.tagName) &&
+                this.constructor.displayed[this.constructor.displayed.length - 1] === this
+            ) {
+                ev.preventDefault();
+                ev.stopImmediatePropagation();
+                ev.stopPropagation();
+                this._close();
+            }
+        }
+
+        //--------------------------------------------------------------------------
+        // Static
+        //--------------------------------------------------------------------------
+
+        /**
+         * Push the given dialog at the end of the displayed list then set it as
+         * active and all the others as passive.
+         * @param {(LegacyDialog|OwlDialog)} dialog
+         */
+        static display(dialog) {
+            // Deactivate previous dialog
+            const activeDialogEl = document.querySelector('.modal.o_active_modal');
+            if (activeDialogEl) {
+                activeDialogEl.classList.remove('o_active_modal');
+            }
+            // Push dialog
+            this.displayed.push(dialog);
+            // Add active class
+            const modalEl = dialog instanceof this ?
+                // Owl dialog
+                dialog.modalRef.el :
+                // Legacy dialog
+                dialog.$modal[0];
+            modalEl.classList.add('o_active_modal');
+            // Update body class
+            document.body.classList.add('modal-open');
+        }
+
+        /**
+         * Set the given displayed dialog as passive and the last added displayed dialog
+         * as active, then remove it from the displayed list.
+         * @param {(LegacyDialog|OwlDialog)} dialog
+         */
+        static hide(dialog) {
+            // Remove given dialog from the list
+            this.displayed.splice(this.displayed.indexOf(dialog), 1);
+            // Activate last dialog and update body class
+            const lastDialog = this.displayed[this.displayed.length - 1];
+            if (lastDialog) {
+                lastDialog.el.focus();
+                const modalEl = lastDialog instanceof this ?
+                    // Owl dialog
+                    lastDialog.modalRef.el :
+                    // Legacy dialog
+                    lastDialog.$modal[0];
+                modalEl.classList.add('o_active_modal');
+            } else {
+                document.body.classList.remove('modal-open');
+            }
+        }
+    }
+
+    Dialog.displayed = [];
+
+    Dialog.components = { Portal };
+    Dialog.defaultProps = {
+        backdrop: 'static',
+        renderFooter: true,
+        renderHeader: true,
+        size: 'large',
+        technical: true,
+        title: "Odoo",
+    };
+    Dialog.props = {
+        backdrop: { validate: b => ['static', true, false].includes(b), optional: 1 },
+        contentClass: { type: String, optional: 1 },
+        fullscreen: { type: Boolean, optional: 1 },
+        renderFooter: { type: Boolean, optional: 1 },
+        renderHeader: { type: Boolean, optional: 1 },
+        size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s), optional: 1 },
+        subtitle: { type: String, optional: 1 },
+        technical: { type: Boolean, optional: 1 },
+        title: { type: String, optional: 1 },
+    };
+    Dialog.template = 'OwlDialog';
+
+    return Dialog;
+});
diff --git a/addons/web/static/src/scss/modal.scss b/addons/web/static/src/scss/modal.scss
index 3463d9e20ac50fba2687eeb082bf066bfad03c52..d82ddec6213b76f1e8ef1e15a02d46b68deda6bf 100644
--- a/addons/web/static/src/scss/modal.scss
+++ b/addons/web/static/src/scss/modal.scss
@@ -54,10 +54,9 @@
             footer {
                 > :not(:first-child) { margin-left: .25rem; }
                 > :not(:last-child) { margin-right: .25rem; }
-            }
-
-            button {
-                margin-bottom: .5rem;
+                button {
+                    margin-bottom: .5rem;
+                }
             }
         }
     }
@@ -107,9 +106,28 @@
     }
 }
 
+.modal:not(.o_active_modal) {
+    z-index: 1030;
+}
+
+.o_dialog {
+
+    > .modal {
+        display: block;
+        pointer-events: none;
+    }
+
+    .modal-content {
+        pointer-events: auto;
+    }
+}
+
 body.modal-open {
     // Allow vertical scrolling in modals on iOS
     -webkit-overflow-scrolling: touch;
+    .modal {
+        @include o-scrollbar-overlay;
+    }
 }
 
 // Temporary fix for modals which are not instantiated thanks to the Dialog
diff --git a/addons/web/static/src/scss/utils.scss b/addons/web/static/src/scss/utils.scss
index 794b8bea90e782fa804cfef74b3c063a2651f399..1d410fc74756bcc314b1bc0220f6c75482da0185 100644
--- a/addons/web/static/src/scss/utils.scss
+++ b/addons/web/static/src/scss/utils.scss
@@ -385,6 +385,13 @@
     }
 }
 
+// Scrollbar doesn't overlap with content
+@mixin o-scrollbar-overlay {
+    overflow-y: auto;
+    overflow-y: overlay;
+    -ms-overflow-style: -ms-autohiding-scrollbar;
+}
+
 %o-nocontent-init-image {
     content: "";
     display: block;
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index a3fdb09611c8897533bd38709e9d4d389908949e..961c11cdc90d9a7de8645e2ff8d3f31252394133 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -1,6 +1,54 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <templates id="template" xml:space="preserve">
 
+<!-- Owl Templates -->
+
+<t t-name="DialogButton.tooltip" owl="1">
+    <div class="oe_tooltip_string" role="tooltip">
+        <div class="tooltip-inner">
+            Hit ENTER to <t t-esc="title"/>
+        </div>
+    </div>
+</t>
+
+<t t-name="OwlDialog" owl="1">
+    <Portal target="'body'">
+        <div class="o_dialog" t-on-focus="_onFocus">
+            <div t-if="props.backdrop" class="modal-backdrop show" t-on-click="_onBackdropClick"/>
+            <div role="dialog" class="modal"
+                tabindex="-1"
+                t-att-class="{ o_technical_modal: props.technical, o_modal_full: props.fullscreen }"
+                t-ref="modal"
+                t-on-close_dialog.stop="_close"
+                >
+                <div class="modal-dialog" t-att-class="size">
+                    <div class="modal-content" t-att-class="props.contentClass">
+                        <header t-if="props.renderHeader" class="modal-header">
+                            <h4 class="modal-title">
+                                <t t-esc="props.title"/>
+                                <span t-if="props.subtitle" class="o_subtitle text-muted small" t-esc="props.subtitle"/>
+                            </h4>
+                            <button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="_close">×</button>
+                        </header>
+                        <main class="modal-body">
+                            <t t-slot="default"/>
+                        </main>
+                        <footer t-if="props.renderFooter" class="modal-footer" t-ref="modal-footer">
+                            <t t-slot="buttons">
+                                <button class="btn btn-primary" t-on-click.prevent="_close">
+                                    Ok
+                                </button>
+                            </t>
+                        </footer>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </Portal>
+</t>
+
+<!-- Legacy Templates -->
+
 <div t-name="EmptyComponent"/>
 
 <div t-name="Loading" class="o_loading"/>
diff --git a/addons/web/static/src/xml/dialog.xml b/addons/web/static/src/xml/dialog.xml
index 645e6e2391ea59bb0566109316afe49f41b4cdb6..848f5a1e9b613573bbd8140a7d6820b685d31fa9 100644
--- a/addons/web/static/src/xml/dialog.xml
+++ b/addons/web/static/src/xml/dialog.xml
@@ -3,17 +3,25 @@
 
 <!-- These templates are accessible in backend and frontend -->
 
-<div role="dialog" t-name="Dialog" t-attf-class="modal#{technical ? ' o_technical_modal' : ''}#{fullscreen ? ' o_modal_full': ''}" tabindex="-1" data-backdrop="static" t-att-id="_.uniqueId('modal_')" aria-hidden="true">
-    <div class="modal-dialog">
-        <div class="modal-content">
-            <header t-if="renderHeader" class="modal-header">
-                <h4 class="modal-title"><t t-raw="title"/><span class="o_subtitle text-muted small"><t t-esc="subtitle"/></span></h4>
-                <button type="button" class="close" data-dismiss="modal" aria-label="Close" tabindex="-1">×</button>
-            </header>
-            <main class="modal-body"/>
-            <footer t-if="renderFooter" class="modal-footer"/>
+<t t-name="Dialog">
+    <div role="dialog"
+        t-attf-class="modal o_legacy_dialog #{ technical ? ' o_technical_modal' : '' } #{ fullscreen ? ' o_modal_full': '' }"
+        tabindex="-1"
+        data-backdrop="static"
+        t-att-id="_.uniqueId('modal_')"
+        aria-hidden="true"
+        >
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <header t-if="renderHeader" class="modal-header">
+                    <h4 class="modal-title"><t t-raw="title"/><span class="o_subtitle text-muted small" t-esc="subtitle"/></h4>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" tabindex="-1">×</button>
+                </header>
+                <main class="modal-body"/>
+                <footer t-if="renderFooter" class="modal-footer"/>
+            </div>
         </div>
     </div>
-</div>
+</t>
 
 </templates>
diff --git a/addons/web/static/tests/core/owl_dialog_tests.js b/addons/web/static/tests/core/owl_dialog_tests.js
new file mode 100644
index 0000000000000000000000000000000000000000..8114408bf4890e06b4a2590f3fdfe5845f05b2a2
--- /dev/null
+++ b/addons/web/static/tests/core/owl_dialog_tests.js
@@ -0,0 +1,250 @@
+odoo.define('web.owl_dialog_tests', function (require) {
+    "use strict";
+
+    const LegacyDialog = require('web.Dialog');
+    const makeTestEnvironment = require('web.test_env');
+    const Dialog = require('web.OwlDialog');
+    const testUtils = require('web.test_utils');
+
+    const { Component, tags, useState } = owl;
+    const EscapeKey = { key: 'Escape', keyCode: 27, which: 27 };
+    const { xml } = tags;
+
+    QUnit.module('core', {}, function () {
+        QUnit.module('OwlDialog');
+
+        QUnit.test("Rendering of all props", async function (assert) {
+            assert.expect(26);
+
+            class SubComponent extends Component {
+                // Handlers
+                _onClick() {
+                    assert.step('subcomponent_clicked');
+                }
+            }
+            SubComponent.template = xml`<div class="o_subcomponent" t-esc="props.text" t-on-click="_onClick"/>`;
+
+            class Parent extends Component {
+                constructor() {
+                    super(...arguments);
+                    this.state = useState({ textContent: "sup" });
+                }
+                // Handlers
+                _onButtonClicked(ev) {
+                    assert.step('button_clicked');
+                }
+                _onDialogClosed() {
+                    assert.step('dialog_closed');
+                }
+            }
+            Parent.components = { Dialog, SubComponent };
+            Parent.env = makeTestEnvironment();
+            Parent.template = xml`
+                <Dialog
+                    backdrop="state.backdrop"
+                    contentClass="state.contentClass"
+                    fullscreen="state.fullscreen"
+                    renderFooter="state.renderFooter"
+                    renderHeader="state.renderHeader"
+                    size="state.size"
+                    subtitle="state.subtitle"
+                    technical="state.technical"
+                    title="state.title"
+                    t-on-dialog_closed="_onDialogClosed"
+                    >
+                    <SubComponent text="state.textContent"/>
+                    <t t-set="buttons">
+                        <button class="btn btn-primary" t-on-click="_onButtonClicked">The Button</button>
+                    </t>
+                </Dialog>`;
+
+            const parent = new Parent();
+            await parent.mount(testUtils.prepareTarget());
+            const dialog = document.querySelector('.o_dialog');
+
+            // Helper function
+            async function changeProps(key, value) {
+                parent.state[key] = value;
+                await testUtils.nextTick();
+            }
+
+            // Basic layout with default properties
+            assert.containsOnce(dialog, '.modal-backdrop.show');
+            assert.containsOnce(dialog, '.modal.o_technical_modal');
+            assert.hasClass(dialog.querySelector('.modal .modal-dialog'), 'modal-lg');
+            assert.containsOnce(dialog, '.modal-header > button.close');
+            assert.containsOnce(dialog, '.modal-footer > button.btn.btn-primary');
+            assert.strictEqual(dialog.querySelector('.modal-body').innerText.trim(), "sup",
+                "Subcomponent should match with its given text");
+
+            // Backdrop (default: 'static')
+            // Static backdrop click should focus first button
+            // => we need to reset that property
+            await testUtils.dom.click(dialog.querySelector('.modal-backdrop'));
+            assert.strictEqual(document.activeElement, dialog.querySelector('.btn-primary'),
+                "Button should be focused when clicking on backdrop");
+
+            await changeProps('backdrop', false);
+            assert.containsNone(document.body, '.modal-backdrop');
+
+            await changeProps('backdrop', true);
+            await testUtils.dom.click(dialog.querySelector('.modal-backdrop'));
+
+            // Dialog class (default: '')
+            await changeProps('contentClass', 'my_dialog_class');
+            assert.hasClass(dialog.querySelector('.modal-content'), 'my_dialog_class');
+
+            // Full screen (default: false)
+            assert.doesNotHaveClass(dialog.querySelector('.modal'), 'o_modal_full');
+            await changeProps('fullscreen', true);
+            assert.hasClass(dialog.querySelector('.modal'), 'o_modal_full');
+
+            // Size class (default: 'large')
+            await changeProps('size', 'extra-large');
+            assert.strictEqual(dialog.querySelector('.modal-dialog').className, 'modal-dialog modal-xl',
+                "Modal should have taken the class modal-xl");
+            await changeProps('size', 'medium');
+            assert.strictEqual(dialog.querySelector('.modal-dialog').className, 'modal-dialog',
+                "Modal should not have any additionnal class with 'medium'");
+            await changeProps('size', 'small');
+            assert.strictEqual(dialog.querySelector('.modal-dialog').className, 'modal-dialog modal-sm',
+                "Modal should have taken the class modal-sm");
+
+            // Subtitle (default: '')
+            await changeProps('subtitle', "The Subtitle");
+            assert.strictEqual(dialog.querySelector('span.o_subtitle').innerText.trim(), "The Subtitle",
+                "Subtitle should match with its given text");
+
+            // Technical (default: true)
+            assert.hasClass(dialog.querySelector('.modal'), 'o_technical_modal');
+            await changeProps('technical', false);
+            assert.doesNotHaveClass(dialog.querySelector('.modal'), 'o_technical_modal');
+
+            // Title (default: 'Odoo')
+            assert.strictEqual(dialog.querySelector('h4.modal-title').innerText.trim(), "Odoo" + "The Subtitle",
+                "Title should match with its default text");
+            await changeProps('title', "The Title");
+            assert.strictEqual(dialog.querySelector('h4.modal-title').innerText.trim(), "The Title" + "The Subtitle",
+                "Title should match with its given text");
+
+            // Reactivity of buttons
+            await testUtils.dom.click(dialog.querySelector('.modal-footer .btn-primary'));
+
+            // Render footer (default: true)
+            await changeProps('renderFooter', false);
+            assert.containsNone(dialog, '.modal-footer');
+
+            // Render header (default: true)
+            await changeProps('renderHeader', false);
+            assert.containsNone(dialog, '.header');
+
+            // Reactivity of subcomponents
+            await changeProps('textContent', "wassup");
+            assert.strictEqual(dialog.querySelector('.o_subcomponent').innerText.trim(), "wassup",
+                "Subcomponent should match with its given text");
+            await testUtils.dom.click(dialog.querySelector('.o_subcomponent'));
+
+            assert.verifySteps(['dialog_closed', 'button_clicked', 'subcomponent_clicked']);
+
+            parent.destroy();
+        });
+
+        QUnit.test("Interactions between multiple dialogs", async function (assert) {
+            assert.expect(22);
+
+            class Parent extends Component {
+                constructor() {
+                    super(...arguments);
+                    this.dialogIds = useState([]);
+                }
+                // Handlers
+                _onDialogClosed(id) {
+                    assert.step(`dialog_${id}_closed`);
+                    this.dialogIds.splice(this.dialogIds.findIndex(d => d === id), 1);
+                }
+            }
+            Parent.components = { Dialog };
+            Parent.env = makeTestEnvironment();
+            Parent.template = xml`
+                <div>
+                    <Dialog t-foreach="dialogIds" t-as="dialogId" t-key="dialogId"
+                        contentClass="'dialog_' + dialogId"
+                        t-on-dialog_closed="_onDialogClosed(dialogId)"
+                    />
+                </div>`;
+
+            const parent = new Parent();
+            await parent.mount(testUtils.prepareTarget());
+
+            // Dialog 1 : Owl
+            parent.dialogIds.push(1);
+            await testUtils.nextTick();
+            // Dialog 2 : Legacy
+            new LegacyDialog(null, {}).open();
+            await testUtils.nextTick();
+            // Dialog 3 : Legacy
+            new LegacyDialog(null, {}).open();
+            await testUtils.nextTick();
+            // Dialog 4 : Owl
+            parent.dialogIds.push(4);
+            await testUtils.nextTick();
+            // Dialog 5 : Owl
+            parent.dialogIds.push(5);
+            await testUtils.nextTick();
+
+            let modals = document.querySelectorAll('.modal');
+            assert.ok(modals[modals.length - 1].classList.contains('o_active_modal'),
+                "last dialog should have the active class");
+            assert.notOk(modals[modals.length - 1].classList.contains('o_legacy_dialog'),
+                "active dialog should not have the legacy class");
+            assert.containsN(document.body, '.o_dialog', 3);
+            assert.containsN(document.body, '.o_legacy_dialog', 2);
+
+            // Reactivity with owl dialogs
+            await testUtils.dom.triggerEvent(modals[modals.length - 1], 'keydown', EscapeKey); // Press Escape
+
+            modals = document.querySelectorAll('.modal');
+            assert.ok(modals[modals.length - 1].classList.contains('o_active_modal'),
+                "last dialog should have the active class");
+            assert.notOk(modals[modals.length - 1].classList.contains('o_legacy_dialog'),
+                "active dialog should not have the legacy class");
+            assert.containsN(document.body, '.o_dialog', 2);
+            assert.containsN(document.body, '.o_legacy_dialog', 2);
+
+            await testUtils.dom.click(modals[modals.length - 1].querySelector('.btn.btn-primary')); // Click on 'Ok' button
+
+            modals = document.querySelectorAll('.modal');
+            assert.containsOnce(document.body, '.modal.o_legacy_dialog.o_active_modal',
+                "active dialog should have the legacy class");
+            assert.containsOnce(document.body, '.o_dialog');
+            assert.containsN(document.body, '.o_legacy_dialog', 2);
+
+            // Reactivity with legacy dialogs
+            await testUtils.dom.triggerEvent(modals[modals.length - 1], 'keydown', EscapeKey);
+
+            modals = document.querySelectorAll('.modal');
+            assert.containsOnce(document.body, '.modal.o_legacy_dialog.o_active_modal',
+                "active dialog should have the legacy class");
+            assert.containsOnce(document.body, '.o_dialog');
+            assert.containsOnce(document.body, '.o_legacy_dialog');
+
+            await testUtils.dom.click(modals[modals.length - 1].querySelector('.close'));
+
+            modals = document.querySelectorAll('.modal');
+            assert.ok(modals[modals.length - 1].classList.contains('o_active_modal'),
+                "last dialog should have the active class");
+            assert.notOk(modals[modals.length - 1].classList.contains('o_legacy_dialog'),
+                "active dialog should not have the legacy class");
+            assert.containsOnce(document.body, '.o_dialog');
+            assert.containsNone(document.body, '.o_legacy_dialog');
+
+            parent.unmount();
+
+            assert.containsNone(document.body, '.modal');
+            // dialog 1 is closed through the removal of its parent => no callback
+            assert.verifySteps(['dialog_5_closed', 'dialog_4_closed']);
+
+            parent.destroy();
+        });
+    });
+});
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 8c815e334d0265f145e0c8e53a040412bc7d22d3..6a99aec06724f8973c460d751b9b918035a83405 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -2462,18 +2462,18 @@ QUnit.module('Views', {
         assert.isNotVisible(list.sidebar.$el, 'sidebar should be invisible');
         assert.containsN(list, 'tbody td.o_list_record_selector', 4, "should have 4 records");
 
-        testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+        await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
 
         assert.isVisible(list.sidebar.$el, 'sidebar should be visible');
 
         assert.verifySteps(['/web/dataset/search_read']);
-        testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
+        await testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
         await testUtils.dom.click(list.sidebar.$('a:contains(Archive)'));
         assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
-        testUtils.dom.click($('.modal-footer .btn-secondary'));
+        await testUtils.dom.click($('.modal-footer .btn-secondary'));
         assert.containsN(list, 'tbody td.o_list_record_selector', 4, "still should have 4 records");
 
-        testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
+        await testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
         await testUtils.dom.click(list.sidebar.$('a:contains(Archive)'));
         assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
         await testUtils.dom.click($('.modal-footer .btn-primary'));
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index eae536c47d195ffecaf3c297a4c8cec2f4cc9a4c..5cff868342794644e15d8b1b8403aff8a0586a72 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -138,6 +138,7 @@
         <script type="text/javascript" src="/web/static/src/js/core/collections.js"/>
         <script type="text/javascript" src="/web/static/src/js/core/concurrency.js"></script>
         <script type="text/javascript" src="/web/static/src/js/core/dialog.js"></script>
+        <script type="text/javascript" src="/web/static/src/js/core/owl_dialog.js"></script>
         <script type="text/javascript" src="/web/static/src/js/core/dom.js"></script>
         <script type="text/javascript" src="/web/static/src/js/core/local_storage.js"></script>
         <script type="text/javascript" src="/web/static/src/js/core/mixins.js"></script>
@@ -705,6 +706,7 @@
                 <script type="text/javascript" src="/web/static/tests/core/util_tests.js"></script>
                 <script type="text/javascript" src="/web/static/tests/core/widget_tests.js"></script>
                 <script type="text/javascript" src="/web/static/tests/core/dialog_tests.js"></script>
+                <script type="text/javascript" src="/web/static/tests/core/owl_dialog_tests.js"></script>
                 <script type="text/javascript" src="/web/static/tests/core/dom_tests.js"></script>
 
                 <script type="text/javascript" src="/web/static/tests/chrome/action_manager_tests.js"></script>