diff --git a/apps/www/src/components/docs/sidebar.tsx b/apps/www/src/components/docs/sidebar.tsx
index ef90e734e..4ba98cc9d 100644
--- a/apps/www/src/components/docs/sidebar.tsx
+++ b/apps/www/src/components/docs/sidebar.tsx
@@ -71,7 +71,7 @@ function renderNode(node: Node, pathname: string): ReactNode {
// Handle page items
if (node.type === 'page') {
- return ;
+ return ;
}
// Handle separators (if needed)
diff --git a/apps/www/src/content/docs/components/sidebar/demo.ts b/apps/www/src/content/docs/components/sidebar/demo.ts
index a53317305..227d6525e 100644
--- a/apps/www/src/content/docs/components/sidebar/demo.ts
+++ b/apps/www/src/content/docs/components/sidebar/demo.ts
@@ -402,6 +402,59 @@ export const collapsibleGroupDemo = {
style: styleDemo
};
+export const controlledGroupDemo = {
+ type: 'code',
+ style: styleDemo,
+ code: `
+ function ControlledSidebarGroup() {
+ const [resourcesOpen, setResourcesOpen] = React.useState(true);
+
+ return (
+ ${sidebarLayout(`
+
+
+
+
+
+ Apsara
+
+
+
+ }>
+ Overview
+
+
+ }>
+ Reports
+
+ }>
+ Activities
+
+
+
+
+ setResourcesOpen(open => !open)}
+ />
+ }
+ leadingIcon={}
+ >
+ {resourcesOpen ? 'Collapse Resources' : 'Expand Resources'}
+
+
+ `)}
+ );
+ }`
+};
+
export const groupIconDemo = {
type: 'code',
code: sidebarLayout(`
diff --git a/apps/www/src/content/docs/components/sidebar/index.mdx b/apps/www/src/content/docs/components/sidebar/index.mdx
index 5ed4da6e2..983a6a963 100644
--- a/apps/www/src/content/docs/components/sidebar/index.mdx
+++ b/apps/www/src/content/docs/components/sidebar/index.mdx
@@ -9,6 +9,7 @@ import {
positionDemo,
variantDemo,
collapsibleGroupDemo,
+ controlledGroupDemo,
groupIconDemo,
stateDemo,
tooltipDemo,
@@ -132,10 +133,16 @@ Pass `leadingIcon` to `Sidebar.Group` to render an icon next to the section labe
### Collapsible Group
-Enable `collapsible` on `Sidebar.Group` to make section items collapsible. You can also pass `trailingIcon` for section-level actions.
+Enable `collapsible` on `Sidebar.Group` to make section items collapsible. You can also pass `trailingIcon` for section-level actions. Use `defaultOpen` to set the initial expanded state of an uncontrolled group.
+### Controlled Group
+
+Use `open` together with `onOpenChange` on `Sidebar.Group` to control the group's expanded state from outside the component.
+
+
+
### More
Use `Sidebar.More` when you want to keep a section compact and move secondary items into a menu.
diff --git a/apps/www/src/content/docs/components/sidebar/props.ts b/apps/www/src/content/docs/components/sidebar/props.ts
index b9fedd4fc..084e59908 100644
--- a/apps/www/src/content/docs/components/sidebar/props.ts
+++ b/apps/www/src/content/docs/components/sidebar/props.ts
@@ -47,6 +47,17 @@ export interface SidebarGroupProps {
*/
collapsible?: boolean;
+ /** Controls the group's expanded/collapsed state. Only applies when `collapsible` is true. */
+ open?: boolean;
+
+ /** Default expanded/collapsed state when uncontrolled. Only applies when `collapsible` is true.
+ * @default true
+ */
+ defaultOpen?: boolean;
+
+ /** Callback when the group's expanded/collapsed state changes. */
+ onOpenChange?: (open: boolean) => void;
+
/** Optional ReactNode for group icon. */
leadingIcon?: ReactNode;
diff --git a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
index 39550827a..80361d48b 100644
--- a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
+++ b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
@@ -412,6 +412,102 @@ describe('Sidebar', () => {
expect(screen.getByTestId('group-trailing-icon')).toBeInTheDocument();
});
+ it('respects defaultOpen=false for uncontrolled collapsible group', () => {
+ render(
+
+
+
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ const trigger = screen.getByRole('button', { name: /Main/ });
+ expect(trigger).not.toHaveAttribute('data-panel-open');
+ expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();
+ });
+
+ it('controls open state via open prop and fires onOpenChange', () => {
+ const onOpenChange = vi.fn();
+ const { rerender } = render(
+
+
+
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();
+
+ const trigger = screen.getByRole('button', { name: /Main/ });
+ fireEvent.click(trigger);
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();
+
+ rerender(
+
+
+
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();
+ });
+
+ it('fires onOpenChange when uncontrolled group is toggled', () => {
+ const onOpenChange = vi.fn();
+ render(
+
+
+
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ const trigger = screen.getByRole('button', { name: /Main/ });
+ fireEvent.click(trigger);
+ expect(onOpenChange).toHaveBeenLastCalledWith(false);
+ expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();
+
+ fireEvent.click(trigger);
+ expect(onOpenChange).toHaveBeenLastCalledWith(true);
+ expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();
+ });
+
it('does not toggle collapsible when trailing icon is clicked', () => {
const onTrailingIconClick = vi.fn();
diff --git a/packages/raystack/components/sidebar/sidebar-misc.tsx b/packages/raystack/components/sidebar/sidebar-misc.tsx
index 444ececc0..f3a32036f 100644
--- a/packages/raystack/components/sidebar/sidebar-misc.tsx
+++ b/packages/raystack/components/sidebar/sidebar-misc.tsx
@@ -3,7 +3,13 @@
import { Accordion as AccordionPrimitive } from '@base-ui/react';
import { TriangleDownIcon } from '@radix-ui/react-icons';
import { cx } from 'class-variance-authority';
-import { ComponentProps, ReactNode, useContext } from 'react';
+import {
+ ComponentProps,
+ ReactNode,
+ useCallback,
+ useContext,
+ useState
+} from 'react';
import { Flex } from '../flex';
import styles from './sidebar.module.css';
import { SidebarLeadingVisual } from './sidebar-leading-visual';
@@ -43,8 +49,10 @@ SidebarFooter.displayName = 'Sidebar.Footer';
export interface SidebarNavigationGroupProps extends ComponentProps<'section'> {
label: string;
- value?: string;
collapsible?: boolean;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
classNames?: {
@@ -61,8 +69,10 @@ export interface SidebarNavigationGroupProps extends ComponentProps<'section'> {
export function SidebarNavigationGroup({
className,
label,
- value,
collapsible = false,
+ open: providedOpen,
+ defaultOpen = true,
+ onOpenChange,
leadingIcon,
trailingIcon,
classNames,
@@ -70,7 +80,18 @@ export function SidebarNavigationGroup({
...props
}: SidebarNavigationGroupProps) {
const { isCollapsed } = useContext(SidebarContext);
- const groupValue = value ?? label;
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
+ const isOpen = isCollapsed || (providedOpen ?? internalOpen);
+
+ const handleOpenChange = useCallback(
+ (value: unknown[]) => {
+ if (isCollapsed) return;
+ const nextOpen = value.length > 0;
+ setInternalOpen(nextOpen);
+ onOpenChange?.(nextOpen);
+ },
+ [isCollapsed, onOpenChange]
+ );
if (!collapsible) {
return (
@@ -119,13 +140,13 @@ export function SidebarNavigationGroup({
{...props}
>