GrSidebar
Side navigation for sections, filters and supporting actions.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- there are many sections and they are permanent — an admin area, mail, a control panel: the list is always at hand;
- there is little room on the screen —
collapsedfolds the panel to an icon width without removing the navigation; - the sections are grouped —
GrSidebarGroupgives headings inside the list; - a landmark is needed —
landmarkdeclares the panel anavigationorcomplementarylandmark.
When to take something else
| Need | Take |
|---|---|
| The panel slides out on demand and closes | GrDrawer |
| The top bar of the application | GrNavbar |
| The bottom bar on mobile | GrBottomNav |
| Sections inside a single page | GrTabs |
| A list of actions behind a button | GrDropdownMenu |
The landmark and the name
<GrSidebar landmark="navigation" aria-label="Main navigation">
…
</GrSidebar>
The root is an <aside> (landmark="complementary", the default) or a <nav>
(landmark="navigation"). The component does not draw a nested <nav> inside the <aside>: two
landmarks for one panel clutter the overview of a screen reader, and a filter panel is not
navigation at all — it is the consumer’s side that decides what the panel is.
ariaLabel is mandatory where there is more than one panel on the page: without a name the
landmarks in the overview are indistinguishable.
Groups of sections
<GrSidebarGroup label="Administration">
<GrSidebarItem label="Billing" icon="i-lucide-credit-card" />
<GrSidebarItem label="Settings" icon="i-lucide-settings" />
</GrSidebarGroup>
A group is declared role="group" and is linked to its heading through aria-labelledby. In a
collapsed panel there is nowhere for the heading to fit: it is hidden, and the sections begin to be
separated by a line — without it the icons of neighbouring groups merge into a single column. The
name of the group is not taken out of nothing in the process: aria-labelledby is removed together
with the heading.
Collapsing
collapsed supports v-model:collapsed, and the panel works both as controlled and as
self-contained. show-toggle-button adds the button; its label comes from the locale
(gr.sidebar.expand / gr.sidebar.collapse) and is overridden with the toggleLabel prop.
The chevron always points where the panel will go: on a right-hand panel “collapse” is an arrow pointing right.
When collapsed, a GrSidebarItem shows the icon, and without one the first letter of the label;
the label goes into title and aria-label.
The side and the width
| Prop | What it does |
|---|---|
position | left (the default) or right: the border moves to the inner side, the chevrons are mirrored |
width / collapsedWidth | the widths of the expanded and the collapsed state |
The keyboard
The container of the content scrolls and is therefore a Tab stop with a visible focus ring:
otherwise a panel with text content cannot be scrolled without a mouse.
An unavailable item (disabled) stops being a button — it renders as a <span>, does not catch
the focus and is dimmed with the --gr-disabled-fg token rather than with transparency.
Playground 10
Loading…
<GrSidebar />Install
npm i @feugene/granularityImport
import { GrSidebar } from '@feugene/granularity/components/GrSidebar'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
title | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | The name of the landmark: without it two panels on a page are indistinguishable. |
position | "right" | "left" | undefined | "left" | The side of the screen: the border and the direction of the chevron are mirrored. |
width | string | undefined | "240px" | The width in the expanded state. |
landmark | "complementary" | "navigation" | undefined | "complementary" | The landmark of the root. `complementary` (the default) is an `<aside>`; `navigation` is a `<nav>` for a panel that really is navigation. A nested `<nav>` inside an `<aside>` is not introduced: two landmarks for one panel clutter the overview, and a panel of filters is not navigation at all. |
subtitle | string | undefined | undefined | — |
collapsed | boolean | undefined | false | The collapsed state. It supports `v-model:collapsed`. |
showToggleButton | boolean | undefined | false | Show the collapse/expand button in the header. |
collapsedWidth | string | undefined | "64px" | The width in the collapsed state. |
toggleLabel | string | undefined | undefined | The a11y label of the toggle button. Unset — it is taken from the locale (`gr.sidebar.*`). |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The content of the panel: navigation, groups, arbitrary markup. |
title | any | The heading of the header instead of the `title` prop. |
subtitle | any | A subheading under the heading. |
Events
| Event | Type | Description |
|---|---|---|
update:collapsed | [value: boolean] | — |
Examples 3
Basic section rail
Toggle the sidebar: collapsed items keep their icon, and «Billing» (no icon) falls back to its first letter.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrSidebar, GrSidebarGroup, GrSidebarItem } from '@feugene/granularity'
const currentSection = ref('overview')
const collapsed = ref(false)
// icon — класс UnoCSS-иконки; у «Billing» иконки нет — в свёрнутом виде покажется буква «B».
const groups = [
{
label: 'Workspace',
items: [
{ label: 'Overview', value: 'overview', icon: 'i-lucide-layout-dashboard', badge: undefined as number | undefined },
{ label: 'Team', value: 'team', icon: 'i-lucide-users', badge: 4 },
],
},
{
label: 'Administration',
items: [
{ label: 'Billing', value: 'billing', icon: undefined, badge: undefined },
{ label: 'Settings', value: 'settings', icon: 'i-lucide-settings', badge: undefined },
],
},
]
</script>
<template>
<div class="flex min-h-[240px] gap-3">
<GrSidebar
v-model:collapsed="collapsed"
title="Workspace"
subtitle="Navigation"
show-toggle-button
landmark="navigation"
aria-label="Workspace sections"
class="rounded-xl"
>
<GrSidebarGroup v-for="group in groups" :key="group.label" :label="group.label">
<div class="grid gap-1">
<GrSidebarItem
v-for="section in group.items"
:key="section.value"
:label="section.label"
:icon="section.icon"
:badge="section.badge"
:active="section.value === currentSection"
@click="currentSection = section.value"
/>
</div>
</GrSidebarGroup>
</GrSidebar>
<div class="flex-1 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
<div class="flex items-center justify-between gap-3">
<div class="text-base font-semibold capitalize">
{{ currentSection }}
</div>
<GrBadge tone="neutral">
{{ collapsed ? 'Collapsed' : 'Expanded' }}
</GrBadge>
</div>
<p class="mt-2 text-sm text-[var(--gr-muted-fg)]">
Toggle the sidebar: collapsed items keep their icon, and «Billing» (no icon) falls back to its first letter.
</p>
</div>
</div>
</template>Documentation anchors
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrSidebar } from '@feugene/granularity'
const currentSection = ref('api')
const sections = [
{ label: 'Overview', value: 'overview' },
{ label: 'Examples', value: 'examples' },
{ label: 'API', value: 'api' },
{ label: 'Notes', value: 'notes' },
]
const activeSection = computed(() => {
return sections.find(section => section.value === currentSection.value)?.label ?? 'API'
})
</script>
<template>
<div class="grid gap-3 sm:grid-cols-[260px_minmax(0,1fr)]">
<GrSidebar title="Doc sections" subtitle="Anchored navigation">
<div class="grid gap-2">
<button
v-for="section in sections"
:key="section.value"
type="button"
class="flex items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors"
:class="section.value === currentSection ? 'bg-[var(--gr-sidebar-primary)] text-[var(--gr-sidebar-primary-fg)]' : 'hover:bg-[var(--gr-sidebar-accent)] hover:text-[var(--gr-sidebar-accent-fg)]'"
@click="currentSection = section.value"
>
<span>{{ section.label }}</span>
<GrBadge size="sm" tone="neutral">
#
</GrBadge>
</button>
</div>
</GrSidebar>
<div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
<div class="text-sm text-[var(--gr-muted-fg)]">
Current anchor target
</div>
<div class="mt-1 text-base font-semibold">
{{ activeSection }}
</div>
</div>
</div>
</template>Filter rail composition
<script setup lang="ts">
import { reactive } from 'vue'
import { GrBadge, GrSidebar, GrSwitch } from '@feugene/granularity'
const filters = reactive({
active: true,
assigned: false,
overdue: true,
})
</script>
<template>
<div class="grid gap-3 sm:grid-cols-[260px_minmax(0,1fr)]">
<GrSidebar title="Filters" subtitle="Sticky control rail">
<div class="grid gap-3">
<label class="flex items-center justify-between gap-3 text-sm">
Active only
<GrSwitch v-model="filters.active" />
</label>
<label class="flex items-center justify-between gap-3 text-sm">
Assigned to me
<GrSwitch v-model="filters.assigned" />
</label>
<label class="flex items-center justify-between gap-3 text-sm">
Overdue
<GrSwitch v-model="filters.overdue" />
</label>
</div>
</GrSidebar>
<div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
<div class="flex flex-wrap gap-2">
<GrBadge :tone="filters.active ? 'primary' : 'neutral'">
Active: {{ filters.active ? 'on' : 'off' }}
</GrBadge>
<GrBadge :tone="filters.assigned ? 'primary' : 'neutral'">
Assigned: {{ filters.assigned ? 'on' : 'off' }}
</GrBadge>
<GrBadge :tone="filters.overdue ? 'primary' : 'neutral'">
Overdue: {{ filters.overdue ? 'on' : 'off' }}
</GrBadge>
</div>
</div>
</div>
</template>Accessibility
- APG pattern
—