Keyboard shortcut: Ctrl + K
Get started

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

vue
<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

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.

vue
<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.

vue
<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.

vue
<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.

vue
<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.

vue
<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.

vue
<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.

vue
<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.

vue
<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.

vue
<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.

vue
<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

VSideNavigation
PropTypeDefault
labelstringnone
What screen readers announce for this navigation. A page often has several, a main one, a sidebar, a footer, and this is what tells them apart. It falls back to the design system dictionary.
sizeSideNavigationSize'sm' | 'md''md'
The height of the rows, 32 or 40 pixels, inherited by every level.
compactbooleanfalse
Takes 4px off the height of every row. It is a density setting and not a collapsed icon-only rail, which this component does not offer.
exclusivebooleanfalse
Keeps a single section open at a time within each level, which the browser does on its own. It is off by default: a sidebar normally lets several sections stay open.
expandIconIconSourceexpand_more
The chevron of a closed section.
collapseIconIconSourcenone
The chevron of an open section. Leave it out and the closed one is rotated by 180°.
VSideNavigationItem
PropTypeDefault
labelstringnone
What the row says, and where it goes. The default slot replaces it.
sublabelstringnone
A second line under the label, for a status or a short explanation.
iconIconSourcenone
An icon before the label. The #icon slot replaces it.
hrefstringnone
Where this row leads, which makes it a link. It is ignored on a row that has subitems: such a row opens and closes rather than navigating.
currentbooleanfalse
Marks this row as the page currently being viewed. It is highlighted, and announced as the current page.
disabledbooleanfalse
Makes the row unusable: it greys out through the colour tokens and leaves the keyboard path.
defaultOpenbooleanfalse
Renders a branch already open. Only its initial value is read: the browser owns the state from then on, so changing it later does not fold a branch the reader has opened.
v-model:openboolean | nullnull
Whether the branch is open, when you want to drive or observe it. Left unbound, the browser keeps that state to itself and defaultOpen gives only the initial value.
VSideNavigationGroup
PropTypeDefault
labelstringnone
The name of the section, replaced by the #label slot. One of the two is needed: it is what names the sublist under it.

Events

VSideNavigationItem
EventType
select[]
The row was activated. A branch reports this as well as a link.

Slots

VSideNavigation
SlotType
default{}
The first level of the tree: items, groups and separators.
VSideNavigationItem
SlotType
default{}
The label of the row. It is required: a navigation row must say where it goes.
sublabel{}
A second line made of markup, replacing the sublabel prop.
icon{}
Free content before the label, which takes the place of icon.
end{}
Free content at the end of the row, before the chevron: a counter, a badge. On a branch it must not be focusable, since the row is already a control.
children{}
The subitems, which turn this row into a branch. Nesting is not limited.
VSideNavigationGroup
SlotType
default{}
The items belonging to this section.
label{}
A name made of markup, replacing the label prop.

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