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,openonly drives its width (0when closed, the resolved width when open) with atransition-[width], so open/close animates instead of mounting and unmounting (side-panel.tsx:75-77comment: "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 oftoolbarfound; every real caller instead composes its own combined title node (seePeekPanel'stitleprop below) rather than using the kit header's separatetoolbarslot.noHeader: drops the built-in header row entirely so the consumer renders its own insidechildren. No shipped usage found.children(required): the panel body, scrolling independently (overflow-y-auto) inside amin-h-0 flex-1container.width(default320): starting width in px. A user-dragged width (seeresizable) wins over it once one exists.resizable: shows a left-edge drag handle, clamped240-640px (MIN_WIDTH/MAX_WIDTH,side-panel.tsx:11-12).storageKey: alocalStoragekey that persists the dragged width across visits. Without one, the width resets to thewidthprop 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#
| Combination | Produces | Where used | Why |
|---|---|---|---|
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 PeekPanel | speedway/app/components/PeekPanel.tsx:80-119 | PeekPanel 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 peek | speedway/app/components/ActivityRail.tsx:36, ProductPeekPanel.tsx:45, speedway/app/routes/workspaces/schemas/taxonomy.tsx:160 | Each 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 outlet | The Activity rail's dual role: the slot's default in-grid tenant when nothing has claimed it, and an evictable occupant when something has | ActivityRail.tsx:31-35 | SidePanel 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.
- Does the shell, a parent layout, or a global provider already render this?
PeekPanelclaims the app's one right-hand drawer; a bareSidePanelbypasses that. - Does this app already ship a local implementation of the same thing?
ReviewTab.tsx's own localSidePanelfunction is unrelated; check the import source. - 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.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them?
onClosewires Escape and the close button; omitting it makes the panel non-dismissable. - 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>