Chat Sidebar

Collapsible sidebar chrome composed from slots — brand, nav, sections, rail, footer — with icon quick actions that pop on click, and the single import surface for the whole sidebar family.

Open in
Chats

Streaming markdown bugs

Drag a chat onto an edge zone, or onto another chat to reorder.

  • Drag a row onto another row to reorder it.
  • Drag to the top edge to pin, to the bottom edge to delete.
  • Drag across sections to pin or unpin at a chosen position.
  • Right-click a row to rename, pin, or delete it.
  • Shift-click a range, or Ctrl/⌘-click to toggle. Pin or delete from the bar — or from the context menu, which becomes “Delete N chats”.
  • The dashed “Archive” strip is a custom inline zone.

Installation

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

Also installs sidebar-item, sidebar-dnd, sidebar-drop-zones.

Usage

"use client"

import {
  ChatSidebar,
  ChatSidebarDnd,
  ChatSidebarItemList,
  SideActionRow,
  SideIconBtn,
  SidebarCollapsibleSection,
} from "@/components/ui/chat-sidebar"

export function Nav() {
  const [collapsed, setCollapsed] = useState(false)
  const [open, setOpen] = useState(true)

  return (
    <ChatSidebarDnd onDrop={handleDrop}>
      <ChatSidebar
        collapsed={collapsed}
        onCollapsedChange={setCollapsed}
        edgeZones
        brand={<span className="px-1 text-sm font-medium">Chats</span>}
        nav={
          <SideActionRow>
            <SideIconBtn label="New chat">
              <Pencil className="size-4" />
            </SideIconBtn>
          </SideActionRow>
        }
      >
        <SidebarCollapsibleSection
          title="Recent"
          open={open}
          onToggle={() => setOpen((value) => !value)}
          count={chats.length}
        >
          <ChatSidebarItemList listId="recent" items={chats} sortable />
        </SidebarCollapsibleSection>
      </ChatSidebar>
    </ChatSidebarDnd>
  )
}

Examples

One import surface

chat-sidebar re-exports the whole family — the item list, the DnD provider, the zone helpers, and every type — so application code never reaches into sidebar-item, sidebar-dnd, or sidebar-drop-zones directly.

import {
  ChatSidebar,
  ChatSidebarDnd,
  ChatSidebarItem,
  ChatSidebarItemGhost,
  ChatSidebarItemList,
  DEFAULT_SIDEBAR_ZONES,
  SideActionRow,
  SideIconBtn,
  SideRow,
  SidebarCollapsibleSection,
  SidebarDropZone,
  SidebarEdgeDropZone,
  SidebarEmptyState,
  SidebarItemStatusDot,
  SidebarResizeRail,
  pinDropZone,
  trashDropZone,
  useSidebarDnd,
  useSidebarDndList,
  type ChatSidebarItemData,
  type SidebarDndDrop,
  type SidebarDropZoneDef,
} from "@/components/ui/chat-sidebar"

Dividers are opt-in

The panel paints no rules by default — one column of rows reads as one column without them, and a line every few rows is what makes a chat sidebar look busy. dividers puts them back under the header and above the footer when the footer carries enough weight to want separating.

Settings

default

Settings

Settings

dividers

Settings

Widths and per-region classes

Widths are numbers, so the collapsed rail and the expanded panel are both yours to size; animateWidth eases between them (on by default). classNames reaches each region — rail, panel, header, nav, content, footer — without a single descendant selector. Collapse it below to see both widths.

3 conversations

Threads

3 conversations

Conversation goes here.

Section headers as rules

rule turns a section header into a label, a hairline and a chevron — one line per working folder instead of a stack of captions. Pass action and the chips drop onto a second row so they cannot paint over a long name. live adds the dot that keeps a closed section honest while a chat inside it streams; it animates only while it is on screen and the tab is in front, so a folded sidebar full of them costs nothing. Toggle either section below.

Off-canvas on mobile

