Installation
Install the dependencies
pnpm add cn class-variance-authority github:sveltery/baseCopy the component
Copy the following files into src/lib/components/ui/sidebar.
It imports the button, input, separator, sheet, skeleton and tooltip components, so copy their folders too.
<script lang="ts">
import { cn } from 'cn';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../sheet/index.js';
import { useSidebar } from './context.js';
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
import type { SidebarProps } from './types.js';
let { side = 'left', variant = 'sidebar', collapsible = 'offcanvas', children, class: classProp, dir, ...props }: SidebarProps = $props();
const sidebar = useSidebar();
const floatingOrInset = $derived(variant === 'floating' || variant === 'inset');
</script>
{#if collapsible === 'none'}
<div data-slot="sidebar" {...props} class={cn('flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', classProp)}>
{@render children?.()}
</div>
{:else if sidebar.isMobile}
<Sheet open={sidebar.openMobile} onOpenChange={sidebar.setOpenMobile}>
<SheetContent {dir} data-sidebar="sidebar" data-slot="sidebar" data-mobile="true" {side} class="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE}">
<SheetHeader class="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div class="flex h-full w-full flex-col">{@render children?.()}</div>
</SheetContent>
</Sheet>
{:else}
<div class="group peer hidden text-sidebar-foreground md:block" data-state={sidebar.state} data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''} data-variant={variant} data-side={side} data-slot="sidebar">
<div
data-slot="sidebar-gap"
class={cn(
'transition-[width] duration-200 ease-linear relative w-(--sidebar-width) bg-transparent group-data-[collapsible=offcanvas]:w-0 group-data-[side=right]:rotate-180',
floatingOrInset ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
></div>
<div
data-slot="sidebar-container"
data-side={side}
{...props}
class={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
floatingOrInset ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
classProp,
)}
>
<div data-sidebar="sidebar" data-slot="sidebar-inner" class="bg-sidebar group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col">
{@render children?.()}
</div>
</div>
</div>
{/if}<script lang="ts">
import { cn } from 'cn';
import type { SidebarContentProps } from './types.js';
let { children, class: classProp, ...props }: SidebarContentProps = $props();
</script>
<div data-slot="sidebar-content" data-sidebar="content" {...props} class={cn('no-scrollbar gap-0 flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden', classProp)}>
{@render children?.()}
</div><script lang="ts">
import { cn } from 'cn';
import type { SidebarFooterProps } from './types.js';
let { children, class: classProp, ...props }: SidebarFooterProps = $props();
</script>
<div data-slot="sidebar-footer" data-sidebar="footer" {...props} class={cn('gap-2 p-2 flex flex-col', classProp)}>
{@render children?.()}
</div><script lang="ts">
import { cn } from 'cn';
import type { SidebarGroupProps } from './types.js';
let { children, class: classProp, ...props }: SidebarGroupProps = $props();
</script>
<div data-slot="sidebar-group" data-sidebar="group" {...props} class={cn('p-2 relative flex w-full min-w-0 flex-col', classProp)}>
{@render children?.()}
</div><script lang="ts">
import { cn } from 'cn';
import type { SidebarGroupActionProps, SidebarGroupActionState } from './types.js';
let { children, class: classProp, render, ...props }: SidebarGroupActionProps = $props();
const state: SidebarGroupActionState = { slot: 'sidebar-group-action', sidebar: 'group-action' };
const hostProps = $derived({ 'data-slot': state.slot, 'data-sidebar': state.sidebar, ...props, class: cn('text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-2 [&>svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0', classProp) });
</script>
{#if render}
{@render render(hostProps, state, children)}
{:else}
<button {...hostProps}>{@render children?.()}</button>
{/if}<script lang="ts">
import { cn } from 'cn';
import type { SidebarGroupContentProps } from './types.js';
let { children, class: classProp, ...props }: SidebarGroupContentProps = $props();
</script>
<div data-slot="sidebar-group-content" data-sidebar="group-content" {...props} class={cn('text-sm w-full', classProp)}>
{@render children?.()}
</div><script lang="ts">
import { cn } from 'cn';
import type { SidebarGroupLabelProps, SidebarGroupLabelState } from './types.js';
let { children, class: classProp, render, ...props }: SidebarGroupLabelProps = $props();
const state: SidebarGroupLabelState = { slot: 'sidebar-group-label', sidebar: 'group-label' };
const hostProps = $derived({ 'data-slot': state.slot, 'data-sidebar': state.sidebar, ...props, class: cn('text-sidebar-foreground/70 ring-sidebar-ring h-8 rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0', classProp) });
</script>
{#if render}
{@render render(hostProps, state, children)}
{:else}
<div {...hostProps}>{@render children?.()}</div>
{/if}<script lang="ts">
import { cn } from 'cn';
import type { SidebarHeaderProps } from './types.js';
let { children, class: classProp, ...props }: SidebarHeaderProps = $props();
</script>
<div data-slot="sidebar-header" data-sidebar="header" {...props} class={cn('gap-2 p-2 flex flex-col', classProp)}>
{@render children?.()}
</div><script lang="ts">
import { cn } from 'cn';
import { Input } from '../input/index.js';
import type { SidebarInputProps } from './types.js';
let { class: classProp, value = $bindable(), ...props }: SidebarInputProps = $props();
</script>
<Input data-slot="sidebar-input" data-sidebar="input" {...props} class={cn('bg-background h-8 w-full shadow-none', classProp)} bind:value /><script lang="ts">
import { cn } from 'cn';
import type { SidebarInsetProps } from './types.js';
let { children, class: classProp, ...props }: SidebarInsetProps = $props();
</script>
<main data-slot="sidebar-inset" {...props} class={cn('bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex w-full flex-1 flex-col', classProp)}>
{@render children?.()}
</main><script lang="ts">
import { cn } from 'cn';
import type { SidebarMenuProps } from './types.js';
let { children, class: classProp, ...props }: SidebarMenuProps = $props();
</script>
<ul data-slot="sidebar-menu" data-sidebar="menu" {...props} class={cn('gap-0 flex w-full min-w-0 flex-col', classProp)}>
{@render children?.()}
</ul><script lang="ts">
import { cn } from 'cn';
import type { SidebarMenuActionProps, SidebarMenuActionState } from './types.js';
let { children, class: classProp, render, showOnHover = false, ...props }: SidebarMenuActionProps = $props();
const state: SidebarMenuActionState = { slot: 'sidebar-menu-action', sidebar: 'menu-action' };
const hostProps = $derived({
'data-slot': state.slot,
'data-sidebar': state.sidebar,
...props,
class: cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 aspect-square w-5 rounded-md p-0 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 focus-visible:ring-2 [&>svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0',
showOnHover && 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
classProp,
),
});
</script>
{#if render}
{@render render(hostProps, state, children)}
{:else}
<button {...hostProps}>{@render children?.()}</button>
{/if}<script lang="ts">
import { cn } from 'cn';
import type { SidebarMenuBadgeProps } from './types.js';
let { children, class: classProp, ...props }: SidebarMenuBadgeProps = $props();
</script>
<div data-slot="sidebar-menu-badge" data-sidebar="menu-badge" {...props} class={cn('text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 h-5 min-w-5 rounded-md px-1 text-xs font-medium peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 flex items-center justify-center tabular-nums select-none group-data-[collapsible=icon]:hidden', classProp)}>
{@render children?.()}
</div><script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
import type { TooltipTriggerState } from '@sveltery/base/tooltip';
import { cn } from 'cn';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip/index.js';
import { useSidebar } from './context.js';
import { sidebarMenuButtonVariants } from './variants.js';
import type { SidebarMenuButtonProps, SidebarMenuButtonState } from './types.js';
let { children, class: classProp, render, isActive = false, variant = 'default', size = 'default', tooltip, ...props }: SidebarMenuButtonProps = $props();
const sidebar = useSidebar();
const state: SidebarMenuButtonState = $derived({ slot: 'sidebar-menu-button', sidebar: 'menu-button', size, active: isActive });
const hostProps: HTMLButtonAttributes = $derived({
'data-slot': state.slot,
'data-sidebar': state.sidebar,
'data-size': state.size,
'data-active': isActive || undefined,
...props,
class: cn(sidebarMenuButtonVariants({ variant, size }), classProp),
});
const tooltipHidden = $derived(sidebar.state !== 'collapsed' || sidebar.isMobile);
</script>
{#snippet host(props: HTMLButtonAttributes, hostState: SidebarMenuButtonState | TooltipTriggerState, content?: Snippet)}
{#if render}
{@render render(props, hostState, content)}
{:else}
<button {...props}>{@render content?.()}</button>
{/if}
{/snippet}
{#if tooltip}
<Tooltip>
<TooltipTrigger {...hostProps} render={host} {children} />
{#if typeof tooltip === 'string'}
<TooltipContent side="right" align="center" hidden={tooltipHidden}>{tooltip}</TooltipContent>
{:else}
<TooltipContent side="right" align="center" hidden={tooltipHidden} {...tooltip} />
{/if}
</Tooltip>
{:else}
{@render host(hostProps, state, children)}
{/if}<script lang="ts">
import { cn } from 'cn';
import type { SidebarMenuItemProps } from './types.js';
let { children, class: classProp, ...props }: SidebarMenuItemProps = $props();
</script>
<li data-slot="sidebar-menu-item" data-sidebar="menu-item" {...props} class={cn('group/menu-item relative', classProp)}>
{@render children?.()}
</li><script lang="ts">
import { cn } from 'cn';
import { Skeleton } from '../skeleton/index.js';
import type { SidebarMenuSkeletonProps } from './types.js';
let { class: classProp, showIcon = false, ...props }: SidebarMenuSkeletonProps = $props();
const width = `${Math.floor(Math.random() * 40) + 50}%`;
</script>
<div data-slot="sidebar-menu-skeleton" data-sidebar="menu-skeleton" {...props} class={cn('h-8 gap-2 rounded-md px-2 flex items-center', classProp)}>
{#if showIcon}
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
{/if}
<Skeleton class="h-4 max-w-(--skeleton-width) flex-1" data-sidebar="menu-skeleton-text" style="--skeleton-width: {width}" />
</div><script lang="ts">
import { cn } from 'cn';
import type { SidebarMenuSubProps } from './types.js';
let { children, class: classProp, ...props }: SidebarMenuSubProps = $props();
</script>
<ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" {...props} class={cn('border-sidebar-border mx-3.5 translate-x-px gap-1 border-l px-2.5 py-0.5 flex min-w-0 flex-col group-data-[collapsible=icon]:hidden', classProp)}>
{@render children?.()}
</ul><script lang="ts">
import { cn } from 'cn';
import type { SidebarMenuSubButtonProps, SidebarMenuSubButtonState } from './types.js';
let { children, class: classProp, render, size = 'md', isActive = false, ...props }: SidebarMenuSubButtonProps = $props();
const state: SidebarMenuSubButtonState = $derived({ slot: 'sidebar-menu-sub-button', sidebar: 'menu-sub-button', size, active: isActive });
const hostProps = $derived({
'data-slot': state.slot,
'data-sidebar': state.sidebar,
'data-size': size,
'data-active': isActive || undefined,
...props,
class: cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0',
classProp,
),
});
</script>
{#if render}
{@render render(hostProps, state, children)}
{:else}
<a {...hostProps}>{@render children?.()}</a>
{/if}<script lang="ts">
import { cn } from 'cn';
import type { SidebarMenuSubItemProps } from './types.js';
let { children, class: classProp, ...props }: SidebarMenuSubItemProps = $props();
</script>
<li data-slot="sidebar-menu-sub-item" data-sidebar="menu-sub-item" {...props} class={cn('group/menu-sub-item relative', classProp)}>
{@render children?.()}
</li><script lang="ts">
import { MediaQuery } from 'svelte/reactivity';
import { cn } from 'cn';
import { setSidebarContext } from './context.js';
import { SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './constants.js';
import type { SidebarProviderProps } from './types.js';
let { defaultOpen = true, open = $bindable(defaultOpen), onOpenChange, children, class: classProp, style, ...props }: SidebarProviderProps = $props();
const mobile = new MediaQuery('max-width: 767px');
let openMobile = $state(false);
function setOpen(value: boolean) {
open = value;
onOpenChange?.(value);
document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}
function toggleSidebar() {
if (mobile.current) openMobile = !openMobile;
else setOpen(!open);
}
function onkeydown(event: KeyboardEvent) {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
}
setSidebarContext({
get state() { return open ? 'expanded' : 'collapsed'; },
get open() { return open; },
setOpen,
get openMobile() { return openMobile; },
setOpenMobile: value => (openMobile = value),
get isMobile() { return mobile.current; },
toggleSidebar,
});
</script>
<svelte:window {onkeydown} />
<div data-slot="sidebar-wrapper" {...props} style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON};{style ? ` ${style}` : ''}" class={cn('group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', classProp)}>
{@render children?.()}
</div><script lang="ts">
import { cn } from 'cn';
import { useSidebar } from './context.js';
import type { SidebarRailProps } from './types.js';
let { class: classProp, ...props }: SidebarRailProps = $props();
const sidebar = useSidebar();
</script>
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabindex={-1}
onclick={sidebar.toggleSidebar}
title="Toggle Sidebar"
{...props}
class={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
classProp,
)}
></button><script lang="ts">
import { cn } from 'cn';
import { Separator } from '../separator/index.js';
import type { SidebarSeparatorProps } from './types.js';
let { class: classProp, ...props }: SidebarSeparatorProps = $props();
</script>
<Separator data-slot="sidebar-separator" data-sidebar="separator" {...props} class={cn('bg-sidebar-border mx-2 w-auto', classProp)} /><script lang="ts">
import { cn } from 'cn';
import { Button } from '../button/index.js';
import PanelLeftIcon from '@lucide/svelte/icons/panel-left';
import { useSidebar } from './context.js';
import type { SidebarTriggerProps } from './types.js';
let { class: classProp, onclick, ...props }: SidebarTriggerProps = $props();
const sidebar = useSidebar();
</script>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
{...props}
class={cn('', classProp)}
onclick={event => {
onclick?.(event);
sidebar.toggleSidebar();
}}
>
<PanelLeftIcon class="" />
<span class="sr-only">Toggle Sidebar</span>
</Button>export const SIDEBAR_COOKIE_NAME = 'sidebar_state';
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = '16rem';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '3rem';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';import { createContext } from 'svelte';
export type SidebarContextValue = {
readonly state: 'expanded' | 'collapsed';
readonly open: boolean;
setOpen: (open: boolean) => void;
readonly openMobile: boolean;
setOpenMobile: (open: boolean) => void;
readonly isMobile: boolean;
toggleSidebar: () => void;
};
export const [useSidebar, setSidebarContext] = createContext<SidebarContextValue>();import type { ComponentProps } from 'svelte';
import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
import type { RenderSnippet } from '../shared/render.js';
import type { ButtonProps } from '../button/index.js';
import type { InputProps } from '@sveltery/base/input';
import type { TooltipTriggerState } from '@sveltery/base/tooltip';
import type { SeparatorProps } from '../separator/index.js';
import type { TooltipContent } from '../tooltip/index.js';
import type { SidebarMenuButtonVariants } from './variants.js';
type DivProps = HTMLAttributes<HTMLDivElement>;
export type SidebarProviderProps = DivProps & {
/** Whether the sidebar starts open. Read the `sidebar_state` cookie on the server to restore the last choice. @default true */
defaultOpen?: boolean;
/** Whether the desktop sidebar is expanded. Use `bind:open` to control it. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export type SidebarProps = DivProps & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
};
export type SidebarTriggerProps = ButtonProps;
export type SidebarRailProps = HTMLButtonAttributes;
export type SidebarInsetProps = HTMLAttributes<HTMLElement>;
export type SidebarInputProps = InputProps;
export type SidebarHeaderProps = DivProps;
export type SidebarFooterProps = DivProps;
export type SidebarSeparatorProps = SeparatorProps;
export type SidebarContentProps = DivProps;
export type SidebarGroupProps = DivProps;
export type SidebarGroupContentProps = DivProps;
export type SidebarMenuProps = HTMLAttributes<HTMLUListElement>;
export type SidebarMenuItemProps = HTMLAttributes<HTMLLIElement>;
export type SidebarMenuBadgeProps = DivProps;
export type SidebarMenuSkeletonProps = DivProps & { showIcon?: boolean };
export type SidebarMenuSubProps = HTMLAttributes<HTMLUListElement>;
export type SidebarMenuSubItemProps = HTMLAttributes<HTMLLIElement>;
export type SidebarGroupLabelState = { slot: 'sidebar-group-label'; sidebar: 'group-label' };
export type SidebarGroupLabelProps = DivProps & { render?: RenderSnippet<SidebarGroupLabelState, HTMLDivElement> };
export type SidebarGroupActionState = { slot: 'sidebar-group-action'; sidebar: 'group-action' };
export type SidebarGroupActionProps = HTMLButtonAttributes & { render?: RenderSnippet<SidebarGroupActionState, HTMLButtonElement> };
export type SidebarMenuActionState = { slot: 'sidebar-menu-action'; sidebar: 'menu-action' };
export type SidebarMenuActionProps = HTMLButtonAttributes & { showOnHover?: boolean; render?: RenderSnippet<SidebarMenuActionState, HTMLButtonElement> };
export type SidebarMenuButtonState = { slot: 'sidebar-menu-button'; sidebar: 'menu-button'; size: SidebarMenuButtonVariants['size']; active: boolean };
export type SidebarMenuButtonProps = HTMLButtonAttributes &
SidebarMenuButtonVariants & {
isActive?: boolean;
/** Shown beside the button while the sidebar is collapsed to icons. */
tooltip?: string | ComponentProps<typeof TooltipContent>;
/** Receives the menu-button state, or the tooltip trigger state when `tooltip` is set. */
render?: RenderSnippet<SidebarMenuButtonState | TooltipTriggerState, HTMLButtonElement>;
};
export type SidebarMenuSubButtonState = { slot: 'sidebar-menu-sub-button'; sidebar: 'menu-sub-button'; size: 'sm' | 'md'; active: boolean };
export type SidebarMenuSubButtonProps = HTMLAnchorAttributes & { size?: 'sm' | 'md'; isActive?: boolean; render?: RenderSnippet<SidebarMenuSubButtonState, HTMLAnchorElement> };// Adapted from shadcn-ui/ui d75a96ab; MIT: packages/ui/THIRD_PARTY_NOTICES.md.
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
export const sidebarMenuButtonVariants = cva(
'ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-popup-open:hover:bg-sidebar-accent data-popup-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
{
variants: {
variant: { default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', outline: 'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]' },
size: { default: 'h-8 text-sm', sm: 'h-7 text-xs', lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!' },
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants>;import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
/**
* Replaces a part's default element, like the `render` snippets of Sveltery Base.
* Spread `props` onto the host element and render `children` inside it.
* `Host` is the element the part renders by default, so its typed event handlers carry over.
*/
export type RenderSnippet<State, Host extends HTMLElement = HTMLElement> = Snippet<[props: HTMLAttributes<Host>, state: State, children: Snippet | undefined]>;export { default as SidebarProvider } from './SidebarProvider.svelte';
export { default as Sidebar } from './Sidebar.svelte';
export { default as SidebarTrigger } from './SidebarTrigger.svelte';
export { default as SidebarRail } from './SidebarRail.svelte';
export { default as SidebarInset } from './SidebarInset.svelte';
export { default as SidebarInput } from './SidebarInput.svelte';
export { default as SidebarHeader } from './SidebarHeader.svelte';
export { default as SidebarFooter } from './SidebarFooter.svelte';
export { default as SidebarSeparator } from './SidebarSeparator.svelte';
export { default as SidebarContent } from './SidebarContent.svelte';
export { default as SidebarGroup } from './SidebarGroup.svelte';
export { default as SidebarGroupLabel } from './SidebarGroupLabel.svelte';
export { default as SidebarGroupAction } from './SidebarGroupAction.svelte';
export { default as SidebarGroupContent } from './SidebarGroupContent.svelte';
export { default as SidebarMenu } from './SidebarMenu.svelte';
export { default as SidebarMenuItem } from './SidebarMenuItem.svelte';
export { default as SidebarMenuButton } from './SidebarMenuButton.svelte';
export { default as SidebarMenuAction } from './SidebarMenuAction.svelte';
export { default as SidebarMenuBadge } from './SidebarMenuBadge.svelte';
export { default as SidebarMenuSkeleton } from './SidebarMenuSkeleton.svelte';
export { default as SidebarMenuSub } from './SidebarMenuSub.svelte';
export { default as SidebarMenuSubItem } from './SidebarMenuSubItem.svelte';
export { default as SidebarMenuSubButton } from './SidebarMenuSubButton.svelte';
export { useSidebar } from './context.js';
export type { SidebarContextValue } from './context.js';
export type * from './types.js';Update the import paths
Change the imports to match your project setup.
Usage
<script lang="ts">
import { Sidebar, SidebarContent, SidebarGroup, SidebarHeader, SidebarFooter, SidebarInset, SidebarProvider, SidebarTrigger } from '$lib/components/ui/sidebar';
</script>Wrap the page in SidebarProvider. Put the Sidebar and the page content, in SidebarInset, side by side inside it. Put a SidebarTrigger anywhere inside the provider.
<SidebarProvider>
<Sidebar>
<SidebarHeader />
<SidebarContent>
<SidebarGroup />
<SidebarGroup />
</SidebarContent>
<SidebarFooter />
</Sidebar>
<SidebarInset>
<SidebarTrigger />
{@render children?.()}
</SidebarInset>
</SidebarProvider>A menu is a SidebarMenu of SidebarMenuItems. Each item holds a SidebarMenuButton, which renders a <button>. To render a link, pass a render snippet, spread its props onto the element and render children. isActive marks the current page.
{#snippet link(props, state, children)}
<a href="/inbox" {...props}>{@render children?.()}</a>
{/snippet}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={link} isActive tooltip="Inbox">
<InboxIcon />
<span>Inbox</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>SidebarMenuAction, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton, SidebarMenuSkeleton, SidebarGroupLabel, SidebarGroupAction, SidebarInput, SidebarSeparator and SidebarRail follow the same anatomy as the upstream shadcn/ui sidebar. SidebarGroupLabel, SidebarGroupAction, SidebarMenuAction and SidebarMenuSubButton also take a render snippet.
Collapsible modes
Sidebar takes collapsible.
| Value | Behavior |
|---|---|
offcanvas | The sidebar slides out of view. This is the default |
icon | The sidebar shrinks to a strip of icons. SidebarMenuButton shows its tooltip beside the icon |
none | The sidebar cannot collapse |
<Sidebar collapsible="icon">…</Sidebar>side is left or right, and variant is sidebar, floating or inset. Use inset together with SidebarInset.
Below 768 px wide, the sidebar becomes a Sheet that SidebarTrigger opens. The offcanvas and icon modes behave the same there.
Controlled state
Bind open on SidebarProvider to read and set the desktop state. defaultOpen sets the initial state when you do not bind it, and onOpenChange runs on every change.
<script lang="ts">
let open = $state(true);
</script>
<SidebarProvider bind:open>…</SidebarProvider>Inside the provider, useSidebar() returns the live state. Read it during component initialization.
<script lang="ts">
import { useSidebar } from '$lib/components/ui/sidebar';
const sidebar = useSidebar();
</script>
<button onclick={sidebar.toggleSidebar}>{sidebar.state}</button>| Property | Type |
|---|---|
state | 'expanded' \| 'collapsed' |
open, setOpen(open) | The desktop state |
openMobile, setOpenMobile(open) | The state of the mobile sheet |
isMobile | true below 768 px |
toggleSidebar() | Toggles the sheet on mobile and the sidebar elsewhere |
Persisting the state
Every change writes the sidebar_state cookie, which lasts a week. Read it on the server and pass it as defaultOpen, so the sidebar renders in the same state after a reload.
export function load({ cookies }) {
return { sidebarOpen: cookies.get('sidebar_state') !== 'false' };
}<SidebarProvider defaultOpen={data.sidebarOpen}>…</SidebarProvider>Keyboard shortcut
Ctrl+B and Cmd+B toggle the sidebar from anywhere on the page, including while a field has focus.
Width
SidebarProvider sets --sidebar-width (16rem) and --sidebar-width-icon (3rem). Override them with style on the provider. The mobile sheet is 18rem wide.
<SidebarProvider style="--sidebar-width: 20rem;">…</SidebarProvider>Theming
The sidebar reads the --sidebar, --sidebar-foreground, --sidebar-primary, --sidebar-primary-foreground, --sidebar-accent, --sidebar-accent-foreground, --sidebar-border and --sidebar-ring variables from your theme.
Examples
Floating
variant="floating" detaches the sidebar from the edge as a rounded, bordered card with a shadow.
Icon
collapsible="icon" shrinks the sidebar to a 3rem strip of icons, and each SidebarMenuButton with a tooltip names its icon on hover.
Inset
variant="inset" renders the page content in a rounded panel inside the sidebar's background.
API reference
Sidebar has no Base primitive of its own. On mobile it is a Sheet, styled on the Sveltery Base Dialog primitive, and SidebarMenuButton shows its tooltip with a Tooltip, styled on the Sveltery Base Tooltip primitive. Both own their behavior, focus handling and accessibility. See the Base UI Dialog and Base UI Tooltip documentation for the behavior they port.