@xsolla/xui-b2b-sidebar
v0.214.1
Published
A composable navigation sidebar for B2B admin surfaces. Renders an expanded panel with grouped menu items and a parallel collapsed icon strip with hover popovers, both driven by the same `SidebarProvider` so the active route, link component, collapsed sta
Readme
Sidebar
A composable navigation sidebar for B2B admin surfaces. Renders an expanded panel with grouped menu items and a parallel collapsed icon strip with hover popovers, both driven by the same SidebarProvider so the active route, link component, collapsed state and label translations stay in sync.
Installation
npm install @xsolla/xui-b2b-sidebarImports
import {
Sidebar,
SidebarProvider,
useSidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarMenu,
SidebarMenuItem,
SidebarMenuCollapsible,
SidebarMenuSub,
SidebarTrigger,
SidebarChatButton,
SidebarCollapsed,
SidebarPinnedList,
getPinnedCollapsedItem,
DEFAULT_SIDEBAR_LABELS,
resolveSidebarLabels,
type SidebarProps,
type SidebarProviderProps,
type SidebarMenuItemProps,
type SidebarMenuCollapsibleProps,
type SidebarCollapsedProps,
type SidebarChatButtonProps,
type SidebarTriggerProps,
type SidebarPinnedItem,
type SidebarPinnedListProps,
type SidebarLabels,
type SidebarItemType,
type SidebarLinkProps,
type SidebarLinkActiveCheck,
type SidebarLinkClickHandler,
} from "@xsolla/xui-b2b-sidebar";Quick start
Wrap the sidebar in SidebarProvider, supplying the current pathname, the controlled collapsed state, and your app's link component (React Router, Next.js Link, etc.). The default link is a plain <a> if you omit linkComponent.
import { useState } from "react";
import { Home, Wallet, Settings } from "@xsolla/xui-icons-base";
import {
Sidebar,
SidebarProvider,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarMenu,
SidebarMenuItem,
SidebarMenuCollapsible,
SidebarMenuSub,
SidebarTrigger,
} from "@xsolla/xui-b2b-sidebar";
function AppShell({ pathname }: { pathname: string }) {
const [collapsed, setCollapsed] = useState(false);
return (
<SidebarProvider
collapsed={collapsed}
onCollapsedChange={setCollapsed}
pathname={pathname}
>
<Sidebar>
<SidebarContent>
<SidebarGroup label="Main">
<SidebarMenu>
<SidebarMenuItem
to="/dashboard"
icon={<Home size={18} variant="line" aria-hidden />}
label="Dashboard"
/>
<SidebarMenuCollapsible
icon={<Wallet size={18} variant="line" aria-hidden />}
label="Finance"
matchPaths={["/finance"]}
>
<SidebarMenuSub>
<SidebarMenuItem
to="/finance/payouts"
label="Payouts"
isNested
/>
<SidebarMenuItem
to="/finance/reports"
label="Reports"
isNested
/>
</SidebarMenuSub>
</SidebarMenuCollapsible>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup label="Workspace">
<SidebarMenu>
<SidebarMenuItem
to="/settings"
icon={<Settings size={18} variant="line" aria-hidden />}
label="Settings"
/>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarTrigger />
</SidebarFooter>
</Sidebar>
</SidebarProvider>
);
}Localisation
Menu labels, group headings and popover titles are consumer-supplied props, so they are localisable by construction. The strings the sidebar renders on its own behalf — the chat button, the collapse/expand toggle, the pin controls, the "Beta" tag and the navigation landmark — are collected in a single SidebarLabels map. Override any subset once on SidebarProvider:
import { SidebarProvider } from "@xsolla/xui-b2b-sidebar";
import { useTranslation } from "react-i18next";
function LocalisedShell({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
return (
<SidebarProvider
pathname={location.pathname}
labels={{
navigation: t("sidebar.navigation"),
expandSidebar: t("sidebar.expand"),
collapseSidebar: t("sidebar.collapse"),
chat: t("sidebar.chat"),
openChat: t("sidebar.openChat"),
pin: t("sidebar.pin"),
unpin: t("sidebar.unpin"),
pinItem: t("sidebar.pinItem"),
unpinItem: t("sidebar.unpinItem"),
beta: t("sidebar.beta"),
pinned: t("sidebar.pinned"),
}}
>
{children}
</SidebarProvider>
);
}Every key is optional — omitted keys keep their English default from DEFAULT_SIDEBAR_LABELS.
Label keys
| Key | Default | Where it appears |
| ----------------- | -------------------- | ----------------------------------------------------------------- |
| navigation | "Sidebar navigation" | aria-label on the root role="navigation" landmark. |
| expandSidebar | "Expand sidebar" | aria-label on the toggle while collapsed (both modes). |
| collapseSidebar | "Collapse sidebar" | aria-label on the toggle while expanded. |
| chat | "Chat" | Visible text on the expanded-mode SidebarChatButton. |
| openChat | "Open chat" | aria-label on the collapsed-mode icon-only chat button. |
| pin | "Pin" | Pin-toggle tooltip while the item is unpinned. |
| unpin | "Unpin" | Pin-toggle tooltip while the item is pinned. |
| pinItem | "Pin item" | Pin-toggle aria-label while unpinned. |
| unpinItem | "Unpin item" | Pin-toggle aria-label while pinned. |
| beta | "Beta" | Trailing tag rendered when beta is set on a menu item. |
| pinned | "Pinned" | Default title of the collapsed-mode pinned-shortcuts popover. |
Precedence
Per-instance props beat the provider, which beats the defaults:
component prop > SidebarProvider labels > DEFAULT_SIDEBAR_LABELSUse a per-instance prop only when one occurrence needs to differ — e.g. two sidebars on one page needing distinct landmark names:
<Sidebar navigationLabel={t("sidebar.secondaryNavigation")}>{/* ... */}</Sidebar>getPinnedCollapsedItem
This helper is a plain function, not a component, so it cannot read provider context. Pass the translated title through options.label:
const pinnedItem = getPinnedCollapsedItem(pinnedItems, { label: t("sidebar.pinned") });API Reference
None of the sidebar components extend ThemeOverrideProps — they read theme via useResolvedTheme() from the surrounding provider. Wrap the app in a theme provider if you need to override mode or product context.
<SidebarProvider>
| Prop | Type | Default | Description |
| ------------------- | --------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------- |
| testID | string | — | Test ID for testing frameworks. On web this renders as data-testid; on React Native it renders as testID. |
| collapsed | boolean | uncontrolled | Controlled collapsed state. Omit to use the internal state. |
| onCollapsedChange | (collapsed: boolean) => void | — | Called when the user toggles via SidebarTrigger or the collapsed-mode toggle. |
| pathname | string | "" | Current route, used for active-state matching and matchPaths auto-expand. |
| linkComponent | ComponentType<SidebarLinkProps> | plain <a> | Router link component. |
| labels | Partial<SidebarLabels> | English | Translations for the strings the sidebar renders itself. See Localisation. |
| children | ReactNode | — | Sidebar tree. |
useSidebar()
Read the provider's state from anywhere inside the tree.
function useSidebar(): {
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
pathname: string;
LinkComponent: React.ComponentType<SidebarLinkProps>;
expandedId: string | null;
onExpandedIdChange: (id: string | null) => void;
labels: SidebarLabels;
};expandedId / onExpandedIdChange coordinate which SidebarMenuCollapsible is open — only one at a time. labels is always fully resolved (provider overrides merged over the defaults), so consumers can read it without null checks.
<Sidebar>
| Prop | Type | Default | Description |
| ---------------------- | ------------------- | -------------------- | ------------------------------------------------------ |
| collapsedItems | SidebarItemType[] | [] | Top icons in the collapsed strip. |
| collapsedToolItems | SidebarItemType[] | [] | Tool icons rendered after a spacer. |
| collapsedBottomItems | SidebarItemType[] | [] | Bottom icons (e.g. Settings, Billing). |
| showChat | boolean | true | Render the chat button in the collapsed footer. |
| onChatClick | () => void | — | Click handler for the chat button. |
| chatBadge | boolean | false | Show an unread-indicator dot on the chat button. |
| navigationLabel | string | labels.navigation | aria-label for the navigation landmark. |
| children | ReactNode | — | The expanded tree (SidebarContent, SidebarFooter). |
<SidebarMenuItem>
| Prop | Type | Default | Description |
| ----------------- | ------------------------- | ------------------ | ----------------------------------------------------------------------------------- |
| to | string | — | Route URL passed through to linkComponent. |
| label | ReactNode | — | Text label. |
| icon | ReactNode | — | Leading icon (hidden when isNested). |
| exact | boolean | — | Exact-match flag forwarded to the link component. |
| external | boolean | — | Marks the link as external (sets target/rel). |
| hasExternalIcon | boolean | — | Shows the external-link icon at the trailing edge. |
| target | string \| null | — | Anchor target. |
| onClick | SidebarLinkClickHandler | — | Click handler. |
| dataId | string | — | data-id attribute for tests/analytics. |
| isActive | SidebarLinkActiveCheck | — | Custom active-check forwarded to the link. |
| isPinned | boolean | — | Renders the icon at 12px to indicate a pinned item. |
| onPinToggle | (e) => void | — | Renders a pin/unpin toggle on hover (always visible when pinned). |
| dragHandle | boolean | — | On a pinned item, swaps the pin glyph for a drag handle on row hover. |
| showBadge | boolean | — | Shows a small alert dot next to the label. |
| hasTooltip | boolean | — | Truncates labels longer than 20 chars and shows the full text on hover. |
| beta | boolean | — | Renders a "Beta" tag at the trailing edge. |
| multiLine | boolean | — | Allows the label to wrap to multiple lines. |
| extra | ReactNode | — | Slot rendered between the icon and the label. |
| isNested | boolean | false | Set when rendered inside SidebarMenuSub (drops the icon, indents under the rail). |
| pinLabel | string | labels.pin | Pin-toggle tooltip while unpinned. |
| unpinLabel | string | labels.unpin | Pin-toggle tooltip while pinned. |
| pinAriaLabel | string | labels.pinItem | Pin-toggle aria-label while unpinned. |
| unpinAriaLabel | string | labels.unpinItem | Pin-toggle aria-label while pinned. |
| betaLabel | string | labels.beta | Text inside the trailing tag when beta is set. |
<SidebarMenuCollapsible>
| Prop | Type | Default | Description |
| ------------ | ----------- | ------- | ------------------------------------------------------------------------- |
| icon | ReactNode | — | Leading icon. |
| label | ReactNode | — | Section label. |
| dataId | string | — | data-id attribute. Also seeds the stable id used by aria-controls. |
| matchPaths | string[] | [] | Route prefixes that auto-expand this section. |
| children | ReactNode | — | Nested items, typically a SidebarMenuSub of SidebarMenuItem isNested. |
<SidebarCollapsed>
Lower-level collapsed-strip renderer. The <Sidebar> component composes this for you; reach for it when you need to render the collapsed strip outside the standard layout.
| Prop | Type | Default | Description |
| ------------------ | ------------------- | ---------------------- | -------------------------------------------------- |
| items | SidebarItemType[] | — | Top icon items. |
| toolItems | SidebarItemType[] | [] | Tool icons after a spacer. |
| bottomItems | SidebarItemType[] | [] | Bottom icons. |
| onToggleCollapse | () => void | — | Click handler for the toggle button in the footer. |
| onChatClick | () => void | — | Click handler for the chat button. |
| showChat | boolean | true | Render the chat button. |
| chatBadge | boolean | false | Show the unread dot. |
| chatAriaLabel | string | labels.openChat | aria-label for the icon-only chat button. |
| expandLabel | string | labels.expandSidebar | aria-label for the expand toggle. |
<SidebarContent>
Scrollable body container rendered inside <Sidebar>. Adds the configured padding and a hover-visible scrollbar.
<SidebarFooter>
Bordered footer row for the bottom of the sidebar; typically wraps the chat button and trigger.
<SidebarTrigger>
Collapse/expand toggle button. Reads/writes state from the surrounding SidebarProvider.
| Prop | Type | Default | Description |
| --------------- | -------- | ------------------------ | ------------------------------------ |
| expandLabel | string | labels.expandSidebar | aria-label used while collapsed. |
| collapseLabel | string | labels.collapseSidebar | aria-label used while expanded. |
<SidebarChatButton>
Branded chat button for the footer.
| Prop | Type | Default | Description |
| --------- | ------------ | -------------- | -------------------------------------- |
| onClick | () => void | — | Click handler. |
| badge | boolean | false | Show the unread-indicator dot. |
| label | string | labels.chat | Visible button text. |
<SidebarGroup>
Section container with an optional uppercase label. Props: label?: ReactNode, children: ReactNode.
<SidebarMenu>
Flex-column wrapper around SidebarMenuItem / SidebarMenuCollapsible children.
<SidebarMenuSub>
Fragment used as a structural marker inside collapsibles to group nested menu items.
Types
interface SidebarLabels {
navigation: string;
expandSidebar: string;
collapseSidebar: string;
chat: string;
openChat: string;
pin: string;
unpin: string;
pinItem: string;
unpinItem: string;
beta: string;
pinned: string;
}
declare const DEFAULT_SIDEBAR_LABELS: SidebarLabels;
declare const resolveSidebarLabels: (
overrides?: Partial<SidebarLabels>
) => SidebarLabels;
type SidebarItemType = {
to?: string;
label: ReactNode;
icon?: ReactNode;
dataId?: string;
exact?: boolean;
external?: boolean;
hasExternalIcon?: boolean;
target?: string | null;
onClick?: SidebarLinkClickHandler;
hasTooltip?: boolean;
beta?: boolean;
multiLine?: boolean;
isPinned?: boolean;
showBadge?: boolean;
reverse?: boolean; // reserved for legacy reversed-layout items
isActive?: SidebarLinkActiveCheck;
children?: SidebarItemType[];
privileges?: string[];
visibility?: string;
disallowedIntegrationTypes?: string[];
extra?: ReactNode;
};
interface SidebarLinkProps {
to?: string;
exact?: boolean;
external?: boolean;
target?: string | null;
className?: string;
activeClassName?: string;
isActive?: SidebarLinkActiveCheck;
onClick?: SidebarLinkClickHandler;
dataId?: string;
"aria-label"?: string;
children: ReactNode;
}
type SidebarLinkActiveCheck = (
match: unknown,
location: { pathname: string; search?: string; hash?: string }
) => boolean;
type SidebarLinkClickHandler = (event?: React.MouseEvent<HTMLElement>) => void;Examples
Custom link component
Supply your router's link via the linkComponent prop on SidebarProvider. The component receives to, exact, external, target, className, activeClassName, isActive, onClick, dataId, and renders children.
import { NavLink } from "react-router-dom";
import type { SidebarLinkProps } from "@xsolla/xui-b2b-sidebar";
const RouterLink: React.FC<SidebarLinkProps> = ({
to,
exact,
className,
activeClassName,
children,
onClick,
dataId,
}) => (
<NavLink
to={to ?? "#"}
end={exact}
className={({ isActive }) =>
[className, isActive && activeClassName].filter(Boolean).join(" ")
}
data-id={dataId}
onClick={onClick}
>
{children}
</NavLink>
);
<SidebarProvider linkComponent={RouterLink} pathname={location.pathname}>
{/* ... */}
</SidebarProvider>;Auto-expanding sections
Pass matchPaths to SidebarMenuCollapsible. When the current pathname starts with any of the listed prefixes, the section auto-expands. Only one collapsible can be open at a time, so navigating between sections collapses the others.
import { Wallet } from "@xsolla/xui-icons-base";
import {
SidebarMenuCollapsible,
SidebarMenuSub,
SidebarMenuItem,
} from "@xsolla/xui-b2b-sidebar";
<SidebarMenuCollapsible
icon={<Wallet size={18} variant="line" aria-hidden />}
label="Finance"
matchPaths={["/finance", "/finance/payouts", "/finance/reports"]}
>
<SidebarMenuSub>
<SidebarMenuItem to="/finance/payouts" label="Payouts" isNested />
<SidebarMenuItem to="/finance/reports" label="Reports" isNested />
</SidebarMenuSub>
</SidebarMenuCollapsible>;Item flags
import { Pin, Graph, Layer } from "@xsolla/xui-icons-base";
import { SidebarMenuItem } from "@xsolla/xui-b2b-sidebar";
<>
<SidebarMenuItem
to="/pinned"
label="Pinned report"
icon={<Pin size={18} aria-hidden />}
isPinned
showBadge
/>
<SidebarMenuItem
to="/labs"
label="New analytics"
icon={<Graph size={18} aria-hidden />}
beta
/>
<SidebarMenuItem
to="https://docs.example.com"
label="Documentation"
icon={<Layer size={18} aria-hidden />}
external
hasExternalIcon
target="_blank"
/>
</>;Collapsed icon strip
Because the collapsed layout is fundamentally different (icons only, hover popovers), pass icon-strip items separately as collapsedItems, collapsedToolItems (rendered after a spacer in the main scroll area), and collapsedBottomItems (rendered above the chat/toggle footer). Items with children open a hover popover listing the children; items without children navigate directly on click (the icon is rendered as a link), and show a label tooltip on hover for discoverability.
import { Home, Wallet, Settings } from "@xsolla/xui-icons-base";
import { Sidebar, type SidebarItemType } from "@xsolla/xui-b2b-sidebar";
const mainNavItems: SidebarItemType[] = [
{
to: "/dashboard",
label: "Dashboard",
icon: <Home size={18} variant="line" aria-hidden />,
},
{
label: "Finance",
icon: <Wallet size={18} variant="line" aria-hidden />,
children: [
{ to: "/finance/payouts", label: "Payouts" },
{ to: "/finance/reports", label: "Reports" },
],
},
];
<Sidebar
collapsedItems={mainNavItems}
collapsedBottomItems={[
{
to: "/settings",
label: "Settings",
icon: <Settings size={18} aria-hidden />,
},
]}
onChatClick={() => openSupportChat()}
chatBadge={hasUnreadMessages}
>
{/* expanded tree */}
</Sidebar>;Accessibility
- The root sidebar is
role="navigation"with anaria-labelfromlabels.navigation(default"Sidebar navigation", per-instance override vianavigationLabel). The expanded and collapsed panes are toggled viaaria-hiddenso only one is announced at a time. SidebarTriggerand the collapsed-mode toggle havearia-pressedand a context-awarearia-label(labels.expandSidebar/labels.collapseSidebar).- The pin toggle exposes
aria-pressedpluslabels.pinItem/labels.unpinItemas itsaria-label, and a tooltip carryinglabels.pin/labels.unpin. - Every one of those strings is translatable through the provider's
labelsprop — assistive-technology users get the app's language, not English. - Each
SidebarMenuCollapsibleheader is a<button>witharia-expandedandaria-controlslinking to its region. - Collapsed icon items with children expose
aria-haspopup="menu"; their popovers are keyboard-reachable (focus opens, blur closes after 150ms, Escape closes immediately). Leaf items (no children) render as native links and navigate on click or Enter. - Truncated labels (
hasTooltip) fall back to a Tooltip with the full text.
Behaviour
- Expanded width is sourced from
theme.sizing.sidebar().widthExpanded, collapsed fromwidthCollapsed; the wrapper animateswidthand cross-fades the two views via opacity. - Only one
SidebarMenuCollapsibleis open at a time; the section auto-expands whenpathnamematches one ofmatchPaths(a leading/<merchantId>numeric prefix is stripped before matching). - Hover popovers in collapsed mode are rendered into a portal on
document.bodyand positioned with viewport-clampedposition: fixedmath. - Active route detection lives in the consumer's
linkComponent— applyactiveClassNamewhen the link matches the current route. labelsoverrides are merged over the defaults once per provider render and serialised for memoisation, so passing an inline object literal does not thrash the context.
