From 8879b7440dde1f0246fd2669a49831618e14e4dc Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Sat, 14 Feb 2026 16:50:55 +0100 Subject: [PATCH 01/14] feat(Sidebar): new component --- .../examples/sidebar/SidebarExample.vue | 73 +++ .../examples/sidebar/SidebarOpenExample.vue | 52 ++ docs/content/docs/2.components/sidebar.md | 169 ++++++ playgrounds/nuxt/app/app.vue | 2 + .../nuxt/app/composables/useNavigation.ts | 1 + .../nuxt/app/pages/components/sidebar.vue | 76 +++ src/runtime/components/Sidebar.vue | 281 ++++++++++ src/runtime/locale/en.ts | 3 + src/runtime/types/index.ts | 1 + src/runtime/types/locale.ts | 3 + src/theme/index.ts | 1 + src/theme/sidebar.ts | 78 +++ test/components/Sidebar.spec.ts | 52 ++ .../__snapshots__/Sidebar-vue.spec.ts.snap | 489 ++++++++++++++++++ .../__snapshots__/Sidebar.spec.ts.snap | 489 ++++++++++++++++++ 15 files changed, 1770 insertions(+) create mode 100644 docs/app/components/content/examples/sidebar/SidebarExample.vue create mode 100644 docs/app/components/content/examples/sidebar/SidebarOpenExample.vue create mode 100644 docs/content/docs/2.components/sidebar.md create mode 100644 playgrounds/nuxt/app/pages/components/sidebar.vue create mode 100644 src/runtime/components/Sidebar.vue create mode 100644 src/theme/sidebar.ts create mode 100644 test/components/Sidebar.spec.ts create mode 100644 test/components/__snapshots__/Sidebar-vue.spec.ts.snap create mode 100644 test/components/__snapshots__/Sidebar.spec.ts.snap diff --git a/docs/app/components/content/examples/sidebar/SidebarExample.vue b/docs/app/components/content/examples/sidebar/SidebarExample.vue new file mode 100644 index 0000000000..095b34f2be --- /dev/null +++ b/docs/app/components/content/examples/sidebar/SidebarExample.vue @@ -0,0 +1,73 @@ + + + diff --git a/docs/app/components/content/examples/sidebar/SidebarOpenExample.vue b/docs/app/components/content/examples/sidebar/SidebarOpenExample.vue new file mode 100644 index 0000000000..0799313173 --- /dev/null +++ b/docs/app/components/content/examples/sidebar/SidebarOpenExample.vue @@ -0,0 +1,52 @@ + + + diff --git a/docs/content/docs/2.components/sidebar.md b/docs/content/docs/2.components/sidebar.md new file mode 100644 index 0000000000..7d8a28ad7a --- /dev/null +++ b/docs/content/docs/2.components/sidebar.md @@ -0,0 +1,169 @@ +--- +title: Sidebar +description: 'A collapsible sidebar with multiple visual variants.' +category: layout +links: + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxt/ui/blob/v4/src/runtime/components/Sidebar.vue +--- + +## Usage + +The Sidebar component provides a fixed sidebar with a gap-based layout pattern. It uses CSS variables for width control and data attributes for styling. + +Use the `title`, `description` and `close` props to customize the sidebar header just like the [Modal](/docs/components/modal), [Slideover](/docs/components/slideover) and [Drawer](/docs/components/drawer) components. On desktop it renders inline, on mobile it renders inside the sheet menu. + +Use the `body`, `default` and `footer` slots to customize the sidebar content. The `v-model:open` directive is viewport-aware: on desktop it controls the expanded/collapsed state, on mobile it controls the sheet menu. + +::component-example +--- +collapse: true +name: 'sidebar-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +--- +:: + +### Variant + +Use the `variant` prop to change the visual style of the sidebar. Defaults to `sidebar`. + +::component-example +--- +collapse: true +name: 'sidebar-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +options: + - name: 'variant' + label: 'variant' + default: 'sidebar' + items: + - sidebar + - floating + - inset +--- +:: + +### Collapsible + +Use the `collapsible` prop to change the collapse behavior of the sidebar. Defaults to `none`. + +- `offcanvas`: The sidebar slides out of view completely. +- `icon`: The sidebar shrinks to icon-only width. +- `none`: The sidebar is not collapsible. + +::component-example +--- +collapse: true +name: 'sidebar-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +options: + - name: 'collapsible' + label: 'collapsible' + default: 'icon' + items: + - offcanvas + - icon + - none +--- +:: + +::tip{to="#slots"} +You can access the `state` in the slot props to customize the content of the sidebar when it is collapsed. +:: + +### Side + +Use the `side` prop to change the side of the sidebar. Defaults to `left`. + +::component-example +--- +collapse: true +name: 'sidebar-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +options: + - name: 'side' + label: 'side' + default: 'left' + items: + - left + - right +--- +:: + +### Title / Description / Close + +Use the `title`, `description` and `close` props to customize the sidebar header. + +::tip +You can use the `#title`, `#description` and `#close` slots to customize them. +:: + +### Width + +Use the `width` prop to change the width of the sidebar. Defaults to `16rem`. + +Use the `icon-width` prop to change the width of the sidebar when collapsed to icon mode. Defaults to `3rem`. + +### Mode + +Use the `mode` prop to change the mode of the sidebar menu on mobile. Defaults to `slideover`. + +::tip{to="#props"} +You can use the `menu` prop to customize the menu of the sidebar, it will adapt depending on the mode you choose. +:: + +## Examples + +### Control open state + +You can control the open state by using the `open` prop or the `v-model:open` directive. On desktop it controls the expanded/collapsed state, on mobile it opens/closes the sheet menu. + +::component-example +--- +name: 'sidebar-open-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +--- +:: + +::note +In this example, leveraging [`defineShortcuts`](/docs/composables/define-shortcuts), you can toggle the open state of the Sidebar by pressing :kbd{value="O"}. +:: + +## API + +### Props + +:component-props + +### Slots + +:component-slots + +## Theme + +:component-theme + +## Changelog + +:component-changelog diff --git a/playgrounds/nuxt/app/app.vue b/playgrounds/nuxt/app/app.vue index 2e635104d3..3d2df62db2 100644 --- a/playgrounds/nuxt/app/app.vue +++ b/playgrounds/nuxt/app/app.vue @@ -51,7 +51,9 @@ provide('components', components) + +import type { UIMessage } from 'ai' +import { Chat } from '@ai-sdk/vue' + +const open = ref(true) +const input = ref('') + +const messages: UIMessage[] = [{ + id: '1', + role: 'user', + parts: [{ type: 'text', text: 'What is Nuxt UI?' }] +}, { + id: '2', + role: 'assistant', + parts: [{ type: 'text', text: 'Nuxt UI is a Vue component library built on Reka UI, Tailwind CSS, and Tailwind Variants. It provides 125+ accessible components for building modern web apps.' }] +}] + +const chat = new Chat({ + messages, + onError() {} +}) + +function onSubmit() { + chat.sendMessage({ text: input.value }) + input.value = '' +} + + + diff --git a/src/runtime/components/Sidebar.vue b/src/runtime/components/Sidebar.vue new file mode 100644 index 0000000000..88bcf1ae74 --- /dev/null +++ b/src/runtime/components/Sidebar.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/src/runtime/locale/en.ts b/src/runtime/locale/en.ts index 0cffb91108..873d049256 100644 --- a/src/runtime/locale/en.ts +++ b/src/runtime/locale/en.ts @@ -112,6 +112,9 @@ export default defineLocale({ copy: 'Copy code to clipboard' } }, + sidebar: { + close: 'Close sidebar' + }, selectMenu: { create: 'Create "{label}"', noData: 'No data', diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index 386a1d570f..61b5ec6283 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -95,6 +95,7 @@ export * from '../components/ScrollArea.vue' export * from '../components/Select.vue' export * from '../components/SelectMenu.vue' export * from '../components/Separator.vue' +export * from '../components/Sidebar.vue' export * from '../components/Skeleton.vue' export * from '../components/Slideover.vue' export * from '../components/Slider.vue' diff --git a/src/runtime/types/locale.ts b/src/runtime/types/locale.ts index 20beb0f16d..8e1f34a06c 100644 --- a/src/runtime/types/locale.ts +++ b/src/runtime/types/locale.ts @@ -116,6 +116,9 @@ export type Messages = { copy: string } } + sidebar: { + close: string + } selectMenu: { create: string noData: string diff --git a/src/theme/index.ts b/src/theme/index.ts index 687acd97db..0fed9999de 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -93,6 +93,7 @@ export { default as scrollArea } from './scroll-area' export { default as select } from './select' export { default as selectMenu } from './select-menu' export { default as separator } from './separator' +export { default as sidebar } from './sidebar' export { default as skeleton } from './skeleton' export { default as slideover } from './slideover' export { default as slider } from './slider' diff --git a/src/theme/sidebar.ts b/src/theme/sidebar.ts new file mode 100644 index 0000000000..e43f0a078b --- /dev/null +++ b/src/theme/sidebar.ts @@ -0,0 +1,78 @@ +export default { + slots: { + root: 'group/sidebar peer text-default hidden lg:block', + gap: 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', + container: 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear lg:flex', + inner: 'flex size-full flex-col overflow-hidden bg-default', + header: 'flex items-center gap-1.5 p-4', + wrapper: '', + title: 'text-highlighted font-semibold', + description: 'mt-1 text-muted text-sm', + close: 'ms-auto', + body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-2', + footer: 'flex flex-col gap-2 p-4', + overlay: 'lg:hidden' + }, + variants: { + side: { + left: { + container: 'left-0 border-e border-default' + }, + right: { + container: 'right-0 border-s border-default' + } + }, + collapsible: { + offcanvas: { + gap: 'data-[state=collapsed]:w-0', + container: 'data-[state=collapsed]:w-0' + }, + icon: { + gap: 'data-[state=collapsed]:w-(--sidebar-width-icon)', + container: 'data-[state=collapsed]:w-(--sidebar-width-icon)', + header: 'group-data-[state=collapsed]/sidebar:overflow-hidden group-data-[state=collapsed]/sidebar:p-2', + body: 'group-data-[state=collapsed]/sidebar:overflow-hidden', + footer: 'group-data-[state=collapsed]/sidebar:overflow-hidden group-data-[state=collapsed]/sidebar:p-2' + }, + none: {} + }, + variant: { + sidebar: {}, + floating: { + container: 'p-2', + inner: 'rounded-lg border border-default shadow-sm' + }, + inset: { + container: 'p-2', + inner: 'rounded-lg' + } + } + }, + compoundVariants: [{ + side: 'left' as const, + collapsible: 'offcanvas' as const, + class: { + container: 'data-[state=collapsed]:-left-(--sidebar-width)' + } + }, { + side: 'right' as const, + collapsible: 'offcanvas' as const, + class: { + container: 'data-[state=collapsed]:-right-(--sidebar-width)' + } + }, { + variant: 'floating' as const, + collapsible: 'icon' as const, + class: { + gap: 'data-[state=collapsed]:w-[calc(var(--sidebar-width-icon)+theme(spacing.4))]', + container: 'data-[state=collapsed]:w-[calc(var(--sidebar-width-icon)+theme(spacing.4)+2px)]' + } + }, { + variant: 'inset' as const, + collapsible: 'icon' as const, + class: { + gap: 'data-[state=collapsed]:w-[calc(var(--sidebar-width-icon)+theme(spacing.4))]', + container: 'data-[state=collapsed]:w-[calc(var(--sidebar-width-icon)+theme(spacing.4)+2px)]' + } + }] +} diff --git a/test/components/Sidebar.spec.ts b/test/components/Sidebar.spec.ts new file mode 100644 index 0000000000..7297415be6 --- /dev/null +++ b/test/components/Sidebar.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { axe } from 'vitest-axe' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import Sidebar from '../../src/runtime/components/Sidebar.vue' +import type { SidebarProps, SidebarSlots } from '../../src/runtime/components/Sidebar.vue' +import ComponentRender from '../component-render' + +describe('Sidebar', () => { + it.each([ + // Props + ['with variant sidebar', { props: { variant: 'sidebar' as const } }], + ['with variant floating', { props: { variant: 'floating' as const } }], + ['with variant inset', { props: { variant: 'inset' as const } }], + ['with collapsible offcanvas', { props: { collapsible: 'offcanvas' as const } }], + ['with collapsible icon', { props: { collapsible: 'icon' as const } }], + ['with collapsible none', { props: { collapsible: 'none' as const } }], + ['with side left', { props: { side: 'left' as const } }], + ['with side right', { props: { side: 'right' as const } }], + ['with title', { props: { title: 'Sidebar Title' } }], + ['with description', { props: { title: 'Sidebar Title', description: 'Sidebar Description' } }], + ['with close', { props: { title: 'Sidebar Title', close: true } }], + ['with mode modal', { props: { mode: 'modal' as const, menu: { portal: false } } }], + ['with mode slideover', { props: { mode: 'slideover' as const, menu: { portal: false } } }], + ['with mode drawer', { props: { mode: 'drawer' as const, menu: { portal: false } } }], + ['with width', { props: { width: '20rem' } }], + ['with iconWidth', { props: { iconWidth: '4rem' } }], + ['with collapsed offcanvas', { props: { open: false, collapsible: 'offcanvas' as const } }], + ['with collapsed icon', { props: { open: false, collapsible: 'icon' as const } }], + ['with class', { props: { class: 'custom-class' } }], + ['with ui', { props: { ui: { body: 'py-4' } } }], + // Slots + ['with header slot', { slots: { header: () => 'Header slot' } }], + ['with default slot', { slots: { default: () => 'Default slot' } }], + ['with body slot', { slots: { body: () => 'Body slot' } }], + ['with footer slot', { slots: { footer: () => 'Footer slot' } }], + ['with content slot', { slots: { content: () => 'Content slot' } }] + ])('renders %s correctly', async (_: string, options: { props?: SidebarProps, slots?: Partial }) => { + const html = await ComponentRender(_, options, Sidebar) + expect(html).toMatchSnapshot() + }) + + it('passes accessibility tests', async () => { + const wrapper = await mountSuspended(Sidebar, { + props: { + variant: 'sidebar', + collapsible: 'icon' + } + }) + + expect(await axe(wrapper.element)).toHaveNoViolations() + }) +}) diff --git a/test/components/__snapshots__/Sidebar-vue.spec.ts.snap b/test/components/__snapshots__/Sidebar-vue.spec.ts.snap new file mode 100644 index 0000000000..2cad13f0d8 --- /dev/null +++ b/test/components/__snapshots__/Sidebar-vue.spec.ts.snap @@ -0,0 +1,489 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Sidebar > renders with body slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with class correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with close correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsed icon correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsed offcanvas correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsible icon correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsible none correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsible offcanvas correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with content slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with default slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with description correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with footer slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with header slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with iconWidth correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with mode drawer correctly 1`] = ` +" + + + + + + + + + +" +`; + +exports[`Sidebar > renders with mode modal correctly 1`] = ` +" + + + + + + + + + + + +" +`; + +exports[`Sidebar > renders with mode slideover correctly 1`] = ` +" + + + + + + + + + +" +`; + +exports[`Sidebar > renders with side left correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with side right correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with title correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with ui correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with variant floating correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with variant inset correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with variant sidebar correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with width correctly 1`] = ` +" + + + +" +`; diff --git a/test/components/__snapshots__/Sidebar.spec.ts.snap b/test/components/__snapshots__/Sidebar.spec.ts.snap new file mode 100644 index 0000000000..2cad13f0d8 --- /dev/null +++ b/test/components/__snapshots__/Sidebar.spec.ts.snap @@ -0,0 +1,489 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Sidebar > renders with body slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with class correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with close correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsed icon correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsed offcanvas correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsible icon correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsible none correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with collapsible offcanvas correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with content slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with default slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with description correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with footer slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with header slot correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with iconWidth correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with mode drawer correctly 1`] = ` +" + + + + + + + + + +" +`; + +exports[`Sidebar > renders with mode modal correctly 1`] = ` +" + + + + + + + + + + + +" +`; + +exports[`Sidebar > renders with mode slideover correctly 1`] = ` +" + + + + + + + + + +" +`; + +exports[`Sidebar > renders with side left correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with side right correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with title correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with ui correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with variant floating correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with variant inset correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with variant sidebar correctly 1`] = ` +" + + + +" +`; + +exports[`Sidebar > renders with width correctly 1`] = ` +" + + + +" +`; From a4795bdeb8bb1c4250a7e2ccbd6852cd0881469b Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Sat, 14 Feb 2026 16:57:32 +0100 Subject: [PATCH 02/14] docs(app): add sidebar --- docs/app/app.vue | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/app/app.vue b/docs/app/app.vue index 9880bed14c..7119d4446d 100644 --- a/docs/app/app.vue +++ b/docs/app/app.vue @@ -58,24 +58,28 @@ provide('navigation', rootNavigation) -
- - - - + + + - +
+ + From 472b3de2dc605d27a447c380229d71de21438629 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Sat, 14 Feb 2026 17:15:39 +0100 Subject: [PATCH 03/14] docs: update --- docs/content/docs/2.components/dashboard-sidebar.md | 8 +++++++- docs/content/docs/2.components/sidebar.md | 8 ++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/2.components/dashboard-sidebar.md b/docs/content/docs/2.components/dashboard-sidebar.md index 9fec5099fe..c2ef3230d9 100644 --- a/docs/content/docs/2.components/dashboard-sidebar.md +++ b/docs/content/docs/2.components/dashboard-sidebar.md @@ -10,7 +10,13 @@ links: ## Usage -The DashboardSidebar component is used to display a sidebar. Its state (size, collapsed, etc.) will be saved based on the `storage` and `storage-key` props you provide to the [DashboardGroup](/docs/components/dashboard-group#props) component. +The DashboardSidebar component is used to display a sidebar in a dashboard layout. It supports drag-to-resize, state persistence and integrates with [DashboardGroup](/docs/components/dashboard-group), [DashboardPanel](/docs/components/dashboard-panel) and [DashboardNavbar](/docs/components/dashboard-navbar). + +::tip{to="/docs/components/sidebar"} +For a standalone sidebar that doesn't require a dashboard layout (e.g., a chat panel or settings panel), use the [Sidebar](/docs/components/sidebar) component instead. +:: + +Its state (size, collapsed, etc.) will be saved based on the `storage` and `storage-key` props you provide to the [DashboardGroup](/docs/components/dashboard-group#props) component. Use it inside the default slot of the [DashboardGroup](/docs/components/dashboard-group) component: diff --git a/docs/content/docs/2.components/sidebar.md b/docs/content/docs/2.components/sidebar.md index 7d8a28ad7a..59dd545914 100644 --- a/docs/content/docs/2.components/sidebar.md +++ b/docs/content/docs/2.components/sidebar.md @@ -10,9 +10,13 @@ links: ## Usage -The Sidebar component provides a fixed sidebar with a gap-based layout pattern. It uses CSS variables for width control and data attributes for styling. +The Sidebar component is a standalone, fixed sidebar that pushes the page content. On desktop, it renders inline and can be collapsed; on mobile, it opens as a sheet (Modal, Slideover or Drawer). -Use the `title`, `description` and `close` props to customize the sidebar header just like the [Modal](/docs/components/modal), [Slideover](/docs/components/slideover) and [Drawer](/docs/components/drawer) components. On desktop it renders inline, on mobile it renders inside the sheet menu. +::tip{to="/docs/components/dashboard-sidebar"} +If you're building a dashboard layout with drag-to-resize, state persistence and integration with `DashboardGroup`, `DashboardPanel` and `DashboardNavbar`, use the [DashboardSidebar](/docs/components/dashboard-sidebar) instead. The **Sidebar** component is designed for standalone use cases like a chat panel or a settings panel on any page. +:: + +Use the `title`, `description` and `close` props to customize the sidebar header just like the [Modal](/docs/components/modal), [Slideover](/docs/components/slideover) and [Drawer](/docs/components/drawer) components. Use the `body`, `default` and `footer` slots to customize the sidebar content. The `v-model:open` directive is viewport-aware: on desktop it controls the expanded/collapsed state, on mobile it controls the sheet menu. From 9e28ad72bec865676f9d0ad54955f7d8861c074d Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Sat, 14 Feb 2026 17:18:39 +0100 Subject: [PATCH 04/14] fix(Sidebar): improve open state --- src/runtime/components/Sidebar.vue | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/runtime/components/Sidebar.vue b/src/runtime/components/Sidebar.vue index 88bcf1ae74..2f744d0426 100644 --- a/src/runtime/components/Sidebar.vue +++ b/src/runtime/components/Sidebar.vue @@ -116,6 +116,9 @@ const isMobile = useMediaQuery('(max-width: 1023px)') const modelOpen = defineModel('open', { default: true }) const openMobile = ref(false) +// Saved desktop state so viewport transitions don't lose it +const desktopOpen = ref(modelOpen.value) + const open = computed({ get: () => isMobile.value ? openMobile.value : modelOpen.value, set: (value: boolean) => { @@ -127,27 +130,32 @@ const open = computed({ } }) -// Sync model changes into internal state +// Handle viewport transitions and initial mobile state +watch(isMobile, (mobile) => { + if (mobile) { + // Save desktop state and align model to mobile (closed) + desktopOpen.value = modelOpen.value + modelOpen.value = false + } else { + // Restore desktop state + modelOpen.value = desktopOpen.value + } +}, { immediate: true }) + +// Sync model changes into mobile state watch(modelOpen, (value) => { if (isMobile.value) { openMobile.value = value } }) -// Sync mobile state back to model so parent toggle stays in sync +// Sync mobile dismissal (overlay click, swipe) back to model so toggle stays in sync watch(openMobile, (value) => { if (isMobile.value) { modelOpen.value = value } }) -// When transitioning to mobile, align model with mobile state (closed) -watch(isMobile, (mobile) => { - if (mobile) { - modelOpen.value = openMobile.value - } -}) - const { t } = useLocale() const appConfig = useAppConfig() as Sidebar['AppConfig'] const uiProp = useComponentUI('sidebar', props) From 4bd0801f4e8872291681efea765580750895bfef Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Sat, 14 Feb 2026 18:46:27 +0100 Subject: [PATCH 05/14] up --- playgrounds/nuxt/app/pages/components/sidebar.vue | 2 +- src/runtime/components/Sidebar.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/playgrounds/nuxt/app/pages/components/sidebar.vue b/playgrounds/nuxt/app/pages/components/sidebar.vue index 836e8c0feb..97b44965d4 100644 --- a/playgrounds/nuxt/app/pages/components/sidebar.vue +++ b/playgrounds/nuxt/app/pages/components/sidebar.vue @@ -57,7 +57,7 @@ function onSubmit() { diff --git a/src/runtime/components/Sidebar.vue b/src/runtime/components/Sidebar.vue index 2f744d0426..86f233c441 100644 --- a/src/runtime/components/Sidebar.vue +++ b/src/runtime/components/Sidebar.vue @@ -27,7 +27,7 @@ export interface SidebarProps { * The side to render the sidebar on. * @defaultValue 'left' */ - side?: 'left' | 'right' + side?: Sidebar['variants']['side'] /** * The title displayed in the sidebar header. */ From 1be9f9bedb4ba9e3b6b921aeba6dee78f98715a3 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 18 Feb 2026 15:49:15 +0100 Subject: [PATCH 06/14] fix(theme): update global css variables --- .../docs/1.getting-started/5.theme/2.css-variables.md | 2 +- src/runtime/index.css | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/content/docs/1.getting-started/5.theme/2.css-variables.md b/docs/content/docs/1.getting-started/5.theme/2.css-variables.md index b0d5202390..9610a89068 100644 --- a/docs/content/docs/1.getting-started/5.theme/2.css-variables.md +++ b/docs/content/docs/1.getting-started/5.theme/2.css-variables.md @@ -373,7 +373,7 @@ Nuxt UI provides a `--ui-header-height` CSS variable that controls the height of ```css :root { - --ui-header-height: --spacing(16); + --ui-header-height: 4rem; } ``` diff --git a/src/runtime/index.css b/src/runtime/index.css index 25caadb162..11f685d967 100644 --- a/src/runtime/index.css +++ b/src/runtime/index.css @@ -7,7 +7,9 @@ @layer theme { :root, :host { - --ui-header-height: --spacing(16); + --ui-header-height: 4rem; + --ui-radius: 0.25rem; + --ui-container: 80rem; } :root, :host, .light { @@ -28,9 +30,6 @@ --ui-border-muted: var(--ui-color-neutral-200); --ui-border-accented: var(--ui-color-neutral-300); --ui-border-inverted: var(--ui-color-neutral-900); - - --ui-radius: 0.25rem; - --ui-container: 80rem; } .dark { From f61a35c62b7e2b9b73e89d6aa29b465bd2661fa0 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 18 Feb 2026 17:57:50 +0100 Subject: [PATCH 07/14] feat(Sidebar): update --- .../examples/sidebar/SidebarCloseExample.vue | 49 +++ .../sidebar/SidebarCloseIconExample.vue | 50 +++ .../sidebar/SidebarDescriptionExample.vue | 49 +++ .../examples/sidebar/SidebarExample.vue | 2 + .../sidebar/SidebarPersistExample.vue | 52 +++ .../examples/sidebar/SidebarTitleExample.vue | 48 +++ .../docs/2.components/dashboard-sidebar.md | 2 +- docs/content/docs/2.components/sidebar.md | 132 +++++- .../nuxt/app/pages/components/sidebar.vue | 27 +- src/runtime/components/Sidebar.vue | 47 +-- src/theme/sidebar.ts | 16 +- test/components/Sidebar.spec.ts | 4 +- .../__snapshots__/Sidebar-vue.spec.ts.snap | 396 +++++++----------- .../__snapshots__/Sidebar.spec.ts.snap | 396 +++++++----------- 14 files changed, 721 insertions(+), 549 deletions(-) create mode 100644 docs/app/components/content/examples/sidebar/SidebarCloseExample.vue create mode 100644 docs/app/components/content/examples/sidebar/SidebarCloseIconExample.vue create mode 100644 docs/app/components/content/examples/sidebar/SidebarDescriptionExample.vue create mode 100644 docs/app/components/content/examples/sidebar/SidebarPersistExample.vue create mode 100644 docs/app/components/content/examples/sidebar/SidebarTitleExample.vue diff --git a/docs/app/components/content/examples/sidebar/SidebarCloseExample.vue b/docs/app/components/content/examples/sidebar/SidebarCloseExample.vue new file mode 100644 index 0000000000..181ef571af --- /dev/null +++ b/docs/app/components/content/examples/sidebar/SidebarCloseExample.vue @@ -0,0 +1,49 @@ + + + diff --git a/docs/app/components/content/examples/sidebar/SidebarCloseIconExample.vue b/docs/app/components/content/examples/sidebar/SidebarCloseIconExample.vue new file mode 100644 index 0000000000..aef14d6fe0 --- /dev/null +++ b/docs/app/components/content/examples/sidebar/SidebarCloseIconExample.vue @@ -0,0 +1,50 @@ + + + diff --git a/docs/app/components/content/examples/sidebar/SidebarDescriptionExample.vue b/docs/app/components/content/examples/sidebar/SidebarDescriptionExample.vue new file mode 100644 index 0000000000..0e4c234086 --- /dev/null +++ b/docs/app/components/content/examples/sidebar/SidebarDescriptionExample.vue @@ -0,0 +1,49 @@ + + + diff --git a/docs/app/components/content/examples/sidebar/SidebarExample.vue b/docs/app/components/content/examples/sidebar/SidebarExample.vue index 095b34f2be..7596d802e1 100644 --- a/docs/app/components/content/examples/sidebar/SidebarExample.vue +++ b/docs/app/components/content/examples/sidebar/SidebarExample.vue @@ -6,6 +6,7 @@ const route = useRoute() const variant = computed(() => (route.query.variant as 'sidebar' | 'floating' | 'inset') || 'sidebar') const collapsible = computed(() => (route.query.collapsible as 'offcanvas' | 'icon' | 'none') || 'icon') const side = computed(() => (route.query.side as 'left' | 'right') || 'left') +const mode = computed(() => (route.query.mode as 'modal' | 'slideover' | 'drawer') || 'slideover') const open = ref(true) @@ -33,6 +34,7 @@ const items: NavigationMenuItem[] = [{ :variant="variant" :collapsible="collapsible" :side="side" + :mode="mode" title="Navigation" close > diff --git a/docs/app/components/content/examples/sidebar/SidebarPersistExample.vue b/docs/app/components/content/examples/sidebar/SidebarPersistExample.vue new file mode 100644 index 0000000000..95d4b3fbe9 --- /dev/null +++ b/docs/app/components/content/examples/sidebar/SidebarPersistExample.vue @@ -0,0 +1,52 @@ + + + diff --git a/docs/app/components/content/examples/sidebar/SidebarTitleExample.vue b/docs/app/components/content/examples/sidebar/SidebarTitleExample.vue new file mode 100644 index 0000000000..f07d92b5ac --- /dev/null +++ b/docs/app/components/content/examples/sidebar/SidebarTitleExample.vue @@ -0,0 +1,48 @@ + + + diff --git a/docs/content/docs/2.components/dashboard-sidebar.md b/docs/content/docs/2.components/dashboard-sidebar.md index c2ef3230d9..3b61bd6094 100644 --- a/docs/content/docs/2.components/dashboard-sidebar.md +++ b/docs/content/docs/2.components/dashboard-sidebar.md @@ -13,7 +13,7 @@ links: The DashboardSidebar component is used to display a sidebar in a dashboard layout. It supports drag-to-resize, state persistence and integrates with [DashboardGroup](/docs/components/dashboard-group), [DashboardPanel](/docs/components/dashboard-panel) and [DashboardNavbar](/docs/components/dashboard-navbar). ::tip{to="/docs/components/sidebar"} -For a standalone sidebar that doesn't require a dashboard layout (e.g., a chat panel or settings panel), use the [Sidebar](/docs/components/sidebar) component instead. +**DashboardSidebar vs Sidebar**: This component is designed for dashboard layouts with drag-to-resize, state persistence and `DashboardGroup` integration. For a simple, standalone sidebar (chat panel, settings, navigation), use [Sidebar](/docs/components/sidebar) instead. :: Its state (size, collapsed, etc.) will be saved based on the `storage` and `storage-key` props you provide to the [DashboardGroup](/docs/components/dashboard-group#props) component. diff --git a/docs/content/docs/2.components/sidebar.md b/docs/content/docs/2.components/sidebar.md index 59dd545914..4df4eed4fa 100644 --- a/docs/content/docs/2.components/sidebar.md +++ b/docs/content/docs/2.components/sidebar.md @@ -13,7 +13,7 @@ links: The Sidebar component is a standalone, fixed sidebar that pushes the page content. On desktop, it renders inline and can be collapsed; on mobile, it opens as a sheet (Modal, Slideover or Drawer). ::tip{to="/docs/components/dashboard-sidebar"} -If you're building a dashboard layout with drag-to-resize, state persistence and integration with `DashboardGroup`, `DashboardPanel` and `DashboardNavbar`, use the [DashboardSidebar](/docs/components/dashboard-sidebar) instead. The **Sidebar** component is designed for standalone use cases like a chat panel or a settings panel on any page. +**Sidebar vs DashboardSidebar**: This component is a simple, standalone sidebar you can drop anywhere (chat panel, settings, navigation). If you need drag-to-resize, state persistence and integration with [DashboardGroup](/docs/components/dashboard-group), use [DashboardSidebar](/docs/components/dashboard-sidebar) instead. :: Use the `title`, `description` and `close` props to customize the sidebar header just like the [Modal](/docs/components/modal), [Slideover](/docs/components/slideover) and [Drawer](/docs/components/drawer) components. @@ -111,9 +111,71 @@ options: --- :: -### Title / Description / Close +### Title -Use the `title`, `description` and `close` props to customize the sidebar header. +Use the `title` prop to set the title of the sidebar header. + +::component-example +--- +collapse: true +name: 'sidebar-title-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +--- +:: + +### Description + +Use the `description` prop to set the description of the sidebar header. + +::component-example +--- +collapse: true +name: 'sidebar-description-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +--- +:: + +### Close + +Use the `close` prop to display a close button in the sidebar header. The close button is only rendered when `collapsible` is not `none`. + +You can pass any property from the [Button](/docs/components/button) component to customize it. + +::component-example +--- +collapse: true +name: 'sidebar-close-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +--- +:: + +### Close Icon + +Use the `close-icon` prop to customize the close button [Icon](/docs/components/icon). Defaults to `i-lucide-x`. + +::component-example +--- +collapse: true +name: 'sidebar-close-icon-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +--- +:: ::tip You can use the `#title`, `#description` and `#close` slots to customize them. @@ -121,14 +183,55 @@ You can use the `#title`, `#description` and `#close` slots to customize them. ### Width -Use the `width` prop to change the width of the sidebar. Defaults to `16rem`. +The sidebar width is controlled by the `--sidebar-width` CSS variable (defaults to `28rem`). The collapsed icon width is controlled by `--sidebar-width-icon` (defaults to `4rem`). + +Override them globally in your CSS or per-instance with the `style` attribute: + +```vue + +``` + +### With Navbar + +To position the sidebar below a fixed navbar, customize the container position using the `ui` prop: -Use the `icon-width` prop to change the width of the sidebar when collapsed to icon mode. Defaults to `3rem`. +```vue + +``` + +::note +The `--ui-header-height` variable defaults to `4rem` and is used by the [Header](/docs/components/header) and [DashboardNavbar](/docs/components/dashboard-navbar) components. Adjust it if your navbar uses a different height. +:: ### Mode Use the `mode` prop to change the mode of the sidebar menu on mobile. Defaults to `slideover`. +::component-example +--- +collapse: true +name: 'sidebar-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +options: + - name: 'mode' + label: 'mode' + default: 'slideover' + items: + - modal + - slideover + - drawer +--- +:: + ::tip{to="#props"} You can use the `menu` prop to customize the menu of the sidebar, it will adapt depending on the mode you choose. :: @@ -154,6 +257,25 @@ overflowHidden: true In this example, leveraging [`defineShortcuts`](/docs/composables/define-shortcuts), you can toggle the open state of the Sidebar by pressing :kbd{value="O"}. :: +### Persist open state + +Use [`useLocalStorage`](https://vueuse.org/core/useLocalStorage/) from VueUse or [`useCookie`](https://nuxt.com/docs/4.x/api/composables/use-cookie) instead of `ref` to persist the sidebar state across page reloads. + +::component-example +--- +name: 'sidebar-persist-example' +class: '!p-0 !justify-start' +iframe: + height: 500px; +iframeMobile: true +overflowHidden: true +--- +:: + +::note +The only difference with the previous example is replacing `ref(true)` with `useLocalStorage('sidebar-open', true)`. +:: + ## API ### Props diff --git a/playgrounds/nuxt/app/pages/components/sidebar.vue b/playgrounds/nuxt/app/pages/components/sidebar.vue index 97b44965d4..53ca8ac47b 100644 --- a/playgrounds/nuxt/app/pages/components/sidebar.vue +++ b/playgrounds/nuxt/app/pages/components/sidebar.vue @@ -27,21 +27,23 @@ function onSubmit() { diff --git a/src/runtime/components/Sidebar.vue b/src/runtime/components/Sidebar.vue index 86f233c441..b686bc9a4e 100644 --- a/src/runtime/components/Sidebar.vue +++ b/src/runtime/components/Sidebar.vue @@ -6,10 +6,16 @@ import type { ComponentConfig } from '../types/tv' type Sidebar = ComponentConfig +type SidebarState = 'expanded' | 'collapsed' type SidebarMode = 'modal' | 'slideover' | 'drawer' type SidebarMenu = T extends 'modal' ? ModalProps : T extends 'slideover' ? SlideoverProps : T extends 'drawer' ? DrawerProps : never export interface SidebarProps { + /** + * The element or component this component should render as. + * @defaultValue 'aside' + */ + as?: any /** * The visual variant of the sidebar. * @defaultValue 'sidebar' @@ -58,34 +64,25 @@ export interface SidebarProps { * The props for the sidebar menu component on mobile. */ menu?: SidebarMenu - /** - * The width of the sidebar. - * @defaultValue '16rem' - */ - width?: string - /** - * The width of the sidebar when collapsed to icon mode. - * @defaultValue '3rem' - */ - iconWidth?: string class?: any ui?: Sidebar['slots'] } export interface SidebarSlots { - header(props: { state: 'expanded' | 'collapsed', open: boolean, close: () => void }): any + header(props: { state: SidebarState, open: boolean, close: () => void }): any title(props?: {}): any description(props?: {}): any close(props: { ui: Sidebar['ui'] }): any - body(props: { state: 'expanded' | 'collapsed', open: boolean, close: () => void }): any - default(props: { state: 'expanded' | 'collapsed', open: boolean, close: () => void }): any - footer(props: { state: 'expanded' | 'collapsed', open: boolean, close: () => void }): any + body(props: { state: SidebarState, open: boolean, close: () => void }): any + default(props: { state: SidebarState, open: boolean, close: () => void }): any + footer(props: { state: SidebarState, open: boolean, close: () => void }): any content(props: { close: () => void }): any } diff --git a/src/theme/sidebar.ts b/src/theme/sidebar.ts index e43f0a078b..4668c7af2e 100644 --- a/src/theme/sidebar.ts +++ b/src/theme/sidebar.ts @@ -1,16 +1,16 @@ export default { slots: { - root: 'group/sidebar peer text-default hidden lg:block', + root: 'group/sidebar peer text-default hidden lg:block [--sidebar-width:28rem] [--sidebar-width-icon:4rem]', gap: 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', container: 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear lg:flex', - inner: 'flex size-full flex-col overflow-hidden bg-default', - header: 'flex items-center gap-1.5 p-4', + inner: 'flex size-full flex-col overflow-hidden bg-default divide-y divide-default', + header: 'flex items-center gap-1.5 p-4 sm:px-6 min-h-16', wrapper: '', title: 'text-highlighted font-semibold', description: 'mt-1 text-muted text-sm', - close: 'ms-auto', - body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-2', - footer: 'flex flex-col gap-2 p-4', + close: 'absolute top-4 end-4', + body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 sm:p-6', + footer: 'flex items-center gap-1.5 p-4 sm:px-6', overlay: 'lg:hidden' }, variants: { @@ -39,11 +39,11 @@ export default { variant: { sidebar: {}, floating: { - container: 'p-2', + container: 'p-2 border-0', inner: 'rounded-lg border border-default shadow-sm' }, inset: { - container: 'p-2', + container: 'p-2 border-0', inner: 'rounded-lg' } } diff --git a/test/components/Sidebar.spec.ts b/test/components/Sidebar.spec.ts index 7297415be6..fa2e4ea058 100644 --- a/test/components/Sidebar.spec.ts +++ b/test/components/Sidebar.spec.ts @@ -18,12 +18,10 @@ describe('Sidebar', () => { ['with side right', { props: { side: 'right' as const } }], ['with title', { props: { title: 'Sidebar Title' } }], ['with description', { props: { title: 'Sidebar Title', description: 'Sidebar Description' } }], - ['with close', { props: { title: 'Sidebar Title', close: true } }], + ['with close', { props: { title: 'Sidebar Title', close: true, collapsible: 'icon' as const } }], ['with mode modal', { props: { mode: 'modal' as const, menu: { portal: false } } }], ['with mode slideover', { props: { mode: 'slideover' as const, menu: { portal: false } } }], ['with mode drawer', { props: { mode: 'drawer' as const, menu: { portal: false } } }], - ['with width', { props: { width: '20rem' } }], - ['with iconWidth', { props: { iconWidth: '4rem' } }], ['with collapsed offcanvas', { props: { open: false, collapsible: 'offcanvas' as const } }], ['with collapsed icon', { props: { open: false, collapsible: 'icon' as const } }], ['with class', { props: { class: 'custom-class' } }], diff --git a/test/components/__snapshots__/Sidebar-vue.spec.ts.snap b/test/components/__snapshots__/Sidebar-vue.spec.ts.snap index 2cad13f0d8..45d2516139 100644 --- a/test/components/__snapshots__/Sidebar-vue.spec.ts.snap +++ b/test/components/__snapshots__/Sidebar-vue.spec.ts.snap @@ -1,489 +1,389 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Sidebar > renders with body slot correctly 1`] = ` -"