From sarah@example.com, 2:14 PM
Thanks for pulling these together. I went through the flows and have a few comments on the onboarding path, mostly about where the empty states go.
Shells for authenticated application screens.
Every shell on this page binds the same three pieces: a .navbar, a
.sidebar carrying a .sidebar-nav, and a main region. The grid
that binds them is the pattern. The pieces have their own component pages, and the small
layouts inside them are on the composition pages.
Flexbox lays out one dimension at a time, which works while the regions nest cleanly. A shell usually does not: the header spans the full width while the sidebar runs beside it and below it. Nested flex containers can do it, but the layout ends up spread across the markup instead of defined in one place.
A named grid-template-areas is that one place. The value defines the shell
as rows and columns of named areas, and a sidebar that crosses the header occupies two
rows of it. Rearranging for small screens means editing the areas value, not re-nesting
the markup. Every pattern here uses one grid.
Media queries resolve against the viewport, and dragging a demo frame does not change
the viewport. So the shells below use container queries instead: a parent carries
.cq-inline, and every @container rule in the demos resolves
against the width you can see changing.
The show-and-hide widths are written into each demo's own CSS, and they are smaller than
you would use in production so the previews start expanded. Choose your own on a real
page. Where a width matches one of the library breakpoints, the
.cq-hide-* utilities handle the hiding for container queries, and the plain
.hide-* utilities cover the @media case. Both sets are on the
utilities page. The last demo shows the queries
staying container-scoped when the shell is itself a column inside something larger.
Two demos include a few lines of script. The state is carried by ARIA attributes: the
collapse toggle flips aria-pressed, the master-detail rows flip
aria-expanded, and :has() turns those attributes into layout.
Real code would also route or persist state, and none of that changes the CSS shown
here.
Header on top, sidebar and content side by side beneath it: two rows and two columns, with the header spanning both columns.
The responsive half is mostly not in the grid. Below 36rem of width, the sidebar is
hidden and a hamburger button appears. The sidebar's auto track has nothing
left to size, so it collapses to zero and main goes full width. There is no
second grid definition and no breakpoint rule on the grid itself.
Drag the frame narrow. The sidebar hands off to the hamburger, and the grid needs no rule of its own to absorb the change.
<style>
.app-dashboard {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto 1fr;
grid-template-areas:
'header header'
'sidebar main';
min-block-size: 100dvh;
}
.app-dashboard > .navbar {
grid-area: header;
}
.app-dashboard > .sidebar {
grid-area: sidebar;
--sidebar-width: 10rem;
}
.app-dashboard > main {
grid-area: main;
min-inline-size: 0;
padding: var(--space-md);
}
/* The menu button stays with the sidebar: hidden while the
sidebar is in flow, revealed when it leaves. */
.app-dashboard .app-menu {
display: none;
}
@container (max-width: 36rem) {
.app-dashboard > .sidebar {
display: none;
}
.app-dashboard .app-menu {
display: inline-flex;
}
}
</style>
<div class="cq-inline">
<div class="app-dashboard">
<header class="navbar">
<div class="cluster is-between">
<div class="cluster is-tight">
<button
class="button is-ghost is-icon app-menu"
type="button"
aria-label="Menu"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<strong>App</strong>
</div>
<button
class="button is-ghost is-icon"
type="button"
aria-label="Account"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</button>
</div>
</header>
<aside class="sidebar">
<nav class="sidebar-nav" aria-label="Main">
<a class="sidebar-link" href="#" aria-current="page">Home</a>
<a class="sidebar-link" href="#">Users</a>
<a class="sidebar-link" href="#">Settings</a>
</nav>
</aside>
<main>
<h3>Dashboard</h3>
<p class="text-muted text--1">
Drag the frame narrow. The sidebar hands off to the hamburger, and the
grid needs no rule of its own to absorb the change.
</p>
</main>
</div>
</div>
The sidebar moves to the first column and spans both rows. That is the only difference from the dashboard grid: in nested flex the same change means restructuring the markup, and here it is a change to the areas value.
Brio's own documentation uses this shape: the sidebar holds until the viewport is under
64rem, then hands off to a drawer opened from the navbar. Moving this demo's threshold
to 64rem and wiring the hamburger to a brio-drawer gives the layout this
documentation site uses.
The sidebar claims both rows. Narrow the frame and it steps out; the header and main were sharing a column anyway.
<style>
.app-full-height {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto 1fr;
grid-template-areas:
'sidebar header'
'sidebar main';
min-block-size: 100dvh;
}
.app-full-height > .sidebar {
grid-area: sidebar;
--sidebar-width: 10rem;
}
.app-full-height > .navbar {
grid-area: header;
}
.app-full-height > main {
grid-area: main;
min-inline-size: 0;
padding: var(--space-md);
}
.app-full-height .app-menu {
display: none;
}
@container (max-width: 36rem) {
.app-full-height > .sidebar {
display: none;
}
.app-full-height .app-menu {
display: inline-flex;
}
}
</style>
<div class="cq-inline">
<div class="app-full-height">
<aside class="sidebar">
<nav class="sidebar-nav" aria-label="Main">
<a class="sidebar-link" href="#" aria-current="page">Overview</a>
<a class="sidebar-link" href="#">Reports</a>
<a class="sidebar-link" href="#">Team</a>
<a class="sidebar-link" href="#">Billing</a>
</nav>
</aside>
<header class="navbar">
<div class="cluster is-between">
<div class="cluster is-tight">
<button
class="button is-ghost is-icon app-menu"
type="button"
aria-label="Menu"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<strong>Workspace</strong>
</div>
<button
class="button is-ghost is-icon"
type="button"
aria-label="Account"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</button>
</div>
</header>
<main>
<h3>Overview</h3>
<p class="text-muted text--1">
The sidebar claims both rows. Narrow the frame and it steps out; the
header and main were sharing a column anyway.
</p>
</main>
</div>
</div>
Brio ships the collapse: data-collapsed on the panel reduces it to an icon
rail at --sidebar-collapsed-width, and the nav hides its labels. The
script's only job is flipping the attribute; aria-pressed on the toggle
carries the state for screen readers, and the grid's auto track follows the
sidebar's width.
The motion comes from the component. The width transition uses
--transition-normal and is disabled by reduced-motion settings like
everything else. It animates because --sidebar-width is registered with
@property as a length, in brio.css where the registration must
sit unlayered; an unregistered custom property changes value without animating. Two
details are left to the page: the panel's header label is the shell's own chrome, so
this demo hides it while collapsed, and icon-only rows need an aria-label,
because a hidden span does not contribute to the accessible name.
Press the chevron in the sidebar's header. The rail thins to 3rem; drag the frame past its threshold and it hides instead.
<style>
.app-collapse {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto 1fr;
grid-template-areas:
'header header'
'sidebar main';
min-block-size: 100dvh;
}
.app-collapse > .navbar {
grid-area: header;
}
.app-collapse > .sidebar:not([data-collapsed]) {
grid-area: sidebar;
--sidebar-width: 10rem;
}
/* The panel's own label is app chrome; the nav's labels
the component already hid. */
.app-collapse > .sidebar[data-collapsed] > header strong {
display: none;
}
.app-collapse > main {
grid-area: main;
min-inline-size: 0;
padding: var(--space-md);
}
.app-collapse .app-menu {
display: none;
}
@container (max-width: 36rem) {
.app-collapse > .sidebar {
display: none;
}
.app-collapse .app-menu {
display: inline-flex;
}
}
</style>
<div class="cq-inline">
<div class="app-collapse" id="app-collapse-demo">
<header class="navbar">
<div class="cluster is-between">
<div class="cluster is-tight">
<button
class="button is-ghost is-icon app-menu"
type="button"
aria-label="Menu"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<strong>App</strong>
</div>
<button
class="button is-ghost is-icon"
type="button"
aria-label="Account"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</button>
</div>
</header>
<aside class="sidebar">
<header>
<div class="cluster is-between">
<strong>Menu</strong>
<button
class="button is-ghost is-sm is-icon"
type="button"
aria-label="Toggle the sidebar"
aria-pressed="false"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="11 17 6 12 11 7" />
<polyline points="18 17 13 12 18 7" />
</svg>
</button>
</div>
</header>
<nav class="sidebar-nav" aria-label="Main">
<a class="sidebar-link" href="#" aria-current="page" aria-label="Home">
<svg
class="sidebar-link-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
<span class="sidebar-link-text">Home</span>
</a>
<a class="sidebar-link" href="#" aria-label="Users">
<svg
class="sidebar-link-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span class="sidebar-link-text">Users</span>
</a>
<a class="sidebar-link" href="#" aria-label="Settings">
<svg
class="sidebar-link-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<span class="sidebar-link-text">Settings</span>
</a>
</nav>
</aside>
<main>
<h3>Dashboard</h3>
<p class="text-muted text--1">
Press the chevron in the sidebar's header. The rail thins to 3rem; drag
the frame past its threshold and it hides instead.
</p>
</main>
</div>
</div>
<script>
(() => {
const shell = document.getElementById('app-collapse-demo');
if (!shell) return;
shell.addEventListener('click', (e) => {
const toggle = e.target.closest('[aria-pressed]');
if (!toggle) return;
e.preventDefault();
const pressed = toggle.getAttribute('aria-pressed') !== 'true';
toggle.setAttribute('aria-pressed', String(pressed));
toggle.closest('.sidebar').toggleAttribute('data-collapsed', pressed);
});
})();
</script>
Adding a filters panel on the trailing edge is one more column and one more
auto track. The order the regions leave at small widths is the part to
decide: the filters hide first, with a funnel button in the navbar showing where they
went; the sidebar follows, replaced by the hamburger. An auto track with
nothing in it sizes to zero, so the shell reflows as each region leaves.
Drag the frame: the filters go first and the funnel appears; the sidebar follows.
<style>
.app-three-col {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto 1fr;
grid-template-areas:
'sidebar header header'
'sidebar main aside';
min-block-size: 100dvh;
}
.app-three-col > .sidebar {
grid-area: sidebar;
--sidebar-width: 9rem;
}
.app-three-col > .navbar {
grid-area: header;
}
.app-three-col > main {
grid-area: main;
min-inline-size: 0;
padding: var(--space-md);
}
.app-three-col > .app-filters {
grid-area: aside;
inline-size: 9rem;
overflow-y: auto;
padding: var(--space-md);
background-color: var(--color-surface-sunken);
border-inline-start: var(--border-width-default) solid
var(--color-border-subtle);
}
/* The filters go first, the sidebar second; each departure
brings its navbar stand-in forward. */
.app-three-col .app-menu,
.app-three-col .app-funnel {
display: none;
}
@container (max-width: 38rem) {
.app-three-col > .app-filters {
display: none;
}
.app-three-col .app-funnel {
display: inline-flex;
}
}
@container (max-width: 30rem) {
.app-three-col > .sidebar {
display: none;
}
.app-three-col .app-menu {
display: inline-flex;
}
}
</style>
<div class="cq-inline">
<div class="app-three-col">
<aside class="sidebar">
<nav class="sidebar-nav" aria-label="Mail">
<a class="sidebar-link" href="#" aria-current="page">Inbox</a>
<a class="sidebar-link" href="#">Sent</a>
<a class="sidebar-link" href="#">Drafts</a>
</nav>
</aside>
<header class="navbar">
<div class="cluster is-between">
<div class="cluster is-tight">
<button
class="button is-ghost is-icon app-menu"
type="button"
aria-label="Menu"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<strong>Mail</strong>
</div>
<button
class="button is-ghost is-icon app-funnel"
type="button"
aria-label="Filters"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
</button>
</div>
</header>
<main>
<h3>Mail</h3>
<p class="text-muted text--1">
Drag the frame: the filters go first and the funnel appears; the sidebar
follows.
</p>
</main>
<aside class="app-filters">
<strong class="text--1">Filters</strong>
<nav class="sidebar-nav" aria-label="Filters">
<a class="sidebar-link" href="#">Unread</a>
<a class="sidebar-link" href="#">Starred</a>
<a class="sidebar-link" href="#">Attachments</a>
</nav>
</aside>
</div>
</div>
List on the left, detail on the right. Below 32rem of container width the detail leaves
the flow and the list takes the full width; opening a row shows the detail over the
list, with a back button to close it. The rows carry aria-expanded, and the
pane reads that attribute with :has(). Wiring the rows to real routing
changes nothing in the layout CSS.
From sarah@example.com, 2:14 PM
Thanks for pulling these together. I went through the flows and have a few comments on the onboarding path, mostly about where the empty states go.
<style>
.app-split {
position: relative;
display: grid;
grid-template-columns: 14rem minmax(0, 1fr);
grid-template-areas: 'list detail';
min-block-size: 100dvh;
}
.app-split > .list {
grid-area: list;
border-inline-end: var(--border-width-default) solid var(--color-border-subtle);
overflow-y: auto;
padding: var(--space-sm);
}
.app-split > .detail {
grid-area: detail;
overflow-y: auto;
background-color: var(--color-surface);
}
.app-split-detail-header {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-block-end: var(--border-width-default) solid var(--color-border-subtle);
}
.app-split-detail-header .app-back {
display: none;
}
.app-split-detail-body {
padding: var(--space-md);
}
.app-split-items {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: var(--space-3xs);
}
.app-split-row {
display: block;
padding: var(--space-2xs) var(--space-xs);
border-radius: var(--radius-sm);
color: var(--color-text);
text-decoration: none;
}
.app-split-row:hover {
background-color: var(--color-fill);
}
.app-split-row[aria-expanded='true'] {
background-color: var(--color-state-fill);
}
.app-split-row-meta {
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
/* Narrow: list only, detail as an overlay that an open
row floats back in. */
@container (max-width: 32rem) {
.app-split {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'list';
}
.app-split > .list {
border-inline-end: 0;
}
.app-split > .detail {
display: none;
}
.app-split:has([aria-expanded='true']) > .detail {
display: block;
position: absolute;
inset: 0;
z-index: var(--zindex-raised);
}
.app-split-detail-header .app-back {
display: inline-flex;
}
}
</style>
<div class="cq-inline">
<div class="app-split" id="app-split-demo">
<aside class="list" aria-label="Messages">
<ul class="app-split-items">
<li>
<a class="app-split-row" href="#" aria-expanded="false">
<strong>Sarah Chen</strong>
<span class="app-split-row-meta">Re: design review</span>
</a>
</li>
<li>
<a class="app-split-row" href="#" aria-expanded="false">
<strong>Build system</strong>
<span class="app-split-row-meta">Deploy succeeded</span>
</a>
</li>
<li>
<a class="app-split-row" href="#" aria-expanded="false">
<strong>Alex Park</strong>
<span class="app-split-row-meta">Quick question</span>
</a>
</li>
<li>
<a class="app-split-row" href="#" aria-expanded="false">
<strong>Team weekly</strong>
<span class="app-split-row-meta">Agenda for Thursday</span>
</a>
</li>
</ul>
</aside>
<article class="detail">
<header class="app-split-detail-header">
<button
class="button is-ghost is-icon app-back"
type="button"
aria-label="Back"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<strong>Re: design review</strong>
</header>
<div class="flow app-split-detail-body">
<p class="text-muted text--1">
<strong>From</strong> sarah@example.com, 2:14 PM
</p>
<p>
Thanks for pulling these together. I went through the flows and have
a few comments on the onboarding path, mostly about where the empty
states go.
</p>
</div>
</article>
</div>
</div>
<script>
(() => {
const pane = document.getElementById('app-split-demo');
if (!pane) return;
pane.addEventListener('click', (e) => {
const row = e.target.closest('.app-split-row');
const back = e.target.closest('.app-back');
if (!row && !back) return;
e.preventDefault();
pane.querySelectorAll('.app-split-row').forEach((link) =>
link.setAttribute('aria-expanded', String(link === row))
);
});
})();
</script>
The split is a region like any other and composes into a shell the same way. The width
its breakpoint resolves against is the part to note: main carries
.cq-inline itself, so the split's threshold is measured in the content
column, not the shell. Drag the frame and the regions leave in order, the shell's
sidebar first and the split's detail later. Each container context resolves on its own.
From sarah@example.com
Drag the frame slowly. The shell's sidebar lets go first, and the split's detail holds on a little longer, because it listens to the content column instead of the shell.
<style>
.app-inbox {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto 1fr;
grid-template-areas:
'header header'
'sidebar main';
min-block-size: 100dvh;
}
.app-inbox > .navbar {
grid-area: header;
}
.app-inbox > .sidebar {
grid-area: sidebar;
--sidebar-width: 8rem;
}
.app-inbox > main {
grid-area: main;
min-inline-size: 0;
}
/* The pane is its own query context (main carries
.cq-inline in the markup), so its threshold measures
the content column, not the shell. */
.app-inbox-pane {
position: relative;
display: grid;
grid-template-columns: 12rem minmax(0, 1fr);
grid-template-areas: 'list detail';
block-size: 100%;
}
.app-inbox-pane > .list {
grid-area: list;
border-inline-end: var(--border-width-default) solid var(--color-border-subtle);
overflow-y: auto;
padding: var(--space-sm);
}
.app-inbox-pane > .detail {
grid-area: detail;
overflow-y: auto;
background-color: var(--color-surface);
}
.app-inbox-detail-header {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-block-end: var(--border-width-default) solid var(--color-border-subtle);
}
.app-inbox-detail-header .app-back {
display: none;
}
.app-inbox-detail-body {
padding: var(--space-md);
}
.app-inbox-pane .sidebar-link[aria-expanded='true'] {
background-color: var(--color-state-fill);
}
@container (max-width: 36rem) {
.app-inbox > .sidebar {
display: none;
}
.app-inbox .app-menu {
display: inline-flex;
}
}
@container (max-width: 28rem) {
.app-inbox-pane {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'list';
}
.app-inbox-pane > .list {
border-inline-end: 0;
}
.app-inbox-pane > .detail {
display: none;
}
.app-inbox-pane:has([aria-expanded='true']) > .detail {
display: block;
position: absolute;
inset: 0;
z-index: var(--zindex-raised);
}
.app-inbox-detail-header .app-back {
display: inline-flex;
}
}
</style>
<div class="cq-inline">
<div class="app-inbox">
<header class="navbar">
<div class="cluster is-between">
<div class="cluster is-tight">
<button
class="button is-ghost is-icon app-menu"
type="button"
aria-label="Menu"
disabled
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<strong>Inbox</strong>
</div>
</div>
</header>
<aside class="sidebar">
<nav class="sidebar-nav" aria-label="Mail">
<a class="sidebar-link" href="#" aria-current="page">Inbox</a>
<a class="sidebar-link" href="#">Sent</a>
<a class="sidebar-link" href="#">Drafts</a>
<a class="sidebar-link" href="#">Archive</a>
</nav>
</aside>
<main class="cq-inline">
<div class="app-inbox-pane" id="app-inbox-demo">
<aside class="list" aria-label="Messages">
<nav class="sidebar-nav">
<a class="sidebar-link" href="#" aria-expanded="false"
>Sarah Chen</a
>
<a class="sidebar-link" href="#" aria-expanded="false"
>Build system</a
>
<a class="sidebar-link" href="#" aria-expanded="false"
>Alex Park</a
>
</nav>
</aside>
<article class="detail">
<header class="app-inbox-detail-header">
<button
class="button is-ghost is-icon app-back"
type="button"
aria-label="Back"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<strong>Re: design review</strong>
</header>
<div class="flow app-inbox-detail-body">
<p class="text-muted text--1">
<strong>From</strong> sarah@example.com
</p>
<p>
Drag the frame slowly. The shell's sidebar lets go first,
and the split's detail holds on a little longer, because it
listens to the content column instead of the shell.
</p>
</div>
</article>
</div>
</main>
</div>
</div>
<script>
(() => {
const shell = document.getElementById('app-inbox-demo');
if (!shell) return;
shell.addEventListener('click', (e) => {
const row = e.target.closest('.sidebar-link');
const back = e.target.closest('.app-back');
if (!row && !back) return;
e.preventDefault();
shell
.querySelectorAll('.sidebar-link')
.forEach((link) =>
link.setAttribute('aria-expanded', String(link === row))
);
});
})();
</script>