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:

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:

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?


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:

Behavior:

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:


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 #6

Summary Table

Aspect

Implementation

File

Key Points

Offcanvas Trigger

collapsible="offcanvas" on Sidebar

sidebar.tsx

Desktop hides completely, mobile uses Sheet

Hover Trigger

3px fixed zone on left edge

SidebarVisibilityWrapper.component.tsx

onMouseEnter={onShowFloating}

Floating Show

useFloatingSidebar() hook

SidebarVisibilityWrapper.component.tsx

Sets isFloating=true only when !open && !isMobile

Floating Hide

Mouse leave with 300ms delay

FloatingSidebarWrapper

onMouseLeave → scheduleHide()

Popover Handling

MutationObserver + state check

FloatingSidebarWrapper

Keeps sidebar open during dropdown interaction

CSS Animation

transition-[left,right,width] 200ms

sidebar.tsx + global.css

Smooth slide left/right, z-index management

State Persistence

Zustand store with localStorage

sidePanel.slice.ts

Persists to side-panel-storage

Keyboard Control

⌘B / Ctrl+B in provider

sidebar.tsx

Toggles via toggleSidebar()


Integration Points

Where Offcanvas Behavior is Used

  1. EditorShell Layout - Main layout wrapper for all editor routes

  2. AppLayout Component - Alternative app layout with floating sidebar

  3. DocumentSidebar - The actual sidebar content component

Where State is Managed

  1. SidebarProvider (shadcn) - React Context for sidebar state

  2. useSidePanelStore (Zustand) - Persistence layer

  3. useFloatingSidebar (custom hook) - Floating-specific state

Where Styling Happens

  1. Tailwind classes in sidebar.tsx - Responsive behavior

  2. Global CSS in global.css - Floating sidebar overrides, animations, z-index

  3. 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.