diff --git a/addons/web/static/src/js/fields/abstract_field_owl.js b/addons/web/static/src/js/fields/abstract_field_owl.js
new file mode 100644
index 0000000000000000000000000000000000000000..c9beafc9203e08f6599195b0d3e67af3c98bc955
--- /dev/null
+++ b/addons/web/static/src/js/fields/abstract_field_owl.js
@@ -0,0 +1,521 @@
+odoo.define('web.AbstractFieldOwl', function (require) {
+    "use strict";
+
+    const field_utils = require('web.field_utils');
+    const { useListener } = require('web.custom_hooks');
+
+    const { onMounted, onPatched } = owl.hooks;
+
+    /**
+     * This file defines the Owl version of the AbstractField. Specific fields
+     * written in Owl should override this component.
+     *
+     * Note that the API is not complete yet. Some features may not work properly
+     * yet (e.g. part of keyboard navigation, invalid fields notification).
+     *
+     * This is the basic field widget used by all the views to render a field in a view.
+     * These field widgets are mostly common to all views, in particular form and list
+     * views.
+     *
+     * The responsabilities of a field widget are mainly:
+     * - render a visual representation of the current value of a field
+     * - that representation is either in 'readonly' or in 'edit' mode
+     * - notify the rest of the system when the field has been changed by
+     *   the user (in edit mode)
+     *
+     * Notes
+     * - the widget is not supposed to be able to switch between modes.  If another
+     *   mode is required, the view will take care of instantiating another widget.
+     * - notify the system when its value has changed and its mode is changed to 'readonly'
+     * - notify the system when some action has to be taken, such as opening a record
+     * - the Field widget should not, ever, under any circumstance, be aware of
+     *   its parent.  The way it communicates changes with the rest of the system is by
+     *   triggering events (with trigger_up).  These events bubble up and are interpreted
+     *   by the most appropriate parent.
+     *
+     * Also, in some cases, it may not be practical to have the same widget for all
+     * views. In that situation, you can have a 'view specific widget'.  Just register
+     * the widget in the registry prefixed by the view type and a dot.  So, for example,
+     * a form specific many2one widget should be registered as 'form.many2one'.
+     *
+     * @module web.AbstractFieldOwl
+     */
+    class AbstractField extends owl.Component {
+        /**
+         * Abstract field class
+         *
+         * @constructor
+         * @param {Widget} parent
+         * @param {string} name The field name defined in the model
+         * @param {Object} record A record object (result of the get method of
+         *   a basic model)
+         * @param {Object} [options]
+         * @param {string} [options.mode=readonly] should be 'readonly' or 'edit'
+         */
+        constructor(parent, props) {
+            super(parent, props);
+
+            const options = Object.assign({}, props.options);
+            const record = props.record;
+            // 'name' is the field name displayed by this widget
+            this.name = props.fieldName;
+
+            // the datapoint fetched from the model
+            this.record = record;
+
+            // the 'field' property is a description of all the various field properties,
+            // such as the type, the comodel (relation), ...
+            this.field = record.fields[this.name];
+
+            // the 'viewType' is the type of the view in which the field widget is
+            // instantiated. For standalone widgets, a 'default' viewType is set.
+            this.viewType = options.viewType || 'default';
+
+            // the 'attrs' property contains the attributes of the xml 'field' tag,
+            // the inner views...
+            const fieldsInfo = record.fieldsInfo[this.viewType];
+            this.attrs = options.attrs || (fieldsInfo && fieldsInfo[this.name]) || {};
+
+            // the 'additionalContext' property contains the attributes to pass through the context.
+            this.additionalContext = options.additionalContext || {};
+
+            // this property tracks the current (parsed if needed) value of the field.
+            // Note that we don't use an event system anymore, using this.get('value')
+            // is no longer valid.
+            this.value = record.data[this.name];
+
+            // recordData tracks the values for the other fields for the same record.
+            // note that it is expected to be mostly a readonly property, you cannot
+            // use this to try to change other fields value, this is not how it is
+            // supposed to work. Also, do not use this.recordData[this.name] to get
+            // the current value, this could be out of sync after a _setValue.
+            this.recordData = record.data;
+
+            // the 'string' property is a human readable (and translated) description
+            // of the field. Mostly useful to be displayed in various places in the
+            // UI, such as tooltips or create dialogs.
+            this.string = this.attrs.string || this.field.string || this.name;
+
+            // Widget can often be configured in the 'options' attribute in the
+            // xml 'field' tag.  These options are saved (and evaled) in nodeOptions
+            this.nodeOptions = this.attrs.options || {};
+
+            // dataPointID is the id corresponding to the current record in the model.
+            // Its intended use is to be able to tag any messages going upstream,
+            // so the view knows which records was changed for example.
+            this.dataPointID = record.id;
+
+            // this is the res_id for the record in database.  Obviously, it is
+            // readonly.  Also, when the user is creating a new record, there is
+            // no res_id.  When the record will be created, the field widget will
+            // be destroyed (when the form view switches to readonly mode) and a new
+            // widget with a res_id in mode readonly will be created.
+            this.res_id = record.res_id;
+
+            // useful mostly to trigger rpcs on the correct model
+            this.model = record.model;
+
+            // a widget can be in two modes: 'edit' or 'readonly'.  This mode should
+            // never be changed, if a view changes its mode, it will destroy and
+            // recreate a new field widget.
+            this.mode = options.mode || "readonly";
+
+            // this flag tracks if the widget is in a valid state, meaning that the
+            // current value represented in the DOM is a value that can be parsed
+            // and saved.  For example, a float field can only use a number and not
+            // a string.
+            this._isValid = true;
+
+            // this is the last value that was set by the user, unparsed.  This is
+            // used to avoid setting the value twice in a row with the exact value.
+            this.lastSetValue = undefined;
+
+            // formatType is used to determine which format (and parse) functions
+            // to call to format the field's value to insert into the DOM (typically
+            // put into a span or an input), and to parse the value from the input
+            // to send it to the server. These functions are chosen according to
+            // the 'widget' attrs if is is given, and if it is a valid key, with a
+            // fallback on the field type, ensuring that the value is formatted and
+            // displayed according to the chosen widget, if any.
+            this.formatType = this.attrs.widget in field_utils.format ?
+                                this.attrs.widget :
+                                this.field.type;
+            // formatOptions (resp. parseOptions) is a dict of options passed to
+            // calls to the format (resp. parse) function.
+            this.formatOptions = {};
+            this.parseOptions = {};
+
+            // if we add decorations, we need to reevaluate the field whenever any
+            // value from the record is changed
+            if (this.attrs.decorations) {
+                this.resetOnAnyFieldChange = true;
+            }
+
+            useListener('keydown', this._onKeydown);
+            useListener('navigation-move', this._onNavigationMove);
+            onMounted(() => this._applyDecorations());
+            onPatched(() => this._applyDecorations());
+        }
+
+        /**
+         * Hack: studio tries to find the field with a selector base on its
+         * name, before it is mounted into the DOM. Ideally, this should be
+         * done in the onMounted hook, but in this case we are too late, and
+         * Studio finds nothing. As a consequence, the field can't be edited
+         * by clicking on its label (or on the row formed by the pair label-field).
+         *
+         * TODO: move this to mounted at some point?
+         *
+         * @override
+         */
+        __patch() {
+            const res = super.__patch(...arguments);
+            this.el.setAttribute('name', this.name);
+            this.el.classList.add('o_field_widget');
+            return res;
+        }
+
+        /**
+         * @async
+         * @param {Object} [nextProps]
+         * @param {Object} [nextProps.record]
+         * @param {Object} [nextProps.event]
+         * @returns {Promise}
+         */
+        async willUpdateProps(nextProps) {
+            this.record = nextProps.record;
+            this.recordData = this.record.data;
+            this.value = this.recordData[this.name];
+            this.lastSetValue = undefined;
+            return super.willUpdateProps(nextProps);
+        }
+
+        //--------------------------------------------------------------------------
+        // Public
+        //--------------------------------------------------------------------------
+
+        /**
+         * Returns the main field's DOM element (jQuery form) which can be focused
+         * by the browser.
+         *
+         * @returns {HTMLElement|null} main focusable element inside the widget
+         */
+        get focusableElement() {
+            return null;
+        }
+        /**
+         * Returns whether or not the field is empty and can thus be hidden. This
+         * method is typically called when the widget is in readonly, to hide it
+         * (and its label) if it is empty.
+         *
+         * @returns {boolean}
+         */
+        get isEmpty() {
+            return !this.isSet;
+        }
+        /**
+         * Returns true if the widget has a visible element that can take the focus
+         *
+         * @returns {boolean}
+         */
+        get isFocusable() {
+            const focusable = this.focusableElement;
+            // check if element is visible
+            return focusable && !!(focusable.offsetWidth || focusable.offsetHeight
+                || focusable.getClientRects().length);
+        }
+        /**
+         * @returns {boolean}
+         */
+        get isSet() {
+            return !!this.value;
+        }
+        /**
+         * @returns {boolean}
+         */
+        get isValid() {
+            return this._isValid;
+        }
+        /**
+         * Activates the field widget. By default, activation means focusing and
+         * selecting (if possible) the associated focusable element. The selecting
+         * part can be disabled.  In that case, note that the focused input/textarea
+         * will have the cursor at the very end.
+         *
+         * @param {Object} [options]
+         * @param {boolean} [options.noselect=false] if false and the input
+         *   is of type text or textarea, the content will also be selected
+         * @param {Event} [options.event] the event which fired this activation
+         * @returns {boolean} true if the widget was activated, false if the
+         *                    focusable element was not found or invisible
+         */
+        activate(options) {
+            if (this.isFocusable) {
+                const focusable = this.focusableElement;
+                focusable.focus();
+                if (focusable.matches('input[type="text"], textarea')) {
+                    focusable.selectionStart = focusable.selectionEnd = focusable.value.length;
+                    if (options && !options.noselect) {
+                        focusable.select();
+                    }
+                }
+                return true;
+            }
+            return false;
+        }
+        /**
+         * This function should be implemented by widgets that are not able to
+         * notify their environment when their value changes (maybe because their
+         * are not aware of the changes) or that may have a value in a temporary
+         * state (maybe because some action should be performed to validate it
+         * before notifying it). This is typically called before trying to save the
+         * widget's value, so it should call _setValue() to notify the environment
+         * if the value changed but was not notified.
+         *
+         * @abstract
+         * @returns {Promise|undefined}
+         */
+        commitChanges() {}
+        /**
+         * Sets the given id on the focusable element of the field and as 'for'
+         * attribute of potential internal labels.
+         *
+         * @param {string} id
+         */
+        setIDForLabel(id) {
+            if (this.focusableElement) {
+                this.focusableElement.setAttribute('id', id);
+            }
+        }
+        /**
+         * Update the modifiers with the newest value.
+         * Now this.attrs.modifiersValue can be used consistantly even with
+         * conditional modifiers inside field widgets, and without needing new
+         * events or synchronization between the widgets, renderer and controller
+         *
+         * @param {Object | null} modifiers  the updated modifiers
+         * @override
+         */
+        updateModifiersValue(modifiers) {
+            this.attrs.modifiersValue = modifiers || {};
+        }
+
+        //--------------------------------------------------------------------------
+        // Private
+        //--------------------------------------------------------------------------
+
+        /**
+         * Apply field decorations (only if field-specific decorations have been
+         * defined in an attribute).
+         *
+         * @private
+         */
+        _applyDecorations() {
+            for (const dec of this.attrs.decorations || []) {
+                const isToggled = py.PY_isTrue(
+                    py.evaluate(dec.expression, this.record.evalContext)
+                );
+                this.el.classList.toggle(dec.className, isToggled);
+            }
+        }
+        /**
+         * Converts the value from the field to a string representation.
+         *
+         * @private
+         * @param {any} value (from the field type)
+         * @returns {string}
+         */
+        _formatValue(value) {
+            const options = _.extend({}, this.nodeOptions,
+                { data: this.recordData }, this.formatOptions);
+            return field_utils.format[this.formatType](value, this.field, options);
+        }
+        /**
+         * This method check if a value is the same as the current value of the
+         * field.  For example, a fieldDate widget might want to use the moment
+         * specific value isSame instead of ===.
+         *
+         * This method is used by the _setValue method.
+         *
+         * @private
+         * @param {any} value
+         * @returns {boolean}
+         */
+        _isSameValue(value) {
+            return this.value === value;
+        }
+        /**
+         * Converts a string representation to a valid value.
+         *
+         * @private
+         * @param {string} value
+         * @returns {any}
+         */
+        _parseValue(value) {
+            return field_utils.parse[this.formatType](value, this.field, this.parseOptions);
+        }
+        /**
+         * This method is called by the widget, to change its value and to notify
+         * the outside world of its new state.  This method also validates the new
+         * value.  Note that this method does not rerender the widget, it should be
+         * handled by the widget itself, if necessary.
+         *
+         * @private
+         * @param {any} value
+         * @param {Object} [options]
+         * @param {boolean} [options.doNotSetDirty=false] if true, the basic model
+         *   will not consider that this field is dirty, even though it was changed.
+         *   Please do not use this flag unless you really need it.  Our only use
+         *   case is currently the pad widget, which does a _setValue in the
+         *   renderEdit method.
+         * @param {boolean} [options.notifyChange=true] if false, the basic model
+         *   will not notify and not trigger the onchange, even though it was changed.
+         * @param {boolean} [options.forceChange=false] if true, the change event will be
+         *   triggered even if the new value is the same as the old one
+         * @returns {Promise}
+         */
+        _setValue(value, options) {
+            // we try to avoid doing useless work, if the value given has not
+            // changed.  Note that we compare the unparsed values.
+            if (this.lastSetValue === value || (this.value === false && value === '')) {
+                return Promise.resolve();
+            }
+            this.lastSetValue = value;
+            try {
+                value = this._parseValue(value);
+                this._isValid = true;
+            } catch (e) {
+                this._isValid = false;
+                this.trigger('set-dirty', {dataPointID: this.dataPointID});
+                return Promise.reject({message: "Value set is not valid"});
+            }
+            if (!(options && options.forceChange) && this._isSameValue(value)) {
+                return Promise.resolve();
+            }
+            return new Promise((resolve, reject) => {
+                const changes = {};
+                changes[this.name] = value;
+                this.trigger('field-changed', {
+                    dataPointID: this.dataPointID,
+                    changes: changes,
+                    viewType: this.viewType,
+                    doNotSetDirty: options && options.doNotSetDirty,
+                    notifyChange: !options || options.notifyChange !== false,
+                    allowWarning: options && options.allowWarning,
+                    onSuccess: resolve,
+                    onFailure: reject,
+                });
+            });
+        }
+
+        //--------------------------------------------------------------------------
+        // Handlers
+        //--------------------------------------------------------------------------
+
+        /**
+         * Intercepts navigation keyboard events to prevent their default behavior
+         * and notifies the view so that it can handle it its own way.
+         *
+         * Note: the navigation keyboard events are stopped so that potential parent
+         * abstract field does not trigger the navigation_move event a second time.
+         * However, this might be controversial, we might wanna let the event
+         * continue its propagation and flag it to say that navigation has already
+         * been handled (TODO ?).
+         *
+         * @private
+         * @param {KeyEvent} ev
+         */
+        _onKeydown(ev) {
+            switch (ev.which) {
+                case $.ui.keyCode.TAB:
+                    this.trigger('navigation-move', {
+                        direction: ev.shiftKey ? 'previous' : 'next',
+                    });
+                    // TODO: stop/prevent original event if navigation-move event
+                    // has been handled
+                    break;
+                case $.ui.keyCode.ENTER:
+                    // We preventDefault the ENTER key because of two coexisting behaviours:
+                    // - In HTML5, pressing ENTER on a <button> triggers two events: a 'keydown' AND a 'click'
+                    // - When creating and opening a dialog, the focus is automatically given to the primary button
+                    // The end result caused some issues where a modal opened by an ENTER keypress (e.g. saving
+                    // changes in multiple edition) confirmed the modal without any intentionnal user input.
+                    ev.preventDefault();
+                    ev.stopPropagation();
+                    this.trigger('navigation-move', {direction: 'next_line'});
+                    break;
+                case $.ui.keyCode.ESCAPE:
+                    this.trigger('navigation-move', {direction: 'cancel', originalEvent: ev});
+                    break;
+                case $.ui.keyCode.UP:
+                    ev.stopPropagation();
+                    this.trigger('navigation-move', {direction: 'up'});
+                    break;
+                case $.ui.keyCode.RIGHT:
+                    ev.stopPropagation();
+                    this.trigger('navigation-move', {direction: 'right'});
+                    break;
+                case $.ui.keyCode.DOWN:
+                    ev.stopPropagation();
+                    this.trigger('navigation-move', {direction: 'down'});
+                    break;
+                case $.ui.keyCode.LEFT:
+                    ev.stopPropagation();
+                    this.trigger('navigation-move', {direction: 'left'});
+                    break;
+            }
+        }
+        /**
+         * Updates the target data value with the current AbstractField instance.
+         * This allows to consider the parent field in case of nested fields. The
+         * field which triggered the event is still accessible through ev.target.
+         *
+         * @private
+         * @param {CustomEvent} ev
+         */
+        _onNavigationMove(ev) {
+            ev.detail.target = this;
+        }
+    }
+
+    /**
+     * An object representing fields to be fetched by the model eventhough not present in the view
+     * This object contains "field name" as key and an object as value.
+     * That value object must contain the key "type"
+     * see FieldBinaryImage for an example.
+     */
+    AbstractField.fieldDependencies = {};
+    /**
+     * If this flag is set to true, the field widget will be reset on every
+     * change which is made in the view (if the view supports it). This is
+     * currently a form view feature.
+     */
+    AbstractField.resetOnAnyFieldChange = false;
+    /**
+     * If this flag is given a string, the related BasicModel will be used to
+     * initialize specialData the field might need. This data will be available
+     * through this.record.specialData[this.name].
+     *
+     * @see BasicModel._fetchSpecialData
+     */
+    AbstractField.specialData = false;
+    /**
+     * to override to indicate which field types are supported by the widget
+     *
+     * @type Array<String>
+     */
+    AbstractField.supportedFieldTypes = [];
+    /**
+     * To override to give a user friendly name to the widget.
+     *
+     * @type <string>
+     */
+    AbstractField.description = "";
+    /**
+     * Currently only used in list view.
+     * If this flag is set to true, the list column name will be empty.
+     */
+    AbstractField.noLabel = false;
+
+    return AbstractField;
+});
diff --git a/addons/web/static/src/js/fields/field_wrapper.js b/addons/web/static/src/js/fields/field_wrapper.js
new file mode 100644
index 0000000000000000000000000000000000000000..0b808d0c732f9bfe19f57eb44ed29c0599aa011f
--- /dev/null
+++ b/addons/web/static/src/js/fields/field_wrapper.js
@@ -0,0 +1,126 @@
+odoo.define('web.FieldWrapper', function (require) {
+    "use strict";
+
+    const { ComponentWrapper } = require('web.OwlCompatibility');
+    const field_utils = require('web.field_utils');
+
+    /**
+     * This file defines the FieldWrapper component, an extension of ComponentWrapper,
+     * needed to instanciate Owl fields inside legacy widgets. This component
+     * will be no longer necessary as soon as all legacy widgets using fields will
+     * be rewritten in Owl.
+     */
+    class FieldWrapper extends ComponentWrapper {
+        constructor() {
+            super(...arguments);
+
+            this._data = {};
+
+            const options = this.props.options || {};
+            const record = this.props.record;
+            this._data.name = this.props.fieldName;
+            this._data.record = record;
+            this._data.field = record.fields[this._data.name];
+            this._data.viewType = options.viewType || 'default';
+            const fieldsInfo = record.fieldsInfo[this._data.viewType];
+            this._data.attrs = options.attrs || (fieldsInfo && fieldsInfo[this._data.name]) || {};
+            this._data.additionalContext = options.additionalContext || {};
+            this._data.value = record.data[this._data.name];
+            this._data.recordData = record.data;
+            this._data.string = this._data.attrs.string || this._data.field.string || this._data.name;
+            this._data.nodeOptions = this._data.attrs.options || {};
+            this._data.dataPointID = record.id;
+            this._data.res_id = record.res_id;
+            this._data.model = record.model;
+            this._data.mode = options.mode || "readonly";
+            this._data._isValid = true;
+            this._data.lastSetValue = undefined;
+            this._data.formatType = this._data.attrs.widget in field_utils.format ?
+                                this._data.attrs.widget :
+                                this._data.field.type;
+            this._data.formatOptions = {};
+            this._data.parseOptions = {};
+            if (this._data.attrs.decorations) {
+                this._data.resetOnAnyFieldChange = true;
+            }
+            for (const key in this._data) {
+                Object.defineProperty(this, key, {
+                    get: () => (this.el ? this.componentRef.comp : this._data)[key],
+                });
+            }
+        }
+
+        //----------------------------------------------------------------------
+        // Getters
+        //----------------------------------------------------------------------
+
+        get $el() {
+            return $(this.el);
+        }
+        get fieldDependencies() {
+            return this.Component.fieldDependencies;
+        }
+        get resetOnAnyFieldChange() {
+            return this.Component.resetOnAnyFieldChange;
+        }
+        get specialData() {
+            return this.Component.specialData;
+        }
+        get supportedFieldTypes() {
+            return this.Component.supportedFieldTypes;
+        }
+        get description() {
+            return this.Component.description;
+        }
+        get noLabel() {
+            return this.Component.noLabel;
+        }
+
+        //----------------------------------------------------------------------
+        // Public
+        //----------------------------------------------------------------------
+
+        activate(options) {
+            return this.componentRef.comp.activate(options);
+        }
+        commitChanges() {
+            return this.componentRef.comp.commitChanges();
+        }
+        getFocusableElement() {
+            return $(this.componentRef.comp.focusableElement);
+        }
+        isEmpty() {
+            return this.componentRef.comp.isEmpty;
+        }
+        isFocusable() {
+            return this.componentRef.comp.isFocusable;
+        }
+        isSet() {
+            if (this.componentRef.comp) {
+                return this.componentRef.comp.isSet;
+            }
+            // because of the willStart, the real field widget may not be
+            // instantiated yet when the renderer first asks if it is set
+            // (only the wrapper is instantiated), so we instantiate one
+            // with the same props, get its 'isSet' status, and destroy it.
+            const c = new this.Component(null, this.props);
+            const isSet = c.isSet;
+            c.destroy();
+            return isSet;
+        }
+        isValid() {
+            return this.componentRef.comp.isValid;
+        }
+        reset(record, event) {
+            return this.update({record, event});
+        }
+        setIDForLabel(id) {
+            return this.componentRef.comp.setIDForLabel(id);
+        }
+        updateModifiersValue(modifiers) {
+            return this.componentRef.comp.updateModifiersValue(modifiers);
+        }
+    }
+
+    return FieldWrapper;
+});
diff --git a/addons/web/static/src/js/owl_compatibility.js b/addons/web/static/src/js/owl_compatibility.js
index d2a82e1f8b40973f285cd656d53b9b344af4dbbe..abf07d9681ddf7c0f0e0c620b8762bc2554aa831 100644
--- a/addons/web/static/src/js/owl_compatibility.js
+++ b/addons/web/static/src/js/owl_compatibility.js
@@ -10,7 +10,7 @@ odoo.define('web.OwlCompatibility', function () {
      */
 
     const { Component, hooks, tags } = owl;
-    const { useSubEnv } = hooks;
+    const { useRef, useSubEnv } = hooks;
     const { xml } = tags;
 
     const widgetSymbol = odoo.widgetSymbol;
@@ -297,28 +297,23 @@ odoo.define('web.OwlCompatibility', function () {
      */
     const WidgetAdapterMixin = {
         /**
-         * Calls __callMounted on each sub component, and its descendants (this
-         * function isn't recursive) when the widget is appended into the DOM.
+         * Calls on_attach_callback on each child ComponentWrapper, which will
+         * call __callMounted on each sub component (recursively), to mark them
+         * as mounted.
          */
         on_attach_callback() {
-            function recursiveCallMounted(component) {
-                for (const key in component.__owl__.children) {
-                    recursiveCallMounted(component.__owl__.children[key]);
-                }
-                component.__callMounted();
-            }
             for (const component of children.get(this) || []) {
-                recursiveCallMounted(component);
+                component.on_attach_callback();
             }
         },
         /**
-         * Calls __callWillUnmount on each sub component (this function is
-         * recursive and thus will be automatically called on its descendants)
-         * when the widget is removed/detached from the DOM.
+         * Calls on_detach_callback on each child ComponentWrapper, which will
+         * call __callWillUnmount to mark itself and its children as no longer
+         * mounted.
          */
         on_detach_callback() {
             for (const component of children.get(this) || []) {
-                component.__callWillUnmount();
+                component.on_detach_callback();
             }
         },
         /**
@@ -362,16 +357,29 @@ odoo.define('web.OwlCompatibility', function () {
             this.Component = Component;
             this.props = props || {};
             this._handledEvents = new Set(); // Owl events we are redirecting
+
+            this.componentRef = useRef("component");
         }
 
         /**
-         * Define (empty) on_attach_callback (resp. on_detach_callback) on the
-         * Wrapper in case the parent would call them, for instance in
-         * on_attach_callback (resp. on_detach_callback). We only do that to
-         * prevent a crash, but the logic is already handled by the mixin.
+         * Calls __callMounted on itself and on each sub component (as this
+         * function isn't recursive) when the component is appended into the DOM.
          */
-        on_attach_callback() {}
-        on_detach_callback() {}
+        on_attach_callback() {
+            function recursiveCallMounted(component) {
+                for (const key in component.__owl__.children) {
+                    recursiveCallMounted(component.__owl__.children[key]);
+                }
+                component.__callMounted();
+            }
+            recursiveCallMounted(this);
+        }
+        /**
+         * Calls __callWillUnmount to notify the component it will be unmounted.
+         */
+        on_detach_callback() {
+            this.__callWillUnmount();
+        }
 
         /**
          * Overrides to remove the reference to this component in the parent.
@@ -483,7 +491,7 @@ odoo.define('web.OwlCompatibility', function () {
             parentChildren.push(this);
         }
     }
-    ComponentWrapper.template = xml`<t t-component="Component" t-props="props"/>`;
+    ComponentWrapper.template = xml`<t t-component="Component" t-props="props" t-ref="component"/>`;
 
     return {
         ComponentAdapter,
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
index 07edeae343aaba64159cbd8fd9080c5986f26b20..046d10a741f8138372e48ab97ff31497ce93b9cd 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -13,9 +13,12 @@ var core = require('web.core');
 var dom = require('web.dom');
 var widgetRegistry = require('web.widget_registry');
 
+const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+const FieldWrapper = require('web.FieldWrapper');
+
 var qweb = core.qweb;
 
-var BasicRenderer = AbstractRenderer.extend({
+var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
     custom_events: {
         navigation_move: '_onNavigationMove',
     },
@@ -35,6 +38,30 @@ var BasicRenderer = AbstractRenderer.extend({
         // and on which field it is set.
         this.handleField = null;
     },
+    /**
+     * @override
+     */
+    destroy: function () {
+        this._super.apply(this, arguments);
+        WidgetAdapterMixin.destroy.call(this);
+    },
+    /**
+     * Called each time the renderer is attached into the DOM.
+     */
+    on_attach_callback: function () {
+        WidgetAdapterMixin.on_attach_callback.call(this);
+    },
+    /**
+     * Called each time the renderer is detached from the DOM.
+     */
+    on_detach_callback: function () {
+        WidgetAdapterMixin.on_detach_callback.call(this);
+    },
+
+    //--------------------------------------------------------------------------
+    // Public
+    //--------------------------------------------------------------------------
+
     /**
      * This method has two responsabilities: find every invalid fields in the
      * current view, and making sure that they are displayed as invalid, by
@@ -625,10 +652,21 @@ var BasicRenderer = AbstractRenderer.extend({
         // Initialize and register the widget
         // Readonly status is known as the modifiers have just been registered
         var Widget = record.fieldsInfo[this.viewType][fieldName].Widget;
-        var widget = new Widget(this, fieldName, record, {
+        const legacy = !(Widget.prototype instanceof owl.Component);
+        const widgetOptions = {
             mode: modifiers.readonly ? 'readonly' : mode,
             viewType: this.viewType,
-        });
+        };
+        let widget;
+        if (legacy) {
+            widget = new Widget(this, fieldName, record, widgetOptions);
+        } else {
+            widget = new FieldWrapper(this, Widget, {
+                fieldName,
+                record,
+                options: widgetOptions,
+            });
+        }
 
         // Register the widget so that it can easily be found again
         if (this.allFieldWidgets[record.id] === undefined) {
@@ -639,8 +677,13 @@ var BasicRenderer = AbstractRenderer.extend({
         widget.__node = node; // TODO get rid of this if possible one day
 
         // Prepare widget rendering and save the related promise
-        var def = widget._widgetRenderAndInsert(function () {});
         var $el = $('<div>');
+        let def;
+        if (legacy) {
+            def = widget._widgetRenderAndInsert(function () {});
+        } else {
+            def = widget.mount(document.createDocumentFragment());
+        }
 
         this.defs.push(def);
 
@@ -746,13 +789,23 @@ var BasicRenderer = AbstractRenderer.extend({
     _rerenderFieldWidget: function (widget, record, options) {
         // Render the new field widget
         var $el = this._renderFieldWidget(widget.__node, record, options);
-        widget.$el.replaceWith($el);
-
-        // Destroy the old widget and position the new one at the old one's
-        var oldIndex = this._destroyFieldWidget(record.id, widget);
-        var recordWidgets = this.allFieldWidgets[record.id];
-        var newWidget = recordWidgets.pop();
-        recordWidgets.splice(oldIndex, 0, newWidget);
+        const def = this.defs[this.defs.length - 1]; // this is the widget's def, resolved when it is ready
+        const $div = $('<div>');
+        $div.append($el); // $el will be replaced when widget is ready (see _renderFieldWidget)
+        def.then(() => {
+            widget.$el.replaceWith($div.children());
+
+            // Destroy the old widget and position the new one at the old one's
+            var oldIndex = this._destroyFieldWidget(record.id, widget);
+            var recordWidgets = this.allFieldWidgets[record.id];
+            let newWidget = recordWidgets.pop();
+            recordWidgets.splice(oldIndex, 0, newWidget);
+
+            // Mount new widget if necessary (mainly for Owl components)
+            if (this._isInDom && newWidget.on_attach_callback) {
+                newWidget.on_attach_callback();
+            }
+        });
     },
     /**
      * Unregisters an element of the modifiers data associated to the given
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index e393f22dba4f5066f34527b4973ea0d23ed8f767..89972e700aa37e75ab541d05fd214b16b810064a 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -1060,11 +1060,15 @@ var FormRenderer = BasicRenderer.extend({
     _onNavigationMove: function (ev) {
         ev.stopPropagation();
         var index;
+        let target = ev.data.target || ev.target;
+        if (target.__owl__) {
+            target = target.__owl__.parent; // Owl fields are wrapped by the FieldWrapper
+        }
         if (ev.data.direction === "next") {
-            index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target || ev.target);
+            index = this.allFieldWidgets[this.state.id].indexOf(target);
             this._activateNextFieldWidget(this.state, index);
         } else if (ev.data.direction === "previous") {
-            index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target);
+            index = this.allFieldWidgets[this.state.id].indexOf(target);
             this._activatePreviousFieldWidget(this.state, index);
         }
     },
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 78597e7ada0be84562aad624c33ac3d00b7da74e..83b3aecce4864a96c6992a4593747e7bf9b27e7c 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -298,6 +298,8 @@
         <script type="text/javascript" src="/web/static/src/js/fields/relational_fields.js"></script>
         <script type="text/javascript" src="/web/static/src/js/fields/special_fields.js"></script>
         <script type="text/javascript" src="/web/static/src/js/fields/upgrade_fields.js"></script>
+        <script type="text/javascript" src="/web/static/src/js/fields/field_wrapper.js"></script>
+        <script type="text/javascript" src="/web/static/src/js/fields/abstract_field_owl.js"></script>
 
         <script type="text/javascript" src="/web/static/src/js/views/abstract_view.js"></script>
         <script type="text/javascript" src="/web/static/src/js/views/abstract_renderer.js"></script>