diff --git a/addons/web/static/src/core/resizable_panel/resizable_panel.js b/addons/web/static/src/core/resizable_panel/resizable_panel.js
new file mode 100644
index 00000000000..1d249d3c4da
--- /dev/null
+++ b/addons/web/static/src/core/resizable_panel/resizable_panel.js
@@ -0,0 +1,162 @@
+/** @odoo-module */
+
+import {
+ Component,
+ onMounted,
+ onWillUpdateProps,
+ onWillUnmount,
+ useEffect,
+ useExternalListener,
+ useRef,
+ useComponent,
+} from "@odoo/owl";
+
+function useResizable({
+ containerRef,
+ handleRef,
+ getMinWidth = () => 400,
+ onResize = () => {},
+ getResizeSide = () => "end",
+}) {
+ containerRef = typeof containerRef == "string" ? useRef(containerRef) : containerRef;
+ handleRef = typeof handleRef == "string" ? useRef(handleRef) : handleRef;
+ const props = useComponent().props;
+
+ let minWidth = getMinWidth(props);
+ let resizeSide = getResizeSide(props);
+ let isChangingSize = false;
+
+ useExternalListener(document, "mouseup", () => onMouseUp());
+ useExternalListener(document, "mousemove", (ev) => onMouseMove(ev));
+
+ useExternalListener(window, "resize", () => {
+ const limit = getLimitWidth();
+ if (getContainerRect().width >= limit) {
+ resize(computeFinalWidth(limit));
+ }
+ });
+
+ let docDirection;
+ useEffect(
+ (container) => {
+ if (container) {
+ docDirection = getComputedStyle(container).direction;
+ }
+ },
+ () => [containerRef.el]
+ );
+
+ onMounted(() => {
+ if (handleRef.el) {
+ resize(computeFinalWidth(minWidth));
+ handleRef.el.addEventListener("mousedown", onMouseDown);
+ }
+ });
+
+ onWillUpdateProps((nextProps) => {
+ minWidth = getMinWidth(nextProps);
+ resizeSide = getResizeSide(nextProps);
+ });
+
+ onWillUnmount(() => {
+ if (handleRef.el) {
+ handleRef.el.removeEventListener("mousedown", onMouseDown);
+ }
+ });
+
+ function onMouseDown() {
+ isChangingSize = true;
+ document.body.classList.add("pe-none", "user-select-none");
+ }
+
+ function onMouseUp() {
+ isChangingSize = false;
+ document.body.classList.remove("pe-none", "user-select-none");
+ }
+
+ function onMouseMove(ev) {
+ if (!isChangingSize || !containerRef.el) {
+ return;
+ }
+ const direction =
+ (docDirection === "ltr" && resizeSide === "end") ||
+ (docDirection === "rtl" && resizeSide === "start")
+ ? 1
+ : -1;
+ const fixedSide = direction === 1 ? "left" : "right";
+ const containerRect = getContainerRect();
+ const newWidth = (ev.clientX - containerRect[fixedSide]) * direction;
+ resize(computeFinalWidth(newWidth));
+ }
+
+ function computeFinalWidth(targetContainerWidth) {
+ const handlerSpacing = handleRef.el ? handleRef.el.offsetWidth / 2 : 10;
+ const w = Math.max(minWidth, targetContainerWidth + handlerSpacing);
+ const limit = getLimitWidth();
+ return Math.min(w, limit - handlerSpacing);
+ }
+
+ function getContainerRect() {
+ const container = containerRef.el;
+ const offsetParent = container.offsetParent;
+ let containerRect = {};
+ if (!offsetParent) {
+ containerRect = container.getBoundingClientRect();
+ } else {
+ containerRect.left = container.offsetLeft;
+ containerRect.right = container.offsetLeft + container.offsetWidth;
+ containerRect.width = container.offsetWidth;
+ }
+ return containerRect;
+ }
+
+ function getLimitWidth() {
+ const offsetParent = containerRef.el.offsetParent;
+ return offsetParent ? offsetParent.offsetWidth : window.innerWidth;
+ }
+
+ function resize(width) {
+ containerRef.el.style.setProperty("width", `${width}px`);
+ onResize(width);
+ }
+}
+
+export class ResizablePanel extends Component {
+ static template = "web_studio.ResizablePanel";
+
+ static components = {};
+ static props = {
+ onResize: { type: Function, optional: true },
+ minWidth: { type: Number, optional: true },
+ class: { type: String, optional: true },
+ slots: { type: Object },
+ handleSide: {
+ validate: (val) => ["start", "end"].includes(val),
+ optional: true,
+ },
+ };
+ static defaultProps = {
+ onResize: () => {},
+ minWidth: 400,
+ class: "",
+ handleSide: "end",
+ };
+
+ setup() {
+ useResizable({
+ containerRef: "containerRef",
+ handleRef: "handleRef",
+ onResize: this.props.onResize,
+ getMinWidth: (props) => props.minWidth,
+ getResizeSide: (props) => props.handleSide,
+ });
+ }
+
+ get class() {
+ const classes = this.props.class.split(" ");
+ if (!classes.some((cls) => cls.startsWith("position-"))) {
+ classes.push("position-relative");
+ }
+ return classes.join(" ");
+ }
+}
diff --git a/addons/web/static/src/core/resizable_panel/resizable_panel.scss b/addons/web/static/src/core/resizable_panel/resizable_panel.scss
new file mode 100644
index 00000000000..4a58b71d6ae
--- /dev/null
+++ b/addons/web/static/src/core/resizable_panel/resizable_panel.scss
@@ -0,0 +1,10 @@
+.o_resizable_panel {
+ max-width: 100vw;
+ flex-grow: 0;
+}
+
+.o_resizable_panel_handle {
+ cursor: col-resize;
+ z-index: 10;
+ width: 5px;
+}
diff --git a/addons/web/static/src/core/resizable_panel/resizable_panel.xml b/addons/web/static/src/core/resizable_panel/resizable_panel.xml
new file mode 100644
index 00000000000..43ebf4ea962
--- /dev/null
+++ b/addons/web/static/src/core/resizable_panel/resizable_panel.xml
@@ -0,0 +1,14 @@
+
+ A Cool Paragraph
A
+Cool
+Paragraph
+