Skip to content

App shell ​

The frame every other screen sits inside. Sidebar supplies both halves of it — SidebarPanel is the navigation and SidebarInset is everything else — so the two collapse together without the page arranging them. The header above the content carries the trigger, the Breadcrumbs and the account menu. What goes inside SidebarInset is the rest of this section — see composing patterns.

ts
import { Breadcrumbs, Dropdown, Sidebar, SidebarInset, SidebarPanel, SidebarTrigger } from "ropav";
  1. Acme
  2. Overview
Overview
Whatever this section renders goes here.
app-shell.vue
vue
<script setup lang="ts">
import {
  Avatar,
  AvatarFallback,
  Breadcrumbs,
  BreadcrumbsItem,
  Button,
  Dropdown,
  DropdownMenu,
  DropdownPopover,
  IconCalendar,
  IconSearch,
  InfoIcon,
  Label,
  MenuItem,
  Separator,
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarItem,
  SidebarItemIcon,
  SidebarItemLabel,
  SidebarItemTooltip,
  SidebarPanel,
  SidebarRail,
  SidebarTrigger,
} from "ropav";
import { computed, shallowRef } from "vue";

const sections = [
  { id: "overview", name: "Overview" },
  { id: "search", name: "Search" },
  { id: "schedule", name: "Schedule" },
];

const current = shallowRef("overview");

const section = computed(() => sections.find((entry) => entry.id === current.value));
</script>

<template>
  <div class="frame">
    <Sidebar collapsible="icon">
      <SidebarPanel aria-label="Main">
        <SidebarHeader>Acme</SidebarHeader>

        <SidebarContent>
          <SidebarGroup>
            <SidebarGroupLabel>Workspace</SidebarGroupLabel>

            <SidebarItemTooltip label="Overview">
              <SidebarItem
                :aria-current="current === 'overview' ? 'page' : undefined"
                @press="current = 'overview'"
              >
                <SidebarItemIcon><InfoIcon /></SidebarItemIcon>
                <SidebarItemLabel>Overview</SidebarItemLabel>
              </SidebarItem>
            </SidebarItemTooltip>

            <SidebarItemTooltip label="Search">
              <SidebarItem
                :aria-current="current === 'search' ? 'page' : undefined"
                @press="current = 'search'"
              >
                <SidebarItemIcon><IconSearch /></SidebarItemIcon>
                <SidebarItemLabel>Search</SidebarItemLabel>
              </SidebarItem>
            </SidebarItemTooltip>

            <SidebarItemTooltip label="Schedule">
              <SidebarItem
                :aria-current="current === 'schedule' ? 'page' : undefined"
                @press="current = 'schedule'"
              >
                <SidebarItemIcon><IconCalendar /></SidebarItemIcon>
                <SidebarItemLabel>Schedule</SidebarItemLabel>
              </SidebarItem>
            </SidebarItemTooltip>
          </SidebarGroup>
        </SidebarContent>
      </SidebarPanel>

      <SidebarRail />

      <SidebarInset>
        <div class="bar">
          <SidebarTrigger />
          <Separator class="rule" orientation="vertical" />

          <Breadcrumbs>
            <BreadcrumbsItem href="#">Acme</BreadcrumbsItem>
            <BreadcrumbsItem>{{ section?.name }}</BreadcrumbsItem>
          </Breadcrumbs>

          <Dropdown>
            <Button aria-label="Account" class="account" size="sm" variant="tertiary">
              <Avatar size="sm">
                <AvatarFallback>AL</AvatarFallback>
              </Avatar>
            </Button>

            <DropdownPopover>
              <DropdownMenu>
                <MenuItem id="profile" text-value="Profile">
                  <Label>Profile</Label>
                </MenuItem>
                <MenuItem id="sign-out" text-value="Sign out" variant="danger">
                  <Label>Sign out</Label>
                </MenuItem>
              </DropdownMenu>
            </DropdownPopover>
          </Dropdown>
        </div>

        <div class="page">
          <div class="page__title">{{ section?.name }}</div>
          <div class="page__body">Whatever this section renders goes here.</div>
        </div>
      </SidebarInset>
    </Sidebar>
  </div>
</template>

<style scoped>
.frame {
  width: 100%;
  height: calc(var(--rp-spacing) * 80);
  overflow: hidden;
  border: 1px solid var(--rp-border);
  border-radius: var(--rp-radius);
}

.bar {
  display: flex;
  gap: calc(var(--rp-spacing) * 2);
  align-items: center;
  padding: calc(var(--rp-spacing) * 3);
  border-bottom: 1px solid var(--rp-border);
}

.rule {
  height: calc(var(--rp-spacing) * 5);
}

/* Pushed to the far end, so the account sits opposite the trail whatever the trail says. */
.account {
  margin-inline-start: auto;
}

.page {
  padding: calc(var(--rp-spacing) * 6);
}

.page__title {
  font-size: var(--rp-text-lg);
  line-height: var(--rp-text-lg--line-height);
  font-weight: var(--rp-font-weight-semibold);
}

.page__body {
  margin-top: calc(var(--rp-spacing) * 2);
  font-size: var(--rp-text-sm);
  line-height: var(--rp-text-sm--line-height);
  color: var(--rp-muted);
}
</style>

Collapsed to icons ​

collapsible="icon" narrows the panel to its icons rather than hiding it, and SidebarRail is the edge you drag to do that. A narrowed item still has its label for a screen reader, but a pointer user has nothing to read — which is what SidebarItemTooltip fills in, and only while narrowed.

Below the breakpoint it is a drawer ​

Narrower than breakpoint the panel stops being a column beside the content and becomes a drawer over it, backdrop and swipe-to-close included. SidebarTrigger opens that rather than toggling a width, and its open state is is-mobile-open — separate from is-expanded, so a resize does not overwrite what the other one held. The switch waits for mount, because a server has no matchMedia to ask.

A nav item is a button unless you give it an href ​

SidebarItem renders a button and emits press; pass href and it renders a link instead. Either way aria-current="page" is what marks the one you are on, and RouterProvider can work that out for you from aria-current="auto".

The account menu is on a button ​

Dropdown makes its first child the trigger, and that child has to be something that accepts a press — a Button, or one of the *Trigger parts. SidebarItem presses, but it does not take the trigger handed down, so an account row built from one opens nothing.

Accessibility ​

  • SidebarPanel takes an aria-label: it is a navigation landmark, and an unnamed one is just "navigation" in the landmark list.
  • The last breadcrumb is the current page and carries no href, so it is not a link to where you already are.
  • The account button is icon-only, so it names itself with aria-label.