Agent docs

SidePanel

The persistent right-hand column for secondary content beside the page.

The shared right-hand panel: a persistent in-flow column for secondary content (activity feeds, previews) beside the page, not over it. Content is pluggable; the collapse control lives wherever the app puts it, usually the topbar.

The canon behind it. docs/design-language/10-overlays.md §A1 (10-overlays.md:17, the decision tree: peek versus modal versus page, the authority the section below quotes) and §A2 (10-overlays.md:24, one drawer, claimed, the reason a bare SidePanel bypasses the app's own claim/release logic).

Which overlay shape this is#

Canon doc 10 §A1's decision tree names three shapes: peek panel, modal, full page. SidePanel is the peek panel: "when the surrounding context must stay visible... It is an in-flow column, not an overlay." The component's own docstring says the same thing independently (side-panel.tsx:15-17): "a persistent in-flow column for secondary content... beside the page, not over it." Doc 10 §A2 adds the constraint that makes it singular: "the app's ONE right-hand drawer. Two sidebars can never sit side by side," enforced by a claim/release/eviction slot system, not by the kit component itself (see Travels with).

When to reach for it#

Not directly at the page level. In speedway, every real call site goes through exactly one app-level wrapper, speedway/app/components/PeekPanel.tsx, which layers the claim/release/eviction slot logic (useSidebarSlot), a shared header shape (title line, subtitle line, a "View More →" escape link), and a shared lift-shadow on top of the bare kit SidePanel. Reach for the raw SidePanel when you are building that kind of app-level wrapper, not when you are building an individual page's peek content; every page-level peek in speedway (taxonomy.tsx, ActivityRail.tsx, ProductPeekPanel.tsx) renders <PeekPanel>, never <SidePanel> directly.

Contract#

  • open (required): controlled visibility. The panel stays mounted at all times, open only drives its width (0 when closed, the resolved width when open) with a transition-[width], so open/close animates instead of mounting and unmounting (side-panel.tsx:75-77 comment: "Stays mounted so open/close animates").
  • onClose: fired by Escape and the header's close button. Omitting it makes the panel non-dismissable: no close button renders, and Escape is ignored. Escape defers to any open native <dialog> on the page (side-panel.tsx:47-57): if a modal is open, the panel's own Escape handling is skipped, matching canon doc 10 §A3's "modals own the top layer." Closing returns focus to whatever opened the panel, but only when focus is still inside it at close time; a user whose focus already moved on is never yanked back (the panel is a non-modal inspector, not an overlay).
  • title / toolbar: the built-in header's title text (or JSX) and a header-adjacent zone next to the close button, for filters or a search box. No shipped usage of toolbar found; every real caller instead composes its own combined title node (see PeekPanel's title prop below) rather than using the kit header's separate toolbar slot.
  • noHeader: drops the built-in header row entirely so the consumer renders its own inside children. No shipped usage found.
  • children (required): the panel body, scrolling independently (overflow-y-auto) inside a min-h-0 flex-1 container.
  • width (default 320): starting width in px. A user-dragged width (see resizable) wins over it once one exists.
  • resizable: shows a left-edge drag handle, clamped 240-640px (MIN_WIDTH/MAX_WIDTH, side-panel.tsx:11-12).
  • storageKey: a localStorage key that persists the dragged width across visits. Without one, the width resets to the width prop on remount (side-panel.tsx:39-45, two parallel width stores, only one ever written).
  • Slot classnames per SidePanelClassNames: className, headerClassName, titleClassName, toolbarClassName, bodyClassName.

Sanctioned combinations#

