diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx
index 2f9f94b16..347dc0db5 100644
--- a/apps/www/src/app/examples/page.tsx
+++ b/apps/www/src/app/examples/page.tsx
@@ -1,4 +1,17 @@
'use client';
+import {
+ ActivityLogIcon,
+ BarChartIcon,
+ DashboardIcon,
+ DotsHorizontalIcon,
+ FileTextIcon,
+ GearIcon,
+ HomeIcon,
+ MixerHorizontalIcon,
+ PersonIcon,
+ QuestionMarkCircledIcon,
+ BellIcon as RadixBellIcon
+} from '@radix-ui/react-icons';
import {
Amount,
Avatar,
@@ -30,12 +43,6 @@ import {
TextArea,
Tooltip
} from '@raystack/apsara';
-import {
- BellIcon,
- FilterIcon,
- OrganizationIcon,
- SidebarIcon
-} from '@raystack/apsara/icons';
import dayjs from 'dayjs';
import React, { useState } from 'react';
@@ -59,16 +66,16 @@ const Page = () => {
// Sample options data with icons
const selectOptions = [
- { value: 'dashboard', label: 'Dashboard', icon: },
- { value: 'analytics', label: 'Analytics', icon: },
- { value: 'settings', label: 'Settings', icon: },
- { value: 'profile', label: 'Profile', icon: }
+ { value: 'dashboard', label: 'Dashboard', icon: },
+ { value: 'analytics', label: 'Analytics', icon: },
+ { value: 'settings', label: 'Settings', icon: },
+ { value: 'profile', label: 'Profile', icon: }
];
const filterOptions = [
- { value: 'Option 1', label: 'Option 1', icon: },
- { value: 'Option 2', label: 'Option 2', icon: },
- { value: 'Option 3', label: 'Option 3', icon: }
+ { value: 'Option 1', label: 'Option 1', icon: },
+ { value: 'Option 2', label: 'Option 2', icon: },
+ { value: 'Option 3', label: 'Option 3', icon: }
];
return (
@@ -79,7 +86,7 @@ const Page = () => {
backgroundColor: 'var(--rs-color-background-base-primary)'
}}
>
-
+
{
onClick={() => console.log('Logo clicked')}
aria-label='Logo'
>
-
+
Raystack
@@ -96,31 +103,62 @@ const Page = () => {
- }>
+ }>
Dashboard
- }>
+ }>
Analytics
- }>
- Reports
-
- Activities
+
+ }>
+ Reports
+
+
+ }
+ >
+ Activities
+
+ console.log('Notifications clicked')}
+ leadingIcon={}
+ >
+ Notifications
+
+
- Settings
-
- Notifications
+ }>
+ Settings
+
+ }>
+ }>
+ Notifications
+
+ } disabled>
+ Billing
+
+
- Help & Support
-
- Preferences
+ }>
+ Help & Support
+
+
+ }>
+ Preferences
+
+ }>
+ Documentation
+
+
@@ -285,7 +323,7 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
25%
@@ -300,7 +338,7 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
25%
@@ -315,7 +353,7 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
25%
@@ -352,7 +390,9 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
Sun
@@ -371,7 +411,9 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
15th
@@ -390,7 +432,9 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
Today
@@ -1365,7 +1409,7 @@ const Page = () => {
}
+ icon={}
heading='KYC required for image orders'
subHeading='Please contact your organization owner to complete the KYC process for the image orders. You can also contact support@raystack.io for assistance.'
primaryAction={
@@ -1562,7 +1606,7 @@ const Page = () => {
@@ -1584,7 +1628,7 @@ const Page = () => {
}
+ leadingIcon={}
width='100%'
/>
@@ -1722,7 +1766,7 @@ const Page = () => {
@@ -1755,7 +1799,7 @@ const Page = () => {
}
+ leadingIcon={}
width='100%'
/>
@@ -2197,7 +2241,7 @@ const Page = () => {
}
+ icon={}
heading='Zero state'
variant='empty2'
subHeading='Get started by creating your first user. Filter bar and search are hidden in zero state.'
@@ -2205,7 +2249,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='Empty state'
variant='empty1'
subHeading="We couldn't find any matches for that keyword or filter."
@@ -2277,7 +2321,7 @@ const Page = () => {
}
+ icon={}
heading='zero state'
variant='empty2'
subHeading='Get started by creating your first user.'
@@ -2285,7 +2329,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading="We couldn't find any matches for that filter. Try adjusting your filters or search query. Filter bar remains visible so you can modify filters."
@@ -2352,7 +2396,7 @@ const Page = () => {
}
+ icon={}
heading='zero state'
variant='empty2'
subHeading='Get started by creating your first user.'
@@ -2360,7 +2404,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading="We couldn't find any matches for that search. Try a different search term. Filter bar stays hidden when only search is applied."
@@ -2413,7 +2457,7 @@ const Page = () => {
align='center'
style={{ padding: '40px' }}
>
- {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading='Try adjusting your filters or search query.'
@@ -2483,7 +2527,7 @@ const Page = () => {
}
+ icon={}
heading='zero state'
variant='empty2'
subHeading='Search is enabled even in zero state. Start typing to see empty state. Filter bar will only appear when filters are applied.'
@@ -2491,7 +2535,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading='Search applied but no results. Filter bar stays hidden when only search is used.'
@@ -2557,7 +2601,7 @@ const Page = () => {
}
+ icon={}
heading='zero state'
variant='empty2'
subHeading='Get started by creating your first user.'
@@ -2565,7 +2609,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading="We couldn't find any matches for that keyword or filter."
diff --git a/apps/www/src/content/docs/components/sidebar/demo.ts b/apps/www/src/content/docs/components/sidebar/demo.ts
index cfb8dc34e..0ad382909 100644
--- a/apps/www/src/content/docs/components/sidebar/demo.ts
+++ b/apps/www/src/content/docs/components/sidebar/demo.ts
@@ -27,6 +27,9 @@ export const preview = {
+ }>
+ Overview
+
} active>
Dashboard
@@ -46,11 +49,6 @@ export const preview = {
Activities
-
- }>
- Help
-
-
}>
@@ -76,6 +74,7 @@ export const positionDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -100,6 +99,7 @@ export const positionDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -114,6 +114,75 @@ export const positionDemo = {
]
};
+export const variantDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Plain',
+ code: sidebarLayout(`
+
+
+
+
+
+
+ Apsara
+
+
+
+ }>Overview
+
+ } active>Dashboard
+ }>Analytics
+
+
+ `)
+ },
+ {
+ name: 'Floating',
+ code: sidebarLayout(`
+
+
+
+
+
+
+ Apsara
+
+
+
+ }>Overview
+
+ } active>Dashboard
+ }>Analytics
+
+
+ `)
+ },
+ {
+ name: 'Inset',
+ code: sidebarLayout(`
+
+
+
+
+
+
+ Apsara
+
+
+
+ }>Overview
+
+ } active>Dashboard
+ }>Analytics
+
+
+ `)
+ }
+ ]
+};
+
export const stateDemo = {
type: 'code',
tabs: [
@@ -129,6 +198,7 @@ export const stateDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -152,6 +222,7 @@ export const stateDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -175,6 +246,7 @@ export const stateDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -198,6 +270,7 @@ export const stateDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -227,6 +300,7 @@ export const tooltipDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -251,6 +325,7 @@ export const collapsibleDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -271,10 +346,59 @@ export const hideTooltipDemo = {
+ }>Overview
} active>Dashboard
}>Settings
+ }>
+ Help
+
+
+ `)
+};
+
+export const moreDemo = {
+ type: 'code',
+ code: sidebarLayout(`
+
+
+
+
+
+ Apsara
+
+
+
+ } active>
+ Dashboard
+
+ }>
+ Analytics
+
+
+ }>
+ Reports
+
+
+ }>
+ Activities
+
+ } disabled>
+ Notifications
+
+
+
+
+
+ }>
+ Preferences
+
+ }>
+ Documentation
+
+
+
`)
};
diff --git a/apps/www/src/content/docs/components/sidebar/index.mdx b/apps/www/src/content/docs/components/sidebar/index.mdx
index 7b58eb0d5..3bc09f882 100644
--- a/apps/www/src/content/docs/components/sidebar/index.mdx
+++ b/apps/www/src/content/docs/components/sidebar/index.mdx
@@ -7,10 +7,12 @@ source: packages/raystack/components/sidebar
import {
preview,
positionDemo,
+ variantDemo,
stateDemo,
tooltipDemo,
collapsibleDemo,
- hideTooltipDemo
+ hideTooltipDemo,
+ moreDemo
} from "./demo.ts";
@@ -27,6 +29,9 @@ import { Sidebar } from "@raystack/apsara";
Item
+
+ Hidden item
+
@@ -63,6 +68,14 @@ The main section wraps navigation groups and items. It accepts all `div` props a
The footer section is a container that accepts all `div` props. It's commonly used for secondary links (e.g. Help, Preferences) and stays at the bottom of the sidebar.
+### More
+
+Renders a sidebar row that opens an Apsara menu with additional `Sidebar.Item` entries. It can be used inside `Sidebar.Group` or directly under `Sidebar.Main` / `Sidebar.Footer`.
+
+*Note: if `leadingIcon` is not provided, the trigger uses a default dots icon. In collapsed state, it follows the same item tooltip behavior and respects `hideCollapsedItemTooltip`.*
+
+
+
## Examples
### Position
@@ -71,6 +84,16 @@ The Sidebar can be positioned on either the left or right side of the screen.
+### Variants
+
+Use `variant` to switch the Sidebar surface style:
+
+- `plain` (default): regular surface with side border
+- `floating`: lifted surface with shadow
+- `inset`: transparent surface without border or shadow
+
+
+
### State
The Sidebar supports expanded and collapsed states with smooth transitions.
@@ -99,6 +122,12 @@ Set `hideCollapsedItemTooltip` to disable tooltips on navigation items when the
+### More
+
+Use `Sidebar.More` when you want to keep a section compact and move secondary items into a menu.
+
+
+
## Accessibility
The Sidebar implements the following accessibility features:
diff --git a/apps/www/src/content/docs/components/sidebar/props.ts b/apps/www/src/content/docs/components/sidebar/props.ts
index ccf03e15a..6003ea2a9 100644
--- a/apps/www/src/content/docs/components/sidebar/props.ts
+++ b/apps/www/src/content/docs/components/sidebar/props.ts
@@ -22,6 +22,11 @@ export interface SidebarRootProps {
*/
position?: 'left' | 'right';
+ /** Visual style variant of the Sidebar.
+ * @default "plain"
+ */
+ variant?: 'plain' | 'floating' | 'inset';
+
/** Hide tooltips on sidebar items when sidebar is collapsed.
* @default false
*/
@@ -83,3 +88,28 @@ export interface SidebarItemProps {
text?: string;
};
}
+
+export interface SidebarMoreProps {
+ /** String for the more trigger label. */
+ label?: string;
+
+ /** Optional ReactNode for the trigger icon. */
+ leadingIcon?: ReactNode;
+
+ /** Sidebar items rendered inside the menu content. */
+ children?: ReactNode;
+
+ /** Optional class names for customizing parts of the more trigger/menu. */
+ classNames?: {
+ /** Class name for the trigger root element. */
+ root?: string;
+ /** Class name for the leading icon container. */
+ leadingIcon?: string;
+ /** Class name for the text element. */
+ text?: string;
+ /** Class name for menu item root elements. */
+ menuItem?: string;
+ /** Class name for menu content container. */
+ menuContent?: string;
+ };
+}
diff --git a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
index a56486e53..88bacde54 100644
--- a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
+++ b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
@@ -157,6 +157,20 @@ describe('Sidebar', () => {
const sidebar = container.querySelector('[data-position="right"]');
expect(sidebar).toBeInTheDocument();
});
+
+ it('applies floating variant when specified', () => {
+ const { container } = render();
+
+ const sidebar = container.querySelector('[data-variant="floating"]');
+ expect(sidebar).toBeInTheDocument();
+ });
+
+ it('applies inset variant when specified', () => {
+ const { container } = render();
+
+ const sidebar = container.querySelector('[data-variant="inset"]');
+ expect(sidebar).toBeInTheDocument();
+ });
});
describe('Sidebar Header', () => {
@@ -272,4 +286,38 @@ describe('Sidebar', () => {
expect(group).toBeInTheDocument();
});
});
+
+ describe('Sidebar More', () => {
+ it('renders More trigger and opens menu items', () => {
+ render(
+
+
+ Logs
+ Audit
+
+
+ );
+
+ const trigger = screen.getByText('More items').closest('button');
+ expect(trigger).toBeInTheDocument();
+ if (!trigger) return;
+ fireEvent.click(trigger);
+
+ expect(screen.getByText('Logs')).toBeInTheDocument();
+ expect(screen.getByText('Audit')).toBeInTheDocument();
+ });
+
+ it('sets aria-label for collapsed More trigger', () => {
+ render(
+
+
+ Logs
+
+
+ );
+
+ const trigger = screen.getByRole('listitem', { name: 'Overflow' });
+ expect(trigger).toHaveAttribute('aria-label', 'Overflow');
+ });
+ });
});
diff --git a/packages/raystack/components/sidebar/sidebar-item.tsx b/packages/raystack/components/sidebar/sidebar-item.tsx
index 65c571908..6849fb3c7 100644
--- a/packages/raystack/components/sidebar/sidebar-item.tsx
+++ b/packages/raystack/components/sidebar/sidebar-item.tsx
@@ -8,10 +8,9 @@ import {
ReactNode,
useContext
} from 'react';
-import { Avatar } from '../avatar';
-import { Flex } from '../flex';
import { Tooltip } from '../tooltip';
import styles from './sidebar.module.css';
+import { SidebarLeadingVisual } from './sidebar-leading-visual';
import { SidebarContext } from './sidebar-root';
export interface SidebarItemProps extends ComponentProps<'a'> {
@@ -43,13 +42,6 @@ export function SidebarItem({
typeof children === 'string' &&
children.length > 0;
- const iconProps = {
- align: 'center',
- gap: 3,
- className: cx(styles['nav-leading-icon'], classNames?.leadingIcon),
- 'aria-hidden': true
- } as const;
-
const content = cloneElement(
as,
{
@@ -65,20 +57,11 @@ export function SidebarItem({
...props
},
<>
- {shouldShowFallback ? (
-
-
-
- ) : null}
- {!shouldShowFallback && leadingIcon ? (
- {leadingIcon}
- ) : null}
+
{!isCollapsed && (
{children}
diff --git a/packages/raystack/components/sidebar/sidebar-leading-visual.tsx b/packages/raystack/components/sidebar/sidebar-leading-visual.tsx
new file mode 100644
index 000000000..d55054385
--- /dev/null
+++ b/packages/raystack/components/sidebar/sidebar-leading-visual.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { cx } from 'class-variance-authority';
+import { ReactNode } from 'react';
+import { Avatar } from '../avatar';
+import { Flex } from '../flex';
+import styles from './sidebar.module.css';
+
+interface SidebarLeadingVisualProps {
+ leadingIcon?: ReactNode;
+ fallbackText?: string;
+ className?: string;
+}
+
+export function SidebarLeadingVisual({
+ leadingIcon,
+ fallbackText,
+ className
+}: SidebarLeadingVisualProps) {
+ if (leadingIcon) {
+ return (
+
+ {leadingIcon}
+
+ );
+ }
+
+ if (fallbackText && fallbackText.length > 0) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/packages/raystack/components/sidebar/sidebar-more.tsx b/packages/raystack/components/sidebar/sidebar-more.tsx
new file mode 100644
index 000000000..23b2f7b5b
--- /dev/null
+++ b/packages/raystack/components/sidebar/sidebar-more.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import { DotsHorizontalIcon } from '@radix-ui/react-icons';
+import { cx } from 'class-variance-authority';
+import {
+ Children,
+ cloneElement,
+ isValidElement,
+ ReactElement,
+ ReactNode,
+ useContext
+} from 'react';
+import { Menu } from '../menu';
+import { Tooltip } from '../tooltip';
+import styles from './sidebar.module.css';
+import { SidebarItemProps } from './sidebar-item';
+import { SidebarLeadingVisual } from './sidebar-leading-visual';
+import { SidebarContext } from './sidebar-root';
+
+export interface SidebarMoreProps {
+ children?: ReactNode;
+ label?: string;
+ leadingIcon?: ReactNode;
+ classNames?: {
+ root?: string;
+ leadingIcon?: string;
+ text?: string;
+ menuItem?: string;
+ menuContent?: string;
+ };
+}
+
+export function SidebarMore({
+ children,
+ label = 'More',
+ leadingIcon,
+ classNames
+}: SidebarMoreProps) {
+ const { isCollapsed, position, hideCollapsedItemTooltip } =
+ useContext(SidebarContext);
+
+ const items = Children.toArray(children).filter(
+ isValidElement
+ ) as ReactElement[];
+
+ if (items.length === 0) return null;
+ const triggerIcon = leadingIcon ?? (
+
+ );
+
+ const triggerContent = (
+
+ );
+
+ return (
+
+ );
+}
+
+SidebarMore.displayName = 'Sidebar.More';
diff --git a/packages/raystack/components/sidebar/sidebar-root.tsx b/packages/raystack/components/sidebar/sidebar-root.tsx
index 1438656a0..910ea2d63 100644
--- a/packages/raystack/components/sidebar/sidebar-root.tsx
+++ b/packages/raystack/components/sidebar/sidebar-root.tsx
@@ -13,15 +13,18 @@ import styles from './sidebar.module.css';
export interface SidebarContextValue {
isCollapsed: boolean;
+ position: 'left' | 'right';
hideCollapsedItemTooltip?: boolean;
}
export const SidebarContext = createContext({
- isCollapsed: false
+ isCollapsed: false,
+ position: 'left'
});
export interface SidebarRootProps extends ComponentProps<'aside'> {
position?: 'left' | 'right';
+ variant?: 'plain' | 'floating' | 'inset';
hideCollapsedItemTooltip?: boolean;
collapsible?: boolean;
tooltipMessage?: ReactNode;
@@ -33,6 +36,7 @@ export interface SidebarRootProps extends ComponentProps<'aside'> {
export function SidebarRoot({
className,
position = 'left',
+ variant = 'plain',
open: providedOpen,
onOpenChange,
hideCollapsedItemTooltip,
@@ -54,10 +58,13 @@ export function SidebarRoot({
);
return (
-
+