diff --git a/examples/nextjs/.gitignore b/examples/nextjs/.gitignore
new file mode 100644
index 000000000..5ef6a5207
--- /dev/null
+++ b/examples/nextjs/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/examples/nextjs/eslint.config.mjs b/examples/nextjs/eslint.config.mjs
new file mode 100644
index 000000000..719cea2b5
--- /dev/null
+++ b/examples/nextjs/eslint.config.mjs
@@ -0,0 +1,25 @@
+import { dirname } from "path";
+import { fileURLToPath } from "url";
+import { FlatCompat } from "@eslint/eslintrc";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const compat = new FlatCompat({
+ baseDirectory: __dirname,
+});
+
+const eslintConfig = [
+ ...compat.extends("next/core-web-vitals", "next/typescript"),
+ {
+ ignores: [
+ "node_modules/**",
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ],
+ },
+];
+
+export default eslintConfig;
diff --git a/examples/nextjs/next.config.ts b/examples/nextjs/next.config.ts
new file mode 100644
index 000000000..eec2aea61
--- /dev/null
+++ b/examples/nextjs/next.config.ts
@@ -0,0 +1,9 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+ ignoreBuildErrors: true,
+ optimizePackageImports: ["react-resizable-panels"],
+};
+
+export default nextConfig;
diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json
new file mode 100644
index 000000000..18f80d789
--- /dev/null
+++ b/examples/nextjs/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "react-resizable-panels-nextjs",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build --turbopack",
+ "start": "next start",
+ "lint": "eslint"
+ },
+ "dependencies": {
+ "react": "19.2.0",
+ "react-dom": "19.2.0",
+ "next": "16.0.0",
+ "react-resizable-panels": "workspace:*"
+ },
+ "devDependencies": {
+ "typescript": "^5",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "16.0.0",
+ "@eslint/eslintrc": "^3"
+ }
+}
diff --git a/examples/nextjs/public/file.svg b/examples/nextjs/public/file.svg
new file mode 100644
index 000000000..004145cdd
--- /dev/null
+++ b/examples/nextjs/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/nextjs/public/globe.svg b/examples/nextjs/public/globe.svg
new file mode 100644
index 000000000..567f17b0d
--- /dev/null
+++ b/examples/nextjs/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/nextjs/public/next.svg b/examples/nextjs/public/next.svg
new file mode 100644
index 000000000..5174b28c5
--- /dev/null
+++ b/examples/nextjs/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/nextjs/public/vercel.svg b/examples/nextjs/public/vercel.svg
new file mode 100644
index 000000000..770539603
--- /dev/null
+++ b/examples/nextjs/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/nextjs/public/window.svg b/examples/nextjs/public/window.svg
new file mode 100644
index 000000000..b2b2a44f6
--- /dev/null
+++ b/examples/nextjs/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/nextjs/src/app/favicon.ico b/examples/nextjs/src/app/favicon.ico
new file mode 100644
index 000000000..718d6fea4
Binary files /dev/null and b/examples/nextjs/src/app/favicon.ico differ
diff --git a/examples/nextjs/src/app/globals.css b/examples/nextjs/src/app/globals.css
new file mode 100644
index 000000000..352e78bf8
--- /dev/null
+++ b/examples/nextjs/src/app/globals.css
@@ -0,0 +1,91 @@
+:root {
+ --color-background-code: #050a15;
+ --color-background-default: #081120;
+ --color-brand: #dcadff;
+ --color-button-background: #2a3343;
+ --color-button-background-hover: #39414d;
+ --color-button-border: #18181a;
+ --color-code: #dcadff;
+ --color-default: #ffffff;
+ --color-dim: #91a0ba;
+ --color-horizontal-rule: #39414d;
+ --color-input: #ffffff;
+ --color-input-background: #18181a;
+ --color-input-border: #39414d;
+ --color-input-border-focused: #dcadff;
+ --color-link: #dcadff;
+ --color-panel-background: #192230;
+ --color-panel-background-alternate: #202124;
+ --color-resize-bar: #515b6a;
+ --color-resize-bar-active: #b1bdd0;
+ --color-resize-bar-hover: #515b6a;
+ --color-warning-background: #7400cc;
+
+ --color-scroll-thumb: #515b6a;
+}
+
+:root,
+html,
+body {
+ padding: 0;
+ margin: 0;
+ max-width: 100vw;
+ overflow-x: hidden;
+
+ background-color: var(--color-background-default);
+ color: var(--color-default);
+
+ font-family: Arial, Helvetica, sans-serif;
+ font-size: 12px;
+}
+
+* {
+ box-sizing: border-box;
+ line-height: 1.5em;
+}
+
+p {
+ margin: 0.5rem 0;
+}
+
+code {
+ color: var(--color-code);
+ background-color: var(--color-background-code);
+ font-family: monospace;
+ padding: 0 0.25em;
+ border-radius: 0.25em;
+}
+
+h1,
+h2 {
+ font-weight: normal;
+ margin: 0.5rem 0;
+}
+
+a {
+ color: var(--color-link);
+}
+
+::-webkit-scrollbar {
+ width: 0.75rem;
+ height: 0.75rem;
+ background: transparent;
+}
+
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
+
+::-webkit-scrollbar-track {
+ border-radius: 0.75rem;
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ border-radius: 0.75rem;
+ background: var(--color-scroll-thumb);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-resize-bar-hover);
+}
diff --git a/examples/nextjs/src/app/layout.tsx b/examples/nextjs/src/app/layout.tsx
new file mode 100644
index 000000000..c64d72725
--- /dev/null
+++ b/examples/nextjs/src/app/layout.tsx
@@ -0,0 +1,33 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import "./globals.css";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+ title: "React Resizable Panels - Next.js Example",
+ description:
+ "Nested resizable panels example using react-resizable-panels in Next.js",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/examples/nextjs/src/app/page.module.css b/examples/nextjs/src/app/page.module.css
new file mode 100644
index 000000000..58c71af9e
--- /dev/null
+++ b/examples/nextjs/src/app/page.module.css
@@ -0,0 +1,167 @@
+.page {
+ --gray-rgb: 0, 0, 0;
+ --gray-alpha-200: rgba(var(--gray-rgb), 0.08);
+ --gray-alpha-100: rgba(var(--gray-rgb), 0.05);
+
+ --button-primary-hover: #383838;
+ --button-secondary-hover: #f2f2f2;
+
+ display: grid;
+ grid-template-rows: 20px 1fr 20px;
+ align-items: center;
+ justify-items: center;
+ min-height: 100svh;
+ padding: 80px;
+ gap: 64px;
+ font-family: var(--font-geist-sans);
+}
+
+@media (prefers-color-scheme: dark) {
+ .page {
+ --gray-rgb: 255, 255, 255;
+ --gray-alpha-200: rgba(var(--gray-rgb), 0.145);
+ --gray-alpha-100: rgba(var(--gray-rgb), 0.06);
+
+ --button-primary-hover: #ccc;
+ --button-secondary-hover: #1a1a1a;
+ }
+}
+
+.main {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ grid-row-start: 2;
+}
+
+.main ol {
+ font-family: var(--font-geist-mono);
+ padding-left: 0;
+ margin: 0;
+ font-size: 14px;
+ line-height: 24px;
+ letter-spacing: -0.01em;
+ list-style-position: inside;
+}
+
+.main li:not(:last-of-type) {
+ margin-bottom: 8px;
+}
+
+.main code {
+ font-family: inherit;
+ background: var(--gray-alpha-100);
+ padding: 2px 4px;
+ border-radius: 4px;
+ font-weight: 600;
+}
+
+.ctas {
+ display: flex;
+ gap: 16px;
+}
+
+.ctas a {
+ appearance: none;
+ border-radius: 128px;
+ height: 48px;
+ padding: 0 20px;
+ border: 1px solid transparent;
+ transition:
+ background 0.2s,
+ color 0.2s,
+ border-color 0.2s;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ line-height: 20px;
+ font-weight: 500;
+}
+
+a.primary {
+ background: var(--foreground);
+ color: var(--background);
+ gap: 8px;
+}
+
+a.secondary {
+ border-color: var(--gray-alpha-200);
+ min-width: 158px;
+}
+
+.footer {
+ grid-row-start: 3;
+ display: flex;
+ gap: 24px;
+}
+
+.footer a {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.footer img {
+ flex-shrink: 0;
+}
+
+/* Enable hover only on non-touch devices */
+@media (hover: hover) and (pointer: fine) {
+ a.primary:hover {
+ background: var(--button-primary-hover);
+ border-color: transparent;
+ }
+
+ a.secondary:hover {
+ background: var(--button-secondary-hover);
+ border-color: transparent;
+ }
+
+ .footer a:hover {
+ text-decoration: underline;
+ text-underline-offset: 4px;
+ }
+}
+
+@media (max-width: 600px) {
+ .page {
+ padding: 32px;
+ padding-bottom: 80px;
+ }
+
+ .main {
+ align-items: center;
+ }
+
+ .main ol {
+ text-align: center;
+ }
+
+ .ctas {
+ flex-direction: column;
+ }
+
+ .ctas a {
+ font-size: 14px;
+ height: 40px;
+ padding: 0 16px;
+ }
+
+ a.secondary {
+ min-width: auto;
+ }
+
+ .footer {
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ .logo {
+ filter: invert();
+ }
+}
diff --git a/examples/nextjs/src/app/page.tsx b/examples/nextjs/src/app/page.tsx
new file mode 100644
index 000000000..c138390ef
--- /dev/null
+++ b/examples/nextjs/src/app/page.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { Panel, PanelGroup, PersistScript } from "react-resizable-panels";
+import { ResizeHandle } from "@/components/ResizeHandle";
+import styles from "@/components/shared.module.css";
+
+export default function Home() {
+ return (
+
+
+
+
+
+
+ left
+
+
+
+
+
+ top
+
+
+
+
+
+ left
+
+
+
+ right
+
+
+
+
+
+
+
+ right
+
+
+
+
+
+ );
+}
diff --git a/examples/nextjs/src/components/Icon.module.css b/examples/nextjs/src/components/Icon.module.css
new file mode 100644
index 000000000..22063649a
--- /dev/null
+++ b/examples/nextjs/src/components/Icon.module.css
@@ -0,0 +1,6 @@
+.Icon {
+ flex: 0 0 1rem;
+ width: 1rem;
+ height: 1rem;
+ fill: currentColor;
+}
diff --git a/examples/nextjs/src/components/Icon.tsx b/examples/nextjs/src/components/Icon.tsx
new file mode 100644
index 000000000..6eb0bbabb
--- /dev/null
+++ b/examples/nextjs/src/components/Icon.tsx
@@ -0,0 +1,120 @@
+import { SVGProps } from "react";
+import styles from "./Icon.module.css";
+
+export type IconType =
+ | "chevron-down"
+ | "close"
+ | "collapse"
+ | "css"
+ | "dialog"
+ | "drag"
+ | "expand"
+ | "files"
+ | "horizontal-collapse"
+ | "horizontal-expand"
+ | "html"
+ | "loading"
+ | "markdown"
+ | "resize"
+ | "resize-horizontal"
+ | "resize-vertical"
+ | "search"
+ | "typescript"
+ | "warning";
+
+export default function Icon({
+ className = "",
+ type,
+ ...rest
+}: SVGProps & {
+ className?: string;
+ type: IconType;
+}) {
+ let path = "";
+ switch (type) {
+ case "chevron-down":
+ path = "M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z";
+ break;
+ case "close":
+ path =
+ "M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z";
+ break;
+ case "collapse":
+ path =
+ "M19.5,3.09L15,7.59V4H13V11H20V9H16.41L20.91,4.5L19.5,3.09M4,13V15H7.59L3.09,19.5L4.5,20.91L9,16.41V20H11V13H4Z";
+ break;
+ case "css":
+ path =
+ "M5,3L4.35,6.34H17.94L17.5,8.5H3.92L3.26,11.83H16.85L16.09,15.64L10.61,17.45L5.86,15.64L6.19,14H2.85L2.06,18L9.91,21L18.96,18L20.16,11.97L20.4,10.76L21.94,3H5Z";
+ break;
+ case "dialog":
+ path =
+ "M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z";
+ break;
+ case "drag":
+ path =
+ "M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2";
+ break;
+ case "expand":
+ path =
+ "M10,21V19H6.41L10.91,14.5L9.5,13.09L5,17.59V14H3V21H10M14.5,10.91L19,6.41V10H21V3H14V5H17.59L13.09,9.5L14.5,10.91Z";
+ break;
+ case "files":
+ path =
+ "M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z";
+ break;
+ case "horizontal-collapse":
+ path =
+ "M13,20V4H15.03V20H13M10,20V4H12.03V20H10M5,8L9.03,12L5,16V13H2V11H5V8M20,16L16,12L20,8V11H23V13H20V16Z";
+ break;
+ case "horizontal-expand":
+ path =
+ "M13,4V20H11V4H13M8,20V4H10V20H8M19,8V11H22V13H19V16L15,12L19,8M5,16L9,12L5,8V11H2V13H5V16Z";
+ break;
+ case "html":
+ path =
+ "M12,17.56L16.07,16.43L16.62,10.33H9.38L9.2,8.3H16.8L17,6.31H7L7.56,12.32H14.45L14.22,14.9L12,15.5L9.78,14.9L9.64,13.24H7.64L7.93,16.43L12,17.56M4.07,3H19.93L18.5,19.2L12,21L5.5,19.2L4.07,3Z";
+ break;
+ case "loading":
+ path = "M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z";
+ break;
+ case "markdown":
+ path =
+ "M22.269,19.385H1.731a1.73,1.73,0,0,1-1.73-1.73V6.345a1.73,1.73,0,0,1,1.73-1.73H22.269a1.731,1.731,0,0,1,1.731,1.73V17.654A1.73,1.73,0,0,1,22.269,19.385ZM12,6.923v9.231H9.462V9.077l-2.308,3.846L4.846,9.077v7.077H2.308V6.923H5.769L7.154,9.308,8.538,6.923Zm7.385,2.769V6.923h2.308v9.231H19.385V10.615L16.154,16.154,12.923,10.615v5.539H10.615V6.923h2.308l3.231,5.539Z";
+ break;
+ case "resize":
+ path =
+ "M22,3H2C0.91,3.04 0.04,3.91 0,5V19C0.04,20.09 0.91,20.96 2,21H22C23.09,20.96 23.96,20.09 24,19V5C23.96,3.91 23.09,3.04 22,3M22,19H2V5H22V19Z";
+ break;
+ case "resize-horizontal":
+ path =
+ "M18,16V13H22V11H18V8L15,12L18,16M6,16L9,12L6,8V11H2V13H6V16M11,18H13V6H11V18Z";
+ break;
+ case "resize-vertical":
+ path =
+ "M8,18H11V22H13V18H16L12,15L8,18M8,6L12,9L16,6H13V2H11V6H8M18,11V13H6V11H18Z";
+ break;
+ case "search":
+ path =
+ "M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z";
+ break;
+ case "typescript":
+ path =
+ "M3,3H21V21H3V3M13.71,17.86C14.21,18.84 15.22,19.59 16.8,19.59C18.4,19.59 19.6,18.76 19.6,17.23C19.6,15.82 18.79,15.19 17.35,14.57L16.93,14.39C16.2,14.08 15.89,13.87 15.89,13.37C15.89,12.96 16.2,12.64 16.7,12.64C17.18,12.64 17.5,12.85 17.79,13.37L19.1,12.5C18.55,11.54 17.77,11.17 16.7,11.17C15.19,11.17 14.22,12.13 14.22,13.4C14.22,14.78 15.03,15.43 16.25,15.95L16.67,16.13C17.45,16.47 17.91,16.68 17.91,17.26C17.91,17.74 17.46,18.09 16.76,18.09C15.93,18.09 15.45,17.66 15.09,17.06L13.71,17.86M13,11.25H8V12.75H9.5V20H11.25V12.75H13V11.25Z";
+ break;
+ case "warning":
+ path =
+ "M5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3M13,13V7H11V13H13M13,17V15H11V17H13Z";
+ break;
+ }
+
+ return (
+
+ );
+}
diff --git a/examples/nextjs/src/components/ResizeHandle.module.css b/examples/nextjs/src/components/ResizeHandle.module.css
new file mode 100644
index 000000000..ee3193b22
--- /dev/null
+++ b/examples/nextjs/src/components/ResizeHandle.module.css
@@ -0,0 +1,30 @@
+.ResizeHandle {
+ flex: 0 0 0.5rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ outline: none;
+ color: var(--color-resize-bar);
+}
+.ResizeHandle:hover {
+ color: var(--color-resize-bar-hover);
+}
+.ResizeHandle[data-resize-handle-active] {
+ color: var(--color-resize-bar-active);
+}
+
+.ResizeHandle .ResizeHandleThumb[data-direction="vertical"] {
+ rotate: 90deg;
+}
+
+@media (pointer: coarse) {
+ .ResizeHandle {
+ flex: 0 0 2rem;
+ }
+
+ .ResizeHandle .ResizeHandleThumb {
+ flex: 0 0 1.25rem;
+ height: 1.25rem;
+ width: 1.25rem;
+ }
+}
diff --git a/examples/nextjs/src/components/ResizeHandle.tsx b/examples/nextjs/src/components/ResizeHandle.tsx
new file mode 100644
index 000000000..ce6fc9cce
--- /dev/null
+++ b/examples/nextjs/src/components/ResizeHandle.tsx
@@ -0,0 +1,33 @@
+import {
+ PanelResizeHandle,
+ PanelResizeHandleProps,
+ usePanelGroupContext,
+} from "react-resizable-panels";
+
+import styles from "./ResizeHandle.module.css";
+import Icon from "./Icon";
+
+export function ResizeHandle({
+ className = "",
+ id,
+ ...rest
+}: PanelResizeHandleProps & {
+ className?: string;
+ id?: string;
+}) {
+ const { direction } = usePanelGroupContext();
+
+ return (
+
+
+
+ );
+}
diff --git a/examples/nextjs/src/components/shared.module.css b/examples/nextjs/src/components/shared.module.css
new file mode 100644
index 000000000..2b6e0c2c7
--- /dev/null
+++ b/examples/nextjs/src/components/shared.module.css
@@ -0,0 +1,99 @@
+.PanelGroupWrapper {
+ height: 20rem;
+}
+.PanelGroupWrapper[data-short] {
+ height: 10rem;
+}
+.PanelGroupWrapper[data-tall] {
+ height: 30rem;
+}
+
+.PanelGroup {
+ font-size: 2rem;
+}
+
+.Panel {
+ display: flex;
+ flex-direction: row;
+ font-size: 2rem;
+}
+
+.PanelColumn,
+.PanelRow {
+ display: flex;
+}
+.PanelColumn {
+ flex-direction: column;
+}
+.PanelRow {
+ flex-direction: row;
+}
+
+.Centered {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--color-panel-background);
+ border-radius: 0.5rem;
+ overflow: hidden;
+ font-size: 1rem;
+ padding: 0.5rem;
+ word-break: break-all;
+}
+
+/* ResizeHandle styles are defined in ResizeHandle.module.css */
+
+.Overflow {
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ padding: 1rem;
+
+ /* Firefox fixes */
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-scroll-thumb) transparent;
+}
+
+.Button,
+.ButtonDisabled {
+ background-color: var(--color-button-background);
+ color: var(--color-default);
+ border: none;
+ border-radius: 0.5rem;
+ padding: 0.25rem 0.5rem;
+}
+.Button:hover {
+ background-color: var(--color-button-background-hover);
+}
+.ButtonDisabled {
+ opacity: 0.5;
+}
+
+.Buttons {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 1ch;
+ margin-bottom: 1rem;
+}
+
+.Capitalize {
+ text-transform: capitalize;
+}
+
+.WarningBlock {
+ display: inline-flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1ch;
+ background: var(--color-warning-background);
+ padding: 0.5em;
+ border-radius: 0.5rem;
+}
+.WarningIcon {
+ flex: 0 0 2rem;
+ width: 2rem;
+ height: 2rem;
+}
diff --git a/examples/nextjs/tests/PanelPersistScript.spec.ts b/examples/nextjs/tests/PanelPersistScript.spec.ts
new file mode 100644
index 000000000..1640dc12c
--- /dev/null
+++ b/examples/nextjs/tests/PanelPersistScript.spec.ts
@@ -0,0 +1,34 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("Panel Persist Script", () => {
+ test("should load and persist panel layouts using localStorage on the initial server side render", async ({
+ page,
+ }) => {
+ await page.goto("http://localhost:3000");
+
+ await page.evaluate(() => {
+ localStorage.setItem(
+ "react-resizable-panels:l1",
+ `{"l1:left,l1:middle,l1:right":{"expandToSizes":{},"layout":[{"order":1,"size":29.5488424113},{"order":2,"size":58.984252547},{"order":3,"size":11.4669050417}]}}`
+ );
+ });
+
+ await page.goto("http://localhost:3000");
+
+ const leftPanelSize = await page.$eval("#l1\\:left", (el) =>
+ getComputedStyle(el).getPropertyValue("--panel-size").trim()
+ );
+ const middlePanelSize = await page.$eval("#l1\\:middle", (el) =>
+ getComputedStyle(el).getPropertyValue("--panel-size").trim()
+ );
+ const rightPanelSize = await page.$eval("#l1\\:right", (el) =>
+ getComputedStyle(el).getPropertyValue("--panel-size").trim()
+ );
+
+ expect(middlePanelSize).toBe("58.984");
+ expect(rightPanelSize).toBe("11.467");
+ expect(leftPanelSize).toBe("29.549");
+
+ await page.close();
+ });
+});
diff --git a/examples/nextjs/tsconfig.json b/examples/nextjs/tsconfig.json
new file mode 100644
index 000000000..b575f7dac
--- /dev/null
+++ b/examples/nextjs/tsconfig.json
@@ -0,0 +1,41 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/package.json b/package.json
index 0124d990b..af87dfb92 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"dev": "pnpm run /^dev:.*/",
"dev:core": "cd packages/react-resizable-panels && pnpm watch",
"dev:website": "cd packages/react-resizable-panels-website && pnpm watch",
+ "next:dev": "pnpm --filter react-resizable-panels-nextjs dev",
"docs": "cd packages/react-resizable-panels-website && pnpm build",
"lint": "cd packages/react-resizable-panels && pnpm lint",
"prerelease": "preconstruct build",
diff --git a/packages/react-resizable-panels-website/package.json b/packages/react-resizable-panels-website/package.json
index 3c6f6ba71..d2c04401d 100644
--- a/packages/react-resizable-panels-website/package.json
+++ b/packages/react-resizable-panels-website/package.json
@@ -10,6 +10,7 @@
"kill-port": "kill-port ${PORT:-1234}",
"test:e2e": "playwright test",
"test:e2e:debug": "DEBUG=true playwright test",
+ "test:e2e:nextjs": "PROJECT=nextjs playwright test",
"watch": "parcel \"index.html\""
},
"dependencies": {
diff --git a/packages/react-resizable-panels-website/playwright.config.ts b/packages/react-resizable-panels-website/playwright.config.ts
index 486245298..2fab75957 100644
--- a/packages/react-resizable-panels-website/playwright.config.ts
+++ b/packages/react-resizable-panels-website/playwright.config.ts
@@ -1,8 +1,52 @@
import type { PlaywrightTestConfig } from "@playwright/test";
+declare global {
+ namespace NodeJS {
+ interface ProcessEnv {
+ PROJECT: Projects;
+ }
+ }
+}
+
+const CI = !!process.env.CI;
const { DEBUG } = process.env;
+const project = process.env.PROJECT || "website";
+
+type InferServer = Exclude ? U : T, undefined>;
+type WebServer = InferServer;
+type TestDir = InferServer;
+type Projects = "website" | "nextjs";
+
+function getProjectServer(): WebServer {
+ const projects: Record = {
+ website: {
+ cwd: "./",
+ command: "npm run watch",
+ url: "http://localhost:1234",
+ reuseExistingServer: !CI,
+ },
+ nextjs: {
+ cwd: "../../examples/nextjs",
+ command: "pnpm run dev",
+ url: "http://localhost:3000",
+ reuseExistingServer: !CI,
+ },
+ };
+
+ return projects[project];
+}
+
+function getTestDir(): TestDir {
+ const projects: Record = {
+ website: "./",
+ nextjs: "../../examples/nextjs/tests",
+ };
+
+ return projects[project];
+}
const config: PlaywrightTestConfig = {
+ testDir: getTestDir(),
use: {
browserName: "chromium",
headless: true,
@@ -10,11 +54,7 @@ const config: PlaywrightTestConfig = {
ignoreHTTPSErrors: true,
video: "on-first-retry",
},
- webServer: {
- command: "npm run watch",
- reuseExistingServer: true,
- url: "http://localhost:1234",
- },
+ webServer: getProjectServer(),
timeout: 60_000,
};
diff --git a/packages/react-resizable-panels/README.md b/packages/react-resizable-panels/README.md
index d63429df0..2fc93581f 100644
--- a/packages/react-resizable-panels/README.md
+++ b/packages/react-resizable-panels/README.md
@@ -95,6 +95,12 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
| `style` | `?CSSProperties` | CSS style to attach to root element |
| `tagName` | `?string = "div"` | HTML element tag name for root element |
+
+### `PersistScript`
+
+| prop | type | description |
+| :----------- | :------------ | :-------------------------------------------------------------------------------------------- |
+
---
## FAQ
@@ -180,9 +186,53 @@ Yes. Panel groups with an `autoSaveId` prop will automatically save and restore
### How can I use persistent layouts with SSR?
-By default, this library uses `localStorage` to persist layouts. With server rendering, this can cause a flicker when the default layout (rendered on the server) is replaced with the persisted layout (in `localStorage`). The way to avoid this flicker is to also persist the layout with a cookie like so:
+By default, this library uses `localStorage` to persist layouts. With server rendering, this can cause a flicker when the default layout (rendered on the server) is replaced with the persisted layout (in `localStorage`). There are two ways to avoid this flicker:
+
+#### Option 1: Using `PersistScript`
+
+The `PersistScript` component synchronously applies the persisted layout before React hydration, eliminating layout flicker.
+It injects CSS variable rules into an adopted stylesheet based on the saved layout in `localStorage`.
+
+```tsx
+"use client";
+
+import { Panel, PanelGroup, PanelResizeHandle, PersistScript } from "react-resizable-panels";
+
+export function ClientComponent() {
+ return (
+ <>
+
+
+
+ {/* Panel content */}
+
+
+
+ {/* Panel content */}
+
+
+ >
+ );
+}
+```
+
+Here's the example of injected CSS variables:
+
+```css
+:root {
+ --panel-my-layout-1-size: 60.000;
+ --panel-my-layout-2-size: 40.000;
+}
+```
+
+> [!NOTE]
+> A working example is available in [`examples/nextjs`](https://github.com/bvaughn/react-resizable-panels/tree/main/examples/nextjs).
+
+#### Option 2: Cookie-based persistence
+
+Alternatively, you can persist the layout with cookies and pass it from the server to the client.
-#### Server component
+**Server component:**
```tsx
import ResizablePanels from "@/app/ResizablePanels";
@@ -200,7 +250,7 @@ export function ServerComponent() {
}
```
-#### Client component
+**Client component:**
```tsx
"use client";
diff --git a/packages/react-resizable-panels/package.json b/packages/react-resizable-panels/package.json
index 2b21be798..a075d5739 100644
--- a/packages/react-resizable-panels/package.json
+++ b/packages/react-resizable-panels/package.json
@@ -56,19 +56,26 @@
"clear:builds": "rm -rf ./packages/*/dist",
"clear:node_modules": "rm -rf ./node_modules",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
+ "minify:persist": "tsx scripts/minify.ts src/scripts/persist.ts src/scripts/persist.minified.ts",
+ "prebuild": "pnpm run minify:persist",
"test:browser": "vitest",
"test:node": "vitest -c vitest.node.config.ts",
"watch": "parcel watch --port=2345"
},
"devDependencies": {
+ "@babel/core": "^7.22.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
"@babel/plugin-proposal-optional-chaining": "7.21.0",
+ "@babel/preset-env": "^7.22.0",
+ "@types/babel__core": "^7.20.0",
"@vitest/ui": "^3.1.2",
"eslint": "^8.37.0",
"eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^26.1.0",
"react": "experimental",
"react-dom": "experimental",
+ "terser": "^5.19.0",
+ "tsx": "^4.7.0",
"vitest": "^3.1.2"
},
"peerDependencies": {
diff --git a/packages/react-resizable-panels/scripts/README.md b/packages/react-resizable-panels/scripts/README.md
new file mode 100644
index 000000000..a9e25812d
--- /dev/null
+++ b/packages/react-resizable-panels/scripts/README.md
@@ -0,0 +1,21 @@
+# Build Scripts
+
+## Files
+
+### minify.ts
+Generic TypeScript minification script that:
+1. Accepts input and output file paths as arguments
+2. Strips TypeScript types with `@babel/preset-typescript`
+3. Transforms with Babel using browser compatibility settings
+4. Minifies with Terser
+5. Generates output file with minified code
+
+**Usage:**
+```bash
+tsx scripts/minify.ts
+```
+
+**Example:**
+```bash
+tsx scripts/minify.ts src/scripts/persist.ts src/scripts/persist.minified.ts
+```
\ No newline at end of file
diff --git a/packages/react-resizable-panels/scripts/minify.ts b/packages/react-resizable-panels/scripts/minify.ts
new file mode 100644
index 000000000..78028daa3
--- /dev/null
+++ b/packages/react-resizable-panels/scripts/minify.ts
@@ -0,0 +1,199 @@
+#!/usr/bin/env node
+
+/**
+ * Generic script to minify TypeScript/JavaScript files.
+ * Strips TypeScript types, transforms with Babel, and minifies with Terser.
+ *
+ * Usage:
+ * tsx scripts/minify.ts
+ *
+ * Example:
+ * tsx scripts/minify.ts src/scripts/persist.ts src/scripts/persist.minified.ts
+ */
+
+import { transformSync, BabelFileResult } from "@babel/core";
+import {
+ minify,
+ MinifyOptions as TerserMinifyOptions,
+ MinifyOutput,
+} from "terser";
+import { writeFileSync, readFileSync } from "fs";
+import { fileURLToPath } from "url";
+import { dirname, join, relative, basename } from "path";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+interface MinifyFileOptions {
+ inputPath: string;
+ outputPath: string;
+ removeComments?: boolean;
+ removeExports?: boolean;
+}
+
+async function minifyFile(options: MinifyFileOptions): Promise {
+ const {
+ inputPath,
+ outputPath,
+ removeComments = true,
+ removeExports = true,
+ } = options;
+
+ const packageRoot = join(__dirname, "..");
+ const absoluteInputPath = join(packageRoot, inputPath);
+ const absoluteOutputPath = join(packageRoot, outputPath);
+
+ console.log("šØ Minifying:", relative(packageRoot, absoluteInputPath));
+
+ const sourceContent = readFileSync(absoluteInputPath, "utf-8");
+ const isTypeScript = inputPath.endsWith(".ts") || inputPath.endsWith(".tsx");
+
+ let processedCode = sourceContent;
+
+ if (isTypeScript) {
+ const typeStripResult: BabelFileResult | null = transformSync(
+ sourceContent,
+ {
+ filename: basename(inputPath),
+ presets: [
+ ["@babel/preset-typescript", { onlyRemoveTypeImports: true }],
+ ],
+ }
+ );
+
+ if (!typeStripResult || !typeStripResult.code) {
+ throw new Error("TypeScript stripping failed");
+ }
+
+ processedCode = typeStripResult.code;
+ }
+
+ if (removeComments) {
+ processedCode = processedCode.replace(/^\/\*\*[\s\S]*?\*\/\s*/gm, ""); // JSDoc
+ processedCode = processedCode.replace(/\/\/.*$/gm, ""); // Single-line comments
+ }
+
+ if (removeExports) {
+ processedCode = processedCode
+ .replace(/export\s+default\s+/g, "")
+ .replace(/export\s*{\s*[^}]*\s*};?\s*$/gm, "")
+ .replace(/export\s+(const|let|var|function|class)\s+/g, "$1 ");
+ }
+
+ processedCode = processedCode.trim();
+
+ const babelResult: BabelFileResult | null = transformSync(processedCode, {
+ presets: [
+ [
+ "@babel/preset-env",
+ {
+ targets: {
+ chrome: "79", // Match browserslist in package.json
+ },
+ modules: false,
+ },
+ ],
+ ],
+ plugins: [
+ "@babel/plugin-proposal-nullish-coalescing-operator",
+ "@babel/plugin-proposal-optional-chaining",
+ ],
+ });
+
+ if (!babelResult || !babelResult.code) {
+ throw new Error("Babel transformation failed");
+ }
+
+ const terserOptions: TerserMinifyOptions = {
+ compress: {
+ dead_code: true,
+ drop_debugger: true,
+ conditionals: true,
+ evaluate: true,
+ booleans: true,
+ loops: true,
+ unused: true,
+ hoist_funs: true,
+ keep_fargs: false,
+ hoist_vars: false,
+ if_return: true,
+ join_vars: true,
+ side_effects: true,
+ },
+ mangle: {
+ toplevel: false,
+ },
+ format: {
+ comments: false,
+ preamble: "",
+ },
+ };
+
+ const minifyResult: MinifyOutput = await minify(
+ babelResult.code,
+ terserOptions
+ );
+
+ if (!minifyResult || !minifyResult.code) {
+ throw new Error("Minification failed");
+ }
+
+ const outputContent = `// Auto-generated by scripts/minify.ts
+// DO NOT EDIT MANUALLY
+// Source: ${inputPath}
+
+/**
+ * Minified version of ${basename(inputPath)}
+ * Pre-minified to match browser bundle transformations.
+ */
+export const MINIFIED_PERSIST = ${JSON.stringify(minifyResult.code)};
+`;
+
+ writeFileSync(absoluteOutputPath, outputContent, "utf-8");
+
+ console.log("ā
Generated:", relative(packageRoot, absoluteOutputPath));
+ console.log("š¦ Original size:", sourceContent.length, "bytes");
+ console.log("š¦ Minified size:", minifyResult.code.length, "bytes");
+ console.log(
+ "š Reduction:",
+ ((1 - minifyResult.code.length / sourceContent.length) * 100).toFixed(1) +
+ "%"
+ );
+ console.log("\nš” Preview:");
+ console.log(minifyResult.code.substring(0, 200) + "...");
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+
+ if (args.length < 2) {
+ console.error("ā Usage: tsx scripts/minify.ts ");
+ console.error("\nExample:");
+ console.error(
+ " tsx scripts/minify.ts src/scripts/persist.ts src/scripts/persist.minified.ts"
+ );
+ process.exit(1);
+ }
+
+ const inputPath = args[0];
+ const outputPath = args[1];
+
+ if (!inputPath || !outputPath) {
+ console.error("ā Both input and output paths are required");
+ process.exit(1);
+ }
+
+ try {
+ await minifyFile({
+ inputPath,
+ outputPath,
+ removeComments: true,
+ removeExports: true,
+ });
+ } catch (err) {
+ console.error("ā Error:", err);
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/packages/react-resizable-panels/src/Panel.test.tsx b/packages/react-resizable-panels/src/Panel.test.tsx
index ed3f24b64..44c78c4cd 100644
--- a/packages/react-resizable-panels/src/Panel.test.tsx
+++ b/packages/react-resizable-panels/src/Panel.test.tsx
@@ -11,6 +11,7 @@ import {
} from ".";
import { assert } from "./utils/assert";
import { getPanelElement } from "./utils/dom/getPanelElement";
+import { getPanelGroupElement } from "./utils/dom/getPanelGroupElement";
import {
mockPanelGroupOffsetWidthAndHeight,
verifyAttribute,
@@ -430,9 +431,11 @@ describe("PanelGroup", () => {
describe("constraints", () => {
test("should resize a collapsed panel if the collapsedSize prop changes", () => {
+ const panelGroupId = "test-panel-group-collapsed";
+
act(() => {
root.render(
-
+
{
);
});
- let leftElement = getPanelElement("left", container);
- let middleElement = getPanelElement("middle", container);
- let rightElement = getPanelElement("right", container);
- assert(leftElement, "");
- assert(middleElement, "");
- assert(rightElement, "");
- expect(leftElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("10.0");
- expect(middleElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "80.0"
+ const leftPanelElement = getPanelElement("left", container);
+ const leftElementOrder = leftPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+ const middlePanelElement = getPanelElement("middle", container);
+ const middleElementOrder = middlePanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
);
- expect(rightElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("10.0");
+ const rightPanelElement = getPanelElement("right", container);
+ const rightElementOrder = rightPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+
+ expect(leftElementOrder).toBe("1");
+ expect(middleElementOrder).toBe("2");
+ expect(rightElementOrder).toBe("3");
+
+ // get panel group element and select css variable with order
+ const panelGroupElement = getPanelGroupElement(panelGroupId, container);
+ const leftPanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const middlePanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${middleElementOrder}-size`
+ );
+ const rightPanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
+ );
+
+ expect(leftPanelSize).toBe("10.000");
+ expect(middlePanelSize).toBe("80.000");
+ expect(rightPanelSize).toBe("10.000");
act(() => {
root.render(
-
+
@@ -478,17 +502,27 @@ describe("PanelGroup", () => {
);
});
- expect(leftElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("5.0");
- expect(middleElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "90.0"
+ const leftPanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const middlePanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${middleElementOrder}-size`
+ );
+ const rightPanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
);
- expect(rightElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("5.0");
+
+ expect(leftPanelSizeAfter).toBe("5.000");
+ expect(middlePanelSizeAfter).toBe("90.000");
+ expect(rightPanelSizeAfter).toBe("5.000");
});
test("it should not expand a collapsed panel if other constraints change", () => {
+ const panelGroupId = "test-panel-group-expand";
+
act(() => {
root.render(
-
+
{
);
});
- let leftElement = getPanelElement("left", container);
- let middleElement = getPanelElement("middle", container);
- let rightElement = getPanelElement("right", container);
- assert(leftElement, "");
- assert(middleElement, "");
- assert(rightElement, "");
- expect(leftElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("10.0");
- expect(middleElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "80.0"
+ const leftPanelElement = getPanelElement("left", container);
+ const leftElementOrder = leftPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+ const middlePanelElement = getPanelElement("middle", container);
+ const middleElementOrder = middlePanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+ const rightPanelElement = getPanelElement("right", container);
+ const rightElementOrder = rightPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+
+ expect(leftElementOrder).toBe("1");
+ expect(middleElementOrder).toBe("2");
+ expect(rightElementOrder).toBe("3");
+
+ // get panel group element and select css variable with order
+ const panelGroupElement = getPanelGroupElement(panelGroupId, container);
+ const leftPanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const middlePanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${middleElementOrder}-size`
+ );
+ const rightPanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
);
- expect(rightElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("10.0");
+
+ expect(leftPanelSize).toBe("10.000");
+ expect(middlePanelSize).toBe("80.000");
+ expect(rightPanelSize).toBe("10.000");
act(() => {
root.render(
-
+
@@ -534,17 +589,27 @@ describe("PanelGroup", () => {
);
});
- expect(leftElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("10.0");
- expect(middleElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "80.0"
+ const leftPanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
);
- expect(rightElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("10.0");
+ const middlePanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${middleElementOrder}-size`
+ );
+ const rightPanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
+ );
+
+ expect(leftPanelSizeAfter).toBe("10.000");
+ expect(middlePanelSizeAfter).toBe("80.000");
+ expect(rightPanelSizeAfter).toBe("10.000");
});
test("should resize a panel if the minSize prop changes", () => {
+ const panelGroupId = "test-panel-group-minsize";
+
act(() => {
root.render(
-
+
@@ -554,21 +619,42 @@ describe("PanelGroup", () => {
);
});
- let leftElement = getPanelElement("left", container);
- let middleElement = getPanelElement("middle", container);
- let rightElement = getPanelElement("right", container);
- assert(leftElement, "");
- assert(middleElement, "");
- assert(rightElement, "");
- expect(leftElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("15.0");
- expect(middleElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "70.0"
+ const leftPanelElement = getPanelElement("left", container);
+ const leftElementOrder = leftPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+ const middlePanelElement = getPanelElement("middle", container);
+ const middleElementOrder = middlePanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+ const rightPanelElement = getPanelElement("right", container);
+ const rightElementOrder = rightPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
);
- expect(rightElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("15.0");
+
+ expect(leftElementOrder).toBe("1");
+ expect(middleElementOrder).toBe("2");
+ expect(rightElementOrder).toBe("3");
+
+ // get panel group element and select css variable with order
+ const panelGroupElement = getPanelGroupElement(panelGroupId, container);
+ const leftPanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const middlePanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${middleElementOrder}-size`
+ );
+ const rightPanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
+ );
+
+ expect(leftPanelSize).toBe("15.000");
+ expect(middlePanelSize).toBe("70.000");
+ expect(rightPanelSize).toBe("15.000");
act(() => {
root.render(
-
+
@@ -578,17 +664,27 @@ describe("PanelGroup", () => {
);
});
- expect(leftElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("20.0");
- expect(middleElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "60.0"
+ const leftPanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const middlePanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${middleElementOrder}-size`
);
- expect(rightElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("20.0");
+ const rightPanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
+ );
+
+ expect(leftPanelSizeAfter).toBe("20.000");
+ expect(middlePanelSizeAfter).toBe("60.000");
+ expect(rightPanelSizeAfter).toBe("20.000");
});
test("should resize a panel if the maxSize prop changes", () => {
+ const panelGroupId = "test-panel-group";
+
act(() => {
root.render(
-
+
@@ -598,21 +694,42 @@ describe("PanelGroup", () => {
);
});
- let leftElement = getPanelElement("left", container);
- let middleElement = getPanelElement("middle", container);
- let rightElement = getPanelElement("right", container);
- assert(leftElement, "");
- assert(middleElement, "");
- assert(rightElement, "");
- expect(leftElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("25.0");
- expect(middleElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "50.0"
+ const leftPanelElement = getPanelElement("left", container);
+ const leftElementOrder = leftPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+ const middlePanelElement = getPanelElement("middle", container);
+ const middleElementOrder = middlePanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
);
- expect(rightElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("25.0");
+ const rightPanelElement = getPanelElement("right", container);
+ const rightElementOrder = rightPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+
+ expect(leftElementOrder).toBe("1");
+ expect(middleElementOrder).toBe("2");
+ expect(rightElementOrder).toBe("3");
+
+ // get panel group element and select css variable with order
+ const panelGroupElement = getPanelGroupElement(panelGroupId, container);
+ const leftPanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const middlePanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${middleElementOrder}-size`
+ );
+ const rightPanelSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
+ );
+
+ expect(leftPanelSize).toBe("25.000");
+ expect(middlePanelSize).toBe("50.000");
+ expect(rightPanelSize).toBe("25.000");
act(() => {
root.render(
-
+
@@ -622,11 +739,19 @@ describe("PanelGroup", () => {
);
});
- expect(leftElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("20.0");
- expect(middleElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "60.0"
+ const leftPanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const middlePanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${middleElementOrder}-size`
);
- expect(rightElement.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe("20.0");
+ const rightPanelSizeAfter = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
+ );
+
+ expect(leftPanelSizeAfter).toBe("20.000");
+ expect(middlePanelSizeAfter).toBe("60.000");
+ expect(rightPanelSizeAfter).toBe("20.000");
});
});
@@ -978,22 +1103,21 @@ describe("PanelGroup", () => {
verifyAttribute(leftElement, DATA_ATTRIBUTES.panel, "");
verifyAttribute(leftElement, DATA_ATTRIBUTES.panelId, "left-panel");
verifyAttribute(leftElement, DATA_ATTRIBUTES.groupId, "test-group");
- verifyAttribute(leftElement, DATA_ATTRIBUTES.panelSize, "75.0");
verifyAttribute(leftElement, DATA_ATTRIBUTES.panelCollapsible, null);
verifyAttribute(rightElement, DATA_ATTRIBUTES.panel, "");
verifyAttribute(rightElement, DATA_ATTRIBUTES.panelId, "right-panel");
verifyAttribute(rightElement, DATA_ATTRIBUTES.groupId, "test-group");
- verifyAttribute(rightElement, DATA_ATTRIBUTES.panelSize, "25.0");
verifyAttribute(rightElement, DATA_ATTRIBUTES.panelCollapsible, "true");
});
- test("should update the data-panel-size attribute when the panel resizes", () => {
+ test("should update the CSS variables when the panel resizes", () => {
const leftPanelRef = createRef();
+ const panelGroupId = "test-group-resize";
act(() => {
root.render(
-
+
@@ -1007,15 +1131,42 @@ describe("PanelGroup", () => {
assert(leftElement, "");
assert(rightElement, "");
- verifyAttribute(leftElement, DATA_ATTRIBUTES.panelSize, "75.0");
- verifyAttribute(rightElement, DATA_ATTRIBUTES.panelSize, "25.0");
+ const leftElementOrder = leftElement.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+ const rightElementOrder = rightElement.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
+ const panelGroupElement = getPanelGroupElement(panelGroupId, container);
+
+ expect(leftElementOrder).toBe("1");
+ expect(rightElementOrder).toBe("2");
+
+ // Check initial CSS variable values
+ const leftInitialSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const rightInitialSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
+ );
+
+ expect(leftInitialSize).toBe("75.000");
+ expect(rightInitialSize).toBe("25.000");
act(() => {
leftPanelRef.current?.resize(30);
});
- verifyAttribute(leftElement, DATA_ATTRIBUTES.panelSize, "30.0");
- verifyAttribute(rightElement, DATA_ATTRIBUTES.panelSize, "70.0");
+ // Check CSS variable values after resize
+ const leftResizedSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const rightResizedSize = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
+ );
+
+ expect(leftResizedSize).toBe("30.000");
+ expect(rightResizedSize).toBe("70.000");
});
});
diff --git a/packages/react-resizable-panels/src/Panel.ts b/packages/react-resizable-panels/src/Panel.ts
index 639ce3bb7..73be23f81 100644
--- a/packages/react-resizable-panels/src/Panel.ts
+++ b/packages/react-resizable-panels/src/Panel.ts
@@ -10,6 +10,7 @@ import {
useContext,
useImperativeHandle,
useRef,
+ useState,
} from "react";
import { PanelGroupContext } from "./PanelGroupContext";
import { DATA_ATTRIBUTES } from "./constants";
@@ -42,7 +43,8 @@ export type PanelData = {
constraints: PanelConstraints;
id: string;
idIsFromProps: boolean;
- order: number | undefined;
+ order: number;
+ orderIsFromProps: boolean;
};
export type ImperativePanelHandle = {
@@ -87,7 +89,7 @@ export function PanelWithForwardedRef({
onCollapse,
onExpand,
onResize,
- order,
+ order: orderFromProps,
style: styleFromProps,
tagName: Type = "div",
...rest
@@ -108,6 +110,7 @@ export function PanelWithForwardedRef({
getPanelStyle,
groupId,
isPanelCollapsed,
+ assignPanelOrder,
reevaluatePanelConstraints,
registerPanel,
resizePanel,
@@ -116,6 +119,12 @@ export function PanelWithForwardedRef({
const panelId = useUniqueId(idFromProps);
+ const assignedOrderRef = useRef(null);
+
+ if (assignedOrderRef.current === null) {
+ assignedOrderRef.current = assignPanelOrder(orderFromProps);
+ }
+
const panelDataRef = useRef({
callbacks: {
onCollapse,
@@ -131,7 +140,8 @@ export function PanelWithForwardedRef({
},
id: panelId,
idIsFromProps: idFromProps !== undefined,
- order,
+ order: assignedOrderRef.current,
+ orderIsFromProps: orderFromProps !== undefined,
});
const devWarningsRef = useRef<{
@@ -160,7 +170,8 @@ export function PanelWithForwardedRef({
panelDataRef.current.id = panelId;
panelDataRef.current.idIsFromProps = idFromProps !== undefined;
- panelDataRef.current.order = order;
+ panelDataRef.current.order = assignedOrderRef.current as number;
+ panelDataRef.current.orderIsFromProps = orderFromProps !== undefined;
callbacks.onCollapse = onCollapse;
callbacks.onExpand = onExpand;
@@ -192,7 +203,7 @@ export function PanelWithForwardedRef({
return () => {
unregisterPanel(panelData);
};
- }, [order, panelId, registerPanel, unregisterPanel]);
+ }, [orderFromProps, panelId, registerPanel, unregisterPanel]);
useImperativeHandle(
forwardedRef,
@@ -247,7 +258,7 @@ export function PanelWithForwardedRef({
[DATA_ATTRIBUTES.panel]: "",
[DATA_ATTRIBUTES.panelCollapsible]: collapsible || undefined,
[DATA_ATTRIBUTES.panelId]: panelId,
- [DATA_ATTRIBUTES.panelSize]: parseFloat("" + style.flexGrow).toFixed(1),
+ [DATA_ATTRIBUTES.panelOrder]: assignedOrderRef.current.toString(),
});
}
diff --git a/packages/react-resizable-panels/src/PanelGroup.test.tsx b/packages/react-resizable-panels/src/PanelGroup.test.tsx
index 7c7d8b0c7..e945d270e 100644
--- a/packages/react-resizable-panels/src/PanelGroup.test.tsx
+++ b/packages/react-resizable-panels/src/PanelGroup.test.tsx
@@ -71,6 +71,7 @@ describe("PanelGroup", () => {
});
test("should recalculate layout after being hidden by Activity", () => {
+ const panelGroupId = "test-panel-group";
const panelRef = createRef();
let mostRecentLayout: number[] | null = null;
@@ -82,7 +83,11 @@ describe("PanelGroup", () => {
act(() => {
root.render(
-
+
@@ -95,14 +100,29 @@ describe("PanelGroup", () => {
expect(panelRef.current?.getSize()).toEqual(60);
const leftPanelElement = getPanelElement("left");
+ const leftElementOrder = leftPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
+ );
const rightPanelElement = getPanelElement("right");
- expect(leftPanelElement?.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "60.0"
+ const rightElementOrder = rightPanelElement?.getAttribute(
+ DATA_ATTRIBUTES.panelOrder
);
- expect(rightPanelElement?.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "40.0"
+
+ expect(leftElementOrder).toBe("1");
+ expect(rightElementOrder).toBe("2");
+
+ // get panel group element and select css variable with order
+ const panelGroupElement = getPanelGroupElement(panelGroupId, container);
+ const leftPanelOrder = panelGroupElement?.style.getPropertyValue(
+ `--panel-${leftElementOrder}-size`
+ );
+ const rightPanelOrder = panelGroupElement?.style.getPropertyValue(
+ `--panel-${rightElementOrder}-size`
);
+ expect(leftPanelOrder).toBe("60.000");
+ expect(rightPanelOrder).toBe("40.000");
+
act(() => {
root.render(
@@ -131,12 +151,8 @@ describe("PanelGroup", () => {
expect(panelRef.current?.getSize()).toEqual(60);
// This bug is only observable in the DOM; callbacks will not re-fire
- expect(leftPanelElement?.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "60.0"
- );
- expect(rightPanelElement?.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe(
- "40.0"
- );
+ expect(leftPanelOrder).toBe("60.000");
+ expect(rightPanelOrder).toBe("40.000");
});
// github.com/bvaughn/react-resizable-panels/issues/303
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index 23c627985..2b391587c 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -28,7 +28,7 @@ import {
EXCEEDED_VERTICAL_MIN,
reportConstraintsViolation,
} from "./PanelResizeHandleRegistry";
-import { DATA_ATTRIBUTES } from "./constants";
+import { DATA_ATTRIBUTES, PANEL_SIZE_CSS_VARIABLE_TEMPLATE } from "./constants";
import { useForceUpdate } from "./hooks/useForceUpdate";
import useIsomorphicLayoutEffect from "./hooks/useIsomorphicEffect";
import useUniqueId from "./hooks/useUniqueId";
@@ -133,6 +133,7 @@ function PanelGroupWithForwardedRef({
const panelIdToLastNotifiedSizeMapRef = useRef>({});
const panelSizeBeforeCollapseRef = useRef