Sidebar
Sidebar is one persistent native <aside> for application navigation. It remembers whether the user left it open, keeps closed links out of the focus order, and exposes a small imperative handle for an application-owned trigger.
The application still writes every <nav>, list, real <a> or Boring Stack <Link>, current-page marker, logo, menu, permission check, and Tailwind class. There is no item schema, router adapter, provider, collapse icon, breakpoint, visual variant, or application-shell package.
AppShell.vue
<script setup>
import { ref } from 'vue'
import AppNavigation from '@/components/AppNavigation.vue'
import Button from '@/components/ui/button/Button.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import Sidebar from '@/components/ui/sidebar/Sidebar.vue'
const sidebar = ref()
const sheet = ref()
const desktopOpen = ref()
</script>
<div class="flex h-screen overflow-hidden">
<Sidebar
id="primary-navigation"
ref="sidebar"
aria-label="Project navigation"
class="hidden w-56 border-r data-[state=closed]:w-0 md:block"
@update:open="desktopOpen = $event"
>
<AppNavigation />
</Sidebar>
<main id="main-content" class="min-w-0 flex-1">
<Button
commandfor="mobile-navigation"
command="show-modal"
class="md:hidden"
>
Open navigation
</Button>
<Button
type="button"
class="hidden md:inline-flex"
aria-controls="primary-navigation"
:aria-expanded="String(desktopOpen)"
@click="sidebar.toggle()"
>
{{ desktopOpen ? 'Hide navigation' : 'Show navigation' }}
</Button>
<slot />
</main>
<Sheet
id="mobile-navigation"
ref="sheet"
aria-label="Project navigation"
class="right-auto left-0 mr-auto ml-0 w-72 -translate-x-full border-r border-l-0 open:translate-x-0 starting:open:-translate-x-full md:hidden"
>
<AppNavigation @navigate="sheet.close()" />
</Sheet>
</div>
Installation
One command detects Vue, React, or Svelte and copies the matching one-file source into the conventional component directory:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add sidebarpnpm dlx klean-ui add sidebaryarn dlx klean-ui add sidebarbunx klean-ui add sidebar- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
There is no initializer, klean-ui.json, provider, navigation configuration, class helper, barrel file, or Klean runtime.
Sidebar or Sheet?
The two components solve different semantic problems. Do not make one pretend to be both.
| Surface | Use | Browser relationship |
|---|---|---|
| Sidebar | Persistent navigation that occupies desktop layout space | Ordinary non-modal <aside> |
| Sheet | Temporary mobile navigation over the current page | Native modal <dialog> |
The desktop Sidebar may be open or closed by preference. The mobile Sheet is temporary and closes after navigation. Keeping them separate lets the browser own modal focus containment, Escape, background inertness, and focus return without burdening the desktop landmark with dialog behavior.
Usage
The component does not manufacture navigation items. Write honest links directly and connect the external trigger with aria-controls and aria-expanded.
Vue
<script setup>
import { Link } from '@inertiajs/vue3'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Sidebar from '@/components/ui/sidebar/Sidebar.vue'
const sidebar = ref()
const open = ref()
</script>
<div class="flex h-screen overflow-hidden">
<Sidebar
id="primary-navigation"
ref="sidebar"
aria-label="Project navigation"
class="w-56 border-r data-[state=closed]:w-0 data-[state=closed]:opacity-0"
@update:open="open = $event"
>
<nav aria-label="Workspace" class="w-56 p-3">
<Link href="/" aria-current="page" class="block min-h-11 px-3 py-3">
Projects
</Link>
<Link href="/lookout" class="block min-h-11 px-3 py-3">
Lookout
</Link>
</nav>
</Sidebar>
<main class="min-w-0 flex-1">
<Button
type="button"
aria-controls="primary-navigation"
:aria-expanded="String(open)"
@click="sidebar.toggle()"
>
{{ open ? 'Hide navigation' : 'Show navigation' }}
</Button>
</main>
</div>
React
import { Link } from '@inertiajs/react'
import { useRef, useState } from 'react'
import Sidebar from '@/components/ui/sidebar/Sidebar.jsx'
export default function AppShell() {
const sidebar = useRef(null)
const [open, setOpen] = useState()
return (
<div className="flex h-screen overflow-hidden">
<Sidebar
ref={sidebar}
id="primary-navigation"
aria-label="Project navigation"
onOpenChange={setOpen}
className="w-56 border-r data-[state=closed]:w-0 data-[state=closed]:opacity-0"
>
<nav aria-label="Workspace" className="w-56 p-3">
<Link
href="/"
aria-current="page"
className="block min-h-11 px-3 py-3"
>
Projects
</Link>
<Link href="/lookout" className="block min-h-11 px-3 py-3">
Lookout
</Link>
</nav>
</Sidebar>
<main className="min-w-0 flex-1">
<button
type="button"
aria-controls="primary-navigation"
aria-expanded={Boolean(open)}
onClick={() => sidebar.current?.toggle()}
>
{open ? 'Hide navigation' : 'Show navigation'}
</button>
</main>
</div>
)
}
Svelte
<script>
import { Link } from '@inertiajs/svelte'
import Sidebar from '@/components/ui/sidebar/Sidebar.svelte'
let sidebar
let open
</script>
<div class="flex h-screen overflow-hidden">
<Sidebar
bind:this={sidebar}
bind:open
id="primary-navigation"
aria-label="Project navigation"
class="w-56 border-r data-[state=closed]:w-0 data-[state=closed]:opacity-0"
>
<nav aria-label="Workspace" class="w-56 p-3">
<Link href="/" aria-current="page" class="block min-h-11 px-3 py-3">
Projects
</Link>
<Link href="/lookout" class="block min-h-11 px-3 py-3">Lookout</Link>
</nav>
</Sidebar>
<main class="min-w-0 flex-1">
<button
type="button"
aria-controls="primary-navigation"
aria-expanded={Boolean(open)}
onclick={() => sidebar?.toggle()}
>
{open ? 'Hide navigation' : 'Show navigation'}
</button>
</main>
</div>
API
| Purpose | Vue | React | Svelte | Default |
|---|---|---|---|---|
| Stable landmark and memory namespace | id | id | id | app-sidebar |
| Open state | v-model:open | open, onOpenChange | bind:open | uncontrolled |
| Initial state | default-open | defaultOpen | defaultOpen | true |
| Remember the choice | remember | remember | remember | true |
| Styling | class | className | class | structural motion only |
| Actions | component ref | forwarded ref | component binding | show(), hide(), toggle() |
The content slot, render function, or snippet also receives { open, show, hide, toggle }. That is useful for an internal collapse control, but the closed Sidebar becomes inert; the application should keep at least one show/toggle button outside it.
Use a stable, unique id whenever an application has more than one shell, such as primary-navigation and bridge-navigation. That gives each Sidebar an independent remembered choice without another configuration prop.
Set remember={false} only when persistence would be dishonest: an embedded preview, test fixture, kiosk, or other intentionally transient shell.
Durable behavior
Sidebar remembers the last desktop choice across visits and keeps open application tabs in agreement. A controlled caller remains authoritative, malformed or unavailable browser memory does not break navigation, and server rendering never assumes a browser exists.
The restored value is reported through the framework-native binding callback. Initializing a bound value as undefined lets Sidebar restore its remembered state and gives the parent the correct value for the trigger's aria-expanded. Passing an explicit boolean makes the caller authoritative from the first render.
Closing sets data-state="closed", aria-hidden="true", and inert on the aside. Caller Tailwind then collapses its width and opacity. This matters: visually hiding a rail while leaving its links keyboard-focusable would be a regression.
Back/Forward navigation and route changes do not rewrite the desktop preference. The destination remains durable because every item is still a real link; Sidebar never turns navigation into button state.
Responsive application shell
Extract the app-owned navigation links into a local AppNavigation component, then compose it into Sidebar and Sheet. That shares routes, active state, authorization, labels, and product styling without making Klean accept a navigation-data schema.
<script setup>
import { ref } from 'vue'
import AppNavigation from '@/components/AppNavigation.vue'
import Button from '@/components/ui/button/Button.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import Sidebar from '@/components/ui/sidebar/Sidebar.vue'
const sidebar = ref()
const sheet = ref()
const desktopOpen = ref()
</script>
<div class="flex h-screen overflow-hidden">
<Sidebar
id="primary-navigation"
ref="sidebar"
aria-label="Project navigation"
class="hidden w-56 border-r data-[state=closed]:w-0 md:block"
@update:open="desktopOpen = $event"
>
<AppNavigation />
</Sidebar>
<main id="main-content" class="min-w-0 flex-1">
<Button
commandfor="mobile-navigation"
command="show-modal"
class="md:hidden"
>
Open navigation
</Button>
<Button
type="button"
class="hidden md:inline-flex"
aria-controls="primary-navigation"
:aria-expanded="String(desktopOpen)"
@click="sidebar.toggle()"
>
{{ desktopOpen ? 'Hide navigation' : 'Show navigation' }}
</Button>
<slot />
</main>
<Sheet
id="mobile-navigation"
ref="sheet"
aria-label="Project navigation"
class="right-auto left-0 mr-auto ml-0 w-72 -translate-x-full border-r border-l-0 open:translate-x-0 starting:open:-translate-x-full md:hidden"
>
<AppNavigation @navigate="sheet.close()" />
</Sheet>
</div>
Desktop and mobile may need different surrounding headers or close controls. Keep those surface-specific elements outside AppNavigation; the navigation component should contain the truthful destinations shared by both.
Link and current-page semantics
Use a real <a href> for document navigation or the framework-native Boring Stack Link for an Inertia visit. Mark only the current destination with aria-current="page". Sidebar does not infer the route because applications differ on nested resources, query-backed workspaces, permissions, and what counts as a current section.
Buttons inside the Sidebar are for real actions such as switching a team or signing out. Do not use a button for a destination and do not give ordinary navigation links tab roles.
Accessibility
- Give the aside an accessible label such as
aria-label="Project navigation"when the page contains more than one complementary landmark. - Put a labeled
<nav>and a list of destinations inside it. Sidebar is the shell landmark, not a replacement for navigation semantics. - Keep the trigger outside the collapsible aside and connect it with
aria-controlsandaria-expanded. - Include a skip link before a dense application shell and give the main landmark a stable target.
- Use visible focus, ordinary link activation, and
aria-current="page"; do not add a custom arrow-key navigation model to a standard list of links. - The neutral width/opacity transition respects reduced motion. Caller-added motion must do the same.
- Use Sheet for narrow-screen modal navigation so the browser owns focus containment, Escape, background inertness, scroll locking, and invoker focus return.
Styling with Tailwind
Sidebar supplies only structural overflow and a short width/opacity transition. The caller supplies the actual open width and closed treatment:
class="w-56 border-r bg-gray-50 data-[state=closed]:w-0
data-[state=closed]:opacity-0"tailwind-merge lets later caller classes replace timing, easing, width, opacity, or overflow. Use ordinary responsive utilities such as hidden md:block; there is no side, size, density, tone, collapsedWidth, or visual variant prop.
An icon-only rail is not the same closed state because its links remain available. Keep an icon rail as an explicit application recipe with accessible names and tooltips rather than forcing it through Sidebar's inert closed contract.
When not to use
- Use ordinary
<aside>markup when the region never opens, closes, or remembers a choice. - Use Sheet for modal mobile navigation or another temporary edge surface.
- Use Tabs for peer sections within the current page or route, not global application hierarchy.
- Use Menu for a compact set of actions or destinations opened from one trigger.
- Do not use Sidebar as a router, authorization layer, nested-tree state machine, resizable panel, or universal page layout.
Complete framework source
Copy, inspect, and change the complete one-file source for your framework.
Vue source
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** Stable landmark id and zero-configuration persistence namespace. */
id: { type: String, default: 'app-sidebar' },
/** Framework-native controlled visibility. Omit for remembered state. */
open: { type: Boolean, default: undefined },
/** Initial visibility before a remembered choice exists. */
defaultOpen: { type: Boolean, default: true },
/** Remember this desktop choice across visits and application tabs. */
remember: { type: Boolean, default: true }
})
const emit = defineEmits(['update:open'])
const attrs = useAttrs()
const root = ref()
const internalOpen = ref(props.defaultOpen)
const restored = ref(false)
const controlled = computed(() => props.open !== undefined)
const visible = computed(() =>
controlled.value ? props.open : internalOpen.value
)
const storageKey = computed(() => `klean:sidebar:${props.id}:open`)
const rootAttrs = computed(() => {
const {
class: _class,
'data-slot': _dataSlot,
'data-state': _dataState,
'data-restored': _dataRestored,
'aria-hidden': _ariaHidden,
inert: _inert,
...rest
} = attrs
return rest
})
const rootClasses = computed(() =>
twMerge(
'min-w-0 shrink-0 overflow-hidden transition-[width,opacity] duration-200 ease-out motion-reduce:transition-none',
attrs.class
)
)
function readRemembered() {
if (!props.remember || typeof window === 'undefined') return undefined
try {
const value = window.localStorage.getItem(storageKey.value)
if (value === 'true') return true
if (value === 'false') return false
} catch {
// Storage may be unavailable in private or constrained browser contexts.
}
return undefined
}
function rememberVisibility(next) {
if (!props.remember || typeof window === 'undefined') return
try {
window.localStorage.setItem(storageKey.value, String(next))
} catch {
// Visibility still works when persistence is unavailable.
}
}
function setOpen(next) {
const normalized = Boolean(next)
if (!controlled.value) internalOpen.value = normalized
emit('update:open', normalized)
rememberVisibility(normalized)
}
function show() {
setOpen(true)
}
function hide() {
setOpen(false)
}
function toggle() {
setOpen(!visible.value)
}
function restore() {
if (controlled.value) {
rememberVisibility(visible.value)
return
}
const remembered = readRemembered()
const next = remembered ?? props.defaultOpen
internalOpen.value = next
emit('update:open', next)
}
function handleStorage(event) {
if (
!props.remember ||
event.storageArea !== window.localStorage ||
event.key !== storageKey.value
) {
return
}
if (event.newValue === 'true') {
if (!controlled.value) internalOpen.value = true
emit('update:open', true)
}
if (event.newValue === 'false') {
if (!controlled.value) internalOpen.value = false
emit('update:open', false)
}
}
onMounted(() => {
restore()
restored.value = true
window.addEventListener('storage', handleStorage)
})
onBeforeUnmount(() => {
window.removeEventListener('storage', handleStorage)
})
watch(
() => props.open,
(next) => {
if (restored.value && next !== undefined) rememberVisibility(next)
}
)
watch(
() => [props.id, props.remember],
() => {
if (restored.value) restore()
}
)
defineExpose({ root, show, hide, toggle })
</script>
<template>
<aside
ref="root"
v-bind="rootAttrs"
:id="props.id"
data-slot="sidebar"
:data-state="visible ? 'open' : 'closed'"
:data-restored="restored ? 'true' : 'false'"
:aria-hidden="visible ? undefined : 'true'"
:inert="visible ? undefined : ''"
:class="rootClasses"
>
<slot :open="visible" :show="show" :hide="hide" :toggle="toggle" />
</aside>
</template>
React source
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react'
import { twMerge } from 'tailwind-merge'
const Sidebar = forwardRef(function Sidebar(
{
id = 'app-sidebar',
open,
defaultOpen = true,
remember = true,
onOpenChange,
className,
children,
'data-slot': _dataSlot,
'data-state': _dataState,
'data-restored': _dataRestored,
'aria-hidden': _ariaHidden,
inert: _inert,
...props
},
forwardedRef
) {
const rootRef = useRef(null)
const onOpenChangeRef = useRef(onOpenChange)
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const [restored, setRestored] = useState(false)
const controlled = open !== undefined
const visible = controlled ? open : internalOpen
const storageKey = `klean:sidebar:${id}:open`
useEffect(() => {
onOpenChangeRef.current = onOpenChange
}, [onOpenChange])
const rememberVisibility = useCallback(
(next) => {
if (!remember || typeof window === 'undefined') return
try {
window.localStorage.setItem(storageKey, String(next))
} catch {
// Visibility still works when persistence is unavailable.
}
},
[remember, storageKey]
)
const setOpen = useCallback(
(next) => {
const normalized = Boolean(next)
if (!controlled) setInternalOpen(normalized)
onOpenChangeRef.current?.(normalized)
rememberVisibility(normalized)
},
[controlled, rememberVisibility]
)
const show = useCallback(() => setOpen(true), [setOpen])
const hide = useCallback(() => setOpen(false), [setOpen])
const toggle = useCallback(() => setOpen(!visible), [setOpen, visible])
useImperativeHandle(forwardedRef, () => ({
root: rootRef.current,
show,
hide,
toggle
}))
useEffect(() => {
if (!controlled && remember) {
try {
const value = window.localStorage.getItem(storageKey)
const next =
value === 'true' ? true : value === 'false' ? false : defaultOpen
setInternalOpen(next)
onOpenChangeRef.current?.(next)
} catch {
// The supplied default remains authoritative without storage.
onOpenChangeRef.current?.(defaultOpen)
}
}
setRestored(true)
function handleStorage(event) {
if (
!remember ||
event.storageArea !== window.localStorage ||
event.key !== storageKey
) {
return
}
if (event.newValue === 'true') {
if (!controlled) setInternalOpen(true)
onOpenChangeRef.current?.(true)
}
if (event.newValue === 'false') {
if (!controlled) setInternalOpen(false)
onOpenChangeRef.current?.(false)
}
}
window.addEventListener('storage', handleStorage)
return () => window.removeEventListener('storage', handleStorage)
}, [controlled, defaultOpen, remember, storageKey])
useEffect(() => {
if (restored && controlled) rememberVisibility(visible)
}, [controlled, rememberVisibility, restored, visible])
const api = { open: visible, show, hide, toggle }
return (
<aside
{...props}
ref={rootRef}
id={id}
data-slot="sidebar"
data-state={visible ? 'open' : 'closed'}
data-restored={restored ? 'true' : 'false'}
aria-hidden={visible ? undefined : 'true'}
inert={visible ? undefined : true}
className={twMerge(
'min-w-0 shrink-0 overflow-hidden transition-[width,opacity] duration-200 ease-out motion-reduce:transition-none',
className
)}
>
{typeof children === 'function' ? children(api) : children}
</aside>
)
})
export default Sidebar
Svelte source
<script>
import { onMount } from "svelte";
import { twMerge } from "tailwind-merge";
let {
id = "app-sidebar",
open = $bindable(),
defaultOpen = true,
remember = true,
onopenchange,
class: className,
children,
"data-slot": _dataSlot,
"data-state": _dataState,
"data-restored": _dataRestored,
"aria-hidden": _ariaHidden,
inert: _inert,
...props
} = $props();
function initialVisibility() {
return defaultOpen;
}
let root = $state();
const controlled = open !== undefined;
let internalOpen = $state(initialVisibility());
let restored = $state(false);
let visible = $derived(open === undefined ? internalOpen : open);
let storageKey = $derived(`klean:sidebar:${id}:open`);
function rememberVisibility(next) {
if (!remember || typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, String(next));
} catch {
// Visibility still works when persistence is unavailable.
}
}
function setOpen(next, notify = true) {
const normalized = Boolean(next);
internalOpen = normalized;
open = normalized;
if (notify) onopenchange?.(normalized);
rememberVisibility(normalized);
}
export function show() {
setOpen(true);
}
export function hide() {
setOpen(false);
}
export function toggle() {
setOpen(!visible);
}
export function getRoot() {
return root;
}
onMount(() => {
if (open === undefined && remember) {
try {
const value = window.localStorage.getItem(storageKey);
const next =
value === "true" ? true : value === "false" ? false : defaultOpen;
internalOpen = next;
open = next;
onopenchange?.(next);
} catch {
// The supplied default remains authoritative without storage.
onopenchange?.(defaultOpen);
}
} else if (open !== undefined) {
rememberVisibility(open);
}
restored = true;
function handleStorage(event) {
if (
!remember ||
event.storageArea !== window.localStorage ||
event.key !== storageKey
) {
return;
}
if (event.newValue === "true") {
if (!controlled) internalOpen = true;
open = true;
onopenchange?.(true);
}
if (event.newValue === "false") {
if (!controlled) internalOpen = false;
open = false;
onopenchange?.(false);
}
}
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
});
$effect(() => {
if (restored) rememberVisibility(visible);
});
let api = {
get open() {
return visible;
},
show,
hide,
toggle,
};
</script>
<aside
{...props}
bind:this={root}
{id}
data-slot="sidebar"
data-state={visible ? "open" : "closed"}
data-restored={restored ? "true" : "false"}
aria-hidden={visible ? undefined : "true"}
inert={visible ? undefined : ""}
class={twMerge(
"min-w-0 shrink-0 overflow-hidden transition-[width,opacity] duration-200 ease-out motion-reduce:transition-none",
className,
)}
>
{@render children?.(api)}
</aside>
Related components
- Sheet — presents the same app-owned navigation as a native modal on narrow screens.
- Button — supplies the external open/close trigger and honest action controls.
- Menu — handles team, account, and contextual actions inside the shell.
- Avatar — renders resilient team or account identity without becoming navigation.
- Tooltip — names evidenced icon-only rail controls when an application keeps them visible.
- Breadcrumb — represents the current location inside the hierarchy.
- Tabs — handles peer page sections or route destinations below the application shell.