Side navigation
The navigation of a sidebar: a tree of links, shown in place rather than in a floating panel, whose branches open and close. It is written out level by level with its own subcomponents, never described as a list of data.
Usage
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Project">
<VSideNavigationItem href="#usage" current>Overview</VSideNavigationItem>
<VSideNavigationItem href="#usage">Activity</VSideNavigationItem>
<VSideNavigationItem href="#usage">Settings</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Examples
Links and actions
href renders a row as a real link; without it the row is a button reporting its activation through select. current marks the row the reader is on and announces it as the current page. A row with subitems ignores href.
<script setup lang="ts">
import { ref } from 'vue'
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import {
arrow_right_alt as arrowRightAlt,
description,
info,
schedule,
table_chart as tableChart,
} from 'vectis-ui/icons'
const selected = ref<string | null>(null)
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Workspace">
<!-- With `href` the row is a real link: it can be middle-clicked, its address
copied, and a crawler follows it. `current` says which one is the page being
read, and that is what a screen reader announces as the current page. -->
<VSideNavigationItem href="#usage" :icon="description" current>Usage</VSideNavigationItem>
<VSideNavigationItem href="#api" :icon="info">API</VSideNavigationItem>
<VSideNavigationItem
href="https://vuejs.org"
:icon="arrowRightAlt"
target="_blank"
rel="noreferrer"
>
Vue documentation
</VSideNavigationItem>
<!-- Without `href` the row is a button instead, and reports its activation
through `select`. That is the shape for what switches a view rather than
for what leads to an address. -->
<VSideNavigationItem :icon="tableChart" @select="selected = 'Board'">
Board
</VSideNavigationItem>
<VSideNavigationItem :icon="schedule" @select="selected = 'Timeline'">
Timeline
</VSideNavigationItem>
</VSideNavigation>
<p class="log">{{ selected ? `Selected: ${selected}` : 'Nothing selected yet' }}</p>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.log {
margin: var(--vectis-space-3) var(--vectis-space-2) 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>Sublabels
sublabel adds a second line under the label, for a count, a sync status or a size. The #sublabel slot takes the same place when that line needs markup.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { cloud_upload as cloudUpload, description, folder_zip as folderZip } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Project">
<!-- A second line under the label, for what the row does not already say: a
count, a status, a size. The row grows to hold both and the icon stays
centred on the pair rather than on the first line. -->
<VSideNavigationItem href="#usage" :icon="description" sublabel="12 open tasks">
Alpha
</VSideNavigationItem>
<VSideNavigationItem :icon="cloudUpload" default-open>
Storage
<!-- The slot takes markup where the prop takes a string. -->
<template #sublabel>Synced <strong>2 min</strong> ago</template>
<template #children>
<VSideNavigationItem href="#usage" :icon="folderZip" sublabel="4.2 GB">
Archives
</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Content at the end of a row
The #end slot places a counter, a badge or a small control after the label and before the chevron. On a branch row it lands inside the native disclosure summary, so keep it to content that is not interactive.
<script setup lang="ts">
import { VBadge, VIconButton, VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, more_horiz as moreHoriz, notifications, search } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Project">
<!-- On a leaf the action is stretched over the row by an overlay, so whatever
sits at the end stays a sibling of it rather than inside it. A real control
belongs here and nowhere else. -->
<VSideNavigationItem href="#usage" :icon="description">
Drafts
<template #end>
<VIconButton
label="Draft options"
:icon="moreHoriz"
size="xs"
variant="ghost"
tone="neutral"
/>
</template>
</VSideNavigationItem>
<!-- A branch is a native disclosure and its row is the summary, which has to
hold the whole line. So the end of a branch row takes a counter or a badge,
never a control: a control there would be nested inside the one the row
already is. -->
<VSideNavigationItem :icon="notifications">
Notifications
<template #end><VBadge :count="12" tone="accent" /></template>
<template #children>
<VSideNavigationItem href="#usage">Mentions</VSideNavigationItem>
<VSideNavigationItem href="#usage">Replies</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem :icon="search">
Saved searches
<template #end><span class="count">8</span></template>
<template #children>
<VSideNavigationItem href="#usage">Open bugs</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.count {
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>Groups and separators
VSideNavigationGroup names a block of rows and VSideNavigationSeparator draws a rule between two of them. A heading is not a row: nothing happens when it is clicked and the arrow keys never stop on it. A group is not a level of the hierarchy either, so the items inside it are not indented.
<script setup lang="ts">
import {
VSideNavigation,
VSideNavigationGroup,
VSideNavigationItem,
VSideNavigationSeparator,
} from 'vectis-ui'
import { description, image, schedule, table_chart as tableChart } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Workspace">
<VSideNavigationItem href="#usage" :icon="tableChart" current>Overview</VSideNavigationItem>
<!-- A rule, for a break between two runs of rows. It says nothing and is
announced as nothing: real grouping is the group's job. -->
<VSideNavigationSeparator />
<!-- A named block. The heading is not a row: nothing happens when it is clicked
and the arrow keys never stop on it. It takes the height of a row all the
same, which is what keeps the vertical rhythm of the list. What it names is
the sublist under it, so a screen reader reads the section and its items as
one thing. -->
<VSideNavigationGroup label="Content">
<VSideNavigationItem href="#usage" :icon="description">Documents</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="image">Media</VSideNavigationItem>
</VSideNavigationGroup>
<!-- A group is not a level of the hierarchy: its items are indented as if it
were not there. -->
<VSideNavigationGroup>
<!-- The slot takes markup where the prop takes a string. One of the two is
needed, since this is what names the sublist. -->
<template #label>Recent <span class="hint">(7 days)</span></template>
<VSideNavigationItem href="#usage" :icon="schedule">Release notes</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="schedule">Meeting minutes</VSideNavigationItem>
</VSideNavigationGroup>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.hint {
color: var(--vectis-color-text-muted);
}
</style>Nesting
A row given an #children slot becomes a branch, and a branch may hold branches of its own as deep as the tree goes. Each level is indented by exactly the room a start icon takes.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { code, description, folder_zip as folderZip } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Repository">
<!-- Nesting is not limited. Each level is indented by the room a start icon
takes, so a subitem's label lands on the same vertical as the label of the
branch holding it. -->
<VSideNavigationItem :icon="folderZip" default-open>
src
<template #children>
<VSideNavigationItem :icon="folderZip" default-open>
components
<template #children>
<VSideNavigationItem href="#usage" :icon="code">VButton.vue</VSideNavigationItem>
<VSideNavigationItem :icon="folderZip" default-open>
icons
<template #children>
<VSideNavigationItem href="#usage" :icon="code">index.ts</VSideNavigationItem>
</template>
</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="code">index.ts</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="description">README.md</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Section chevrons
expandIcon is the glyph on a closed branch, turned by 180° when the section opens. Naming collapseIcon as well swaps one drawing for the other instead. Both are set on the navigation as a whole.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import {
arrow_drop_down as arrowDropDown,
chevron_right as chevronRight,
expand_more as expandMore,
} from 'vectis-ui/icons'
</script>
<template>
<div class="row">
<aside class="sidebar">
<p class="caption">One icon, rotated</p>
<!-- Given only the closed chevron, the open one is that same drawing turned by
180 degrees. It is the right shape for a glyph that reads both ways up. -->
<VSideNavigation label="Rotated chevron" :expand-icon="arrowDropDown">
<VSideNavigationItem default-open>
Documents
<template #children>
<VSideNavigationItem href="#usage">Drafts</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
<aside class="sidebar">
<p class="caption">Two icons, swapped</p>
<!-- Naming both swaps one drawing for the other instead, which is what a file
tree wants: a chevron pointing along the reading direction when the branch
is closed, and down when it is open. -->
<VSideNavigation
label="Swapped chevrons"
:expand-icon="chevronRight"
:collapse-icon="expandMore"
>
<VSideNavigationItem default-open>
Documents
<template #children>
<VSideNavigationItem href="#usage">Drafts</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</div>
</template>
<style scoped>
.row {
display: flex;
gap: var(--vectis-space-5);
}
.sidebar {
inline-size: 15rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.caption {
margin: 0 var(--vectis-space-2) var(--vectis-space-2);
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>One section at a time
exclusive keeps a single section open at a time within each level. It is off by default, the opposite of VAccordion, whose sections close each other unless multiple is set.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, image, table_chart as tableChart } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<!-- Opening one section closes the one beside it. The browser does this on its
own, through the name the sections of a level share, so there is no state to
keep. The exclusivity is local to each level: opening a subsection leaves its
parent's neighbours alone. -->
<VSideNavigation label="Workspace" exclusive>
<VSideNavigationItem :icon="description" default-open>
Documents
<template #children>
<VSideNavigationItem href="#usage">Drafts</VSideNavigationItem>
<VSideNavigationItem href="#usage">Published</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem :icon="image">
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
<VSideNavigationItem href="#usage">Video</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem :icon="tableChart">
Reports
<template #children>
<VSideNavigationItem href="#usage">Weekly</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Knowing whether a section is open
defaultOpen decides the state a branch starts in and then hands it over to the browser. v-model:open reports every fold back instead, and opens or closes the branch when written to.
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, image } from 'vectis-ui/icons'
// The model is typed `boolean | null`, `null` being how the component writes "nobody is
// driving this branch".
const documentsOpen = ref<boolean | null>(true)
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Workspace">
<!-- Bound, the branch reports every fold back to the model, so a click on the
row is enough to keep it in step, and writing to the model opens or closes
the branch from code. -->
<VSideNavigationItem v-model:open="documentsOpen" :icon="description">
Documents
<template #children>
<VSideNavigationItem href="#usage">Drafts</VSideNavigationItem>
<VSideNavigationItem href="#usage">Published</VSideNavigationItem>
</template>
</VSideNavigationItem>
<!-- Left unbound, the browser keeps that state to itself and `defaultOpen`
gives only the value it starts on. -->
<VSideNavigationItem :icon="image" default-open>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
<div class="controls">
<p class="state">Documents is {{ documentsOpen ? 'open' : 'closed' }}</p>
<VButton size="sm" variant="outline" tone="neutral" @click="documentsOpen = !documentsOpen">
Toggle from code
</VButton>
</div>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.controls {
display: flex;
flex-direction: column;
align-items: start;
gap: var(--vectis-space-2);
margin: var(--vectis-space-4) var(--vectis-space-2) 0;
}
.state {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>Disabled rows
disabled greys a row out through the colour tokens and takes it off the keyboard path. On a branch, the section no longer unfolds at all.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, image, table_chart as tableChart } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Workspace">
<VSideNavigationItem href="#usage" :icon="description" current>Documents</VSideNavigationItem>
<!-- A disabled link stops leading anywhere and the arrow keys step over it. It
greys out through the colour tokens rather than through an opacity, so it
keeps its contrast against the sidebar. -->
<VSideNavigationItem href="#usage" :icon="image" disabled>Media</VSideNavigationItem>
<!-- On a branch it goes further: the section can no longer be unfolded, so what
it holds is out of reach for good. -->
<VSideNavigationItem :icon="tableChart" disabled>
Reports
<template #children>
<VSideNavigationItem href="#usage">Weekly</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Sizes
size sets the row height to 32 or 40 pixels, and compact takes 4px off it. It is set once on the navigation and every level reads it from there.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, image, table_chart as tableChart } from 'vectis-ui/icons'
</script>
<template>
<div class="row">
<!-- The size is set once on the navigation and every level follows, however deep:
a sublist never restates it. `compact` takes 4px off each row on top of that.
It is a density setting, not a rail folded down to its icons. -->
<aside class="sidebar">
<p class="caption">sm</p>
<VSideNavigation label="Rows of 32px" size="sm">
<VSideNavigationItem href="#usage" :icon="description" current
>Documents</VSideNavigationItem
>
<VSideNavigationItem :icon="image" default-open>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="tableChart">Reports</VSideNavigationItem>
</VSideNavigation>
</aside>
<aside class="sidebar">
<p class="caption">md</p>
<VSideNavigation label="Rows of 40px" size="md">
<VSideNavigationItem href="#usage" :icon="description" current
>Documents</VSideNavigationItem
>
<VSideNavigationItem :icon="image" default-open>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="tableChart">Reports</VSideNavigationItem>
</VSideNavigation>
</aside>
<aside class="sidebar">
<p class="caption">md compact</p>
<VSideNavigation label="Rows of 36px" size="md" compact>
<VSideNavigationItem href="#usage" :icon="description" current
>Documents</VSideNavigationItem
>
<VSideNavigationItem :icon="image" default-open>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="tableChart">Reports</VSideNavigationItem>
</VSideNavigation>
</aside>
</div>
</template>
<style scoped>
.row {
display: flex;
flex-wrap: wrap;
gap: var(--vectis-space-4);
}
.sidebar {
inline-size: 12rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.caption {
margin: 0 var(--vectis-space-2) var(--vectis-space-2);
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>API
Props
Events
Slots
Types
The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.
export interface BuiltinIcon {
name: string
paths: readonly [string] | readonly [string, string]
}export type IconRender =
| { path: string; viewBox?: string }
| { component: Component; props?: Record<string, unknown> }
| { src: string }
| { text: string; class?: string }
| { class: string }export type IconSource = string | BuiltinIcon | IconRender