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.
Installation
npx shadcn@latest add miskibin/chat-components/sidebar-resize-railAlso 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
widthif you change it. shouldAcceptWidth(info) => boolean- Veto a width mid-drag — the pointer keeps moving, the panel does not.
infois{ 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
ChatSidebarreads.
Data slots
Every part below is addressable from a parent, so you can restyle internals without forking the component.
sidebar-rail