The sidebar itself never changes shape — a wrapper slides it over the conversation and a backdrop closes it. Below, absolute keeps the demo inside its frame; in a real layout use fixed with max-md: guards, exactly as app/demo/page.tsx does.

Chats

Streaming markdown bugs

Tap the panel icon to slide the sidebar in.

Notes

API reference

ChatSidebar

collapsed*boolean
Controlled. Collapsed swaps the panel for the icon rail.
onCollapsedChange*(collapsed: boolean) => void
Fired by the header and rail toggles.
brandReact.ReactNode
Header content of the expanded panel.
navReact.ReactNode
Quick actions under the header — new chat, search. Usually SideActionRow of SideIconBtn.
railReact.ReactNode
Content of the collapsed rail. Usually SideIconBtn mirrors of nav.
childrenReact.ReactNode
The scrollable body: sections and item lists.
footerReact.ReactNode
Rendered in both the panel footer and the rail.
overlaysReact.ReactNode
Absolutely positioned extras inside the panel — a search palette, for instance.
edgeZonesboolean = false
Render the edge drop zones declared on the surrounding ChatSidebarDnd while a drag is in progress.
dividersboolean = false
Rules under the header and above the footer. Off by default — the panel already reads as one column, and a line every few rows is what makes a chat sidebar look busy.
widthExpandednumber = 290
Panel width in pixels.
widthCollapsednumber = 60
Rail width in pixels.
widthnumber | string
Expanded width, overriding widthExpanded. A number is pixels, a string is used verbatim. Left alone the panel reads var(--chat-sidebar-width, widthExpanded), which is how SidebarResizeRail drives it without a React render per frame.
animateWidthboolean = true
Eases the collapse over 300ms and fades the inactive view so the swap is visible. Pass false to snap — width is a layout property, and the transition reflows everything beside the sidebar.
classNames{ rail?, panel?, header?, nav?, content?, footer? }
Per-region class overrides.
collapseLabelstring = "Collapse sidebar"
Accessible label for the collapse button.
expandLabelstring = "Open sidebar"
Accessible label for the rail button.

SideRow / SideActionRow / SideIconBtn / SidebarCollapsibleSection

SideRow{ icon?, hint? } & ComponentProps<"button">
Full-width nav row with a leading icon and a trailing hint (a shortcut, usually).
SideIconBtn{ label: string } & ComponentProps<"button">
Square icon button for quick actions, the rail and the header. label becomes the title and aria-label. A click pops the glyph — no extra icon library.
SideActionRowComponentProps<"div">
Horizontal cluster of SideIconBtn — New chat, Search, and the like.
SidebarCollapsibleSection{ title, open, onToggle, count?, description?, action?, rule?, live?, className?, children }
Uppercase section header with a rotating chevron, an optional count, and a slot for a header action.
ruleboolean = false
On SidebarCollapsibleSection: label, a hairline across the rest of the row, chevron at the far end — the shape a per-folder list wants. With action set, the chips move onto a second row so a long label can truncate instead of colliding with them.
actionReactNode
Extra header content on SidebarCollapsibleSection — a branch, git chips, a port. With rule it sits on a second row; otherwise at the right edge, before the count.
liveboolean = false
On SidebarCollapsibleSection: a dot on the header while the section is closed, so a folded folder still says something inside it is running. Animates only while it is on screen and the tab is in front.
SidebarEmptyState{ children, className? }
Muted placeholder for an empty list — pass it as emptyState.

Constants

SIDEBAR_WIDTH_EXPANDED290
Default panel width.
SIDEBAR_WIDTH_COLLAPSED60
Default rail width.

Data slots

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

chat-sidebarchat-sidebar-railchat-sidebar-panelchat-sidebar-headerchat-sidebar-navchat-sidebar-contentchat-sidebar-footersidebar-rowsidebar-icon-buttonsidebar-action-rowsidebar-sectionsidebar-section-triggersidebar-section-rulesidebar-section-livesidebar-section-action