diff --git a/README.md b/README.md
index 1a98f832b..0d9c98ab0 100644
--- a/README.md
+++ b/README.md
@@ -135,6 +135,14 @@ This method is recommended when saving layouts to some storage api.
The isUserInteraction attribute signals whether the resize was caused by direct user input.
It is true for resizes caused by pointer or keyboard input
and false for other triggers (e.g. imperative API calls, initial mount, etc.)
+
+
+
+
resizePreviewMode
+
Controls whether pointer dragging updates Panels sizes immediately,
+or renders overlay separator previews until the pointer is released.
+
Defaults to "panel" (immediate resizing); "separator" defers resizing until release.
+
Customize previews using the SeparatorOverlay component.
@@ -392,6 +400,11 @@ To prevent a panel from being resized at all, it needs to also be disabled.
elementRef
Ref attached to the root HTMLDivElement.
+
+
+
+
preview
+
Overrides the Group default preview for this Separator when resizePreviewMode is "separator".
diff --git a/lib/components/group/Group.tsx b/lib/components/group/Group.tsx
index aef2a5d64..6831c83c4 100644
--- a/lib/components/group/Group.tsx
+++ b/lib/components/group/Group.tsx
@@ -1,6 +1,12 @@
"use client";
-import { useEffect, useMemo, useRef, type CSSProperties } from "react";
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type CSSProperties
+} from "react";
import { calculatePanelConstraints } from "../../global/dom/calculatePanelConstraints";
import { mountGroup } from "../../global/mountGroup";
import {
@@ -19,8 +25,12 @@ import { useMergedRefs } from "../../hooks/useMergedRefs";
import { useStableCallback } from "../../hooks/useStableCallback";
import { useStableObject } from "../../hooks/useStableObject";
import type { RegisteredPanel } from "../panel/types";
-import type { RegisteredSeparator } from "../separator/types";
+import type {
+ RegisteredSeparator,
+ SeparatorOverlayProps
+} from "../separator/types";
import { GroupContext } from "./GroupContext";
+import { ResizePreview } from "./ResizePreview";
import { sortByElementOffset } from "./sortByElementOffset";
import type {
GroupProps,
@@ -29,6 +39,7 @@ import type {
ResizeTargetMinimumSize
} from "./types";
import { useGroupImperativeHandle } from "./useGroupImperativeHandle";
+import { useResizePreviews } from "./useResizePreviews";
/**
* A Group wraps a set of resizable Panel components.
@@ -54,6 +65,7 @@ export function Group({
onLayoutChange: onLayoutChangeUnstable,
onLayoutChanged: onLayoutChangedUnstable,
orientation = "horizontal",
+ resizePreviewMode = "panel",
resizeTargetMinimumSize = {
coarse: 20,
fine: 10
@@ -93,6 +105,13 @@ export function Group({
const id = useId(idProp);
+ const [overlay, setOverlay] = useState();
+
+ const previews = useResizePreviews({
+ groupId: id,
+ resizePreviewMode
+ });
+
const elementRef = useRef(null);
const [panelOrSeparatorChangeSigil, forceUpdate] = useForceUpdate();
@@ -164,6 +183,13 @@ export function Group({
forceUpdate();
};
},
+ registerOverlay: (props: SeparatorOverlayProps) => {
+ setOverlay(props);
+
+ return () => {
+ setOverlay(undefined);
+ };
+ },
registerSeparator: (separator: RegisteredSeparator) => {
const inMemoryValues = inMemoryValuesRef.current;
inMemoryValues.separators = sortByElementOffset(orientation, [
@@ -266,6 +292,7 @@ export function Group({
},
orientation,
panels: inMemoryValues.panels,
+ resizePreviewMode,
resizeTargetMinimumSize: inMemoryValues.resizeTargetMinimumSize,
separators: inMemoryValues.separators
};
@@ -343,6 +370,7 @@ export function Group({
onLayoutChangeStable,
orientation,
panelOrSeparatorChangeSigil,
+ resizePreviewMode,
stableProps
]);
@@ -369,6 +397,7 @@ export function Group({
height: "100%",
width: "100%",
overflow: "hidden",
+ position: resizePreviewMode === "separator" ? "relative" : undefined,
...style,
@@ -384,6 +413,13 @@ export function Group({
}}
>
{children}
+ {previews.map((preview) => (
+
+ ))}
);
diff --git a/lib/components/group/ResizePreview.test.tsx b/lib/components/group/ResizePreview.test.tsx
new file mode 100644
index 000000000..cd5daa3b6
--- /dev/null
+++ b/lib/components/group/ResizePreview.test.tsx
@@ -0,0 +1,484 @@
+import { act, render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createRef, Profiler } from "react";
+import { createPortal } from "react-dom";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { getRegisteredGroup } from "../../global/mutable-state/groups";
+import {
+ getInteractionState,
+ updateInteractionState
+} from "../../global/mutable-state/interactions";
+import type { InteractionActive } from "../../global/mutable-state/types";
+import { mockGroup } from "../../global/test/mockGroup";
+import { setElementBoundsFunction } from "../../utils/test/mockBoundingClientRect";
+import { Panel } from "../panel/Panel";
+import { Separator } from "../separator/Separator";
+import { SeparatorOverlay } from "../separator/SeparatorOverlay";
+import { Group } from "./Group";
+import { ResizePreview } from "./ResizePreview";
+import type { GroupImperativeHandle } from "./types";
+
+describe("separator previews", () => {
+ afterEach(() =>
+ updateInteractionState({ state: "inactive", cursorFlags: 0 })
+ );
+
+ describe("preview lifecycle", () => {
+ function setBounds() {
+ setElementBoundsFunction((element) => {
+ switch (element.id) {
+ case "group": {
+ return new DOMRect(0, 0, 210, 100);
+ }
+ case "a": {
+ return new DOMRect(0, 0, 100, 100);
+ }
+ case "separator": {
+ return new DOMRect(100, 0, 10, 100);
+ }
+ case "b": {
+ return new DOMRect(110, 0, 100, 100);
+ }
+ }
+ });
+ }
+
+ function ui(label: string, key = "group", disabled = false) {
+ return (
+
+ default
+
+ {label} : undefined
+ }
+ />
+
+
+ );
+ }
+
+ test("updates and removes a custom preview without moving the pointer", async () => {
+ setBounds();
+ const { container, rerender } = render(ui("old"));
+ const user = userEvent.setup();
+
+ await user.pointer({
+ keys: "[MouseLeft>]",
+ coords: { clientX: 105, clientY: 50 }
+ });
+ const group = getRegisteredGroup("group", true);
+ expect(
+ container.querySelector("[data-resize-preview]")
+ ).toHaveTextContent("old");
+
+ rerender(ui("new"));
+ expect(
+ container.querySelector("[data-resize-preview]")
+ ).toHaveTextContent("new");
+ expect(getRegisteredGroup("group", true)).toBe(group);
+ expect(getInteractionState().state).toBe("active");
+
+ rerender(ui(""));
+ expect(
+ container.querySelector("[data-resize-preview]")
+ ).toHaveTextContent("default");
+ await user.pointer({ keys: "[/MouseLeft]" });
+ });
+
+ for (const replacement of ["remount", "disable"] as const) {
+ test(`clears previews on group ${replacement}`, async () => {
+ setBounds();
+ const { container, rerender } = render(ui("old"));
+ const user = userEvent.setup();
+
+ await user.pointer({
+ keys: "[MouseLeft>]",
+ coords: { clientX: 105, clientY: 50 }
+ });
+ expect(
+ container.querySelector("[data-resize-preview]")
+ ).toHaveTextContent("old");
+
+ rerender(
+ ui(
+ "new",
+ replacement === "remount" ? "new" : "group",
+ replacement === "disable"
+ )
+ );
+ expect(container.querySelector("[data-resize-preview]")).toBeNull();
+ expect(getInteractionState().state).toBe("inactive");
+
+ await user.pointer({ keys: "[/MouseLeft]" });
+ });
+ }
+ });
+
+ test("copies computed HTML and SVG styles inside an iframe", () => {
+ const iframe = document.createElement("iframe");
+ document.body.appendChild(iframe);
+
+ const ownerDocument = iframe.contentDocument!;
+ const ownerWindow = ownerDocument.defaultView!;
+ ownerDocument.body.innerHTML = `
+
+
"
+}
\ No newline at end of file
diff --git a/public/generated/site-map.json b/public/generated/site-map.json
index 041f47e29..28285d93d 100644
--- a/public/generated/site-map.json
+++ b/public/generated/site-map.json
@@ -75,6 +75,12 @@
"text": " Panel and Separator components can be disabled to disable or limit resize behavior. Below are a few examples of how this can be used to implement different types of UIs. In groups with only two panels, disabling a separator is sufficient to completely prevent resizing. \n left\n \n right\n The same applies to disabling one or both panels when there is no explicit separator element. Note this is functionally the same as disabling the entire Group component. \n left\n right\n In groups with three or more panels, disabling a separator does not completely prevent a panel from being resized. In the example below, resizing the center panel can indirectly cause the left panel to be resized as well. Disabling a panel prevents it from being resized, though its separator can still be used to resize other panels. \n left\n \n center (disabled)\n \n right\n When there is no separator, a disabled panel's edges can also be used to resize other panels. You can also disable both a panel and its separator to completely prevent them from being resized or interacted with in any way. \n left (disabled\n \n center\n \n right\n Note that a disabled Panel can still be resized using the imperative API. ",
"title": "Disabling interactions"
},
+ {
+ "path": "/examples/panel-resize-behavior",
+ "section": "Examples",
+ "text": " Dragging a resize separator causes panels to re-render with updated sizes. In most cases, this is what you want, but if re-rendering the contents of a panel is too slow, the resizePreviewMode prop can be used to defer the re-render until the resize is finished. \n left\n \n right\n In place of a panel update, an overlay separator will be rendered instead, as shown in the group below. By default, this overlay separator is just a partially transparent copy of the separator element being dragged. The `SeparatorOverlay` component allows users to customize the overlay. \n left\n \n center\n \n right\n \n \n Resize preview mode works even for groups with implicit separators. \n left\n center\n right\n right\n \n \n The data-separator-overlay attribute can be used to differentiate between a separator that's being active dragged and one that's being moved as a result of min/max size constraints. ",
+ "title": "Panel resize behavior"
+ },
{
"path": "/examples/group-resize-behavior",
"section": "Examples",
@@ -96,7 +102,7 @@
{
"path": "/props/group",
"section": "Props",
- "text": " A Group wraps a set of resizable Panel components.\nGroup content can be resized horizontally or vertically.\n Group elements always include the following attributes:\n < div data-group data-testid = \"group-id-prop\" id = \"group-id-prop\" > Test id can be used to narrow selection when unit testing.\n Optional props children?: ReactNode Panel and Separator components that comprise this group.\n className?: string CSS class name.\n defaultLayout?: Layout Default layout for the Group.\n This value allows layouts to be remembered between page reloads.\n Slight layout shift may occur when server-rendering panels with percentage-based default sizes.\nRefer to the documentation for suggestions on how to minimize the impact of this.\n disableCursor?: boolean This library sets custom mouse cursor styles to indicate drag state.\nUse this prop to disable that behavior for Panels and Separators in this group.\n disabled?: boolean Disable resize functionality.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupRef?: Ref Exposes the following imperative API:\n\n getLayout(): Layout \n setLayout(layout: Layout): void \n\n The useGroupRef and useGroupCallbackRef hooks are exported for convenience use in TypeScript projects.\n id?: string | number Uniquely identifies this group within an application.\nFalls back to useId when not provided.\n This value will also be assigned to the data-group attribute.\n onLayoutChange?: (layout: Layout) => void Called when the Group's layout is changing.\n For layout changes caused by pointer events, this method is called each time the pointer is moved.\nFor most cases, it is recommended to use the onLayoutChanged callback instead.\n onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void Called after the Group's layout has been changed.\n For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.\n The second argument contains meta information about the layout change.\nThe isUserInteraction attribute signals whether the resize was caused by direct user input.\nIt is true for resizes caused by pointer or keyboard input\nand false for other triggers (e.g. imperative API calls, initial mount, etc.)\n orientation?: \"horizontal\" | \"vertical\" = \"horizontal\" Specifies the resizable orientation (\"horizontal\" or \"vertical\"); defaults to \"horizontal\"\n resizeTargetMinimumSize?: { coarse: number; fine: number; } = {\n coarse: 20,\n fine: 10\n } Minimum size of the resizable hit target area (either Separator or Panel edge)\nThis threshold ensures are large enough to avoid mis-clicks.\n \nCoarse inputs (typically a finger on a touchscreen) have reduced accuracy;\nto ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks.\nFine inputs (typically a mouse) can be smaller\n\n Apple interface guidelines suggest 20pt (27px) on desktops and 28pt (37px) for touch devices\nIn practice this seems to be much larger than many of their own applications use though.\n style?: CSSProperties CSS properties.\n The default inline styles cannot be overridden, except for overflow .\n ",
+ "text": " A Group wraps a set of resizable Panel components.\nGroup content can be resized horizontally or vertically.\n Group elements always include the following attributes:\n < div data-group data-testid = \"group-id-prop\" id = \"group-id-prop\" > Test id can be used to narrow selection when unit testing.\n Optional props children?: ReactNode Panel and Separator components that comprise this group.\n className?: string CSS class name.\n defaultLayout?: Layout Default layout for the Group.\n This value allows layouts to be remembered between page reloads.\n Slight layout shift may occur when server-rendering panels with percentage-based default sizes.\nRefer to the documentation for suggestions on how to minimize the impact of this.\n disableCursor?: boolean This library sets custom mouse cursor styles to indicate drag state.\nUse this prop to disable that behavior for Panels and Separators in this group.\n disabled?: boolean Disable resize functionality.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupRef?: Ref Exposes the following imperative API:\n\n getLayout(): Layout \n setLayout(layout: Layout): void \n\n The useGroupRef and useGroupCallbackRef hooks are exported for convenience use in TypeScript projects.\n id?: string | number Uniquely identifies this group within an application.\nFalls back to useId when not provided.\n This value will also be assigned to the data-group attribute.\n onLayoutChange?: (layout: Layout) => void Called when the Group's layout is changing.\n For layout changes caused by pointer events, this method is called each time the pointer is moved.\nFor most cases, it is recommended to use the onLayoutChanged callback instead.\n onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void Called after the Group's layout has been changed.\n For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.\n The second argument contains meta information about the layout change.\nThe isUserInteraction attribute signals whether the resize was caused by direct user input.\nIt is true for resizes caused by pointer or keyboard input\nand false for other triggers (e.g. imperative API calls, initial mount, etc.)\n orientation?: \"horizontal\" | \"vertical\" = \"horizontal\" Specifies the resizable orientation (\"horizontal\" or \"vertical\"); defaults to \"horizontal\"\n resizePreviewMode?: ResizePreviewMode = \"panel\" Controls whether pointer dragging updates Panels sizes immediately,\nor renders overlay separator previews until the pointer is released.\n Defaults to \"panel\" (immediate resizing); \"separator\" defers resizing until release.\n Customize previews using the SeparatorOverlay component.\n resizeTargetMinimumSize?: { coarse: number; fine: number; } = {\n coarse: 20,\n fine: 10\n } Minimum size of the resizable hit target area (either Separator or Panel edge)\nThis threshold ensures are large enough to avoid mis-clicks.\n \nCoarse inputs (typically a finger on a touchscreen) have reduced accuracy;\nto ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks.\nFine inputs (typically a mouse) can be smaller\n\n Apple interface guidelines suggest 20pt (27px) on desktops and 28pt (37px) for touch devices\nIn practice this seems to be much larger than many of their own applications use though.\n style?: CSSProperties CSS properties.\n The default inline styles cannot be overridden, except for overflow .\n ",
"title": "Group component props"
},
{
@@ -108,7 +114,7 @@
{
"path": "/props/separator",
"section": "Props",
- "text": " Separators are not required but they are recommended as they improve keyboard accessibility.\n Separator elements must be direct DOM children of their parent Group elements.\n Separator elements always include the following attributes:\n < div data-separator data-testid = \"separator-id-prop\" id = \"separator-id-prop\" role = \"separator\" > Test id can be used to narrow selection when unit testing.\n In addition to the attributes shown above, separator also renders all required WAI-ARIA properties.\n Optional props className?: string CSS class name.\n Use the data-separator attribute for custom hover and active styles\n The following properties cannot be overridden: flex-grow, flex-shrink \n disabled?: boolean When disabled, the separator cannot be used to resize its neighboring panels.\n The panels may still be resized indirectly (while other panels are being resized).\nTo prevent a panel from being resized at all, it needs to also be disabled.\n disableDoubleClick?: boolean When true, double-clicking this Separator will not reset its Panel to its default size.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n id?: string | number Uniquely identifies the separator within the parent group.\nFalls back to useId when not provided.\n This value will also be assigned to the data-separator attribute.\n style?: CSSProperties CSS properties.\n Use the data-separator attribute for custom hover and active styles\n The following properties cannot be overridden: flex-grow, flex-shrink \n ",
+ "text": " Separators are not required but they are recommended as they improve keyboard accessibility.\n Separator elements must be direct DOM children of their parent Group elements.\n Separator elements always include the following attributes:\n < div data-separator data-testid = \"separator-id-prop\" id = \"separator-id-prop\" role = \"separator\" > Test id can be used to narrow selection when unit testing.\n In addition to the attributes shown above, separator also renders all required WAI-ARIA properties.\n Optional props className?: string CSS class name.\n Use the data-separator attribute for custom hover and active styles\n The following properties cannot be overridden: flex-grow, flex-shrink \n disabled?: boolean When disabled, the separator cannot be used to resize its neighboring panels.\n The panels may still be resized indirectly (while other panels are being resized).\nTo prevent a panel from being resized at all, it needs to also be disabled.\n disableDoubleClick?: boolean When true, double-clicking this Separator will not reset its Panel to its default size.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n id?: string | number Uniquely identifies the separator within the parent group.\nFalls back to useId when not provided.\n This value will also be assigned to the data-separator attribute.\n preview?: ReactNode Overrides the Group default preview for this Separator when resizePreviewMode is \"separator\".\n style?: CSSProperties CSS properties.\n Use the data-separator attribute for custom hover and active styles\n The following properties cannot be overridden: flex-grow, flex-shrink \n ",
"title": "Separator component props"
},
{
diff --git a/src/App.tsx b/src/App.tsx
index a37ca5122..447c6acfe 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -54,6 +54,9 @@ export default function App() {
Fixed size panels
Disabled panels
+
+ Panel resize behavior
+
Group resize behavior
diff --git a/src/routes.ts b/src/routes.ts
index 1e6b42051..3e0ea53d2 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -29,6 +29,9 @@ export const routes = {
"/examples/fixed-size-panels": lazy(
() => import("./routes/FixedSizePanelsRoute")
),
+ "/examples/panel-resize-behavior": lazy(
+ () => import("./routes/PanelResizeBehaviorRoute")
+ ),
"/examples/group-resize-behavior": lazy(
() => import("./routes/GroupResizeBehaviorRoute")
),
diff --git a/src/routes/PanelResizeBehaviorRoute.tsx b/src/routes/PanelResizeBehaviorRoute.tsx
new file mode 100644
index 000000000..0c0b2bf31
--- /dev/null
+++ b/src/routes/PanelResizeBehaviorRoute.tsx
@@ -0,0 +1,79 @@
+import { Box, Callout, Code, Header } from "react-lib-tools";
+import { SeparatorOverlay } from "react-resizable-panels";
+import { html as ResizePreviewModeHTML } from "../../public/generated/examples/ResizePreviewMode.json";
+import { html as ResizePreviewWithImplicitSeparatorHTML } from "../../public/generated/examples/ResizePreviewWithImplicitSeparator.json";
+import { html as ResizePreviewWithSeparatorOverlayHTML } from "../../public/generated/examples/ResizePreviewWithSeparatorOverlay.json";
+import { Group } from "../components/styled-panels/Group";
+import { Panel } from "../components/styled-panels/Panel";
+import { Separator } from "../components/styled-panels/Separator";
+import { Link } from "../components/Link";
+
+export default function PanelResizeBehaviorRoute() {
+ return (
+
+
+
+ Dragging a resize separator causes panels to re-render with updated
+ sizes. In most cases, this is what you want, but if re-rendering the
+ contents of a panel is too slow, the resizePreviewMode prop
+ can be used to defer the re-render until the resize is finished.
+
+
+
+ In place of a panel update, an overlay separator will be rendered
+ instead, as shown in the group below.
+
+
+
+ left
+
+
+
+ right
+
+
+
+ By default, this overlay separator is just a partially transparent copy
+ of the separator element being dragged. The `SeparatorOverlay` component
+ allows users to customize the overlay.
+
+
+
+
+ left
+
+
+
+ center
+
+
+
+ right
+
+
+
+
+ Resize preview mode works even for groups with implicit separators.
+
+
+
+
+ left
+
+
+ center
+
+
+ right
+
+
+
+
+ The data-separator-overlay attribute can be used to
+ differentiate between a separator that's being active dragged and one
+ that's being moved as a result of{" "}
+ min/max size constraints.
+
+
+ );
+}
diff --git a/src/routes/examples/ResizePreviewMode.tsx b/src/routes/examples/ResizePreviewMode.tsx
new file mode 100644
index 000000000..d6ccbfc6f
--- /dev/null
+++ b/src/routes/examples/ResizePreviewMode.tsx
@@ -0,0 +1,10 @@
+import { Group, Panel, Separator } from "react-resizable-panels";
+
+//
+
+/* prettier-ignore */
+
+ left
+
+ right
+
diff --git a/src/routes/examples/ResizePreviewWithImplicitSeparator.tsx b/src/routes/examples/ResizePreviewWithImplicitSeparator.tsx
new file mode 100644
index 000000000..61c593dd4
--- /dev/null
+++ b/src/routes/examples/ResizePreviewWithImplicitSeparator.tsx
@@ -0,0 +1,15 @@
+import { Group, Panel, SeparatorOverlay } from "react-resizable-panels";
+
+//
+
+/* prettier-ignore */
+
+ left
+ center
+ right
+ right
+
+
+
diff --git a/src/routes/examples/ResizePreviewWithSeparatorOverlay.tsx b/src/routes/examples/ResizePreviewWithSeparatorOverlay.tsx
new file mode 100644
index 000000000..226f66830
--- /dev/null
+++ b/src/routes/examples/ResizePreviewWithSeparatorOverlay.tsx
@@ -0,0 +1,21 @@
+import {
+ Group,
+ Panel,
+ Separator,
+ SeparatorOverlay
+} from "react-resizable-panels";
+
+//
+
+/* prettier-ignore */
+
+ left
+
+ center
+
+ right
+
+
+