Sidebar Resize Rail

A drag handle for the sidebar's width that never re-renders React: one CSS variable, written once per frame, with keyboard resizing and a veto. The line sits on the seam and is transparent until hover, so titles are not covered.

Open in
Chats
260px

Installation

npx shadcn@latest add miskibin/chat-components/sidebar-resize-rail

Also installs chat-sidebar.

Usage

"use client"

import {
  ChatSidebar,
  SidebarResizeRail,
} from "@/components/ui/chat-sidebar"

export function Nav() {
  const [collapsed, setCollapsed] = useState(false)
  const [width, setWidth] = useState(() => readStoredWidth())

  return (
    <ChatSidebar
      collapsed={collapsed}
      onCollapsedChange={setCollapsed}
      overlays={
        <SidebarResizeRail
          width={width}
          minWidth={240}
          maxWidth={420}
          onWidthChange={(next) => {
            setWidth(next)
            localStorage.setItem("sidebar-width", String(next))
          }}
        />
      }
    >
      {children}
    </ChatSidebar>
  )
}

Examples

Why a CSS variable

A width in React state re-renders the sidebar — and every memoized row in it — on every pointer move. The rail writes one custom property inside a requestAnimationFrame instead, so a drag costs a style recalculation and nothing else, and forces the target's transitions to 0ms while the pointer is down: an animated width would trail the cursor by a whole easing curve. React hears about it once, on release.

// The panel already reads the variable:
//   width: var(--chat-sidebar-width, 290px)
// so the rail only has to write it.

<ChatSidebar
  collapsed={collapsed}
  onCollapsedChange={setCollapsed}
  widthExpanded={290}
  overlays={<SidebarResizeRail onWidthChange={persist} />}
>
  {children}
</ChatSidebar>

Refuse a width

shouldAcceptWidth runs inside the same frame, before the write. Return false and the panel simply stays where it is — useful when something else on the page (a file panel, a table) has a width of its own to defend.

<SidebarResizeRail
  onWidthChange={persist}
  shouldAcceptWidth={({ nextWidth }) =>
    window.innerWidth - nextWidth >= 640
  }
/>

Notes

API reference

SidebarResizeRail

widthnumber
Width to hydrate before the first paint — a persisted one, usually. The rail reads no storage itself; it is written in a layout effect, so a restored sidebar never flashes at the default width first.
onWidthChange(width: number) => void
Fired on release, on the double-click reset and on every keyboard step — never per frame. Persist here.
defaultWidthnumber = 290
Double-click (or Enter) resets to this.
minWidth / maxWidthnumber = 208 / 480
Hard limits on the drag.
contentMinWidthnumber = 420
Room the rest of the layout keeps: on a narrow viewport the maximum shrinks to leave it, and minWidth still wins over that.
targetRefRefObject<HTMLElement | null>
Element the variable is written on. Defaults to the closest [data-slot="chat-sidebar"], then to the rail's own parent.
cssVarstring = "--chat-sidebar-width"
Custom property to drive. Match it with the sidebar's width if you change it.
shouldAcceptWidth(info) => boolean
Veto a width mid-drag — the pointer keeps moving, the panel does not. info is { currentWidth, nextWidth, target, side }.
side"left" | "right" = "left"
Which side of the panel the rail sits on; it flips the drag direction and the keyboard keys.
stepnumber = 16
Keyboard increment. Shift multiplies it by four.
disabledboolean = false
Makes the rail inert — a collapsed sidebar, usually.

Width math (pure)

resolveSidebarWidthBounds(viewportWidth, options?){ min, max }
The pair the rail clamps to. The viewport caps the maximum so the conversation keeps contentMin; min still wins over that.
clampSidebarWidth(width, bounds)number
Clamps one width into those bounds.
resolveInitialSidebarWidth(stored, viewportWidth, defaultWidth, options?)number
First-paint width: the stored value when there is one, the default otherwise, clamped into what this viewport allows.
SIDEBAR_WIDTH_VAR"--chat-sidebar-width"
The variable the rail writes and ChatSidebar reads.

Data slots

Every part below is addressable from a parent, so you can restyle internals without forking the component.

sidebar-rail