From 4b00ceee1d04a7361f1349b58d3047a3b4d1a885 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sun, 12 Apr 2026 18:30:31 -0400 Subject: [PATCH] Smooth side panel transition using CSS grid animation Replace flex layout with CSS grid and animate gridTemplateColumns so the main content card resizes smoothly when navigating between pages with and without a side panel. Avoids Framer Motion layout prop which distorts inner children via scale transforms. - Convert parent flex container to grid with spring-animated columns - Clone removed portal content into ghost div for exit animation - Add useMediaQuery hook to gate panel column on xl breakpoint --- .../components/layouts/dashboard-layout.tsx | 21 ++++++++-- .../layouts/dashboard-side-panel.tsx | 40 +++++++++++++++---- apps/dashboard/src/lib/use-media-query.ts | 15 +++++++ 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/src/lib/use-media-query.ts diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index d1acc52..dd0cacf 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi, Outlet } from "@tanstack/react-router"; +import { motion } from "motion/react"; import { lazy, Suspense } from "react"; import { githubMyIssuesQueryOptions, @@ -7,9 +8,11 @@ import { } from "#/lib/github.query"; import { useGitHubRevalidation } from "#/lib/use-github-revalidation"; import { useHasMounted } from "#/lib/use-has-mounted"; +import { useMediaQuery } from "#/lib/use-media-query"; import { DashboardBottomBar } from "./dashboard-bottombar"; import { DashboardMobileNav } from "./dashboard-mobile-nav"; import { + SIDE_PANEL_WIDTH, SidePanelProvider, SidePanelSlot, SidePanelToggle, @@ -61,6 +64,8 @@ export function DashboardLayout() { const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data); const sidePanel = useSidePanelSlot(); + const isXl = useMediaQuery("(min-width: 1280px)"); + const showPanel = isXl && sidePanel.hasContent && !sidePanel.collapsed; return (
@@ -83,19 +88,29 @@ export function DashboardLayout() { toggle: sidePanel.toggle, }} > -
-
+ +
+ -
+ (null); + const ghostRef = useRef(null); + const exitTimer = useRef | null>(null); const refCallback = useCallback( (el: HTMLDivElement | null) => { @@ -86,31 +91,52 @@ export function SidePanelSlot({ }; check(); - const observer = new MutationObserver(check); + const observer = new MutationObserver((mutations) => { + const has = el.childNodes.length > 0; + + if (!has && ghostRef.current) { + // Content removed — clone into ghost for exit animation + ghostRef.current.innerHTML = ""; + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + ghostRef.current.appendChild(node.cloneNode(true)); + } + } + if (exitTimer.current) clearTimeout(exitTimer.current); + exitTimer.current = setTimeout(() => { + if (ghostRef.current) ghostRef.current.innerHTML = ""; + }, 500); + } else if (has && ghostRef.current) { + // Content added — clear ghost + ghostRef.current.innerHTML = ""; + if (exitTimer.current) clearTimeout(exitTimer.current); + } + + setHasChildren(has); + onHasContent(has); + }); observer.observe(el, { childList: true }); el.addEventListener("sidepanel-content", check); return () => { observer.disconnect(); el.removeEventListener("sidepanel-content", check); + if (exitTimer.current) clearTimeout(exitTimer.current); }; }, [onHasContent]); const show = hasChildren && !collapsed; return ( - +
+
- +
); } diff --git a/apps/dashboard/src/lib/use-media-query.ts b/apps/dashboard/src/lib/use-media-query.ts new file mode 100644 index 0000000..64d49e3 --- /dev/null +++ b/apps/dashboard/src/lib/use-media-query.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function useMediaQuery(query: string) { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const mq = window.matchMedia(query); + setMatches(mq.matches); + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [query]); + + return matches; +}