Skip to content

Commit cfbdc8b

Browse files
Slide the desktop sidebar open and closed (#848)
The desktop panel used to mount and unmount, so hiding or showing it snapped the whole content area sideways. It now stays in the DOM inside a clipping column whose width animates, while the panel itself translates by the same amount, so the nav slides out of view instead of squashing. The collapsed rail animated nowhere before, which would have made the content edge jump partway through the slide, so its width animates on the same curve. Timing lives in one module shared with the mobile overlay. Transitions are dropped while resizing so the drag still tracks the mouse, the hidden panel is inert, and reduced motion keeps the instant toggle. Claude-Session: https://claude.ai/code/session_014wNyh9fXHD9UmbEqDJQ6QH
1 parent 0d09fc5 commit cfbdc8b

3 files changed

Lines changed: 50 additions & 14 deletions

File tree

packages/web/src/components/layout/AppLayout.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
1818
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
1919
import { PaymentIssueBanner } from '@/components/billing/PaymentIssueBanner';
2020
import { MAIN_SCROLL_ID } from '@/config/scroll';
21+
import { SIDEBAR_SLIDE } from './sidebarMotion';
2122
import { Sidebar } from './Sidebar';
2223
import { MobileBar } from './MobileBar';
2324

@@ -121,9 +122,15 @@ export function AppLayout() {
121122
onWidthChange={handleWidthChange}
122123
/>
123124

124-
{/* Own column rather than an overlay: pages with sticky headers would cover it */}
125-
{!desktopSidebarVisible && (
126-
<div className='border-border bg-sidebar hidden w-10 shrink-0 flex-col items-center border-r pt-2 md:flex'>
125+
{/* Own column rather than an overlay: pages with sticky headers would cover it.
126+
Its width animates in step with the sidebar so the content edge moves once. */}
127+
<div
128+
className={`hidden shrink-0 overflow-hidden transition-[width] md:block ${SIDEBAR_SLIDE}`}
129+
style={{ width: desktopSidebarVisible ? 0 : '2.5rem' }}
130+
aria-hidden={desktopSidebarVisible}
131+
inert={desktopSidebarVisible ? true : undefined}
132+
>
133+
<div className='border-border bg-sidebar flex h-full w-10 flex-col items-center border-r pt-2'>
127134
<Tooltip delayDuration={500}>
128135
<TooltipTrigger asChild>
129136
<Button
@@ -139,7 +146,7 @@ export function AppLayout() {
139146
<TooltipContent side='right'>Show sidebar</TooltipContent>
140147
</Tooltip>
141148
</div>
142-
)}
149+
</div>
143150

144151
<main
145152
data-scroll-restoration-id={MAIN_SCROLL_ID}

packages/web/src/components/layout/Sidebar.tsx

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, useEffectEvent } from 'react';
99
import { useLocation } from '@tanstack/react-router';
1010
import { createPortal } from 'react-dom';
1111
import { PanelLeftCloseIcon, XIcon } from 'lucide-react';
12+
import { SIDEBAR_SLIDE, SIDEBAR_SLIDE_MS } from './sidebarMotion';
1213
import { AdminSidebar } from './sidebar/AdminSidebar';
1314
import { AppSidebar } from './sidebar/AppSidebar';
1415
import { SettingsSidebar } from './sidebar/SettingsSidebar';
@@ -35,6 +36,18 @@ export function Sidebar({
3536
const isSettings = pathname.startsWith('/settings');
3637
const isAdmin = pathname.startsWith('/admin');
3738

39+
// The desktop panel stays in the DOM so its width can animate, but the body
40+
// unmounts once the slide finishes to drop its queries while hidden.
41+
const [desktopBodyMounted, setDesktopBodyMounted] = useState(desktopVisible);
42+
useEffect(() => {
43+
if (desktopVisible) {
44+
setDesktopBodyMounted(true);
45+
return;
46+
}
47+
const timer = setTimeout(() => setDesktopBodyMounted(false), SIDEBAR_SLIDE_MS);
48+
return () => clearTimeout(timer);
49+
}, [desktopVisible]);
50+
3851
// Close mobile on escape
3952
useEffect(() => {
4053
if (!mobileOpen) return;
@@ -95,15 +108,26 @@ export function Sidebar({
95108

96109
return (
97110
<>
98-
{/* Desktop sidebar */}
99-
{desktopVisible && (
111+
{/* Desktop sidebar -- outer clips while the panel slides out from under it */}
112+
<div
113+
className={`hidden h-full shrink-0 overflow-hidden md:block ${
114+
isResizing ? '' : `transition-[width] ${SIDEBAR_SLIDE}`
115+
}`}
116+
style={{ width: desktopVisible ? `${width}px` : 0, maxWidth: '100vw' }}
117+
aria-hidden={!desktopVisible}
118+
inert={!desktopVisible ? true : undefined}
119+
>
100120
<div
101-
className={`border-border bg-sidebar relative hidden h-full shrink-0 border-r md:block ${
102-
isResizing ? 'select-none' : ''
121+
className={`border-border bg-sidebar relative h-full border-r ${
122+
isResizing ? 'select-none' : `transition-transform ${SIDEBAR_SLIDE}`
103123
}`}
104-
style={{ width: `${width}px`, maxWidth: '100vw' }}
124+
style={{
125+
width: `${width}px`,
126+
transform: desktopVisible ? undefined : `translateX(-${width}px)`,
127+
}}
105128
>
106-
{renderBody(onHideDesktop, 'Hide sidebar', <PanelLeftCloseIcon className='size-4' />)}
129+
{desktopBodyMounted &&
130+
renderBody(onHideDesktop, 'Hide sidebar', <PanelLeftCloseIcon className='size-4' />)}
107131
<div
108132
className='hover:bg-primary absolute top-0 right-0 hidden h-full w-1 cursor-col-resize bg-transparent transition-colors md:block'
109133
onMouseDown={handleResizeStart}
@@ -112,22 +136,21 @@ export function Sidebar({
112136
aria-label='Resize sidebar'
113137
/>
114138
</div>
115-
)}
139+
</div>
116140

117141
{/* Mobile overlay -- panel always mounted for the CSS slide transition */}
118142
{createPortal(
119143
<div className='md:hidden' aria-hidden={!mobileOpen} inert={!mobileOpen ? true : undefined}>
120144
<div
121-
className={`fixed inset-0 z-40 bg-black/30 transition-opacity duration-200 ${
145+
className={`fixed inset-0 z-40 bg-black/30 transition-opacity ${SIDEBAR_SLIDE} ${
122146
mobileOpen ? 'opacity-100' : 'pointer-events-none opacity-0'
123147
}`}
124148
onClick={onCloseMobile}
125149
/>
126150
<div
127-
className={`bg-sidebar fixed inset-y-0 left-0 z-50 w-64 shadow-xl transition-transform duration-200 ${
151+
className={`bg-sidebar fixed inset-y-0 left-0 z-50 w-64 shadow-xl transition-transform ${SIDEBAR_SLIDE} ${
128152
mobileOpen ? 'translate-x-0' : '-translate-x-full'
129153
}`}
130-
style={{ transitionTimingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)' }}
131154
>
132155
{/* Body mounts only while open so the app renders one sidebar at a time */}
133156
{mobileOpen && renderBody(onCloseMobile, 'Close sidebar', <XIcon className='size-4' />)}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/** Shared timing for the sidebar slide, so the panel and the collapsed rail move together. */
2+
3+
export const SIDEBAR_SLIDE_MS = 200;
4+
5+
export const SIDEBAR_SLIDE =
6+
'duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none';

0 commit comments

Comments
 (0)