Sidebar
App navigation down the side, in labelled groups, that can shrink to a rail of icons or slide in on phones.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add sidebarpnpm dlx ermineui add sidebaryarn dlx ermineui add sidebarbunx ermineui add sidebarThat copies 5 files into src/components/ui/sidebar/, 4 helpers into src/lib/ermine/. Files you already have are kept.
Use
import { Sidebar, SidebarGroup, SidebarItem } from '@/components/ui/sidebar'<Collapsible v-model:open="open">
<CollapsibleTrigger as-child><Button variant="outline">Details</Button></CollapsibleTrigger>
<CollapsibleContent>…</CollapsibleContent>
</Collapsible>
<HoverCard>
<HoverCardTrigger href="/people/ines">Ines Kovač</HoverCardTrigger>
<HoverCardContent>Owner · 214 deployments this year</HoverCardContent>
</HoverCard>
<Sidebar v-model:collapsed="collapsed">
<SidebarGroup label="Project">
<SidebarItem :as="NuxtLink" to="/deployments" active>
<template #icon><svg>…</svg></template>
Deployments
</SidebarItem>
</SidebarGroup>
</Sidebar>
<EmptyState title="No deployments yet" description="Push to main and it will show up here.">
<Button>Deploy now</Button>
</EmptyState>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Sidebar.vue | src/components/ui/sidebar/Sidebar.vue |
SidebarGroup.vue | src/components/ui/sidebar/SidebarGroup.vue |
SidebarItem.vue | src/components/ui/sidebar/SidebarItem.vue |
context.ts | src/components/ui/sidebar/context.ts |
index.ts | src/components/ui/sidebar/index.ts |
context.ts helper | src/lib/ermine/context.ts |
layer.ts helper | src/lib/ermine/layer.ts |
primitive.ts helper | src/lib/ermine/primitive.ts |
scroll-lock.ts helper | src/lib/ermine/scroll-lock.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Sidebar.vue 258 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useLayer } from '../../lib/layer'
import { lockScroll } from '../../lib/scroll-lock'
import { provideSidebar } from './context'
const props = withDefaults(
defineProps<{
/** Accessible name of the navigation. */
label?: string
/** On a narrow screen, keep the sidebar off screen and slide it in over the page when `open` is set. */
offCanvas?: boolean
}>(),
{ label: 'Main' },
)
/** Shrunk to a narrow rail that shows icons only. Ignored while the sidebar is off-canvas. */
const collapsed = defineModel<boolean>('collapsed', { default: false })
/** Off-canvas only: whether the sidebar is slid in over the page. */
const open = defineModel<boolean>('open', { default: false })
const root = ref<HTMLElement | null>(null)
/** True while the screen is narrow enough for the off-canvas layout. Must match the media query in the styles. */
const narrow = ref(false)
const floating = computed(() => props.offCanvas && narrow.value)
const drawerOpen = computed(() => floating.value && open.value)
let media: MediaQueryList | undefined
const onMediaChange = () => {
narrow.value = media?.matches ?? false
// Growing past the breakpoint with the drawer open would leave the page locked behind nothing.
if (!narrow.value) open.value = false
}
onMounted(() => {
media = window.matchMedia('(max-width: 47.999rem)')
media.addEventListener('change', onMediaChange)
narrow.value = media.matches
})
onBeforeUnmount(() => media?.removeEventListener('change', onMediaChange))
provideSidebar({
collapsed: computed(() => collapsed.value && !floating.value),
navigate: () => {
if (floating.value) open.value = false
},
})
// Slid in over the page it behaves like a dialog: focus stays inside, Escape and a press outside close it.
useLayer(root, drawerOpen, {
modal: true,
loopFocus: true,
onDismiss: () => (open.value = false),
})
watch(
drawerOpen,
(isOpen, _previous, onCleanup) => {
if (isOpen) onCleanup(lockScroll())
},
{ immediate: true },
)
</script>
<template>
<div v-if="offCanvas" class="er-sidebar-overlay" :data-open="drawerOpen ? '' : undefined" />
<aside
ref="root"
class="er-sidebar"
tabindex="-1"
:data-collapsed="collapsed && !floating ? '' : undefined"
:data-off-canvas="offCanvas ? '' : undefined"
:data-open="drawerOpen ? '' : undefined"
:role="drawerOpen ? 'dialog' : undefined"
:aria-modal="drawerOpen ? 'true' : undefined"
:aria-label="drawerOpen ? label : undefined"
>
<div v-if="$slots.header" class="er-sidebar-header"><slot name="header" :collapsed="collapsed && !floating" /></div>
<nav class="er-sidebar-nav" :aria-label="label"><slot :collapsed="collapsed && !floating" /></nav>
<div v-if="$slots.footer" class="er-sidebar-footer"><slot name="footer" :collapsed="collapsed && !floating" /></div>
</aside>
</template>
<style>
@layer components {
.er-sidebar {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 15rem;
min-height: 0;
border-right: 1px solid var(--er-border);
background: var(--er-card);
color: var(--er-card-foreground);
font-size: 0.875rem;
transition: width var(--er-duration) var(--er-ease);
}
.er-sidebar[data-collapsed] {
width: 3.25rem;
}
.er-sidebar:focus {
outline: none;
}
.er-sidebar-overlay {
display: none;
}
/* Off-canvas: below this width the sidebar leaves the layout and waits off screen until it is opened.
The script reads the same width (47.999rem), so change both together. */
@media (max-width: 47.999rem) {
.er-sidebar[data-off-canvas] {
position: fixed;
inset: 0 auto 0 0;
z-index: var(--er-z-overlay);
width: min(17rem, calc(100% - 3rem));
box-shadow: var(--er-shadow-lg);
transform: translateX(-100%);
/* Hidden once it has slid away, so nothing in it can be tabbed to while it is off screen. */
visibility: hidden;
transition:
transform calc(var(--er-duration) * 1.5) var(--er-ease),
visibility 0s linear calc(var(--er-duration) * 1.5);
}
.er-sidebar[data-off-canvas][data-open] {
transform: none;
visibility: visible;
transition:
transform calc(var(--er-duration) * 1.5) var(--er-ease),
visibility 0s;
}
.er-sidebar-overlay {
position: fixed;
inset: 0;
z-index: var(--er-z-overlay);
display: block;
background: var(--er-overlay);
opacity: 0;
pointer-events: none;
transition: opacity calc(var(--er-duration) * 1.5) var(--er-ease);
}
.er-sidebar-overlay[data-open] {
opacity: 1;
pointer-events: auto;
}
}
.er-sidebar-header,
.er-sidebar-footer {
flex-shrink: 0;
padding: 0.5rem;
}
.er-sidebar-header {
border-bottom: 1px solid var(--er-border);
}
.er-sidebar-footer {
border-top: 1px solid var(--er-border);
}
.er-sidebar-nav {
display: grid;
flex: 1;
align-content: start;
gap: 1rem;
min-height: 0;
padding: 0.5rem;
overflow: hidden auto;
}
.er-sidebar-group {
display: grid;
gap: 2px;
}
.er-sidebar-group ul {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.er-sidebar-group-label {
padding: 0.25rem 0.5rem;
color: var(--er-muted-foreground);
font-size: 0.75rem;
font-weight: var(--er-weight-medium);
white-space: nowrap;
}
.er-sidebar-item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
height: var(--er-control-md);
padding: 0 0.625rem;
border: 0;
border-radius: var(--er-radius-control);
background: transparent;
color: var(--er-muted-foreground);
font: inherit;
font-weight: var(--er-weight-medium);
text-align: left;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
transition:
background-color var(--er-duration),
color var(--er-duration);
}
.er-sidebar-item:hover {
background: var(--er-accent);
color: var(--er-accent-foreground);
}
.er-sidebar-item[aria-current='page'] {
background: var(--er-accent);
color: var(--er-foreground);
}
.er-sidebar-item:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: -2px;
}
.er-sidebar-item svg {
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
.er-sidebar-item-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* Collapsed, the words are hidden from sight only: screen readers still read each link by name. */
.er-sidebar[data-collapsed] .er-sidebar-item-label,
.er-sidebar[data-collapsed] .er-sidebar-group-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
}
</style>SidebarGroup.vue 18 lines
<script setup lang="ts">
import { useId } from 'vue'
defineProps<{
/** Heading above the group's links. */
label?: string
}>()
const id = useId()
</script>
<template>
<div class="er-sidebar-group" role="group" :aria-labelledby="label ? id : undefined">
<div v-if="label" :id="id" class="er-sidebar-group-label">{{ label }}</div>
<ul><slot /></ul>
</div>
</template>SidebarItem.vue 38 lines
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { Primitive } from '../../lib/primitive'
import { useSidebar } from './context'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
/** Tag or component for the link, e.g. NuxtLink. Attributes such as href or to are passed on to it. */
as?: string | Component
/** The page the reader is on now. */
active?: boolean
}>(),
{ as: 'a' },
)
const sidebar = useSidebar()
const label = ref<HTMLElement | null>(null)
</script>
<template>
<li>
<!-- Collapsed to icons, the name shows as a native tooltip on hover. -->
<Primitive
v-bind="$attrs"
:as="as"
class="er-sidebar-item"
:aria-current="active ? 'page' : undefined"
:title="sidebar.collapsed.value ? label?.textContent?.trim() : undefined"
@click="sidebar.navigate()"
>
<slot name="icon" />
<span ref="label" class="er-sidebar-item-label"><slot /></span>
</Primitive>
</li>
</template>context.ts 11 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'
export interface SidebarContext {
collapsed: Ref<boolean>
/** Called when a link is followed, so an off-canvas sidebar can close itself. */
navigate: () => void
}
export const [provideSidebar, useSidebar] = createContext<SidebarContext>('Sidebar')index.ts 5 lines
/** Sidebar — app navigation down the side, in labelled groups, that can shrink to a rail of icons or slide in on phones. */
export { default as Sidebar } from './Sidebar.vue'
export { default as SidebarGroup } from './SidebarGroup.vue'
export { default as SidebarItem } from './SidebarItem.vue'