Skip to content

Commit e669d63

Browse files
authored
Smooth side panel transition using CSS grid animation (#84)
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
1 parent 7a7decf commit e669d63

3 files changed

Lines changed: 66 additions & 10 deletions

File tree

apps/dashboard/src/components/layouts/dashboard-layout.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { useQuery } from "@tanstack/react-query";
22
import { getRouteApi, Outlet } from "@tanstack/react-router";
3+
import { motion } from "motion/react";
34
import { lazy, Suspense } from "react";
45
import {
56
githubMyIssuesQueryOptions,
67
githubMyPullsQueryOptions,
78
} from "#/lib/github.query";
89
import { useGitHubRevalidation } from "#/lib/use-github-revalidation";
910
import { useHasMounted } from "#/lib/use-has-mounted";
11+
import { useMediaQuery } from "#/lib/use-media-query";
1012
import { DashboardBottomBar } from "./dashboard-bottombar";
1113
import { DashboardMobileNav } from "./dashboard-mobile-nav";
1214
import {
15+
SIDE_PANEL_WIDTH,
1316
SidePanelProvider,
1417
SidePanelSlot,
1518
SidePanelToggle,
@@ -61,6 +64,8 @@ export function DashboardLayout() {
6164
const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data);
6265

6366
const sidePanel = useSidePanelSlot();
67+
const isXl = useMediaQuery("(min-width: 1280px)");
68+
const showPanel = isXl && sidePanel.hasContent && !sidePanel.collapsed;
6469

6570
return (
6671
<div className="isolate flex h-dvh flex-col bg-muted">
@@ -83,19 +88,29 @@ export function DashboardLayout() {
8388
toggle: sidePanel.toggle,
8489
}}
8590
>
86-
<div className="flex flex-1 overflow-hidden p-2 pt-0">
87-
<div className="relative flex-1 overflow-hidden rounded-xl border bg-card shadow-[0_1px_4px_0_rgba(0,0,0,0.03)]">
91+
<motion.div
92+
initial={false}
93+
animate={{
94+
gridTemplateColumns: showPanel
95+
? `minmax(0, 1fr) ${SIDE_PANEL_WIDTH}px`
96+
: "minmax(0, 1fr) 0px",
97+
}}
98+
transition={{ type: "spring", stiffness: 400, damping: 35 }}
99+
className="grid flex-1 overflow-hidden p-2 pt-0"
100+
>
101+
<div className="relative overflow-hidden rounded-xl border bg-card shadow-[0_1px_4px_0_rgba(0,0,0,0.03)]">
88102
<div className="h-full">
89103
<Outlet />
90104
</div>
91105
<SidePanelToggle />
92106
</div>
107+
93108
<SidePanelSlot
94109
slotRef={sidePanel.setNode}
95110
collapsed={sidePanel.collapsed}
96111
onHasContent={sidePanel.setHasContent}
97112
/>
98-
</div>
113+
</motion.div>
99114
</SidePanelProvider>
100115
<DashboardBottomBar />
101116
<DashboardMobileNav

apps/dashboard/src/components/layouts/dashboard-side-panel.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
} from "react";
1111
import { createPortal } from "react-dom";
1212

13+
// w-72 (288px) + pl-2 (8px)
14+
export const SIDE_PANEL_WIDTH = 296;
15+
1316
type SidePanelState = {
1417
node: HTMLDivElement | null;
1518
collapsed: boolean;
@@ -66,6 +69,8 @@ export function SidePanelSlot({
6669
}) {
6770
const [hasChildren, setHasChildren] = useState(false);
6871
const innerRef = useRef<HTMLDivElement | null>(null);
72+
const ghostRef = useRef<HTMLDivElement | null>(null);
73+
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
6974

7075
const refCallback = useCallback(
7176
(el: HTMLDivElement | null) => {
@@ -86,31 +91,52 @@ export function SidePanelSlot({
8691
};
8792

8893
check();
89-
const observer = new MutationObserver(check);
94+
const observer = new MutationObserver((mutations) => {
95+
const has = el.childNodes.length > 0;
96+
97+
if (!has && ghostRef.current) {
98+
// Content removed — clone into ghost for exit animation
99+
ghostRef.current.innerHTML = "";
100+
for (const mutation of mutations) {
101+
for (const node of mutation.removedNodes) {
102+
ghostRef.current.appendChild(node.cloneNode(true));
103+
}
104+
}
105+
if (exitTimer.current) clearTimeout(exitTimer.current);
106+
exitTimer.current = setTimeout(() => {
107+
if (ghostRef.current) ghostRef.current.innerHTML = "";
108+
}, 500);
109+
} else if (has && ghostRef.current) {
110+
// Content added — clear ghost
111+
ghostRef.current.innerHTML = "";
112+
if (exitTimer.current) clearTimeout(exitTimer.current);
113+
}
114+
115+
setHasChildren(has);
116+
onHasContent(has);
117+
});
90118
observer.observe(el, { childList: true });
91119
el.addEventListener("sidepanel-content", check);
92120
return () => {
93121
observer.disconnect();
94122
el.removeEventListener("sidepanel-content", check);
123+
if (exitTimer.current) clearTimeout(exitTimer.current);
95124
};
96125
}, [onHasContent]);
97126

98127
const show = hasChildren && !collapsed;
99128

100129
return (
101-
<motion.div
102-
animate={{ width: show ? "auto" : 0 }}
103-
transition={{ type: "spring", stiffness: 400, damping: 35 }}
104-
className="hidden shrink-0 overflow-hidden xl:block"
105-
>
130+
<div className="hidden overflow-hidden xl:block">
106131
<motion.div
107132
animate={{ opacity: show ? 1 : 0 }}
108133
transition={{ duration: show ? 0.2 : 0.1, delay: show ? 0.1 : 0 }}
109134
className="h-full overflow-y-auto overflow-x-hidden pb-2 pl-2"
110135
>
111136
<div ref={refCallback} />
137+
<div ref={ghostRef} className="pointer-events-none" aria-hidden />
112138
</motion.div>
113-
</motion.div>
139+
</div>
114140
);
115141
}
116142

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useEffect, useState } from "react";
2+
3+
export function useMediaQuery(query: string) {
4+
const [matches, setMatches] = useState(false);
5+
6+
useEffect(() => {
7+
const mq = window.matchMedia(query);
8+
setMatches(mq.matches);
9+
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
10+
mq.addEventListener("change", handler);
11+
return () => mq.removeEventListener("change", handler);
12+
}, [query]);
13+
14+
return matches;
15+
}

0 commit comments

Comments
 (0)