Colbin Editor Sidebar Offcanvas Behavior
Overview
The Colbin editor sidebar uses a hover-triggered floating sidebar pattern with shadcn/ui's offcanvas collapsible mode. When the sidebar is closed, users can hover over the left edge (3px wide zone) to temporarily reveal it as a floating panel that automatically hides when the mouse leaves.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ SidebarProvider (shadcn) │
│ ├─ State: open, setOpen, openMobile, isMobile │
│ └─ Keyboard shortcut: ⌘B to toggle │
└─────────────────────────────────────────────────────────────┘
│
┌─────────┴────────┐
│ │
┌───────▼──────┐ ┌──────▼───────┐
│ Sidebar │ │ SidebarInset │
│ (shadcn) │ │(main content)│
└───────┬──────┘ └──────────────┘
│
┌───────▼────────────────────────┐
│ DocumentSidebar (wrapper) │
│ └─ FloatingSidebarWrapper │
│ └─ useSidebar() context │
└────────────────────────────────┘
│
┌───────────┴─────────────┐
│ │
┌───▼────────────┐ ┌───────▼─────────┐
│ HoverTrigger │ │ FloatingWrapper │
│ Zone (3px) │ │ with mouse │
│ onMouseEnter │ │ handlers │
└────────────────┘ └─────────────────┘1. Sidebar Offcanvas Mode Triggering
How It's Configured
File: /apps/editor/src/features/documentSidebar/components/DocumentSidebar.component.tsx (line 61-62)
<Sidebar
collapsible="offcanvas"
// ...
>The collapsible="offcanvas" prop on shadcn's <Sidebar> component enables:
Desktop behavior: Sidebar completely hidden when
state="collapsed", can be revealed via hoverMobile behavior: Renders as a
<Sheet> dialog instead
When It's Triggered
The offcanvas mode is triggered in two ways:
1. User clicks the sidebar toggle button (programmatically)
File: /apps/editor/src/components/ui/sidebar.tsx (line 261-285)
const SidebarTrigger = React.forwardRef<...>((props, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
onClick={(event) => {
onClick?.(event);
toggleSidebar() // ← Calls setOpen(!open)
}}
// ...
>
<PanelLeft />
</Button>
)
})Or keyboard shortcut (⌘B / Ctrl+B):
File: /apps/editor/src/components/ui/sidebar.tsx (lines 95-108)
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === 'b' &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])2. User hovers over the left edge (automatic floating reveal)
File: /apps/editor/src/features/documentSidebar/components/SidebarVisibilityWrapper.component.tsx (lines 77-91)
export const HoverTriggerZone = memo(function HoverTriggerZone({
onShowFloating,
className,
}: HoverTriggerZoneProps) {
return (
<div
className={cn(
'hidden md:block fixed left-0 top-0 w-3 h-full z-[var(--z-sidebar-trigger)]',
// ... sizing: 3px wide, full height, fixed position
)}
onMouseEnter={onShowFloating} // ← HOVER TRIGGER
aria-hidden="true"
/>
);
});Triggers floating sidebar display via useFloatingSidebar() hook:
File: /apps/editor/src/features/documentSidebar/components/SidebarVisibilityWrapper.component.tsx (lines 199-226)
export function useFloatingSidebar(): UseFloatingSidebarReturn {
const [isFloating, setIsFloating] = useState(false);
const { open, isMobile } = useSidebar();
const showFloating = useCallback(() => {
// Only show floating if sidebar is hidden (not open) and on desktop
if (!open && !isMobile) {
setIsFloating(true); // ← Triggers floating mode
}
}, [open, isMobile]);
const hideFloating = useCallback(() => {
setIsFloating(false); // ← Auto-hide when mouse leaves
}, []);
// Reset floating state when sidebar opens normally
React.useEffect(() => {
if (open) {
setIsFloating(false); // ← Close floating when sidebar is pinned open
}
}, [open]);
return { isFloating, showFloating, hideFloating };
}2. Sidebar Component Implementation
Shadcn Sidebar Primitive
File: /apps/editor/src/components/ui/sidebar.tsx
Desktop Rendering (lines 214-256)
When collapsible="offcanvas", the sidebar renders as:
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state} // "expanded" or "collapsed"
data-collapsible={state === "collapsed" ? collapsible : ""} // "offcanvas"
data-variant={variant}
data-side={side}
>
{/* Spacer - collapses to w-0 when offcanvas */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0", // ← Hides spacer
)}
/>
{/* Actual sidebar container - positioned absolutely, animated left/right */}
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" // ← Slides out left
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
)}
>
<div data-sidebar="sidebar" className="flex h-full w-full flex-col bg-sidebar">
{children}
</div>
</div>
</div>
)Key behavior:
data-state="collapsed" + data-collapsible="offcanvas" → sidebar slides completely off-screenUses
transition-[left,right,width] with duration-200 for smooth animationfixed positioning keeps it always visible when needed
Mobile Rendering (lines 190-211)
When on mobile (isMobile=true), renders as a Radix <Sheet>:
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground"
style={{ "--sidebar-width": SIDEBAR_WIDTH_MOBILE }}
side={side}
>
{/* ... */}
</SheetContent>
</Sheet>
)
}3. Mouse Event Handlers
HoverTriggerZone - Show Floating
File: /apps/editor/src/features/documentSidebar/components/SidebarVisibilityWrapper.component.tsx (line 87)
<div
onMouseEnter={onShowFloating} // ← Single event: triggers floating reveal
// No onMouseLeave — floating is controlled by FloatingSidebarWrapper
/>FloatingSidebarWrapper - Hide Floating
File: /apps/editor/src/features/documentSidebar/components/SidebarVisibilityWrapper.component.tsx (lines 128-168)
The wrapper implements sophisticated mouse handling:
1. Basic Mouse Enter/Leave (lines 128-138)
const handleMouseEnter = useCallback(() => {
mouseInsideRef.current = true;
clearHideTimeout(); // Cancel any pending hide
}, [clearHideTimeout]);
const handleMouseLeave = useCallback(() => {
mouseInsideRef.current = false;
// Don't schedule hide if a dropdown/popover is open
if (hasOpenPopover()) return;
scheduleHide(); // Queue hide with 300ms delay
}, [hasOpenPopover, scheduleHide]);Applied to wrapper:
<div
onMouseEnter={isFloating ? handleMouseEnter : undefined}
onMouseLeave={isFloating ? handleMouseLeave : undefined}
>2. Auto-Hide with Timeout (lines 119-126)
const scheduleHide = useCallback(() => {
clearHideTimeout();
hideTimeoutRef.current = setTimeout(() => {
// Only hide if no popover is open
if (!hasOpenPopover()) {
onHideFloating();
}
}, 300); // ← 300ms delay before hiding
}, [onHideFloating, hasOpenPopover, clearHideTimeout]);3. Popover State Detection (lines 106-110)
const hasOpenPopover = useCallback(() => {
// Check if any Radix dropdown/popover is open
// Radix sets data-state="open" on trigger elements
return !!wrapperRef.current?.querySelector('[data-state="open"]');
}, []);4. Popover Close Listener (lines 142-168)
useEffect(() => {
if (!isFloating) return;
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-state') {
const target = mutation.target;
if (
target instanceof HTMLElement &&
target.getAttribute('data-state') === 'closed'
) {
// A popover just closed — hide if mouse is outside
if (!mouseInsideRef.current && !hasOpenPopover()) {
scheduleHide();
}
}
}
}
});
observer.observe(wrapperRef.current, {
attributes: true,
attributeFilter: ['data-state'],
subtree: true,
});
return () => observer.disconnect();
}, [isFloating, hasOpenPopover, scheduleHide]);Why this complexity?
When user opens a dropdown menu in the sidebar, the menu portal appears outside the sidebar
If we hide the sidebar immediately on mouse leave, the menu closes too
We keep the sidebar open while any Radix dropdown is visible
When dropdown closes, we schedule a hide (but cancel if mouse re-enters)
4. Sidebar Provider/Context
SidebarProvider
File: /apps/editor/src/components/ui/sidebar.tsx (lines 47-151)
The provider manages sidebar state and provides it via React Context:
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
export function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}State Management (lines 74-85)
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState) // Controlled mode (external state)
} else {
_setOpen(openState) // Uncontrolled mode (internal state)
}
},
[setOpenProp, open]
)Mobile vs Desktop Toggle (lines 88-92)
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open) // Mobile: toggle Sheet
: setOpen((open) => !open) // Desktop: toggle open/collapsed
}, [isMobile, setOpen, setOpenMobile])State Persistence
File: /apps/editor/src/store/slices/sidePanel.slice.ts
The sidebar state is persisted via Zustand:
export const useSidePanelStore = create<SidePanelState>()(
devtools(
persist(
(set) => ({
leftOpen: false,
leftWidth: SIDEBAR_DEFAULT_WIDTH,
// ...
setLeftOpen: (open) => set({ leftOpen: open }),
}),
{
name: 'side-panel-storage', // ← Persisted to localStorage
}
),
{ name: 'side-panel-store' }
)
)Context Integration in EditorShell
File: /apps/editor/src/features/editor/pages/layouts/EditorShell.layout.tsx
The layout connects the Zustand store to the SidebarProvider:
const EditorShell: React.FC = () => {
const leftOpen = useSidePanelStore((state) => state.leftOpen);
const setLeftOpen = useSidePanelStore((state) => state.setLeftOpen);
const leftWidth = useSidePanelStore((state) => state.leftWidth);
return (
<SidebarVisibilityContext.Provider value={{ showSidebar, setShowSidebar }}>
<SidebarProvider
open={leftOpen} // ← Controlled by Zustand
onOpenChange={setLeftOpen} // ← Syncs back to Zustand
style={{ "--sidebar-width": `${leftWidth}px` }}
>
{/* ... */}
</SidebarProvider>
</SidebarVisibilityContext.Provider>
);
};5. CSS Transitions & Animations
Offcanvas Sliding Animation
File: /apps/editor/src/components/ui/sidebar.tsx (lines 234-239)
<div
className={cn(
"duration-200 fixed ... transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
)}
>Animation properties:
Duration:
duration-200 (200ms)Easing:
ease-linearProperties animated:
left, right, width
Behavior:
When
state="collapsed" + collapsible="offcanvas":Left sidebar:
left-0 → left-[calc(var(--sidebar-width)*-1)] (slides left by width)Right sidebar:
right-0 → right-[calc(var(--sidebar-width)*-1)] (slides right by width)
Floating Sidebar Styles
File: /apps/editor/src/styles/global.css (lines 286-300)
/* Override shadcn's offcanvas positioning when floating */
[data-sidebar-floating="true"] .group.peer > div:last-child {
left: 0 !important;
z-index: var(--z-sidebar-float); /* 45 */
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12), 0 4px 10px rgba(0, 0, 0, 0.08);
transition: left 200ms ease-in-out, box-shadow 200ms ease-in-out;
}
.dark [data-sidebar-floating="true"] .group.peer > div:last-child {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 4px 10px rgba(0, 0, 0, 0.3);
}Key CSS variables:
File: /apps/editor/src/styles/global.css (lines 27-35)
--z-content: 0;
--z-sticky: 20;
--z-sidebar-float: 45; /* Floating sidebar - above sticky, below dropdowns */
--z-sidebar-trigger: 48; /* Hover trigger zone - above floating sidebar */
--z-dropdown: 50; /* Dropdowns/menus */
--z-modal: 70;
--z-tooltip: 80;
--z-notification: 90;Floating effect:
Uses
!important to override shadcn's left-[calc(var(--sidebar-width)*-1)] when floatingShadow makes it appear elevated above content
z-index: 45 keeps it above sticky headers but below dropdowns
Event Flow Diagram
User Action Component State Change
─────────────────────────────────────────────────────────────
1. HOVER on left edge (3px zone)
└─ HoverTriggerZone.onMouseEnter
└─ showFloating()
└─ useFloatingSidebar: setIsFloating(true)
└─ FloatingSidebarWrapper: data-sidebar-floating="true"
└─ CSS: [data-sidebar-floating="true"] .group.peer > div:last-child { left: 0 }
└─ Sidebar slides in from left
2. MOUSE ENTERS sidebar
└─ FloatingSidebarWrapper.onMouseEnter
└─ clearHideTimeout()
└─ Cancel any scheduled hide
3. MOUSE LEAVES sidebar
└─ FloatingSidebarWrapper.onMouseLeave
└─ scheduleHide()
└─ setTimeout 300ms → onHideFloating()
└─ useFloatingSidebar: setIsFloating(false)
└─ FloatingSidebarWrapper: data-sidebar-floating="false"
└─ CSS: sidebar slides back out left
4. DROPDOWN OPENS while hovering
└─ Radix sets data-state="open"
└─ hasOpenPopover() returns true
└─ scheduleHide() is blocked
└─ Sidebar stays visible
5. DROPDOWN CLOSES while hovering
└─ MutationObserver detects data-state="closed"
└─ scheduleHide()
└─ If no other popovers open: hide sidebar
6. CLICK sidebar toggle button
└─ SidebarTrigger.onClick
└─ toggleSidebar()
└─ SidebarProvider: setOpen(!open)
└─ Sidebar: state="collapsed" → state="expanded"
└─ CSS: left-[calc(...)*-1] → left-0 (pinned open)
└─ useFloatingSidebar: setIsFloating(false)
└─ Floating mode disabled
7. KEYBOARD SHORTCUT ⌘B / Ctrl+B
└─ SidebarProvider.useEffect keydown listener
└─ toggleSidebar()
└─ Same as #6Summary Table
Aspect | Implementation | File | Key Points |
|---|---|---|---|
Offcanvas Trigger |
|
| Desktop hides completely, mobile uses Sheet |
Hover Trigger | 3px fixed zone on left edge |
|
|
Floating Show |
|
| Sets |
Floating Hide | Mouse leave with 300ms delay |
|
|
Popover Handling | MutationObserver + state check |
| Keeps sidebar open during dropdown interaction |
CSS Animation |
|
| Smooth slide left/right, z-index management |
State Persistence | Zustand store with localStorage |
| Persists to |
Keyboard Control | ⌘B / Ctrl+B in provider |
| Toggles via |
Integration Points
Where Offcanvas Behavior is Used
EditorShell Layout - Main layout wrapper for all editor routes
AppLayout Component - Alternative app layout with floating sidebar
DocumentSidebar - The actual sidebar content component
Where State is Managed
SidebarProvider (shadcn) - React Context for sidebar state
useSidePanelStore (Zustand) - Persistence layer
useFloatingSidebar (custom hook) - Floating-specific state
Where Styling Happens
Tailwind classes in sidebar.tsx - Responsive behavior
Global CSS in global.css - Floating sidebar overrides, animations, z-index
CSS variables - Dynamic width, z-index values
Key Behaviors Explained
Why 300ms delay on hide?
Allows user to move mouse from sidebar edge to the open dropdown menu without it closing. If they move slowly, the timeout gives them time.
Why check for open popover?
Dropdowns rendered via Radix Portal appear outside the sidebar DOM. When mouse leaves the sidebar but is over the menu, we don't want to hide the sidebar.
Why use data-state="open"?
Radix UI components set this attribute on trigger elements when their content is visible. It's a reliable way to detect open state without introspecting the component's internal state.
Why floating is only on desktop?
Mobile uses <Sheet> (fullscreen modal) which is better UX. Floating hover wouldn't work on touch devices anyway.
Why persist open state?
Users expect their sidebar preference (open/closed) to persist across sessions. Zustand + localStorage handles this transparently.