CombinationProducesWhere usedWhy
open + onClose + width + resizable + storageKey + a combined JSX title (title line, subtitle line, and an optional "View More" link folded into one node)The app's one production SidePanel shape, wrapped as PeekPanelspeedway/app/components/PeekPanel.tsx:80-119PeekPanel builds its richer header entirely inside the title slot rather than reaching for the kit's separate toolbar; every page-level caller then renders <PeekPanel>, not <SidePanel>
A distinct storageKey per peek kind ("sw:activity-width", "sw:product-peek-width", "sw:type-peek-width")Independent drag-resize memory per surface, so resizing the Activity rail does not resize a product peekspeedway/app/components/ActivityRail.tsx:36, ProductPeekPanel.tsx:45, speedway/app/routes/workspaces/schemas/taxonomy.tsx:160Each peek kind is a different physical panel the user resizes independently; a shared key would make them fight over one remembered width
passive (a PeekPanel-level prop, not a SidePanel prop) rendering the panel in place instead of portaling to the shared outletThe Activity rail's dual role: the slot's default in-grid tenant when nothing has claimed it, and an evictable occupant when something hasActivityRail.tsx:31-35SidePanel itself has no positioning opinion; PeekPanel adds passive on top to decide portal-vs-inline, per doc 10 §A2's claim/release/eviction model

Banned combinations#

Do not render a SidePanel directly from a page component. Every real peek in speedway goes through the PeekPanel wrapper, which is also what enforces the one-drawer-at-a-time rule (doc 10 §A2); a page that renders a bare SidePanel bypasses the claim/release/eviction logic entirely and can end up stacked beside another occupant of the same physical drawer.

Do not use SidePanel for a view where the detail IS the task and context can be safely obscured; that is Modal territory per doc 10 §A1's decision tree, not a peek.

Do not confuse walmart-mvp/frontend/src/features/catalog/ReviewTab.tsx:1096's local function SidePanel({...}) with the kit component. It is a hand-built review-cell editor panel defined and used only inside that file, imported from nowhere (ReviewTab.tsx's only kit import is SkeletonGroup from @versable-git/ui, line 5), not the kit's SidePanel. Same same-name trap as the local EmptyState and Timestamp components documented elsewhere in this kit's docs.

Before you adopt this#

Five questions to answer before reaching for SidePanel.

  1. Does the shell, a parent layout, or a global provider already render this? PeekPanel claims the app's one right-hand drawer; a bare SidePanel bypasses that.
  2. Does this app already ship a local implementation of the same thing? ReviewTab.tsx's own local SidePanel function is unrelated; check the import source.
  3. Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed SidePanel prop is noted in this doc.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? onClose wires Escape and the close button; omitting it makes the panel non-dismissable.
  5. Which canon §A rules bind this surface, and which does the composition break? §A2 makes this the app's one drawer; two sidebars can't sit side by side.

Travels with#

useSidebarSlot / SidebarSlotContext (app-level, speedway/app/components/nav-collapse.tsx), the claim/release/eviction system PeekPanel builds on top of the bare kit component; the kit SidePanel itself has no awareness of "one drawer" and would happily render two instances side by side if two callers rendered it directly.

useKeyPress and useLocalStorage (kit-internal hooks), for the Escape handling and the persisted width respectively.

Snippet#

// speedway/app/components/PeekPanel.tsx:80-119 (trimmed)
<SidePanel
open={open}
onClose={onClose}
width={width}
resizable
storageKey={storageKey}
className={["shadow-[-8px_0_24px_-8px_rgba(15,23,42,0.18)]", className]}
title={
<span className="flex min-w-0 flex-col gap-0.5 leading-tight">
<span className="min-w-0 truncate text-sm font-semibold">{title}</span>
{subtitle != null || viewMoreTo != null ? (
<span className="text-base-content/55 flex min-w-0 items-center gap-2 text-xs font-normal">
{subtitle != null ? <span className="min-w-0 truncate">{subtitle}</span> : null}
{viewMoreTo != null ? (
<Link prefetch="intent" to={viewMoreTo} onClick={onClose} className="text-primary inline-flex min-w-0 items-center gap-1 no-underline hover:underline">
<span className="truncate">{viewMoreLabel ?? "View More"}</span>
<IconFor.RightArrow size={12} aria-hidden="true" className="shrink-0" />
</Link>
) : null}
</span>
) : null}
</span>
}
>
{children}
</SidePanel>
@versable-git/ui · reference, canon, and method, read in place