From 6bd3cba517301f3256297557d98ad3b956287413 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 21:32:45 +0000 Subject: [PATCH 1/3] Overlay: animate backdrop opacity instead of background-color The overlay root is a position: fixed element covering the viewport and transitioned background-color together with opacity on open/close, and updated background-color on every frame of the Modal drag-to-close gesture. background-color changes are painted on the main thread, so every frame repainted the whole viewport. Move the backdrop to a ::before layer with a static 70% black background whose opacity follows --rs-overlay-opacity. Opacity is a compositor-only property, so the transition and the drag updates no longer trigger paint. The pseudo element is attached to the wrapper (which grows with the content) so it still covers the whole scrollable area when the overlay has overflow: auto. Both the root and the backdrop keep the same duration and easing, so the resulting fade curve is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq --- .changeset/overlay-backdrop-compositor.md | 5 ++++ .../src/components/Overlay/Overlay.module.css | 28 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .changeset/overlay-backdrop-compositor.md diff --git a/.changeset/overlay-backdrop-compositor.md b/.changeset/overlay-backdrop-compositor.md new file mode 100644 index 00000000..b401441a --- /dev/null +++ b/.changeset/overlay-backdrop-compositor.md @@ -0,0 +1,5 @@ +--- +"reshaped": patch +--- + +Improved the `Overlay` animation performance by rendering its backdrop as a separate layer with a static color and transitioning only its `opacity`. Previously the full-viewport root transitioned `background-color` on open and close and during the `Modal` drag-to-close gesture, which forced the browser to repaint the whole viewport on every frame on the main thread. The transition now runs entirely on the compositor thread while keeping the same visual result. diff --git a/packages/reshaped/src/components/Overlay/Overlay.module.css b/packages/reshaped/src/components/Overlay/Overlay.module.css index 46fd3aa7..42f9dbba 100644 --- a/packages/reshaped/src/components/Overlay/Overlay.module.css +++ b/packages/reshaped/src/components/Overlay/Overlay.module.css @@ -15,7 +15,6 @@ width: 100%; z-index: var(--rs-z-index-fixed); color: var(--rs-color-white); - background-color: rgb(from var(--rs-color-black) r g b / 0%); opacity: 0; outline: none; isolation: isolate; @@ -25,6 +24,22 @@ display: table; height: 100%; width: 100%; + position: relative; + + /** + * Backdrop is rendered as a separate layer with a static color and only its opacity is animated, + * so the transition and the drag-to-close updates run on the compositor instead of repainting the whole viewport. + * It's attached to the wrapper instead of the root to cover the whole scrollable area when overflow is auto. + */ + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background-color: rgb(from var(--rs-color-black) r g b / 70%); + opacity: var(--rs-overlay-opacity); + will-change: opacity; + } } .inner { @@ -42,7 +57,6 @@ .root.--visible { --rs-overlay-opacity: 1; - background-color: rgb(from var(--rs-color-black) r g b / calc(var(--rs-overlay-opacity) * 0.7)); opacity: 1; } @@ -75,10 +89,18 @@ .root.--animated { transition: var(--rs-duration-medium) var(--rs-easing-accelerate); - transition-property: background-color, opacity; + transition-property: opacity; + + & .wrapper::before { + transition: opacity var(--rs-duration-medium) var(--rs-easing-accelerate); + } &.--visible { transition-timing-function: var(--rs-easing-decelerate); + + & .wrapper::before { + transition-timing-function: var(--rs-easing-decelerate); + } } } From 7bab1a303d7d1c00452ca40c6417b3869483808b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 09:01:46 +0000 Subject: [PATCH 2/3] Overlay: render the backdrop as an element driven by a ref Driving the backdrop opacity through an inherited custom property set on the root made Chromium recalculate style for the whole overlay subtree (the entire modal content) on every drag frame. The backdrop is now a real element and setOpacity writes its opacity directly, so the recalculation stays scoped to that single element. The internal --rs-overlay-opacity custom property is removed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq --- .../src/components/Overlay/Overlay.module.css | 42 ++++++++++--------- .../src/components/Overlay/Overlay.tsx | 6 ++- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/reshaped/src/components/Overlay/Overlay.module.css b/packages/reshaped/src/components/Overlay/Overlay.module.css index 42f9dbba..b7bd744e 100644 --- a/packages/reshaped/src/components/Overlay/Overlay.module.css +++ b/packages/reshaped/src/components/Overlay/Overlay.module.css @@ -2,7 +2,6 @@ /* Add an offset in case overlay is rendered inside a scrolled container */ --rs-overlay-offset-y: 0px; --rs-overlay-offset-x: 0px; - --rs-overlay-opacity: 0; position: fixed; overflow-x: clip; @@ -25,21 +24,20 @@ height: 100%; width: 100%; position: relative; +} - /** - * Backdrop is rendered as a separate layer with a static color and only its opacity is animated, - * so the transition and the drag-to-close updates run on the compositor instead of repainting the whole viewport. - * It's attached to the wrapper instead of the root to cover the whole scrollable area when overflow is auto. - */ - &::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - background-color: rgb(from var(--rs-color-black) r g b / 70%); - opacity: var(--rs-overlay-opacity); - will-change: opacity; - } +/** + * Backdrop is rendered as a separate layer with a static color and only its opacity is animated, + * so the transition and the drag-to-close updates run on the compositor instead of repainting the whole viewport. + * It's rendered inside the wrapper instead of the root to cover the whole scrollable area when overflow is auto. + */ +.backdrop { + position: absolute; + inset: 0; + z-index: -1; + background-color: rgb(from var(--rs-color-black) r g b / 70%); + opacity: 0; + will-change: opacity; } .inner { @@ -55,14 +53,20 @@ } .root.--visible { - --rs-overlay-opacity: 1; - opacity: 1; + + & .backdrop { + opacity: 1; + } } .root.--click-through { color: inherit; pointer-events: none; + + & .backdrop { + opacity: 0; + } } .root.--blurred { @@ -91,14 +95,14 @@ transition: var(--rs-duration-medium) var(--rs-easing-accelerate); transition-property: opacity; - & .wrapper::before { + & .backdrop { transition: opacity var(--rs-duration-medium) var(--rs-easing-accelerate); } &.--visible { transition-timing-function: var(--rs-easing-decelerate); - & .wrapper::before { + & .backdrop { transition-timing-function: var(--rs-easing-decelerate); } } diff --git a/packages/reshaped/src/components/Overlay/Overlay.tsx b/packages/reshaped/src/components/Overlay/Overlay.tsx index 303effad..f2a37fee 100644 --- a/packages/reshaped/src/components/Overlay/Overlay.tsx +++ b/packages/reshaped/src/components/Overlay/Overlay.tsx @@ -42,6 +42,7 @@ const Overlay: React.FC = (props) => { const [animated, setAnimated] = React.useState(false); const [offset, setOffset] = React.useState([0, 0]); const rootRef = React.useRef(null); + const backdropRef = React.useRef(null); const scopeRef = React.useRef(null); const contentRef = React.useRef(null); const { lockScroll, unlockScroll } = useScrollLock({ containerRef }); @@ -183,7 +184,8 @@ const Overlay: React.FC = (props) => { () => ({ setOpacity: (value: number) => { if (transparent) return; - rootRef.current?.style.setProperty("--rs-overlay-opacity", `${value}`); + // Applied directly to the backdrop element to keep the style recalculation scoped to it + backdropRef.current?.style.setProperty("opacity", `${value}`); }, }), [transparent] @@ -205,7 +207,6 @@ const Overlay: React.FC = (props) => { { "--rs-overlay-offset-x": containerRef ? `${offset[0]}px` : undefined, "--rs-overlay-offset-y": containerRef ? `${offset[1]}px` : undefined, - "--rs-overlay-opacity": transparent ? 0 : undefined, } as React.CSSProperties } // oxlint-disable-next-line jsx_a11y/prefer-tag-over-role @@ -217,6 +218,7 @@ const Overlay: React.FC = (props) => { onTransitionEnd={handleTransitionEnd} >
+
{typeof children === "function" ? children({ active: visible }) : children} From 296577a806450eed367cc810739eba397da76ecf Mon Sep 17 00:00:00 2001 From: Dmitry Belyaev Date: Sat, 12 Sep 2026 18:18:59 +0200 Subject: [PATCH 3/3] Overlay: Animation optimization --- .../reshaped/src/components/Overlay/Overlay.module.css | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/reshaped/src/components/Overlay/Overlay.module.css b/packages/reshaped/src/components/Overlay/Overlay.module.css index b7bd744e..39137577 100644 --- a/packages/reshaped/src/components/Overlay/Overlay.module.css +++ b/packages/reshaped/src/components/Overlay/Overlay.module.css @@ -26,11 +26,7 @@ position: relative; } -/** - * Backdrop is rendered as a separate layer with a static color and only its opacity is animated, - * so the transition and the drag-to-close updates run on the compositor instead of repainting the whole viewport. - * It's rendered inside the wrapper instead of the root to cover the whole scrollable area when overflow is auto. - */ +/* Setting opacity separately for swipe updates directly in the dom */ .backdrop { position: absolute; inset: 0; @@ -92,8 +88,7 @@ } .root.--animated { - transition: var(--rs-duration-medium) var(--rs-easing-accelerate); - transition-property: opacity; + transition: opacity var(--rs-duration-medium) var(--rs-easing-accelerate); & .backdrop { transition: opacity var(--rs-duration-medium) var(--rs-easing-accelerate);