Overview
Activity across your workspace.
The application shell: a sidebar, a navbar, the main content and an optional side panel, arranged for desktop and rearranged for phones.
import { AppLayout } from 'frontile';
AppLayout yields its parts, and each part places itself, so the order you
write them in does not matter. The layout fills the viewport and Main
scrolls on its own.
Below the md breakpoint the sidebar moves into a drawer, opened by
MenuToggle. Expand the demo and pick Phone to try it.
import { AppLayout } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
import {
ViewIcon,
UserIcon,
SettingsIcon,
SparklesIcon
} from 'site/components/icons';
<template>
<DemoFrame @id='usage' @label='App shell'>
<AppLayout as |layout|>
<layout.Sidebar as |sidebar|>
<sidebar.Header>
<SparklesIcon class='size-5 shrink-0 text-primary' />
<span class='truncate text-sm font-semibold' data-sidebar-label>
Acme
</span>
<sidebar.Toggle class='ml-auto' />
</sidebar.Header>
<sidebar.Body>
<sidebar.Item @href='#overview' @icon={{ViewIcon}} @isActive={{true}}>
Overview
</sidebar.Item>
<sidebar.Item @href='#members' @icon={{UserIcon}}>
Members
</sidebar.Item>
<sidebar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</sidebar.Item>
</sidebar.Body>
</layout.Sidebar>
<layout.Navbar>
<layout.MenuToggle />
<span class='text-sm font-medium'>Overview</span>
</layout.Navbar>
<layout.Main>
<layout.PageHeader
@title='Overview'
@description='Activity across your workspace.'
/>
</layout.Main>
</AppLayout>
</DemoFrame>
</template>
| Part | What it is |
|---|---|
Navbar |
The top bar, a header landmark |
Sidebar |
The navigation column; see Sidebar |
Main |
The page content, the main landmark |
PageHeader |
A title row with actions, placed inside Main |
Aside |
An optional panel beside Main for details or filters |
BottomNav |
A tab bar that replaces the sidebar on phones; see BottomNav |
MenuToggle |
Opens the sidebar's drawer on small screens |
SidebarToggle, AsideToggle |
Collapse the sidebar, open the aside, from outside them |
inset, the default, sets the content in a rounded card on a tinted frame.
attached runs the content edge to edge, with the sidebar on the same tinted
ground and borders between the sidebar, navbar and content. The layout draws
these, not the parts: a Sidebar on its own has neither. On phones both
variants run edge to edge.
import { AppLayout } from 'frontile';
import { array, concat } from '@ember/helper';
import DemoFrame from 'site/components/demo-frame';
import { ViewIcon, SettingsIcon, SparklesIcon } from 'site/components/icons';
<template>
<div class='demo-stack demo-stack--wide'>
{{#each (array 'inset' 'attached') as |variant|}}
<DemoFrame @id={{variant}} @label={{concat 'Variant: ' variant}}>
<AppLayout @variant={{variant}} as |layout|>
<layout.Sidebar @label={{variant}} as |sidebar|>
<sidebar.Header>
<SparklesIcon class='size-5 shrink-0 text-primary' />
<span class='truncate text-sm font-semibold' data-sidebar-label>
Acme
</span>
<sidebar.Toggle class='ml-auto' />
</sidebar.Header>
<sidebar.Body>
<sidebar.Item
@href='#overview'
@icon={{ViewIcon}}
@isActive={{true}}
>
Overview
</sidebar.Item>
<sidebar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</sidebar.Item>
</sidebar.Body>
</layout.Sidebar>
<layout.Navbar>
<layout.MenuToggle />
<code class='text-sm'>@variant='{{variant}}'</code>
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='Overview' />
</layout.Main>
</AppLayout>
</DemoFrame>
{{/each}}
</div>
</template>
In the inset variant, @navbarPlacement decides what the navbar belongs to.
frame, the default, puts it on the frame with the sidebar: use it for
app-wide controls such as search and the account menu. content makes it the
card's first row, part of the page: use it for the page's own breadcrumb and
actions.
import { AppLayout, Avatar, Breadcrumbs, Button } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
import {
PackageIcon,
SearchIcon,
SettingsIcon,
ViewIcon
} from 'site/components/icons';
<template>
<div class='demo-stack demo-stack--wide'>
<DemoFrame @id='navbar-frame' @label='Navbar placement: frame'>
<AppLayout @navbarPlacement='frame' as |layout|>
<layout.Sidebar @label='Frame' as |sidebar|>
<sidebar.Body>
<sidebar.Item @href='#overview' @icon={{ViewIcon}}>
Overview
</sidebar.Item>
<sidebar.Item
@href='#orders'
@icon={{PackageIcon}}
@isActive={{true}}
>
Orders
</sidebar.Item>
<sidebar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</sidebar.Item>
</sidebar.Body>
</layout.Sidebar>
<layout.Navbar>
<layout.MenuToggle />
<Button @size='xs' @variant='outline' @class='ms-auto'>
<SearchIcon class='size-4' />
Search
</Button>
<Avatar @name='Sample User' @size='sm' />
</layout.Navbar>
<layout.Main>
<layout.PageHeader
@title='Orders'
@description='Everything placed in the last 30 days.'
/>
</layout.Main>
</AppLayout>
</DemoFrame>
<DemoFrame @id='navbar-content' @label='Navbar placement: content'>
<AppLayout @navbarPlacement='content' as |layout|>
<layout.Sidebar @label='Content' as |sidebar|>
<sidebar.Body>
<sidebar.Item @href='#overview' @icon={{ViewIcon}}>
Overview
</sidebar.Item>
<sidebar.Item
@href='#orders'
@icon={{PackageIcon}}
@isActive={{true}}
>
Orders
</sidebar.Item>
<sidebar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</sidebar.Item>
</sidebar.Body>
</layout.Sidebar>
<layout.Navbar>
<layout.MenuToggle />
<Breadcrumbs as |b|>
<b.Item @href='#orders'>Orders</b.Item>
<b.Item>#1042</b.Item>
</Breadcrumbs>
<Button @size='xs' @variant='outline' @class='ms-auto'>
Refund
</Button>
</layout.Navbar>
<layout.Main>
<layout.PageHeader
@title='Order #1042'
@description='Paid · 3 items · Ships tomorrow'
/>
</layout.Main>
</AppLayout>
</DemoFrame>
</div>
</template>
sidebar.Toggle sits inside the sidebar. To collapse the sidebar from
anywhere else in the layout, such as the start of the navbar, use the yielded
layout.SidebarToggle. Below the breakpoint the same button opens the
drawer, so it can stand in for MenuToggle. It pairs well with
@collapsible='offcanvas', where the sidebar has no room left for a toggle of
its own.
import { AppLayout } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
import {
ViewIcon,
PackageIcon,
SettingsIcon,
SparklesIcon
} from 'site/components/icons';
<template>
<DemoFrame @id='navbar-toggle' @label='Sidebar toggle in the navbar'>
<AppLayout as |layout|>
<layout.Sidebar @collapsible='offcanvas' as |sidebar|>
<sidebar.Header>
<SparklesIcon class='size-5 shrink-0 text-primary' />
<span class='truncate text-sm font-semibold'>Acme</span>
</sidebar.Header>
<sidebar.Body>
<sidebar.Item @href='#overview' @icon={{ViewIcon}} @isActive={{true}}>
Overview
</sidebar.Item>
<sidebar.Item @href='#orders' @icon={{PackageIcon}}>
Orders
</sidebar.Item>
<sidebar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</sidebar.Item>
</sidebar.Body>
</layout.Sidebar>
<layout.Navbar>
<layout.SidebarToggle />
<span class='text-sm font-medium'>Overview</span>
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='Overview' />
</layout.Main>
</AppLayout>
</DemoFrame>
</template>
Below @breakpoint (sm 40rem, md 48rem by default, lg 64rem) the
sidebar leaves the page. These are Tailwind's default breakpoints, read in
JavaScript, so changing --breakpoint-* in your theme does not move them.
By default (@mobileNav='drawer') it opens as a drawer from its side, as in
Usage. Put a MenuToggle in the navbar to open it; it shows only
below the breakpoint, and sidebar.Toggle hides there, so one set of markup
works at every width. The drawer closes on Escape, a click outside,
a followed Item, or when the window grows past the breakpoint.
With @mobileNav='bottom' the layout shows its BottomNav as
a tab bar instead, and the sidebar and its toggles render nothing. Write both
the Sidebar and the BottomNav; each appears at its own width. Place
BottomNav after Main. Expand this demo and pick Phone.
import { AppLayout } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
import {
ViewIcon,
UserIcon,
SettingsIcon,
SparklesIcon
} from 'site/components/icons';
<template>
<DemoFrame @id='bottom-nav' @label='Bottom navigation'>
<AppLayout @mobileNav='bottom' as |layout|>
<layout.Sidebar as |sidebar|>
<sidebar.Header>
<SparklesIcon class='size-5 shrink-0 text-primary' />
<span class='truncate text-sm font-semibold' data-sidebar-label>
Acme
</span>
<sidebar.Toggle class='ml-auto' />
</sidebar.Header>
<sidebar.Body>
<sidebar.Item @href='#home' @icon={{ViewIcon}} @isActive={{true}}>
Home
</sidebar.Item>
<sidebar.Item @href='#people' @icon={{UserIcon}} @badge={{2}}>
People
</sidebar.Item>
<sidebar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</sidebar.Item>
</sidebar.Body>
</layout.Sidebar>
<layout.Navbar>
<span class='text-sm font-medium'>Home</span>
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='Home' />
</layout.Main>
<layout.BottomNav as |bar|>
<bar.Item @href='#home' @icon={{ViewIcon}} @isActive={{true}}>
Home
</bar.Item>
<bar.Item @href='#people' @icon={{UserIcon}} @badge={{2}}>
People
</bar.Item>
<bar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</bar.Item>
</layout.BottomNav>
</AppLayout>
</DemoFrame>
</template>
The sidebar's block remounts when the window crosses the breakpoint, so state held by components inside it starts over.
@sidebarSide='end' moves the sidebar to the last column, the aside to the
first, and the drawer to the end edge. Sides are logical, so start is the
right in a right-to-left page.
import { AppLayout } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
import { ViewIcon, SettingsIcon } from 'site/components/icons';
<template>
<DemoFrame @id='sidebar-end' @label='Sidebar on the end'>
<AppLayout @variant='attached' @sidebarSide='end' as |layout|>
<layout.Sidebar as |sidebar|>
<sidebar.Header><sidebar.Toggle class='mr-auto' /></sidebar.Header>
<sidebar.Body>
<sidebar.Item @href='#overview' @icon={{ViewIcon}} @isActive={{true}}>
Overview
</sidebar.Item>
<sidebar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</sidebar.Item>
</sidebar.Body>
</layout.Sidebar>
<layout.Navbar>
<span class='text-sm font-medium'>Overview</span>
<layout.MenuToggle class='ms-auto' />
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='Overview' />
</layout.Main>
</AppLayout>
</DemoFrame>
</template>
Aside is a panel beside Main for details, filters or help, shown while
the layout's asideOpen is true. AsideToggle opens and closes it, and
renders nothing while the layout has no Aside, so a page without one shows
no toggle. From lg (64rem) up it is a column and Main narrows; below lg
the same block opens as a drawer.
If the window narrows while the aside is open, it closes rather than popping
up as a drawer. A layout that starts with the aside open on a narrow screen
does open the drawer, so set @defaultAsideOpen only where the window is
wide.
import { AppLayout } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
<template>
<DemoFrame @id='aside' @label='Aside'>
<AppLayout as |layout|>
<layout.Navbar>
<span class='text-sm font-medium'>Access management</span>
<layout.AsideToggle class='ms-auto' />
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='Roles' />
</layout.Main>
<layout.Aside @label='Role details'>
<h2 class='font-semibold'>Administrator</h2>
<p class='mt-1 text-sm text-neutral-firm'>
Full access to every workspace.
</p>
</layout.Aside>
</AppLayout>
</DemoFrame>
</template>
PageHeader is the title row at the top of Main: a @title, a
@description, buttons in <:actions>, and anything else under the title in
<:default>.
import { AppLayout, Button, Chip } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
<template>
<DemoFrame @id='page-header' @label='Page header'>
<AppLayout as |layout|>
<layout.Navbar>
<span class='text-sm font-medium'>Access management</span>
</layout.Navbar>
<layout.Main>
<layout.PageHeader
@title='Roles'
@description='Roles group the permissions you grant to people.'
>
<:actions>
<Button @size='xs' @variant='outline'>Export</Button>
<Button @size='xs' @color='primary'>New role</Button>
</:actions>
<:default>
<Chip @size='sm'>12 roles</Chip>
</:default>
</layout.PageHeader>
</layout.Main>
</AppLayout>
</DemoFrame>
</template>
By default (@scrollMode='content') the layout is the height of the viewport
and Main scrolls. With @scrollMode='page' the layout grows with its
content and the document scrolls, while the sidebar sticks to the top and a
BottomNav to the bottom. Use page when the browser's own scrolling
matters: scroll restoration, anchor links, pull-to-refresh on phones.
import { AppLayout } from 'frontile';
import { array } from '@ember/helper';
import DemoFrame from 'site/components/demo-frame';
import { BookIcon, ViewIcon } from 'site/components/icons';
<template>
<DemoFrame @id='page-scroll' @label='Page scrolling'>
<AppLayout @scrollMode='page' @variant='attached' as |layout|>
<layout.Sidebar @label='Guides' as |sidebar|>
<sidebar.Body>
<sidebar.Item @href='#start' @icon={{BookIcon}} @isActive={{true}}>
Getting started
</sidebar.Item>
<sidebar.Item @href='#concepts' @icon={{ViewIcon}}>
Concepts
</sidebar.Item>
</sidebar.Body>
</layout.Sidebar>
<layout.Navbar>
<layout.MenuToggle />
<span class='text-sm font-medium'>Guides</span>
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='Getting started' />
<div class='flex flex-col gap-4 px-4 pb-8 text-sm text-neutral-firm'>
{{#each (array 1 2 3 4 5 6 7 8) as |step|}}
<section>
<h2 class='font-semibold text-neutral-strong'>Step {{step}}</h2>
<p>Scroll the page: the sidebar stays where it is.</p>
</section>
{{/each}}
</div>
</layout.Main>
</AppLayout>
</DemoFrame>
</template>
The layout tracks sidebarCollapsed, mobileSidebarOpen and asideOpen, and
yields them as layout.state with methods that change them:
toggleSidebar, collapseSidebar, expandSidebar, openMobileSidebar,
closeMobileSidebar, toggleAside and closeAside. Pass a method straight
to @onPress or {{on}}.
Each value can be:
@defaultSidebarCollapsed, @defaultMobileSidebarOpen
and @defaultAsideOpen set where it starts.@sidebarCollapsed, @mobileSidebarOpen and @asideOpen
set it, and the matching @on…Change callback receives the requested
value. The layout changes once you update the argument.AppLayoutState passed as @state. Anything holding it can
read and change the layout. The individual arguments are then ignored.import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { AppLayout, Button } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
export default class ControlledAsideExample extends Component {
@tracked asideOpen = false;
setAsideOpen = (value: boolean) => {
this.asideOpen = value;
};
<template>
<DemoFrame @id='controlled' @label='Controlled aside'>
<AppLayout
@asideOpen={{this.asideOpen}}
@onAsideOpenChange={{this.setAsideOpen}}
as |layout|
>
<layout.Navbar>
<span class='text-sm font-medium'>Billing</span>
<Button
@size='xs'
@class='ms-auto'
@onPress={{layout.state.toggleAside}}
>
{{if this.asideOpen 'Hide details' 'Show details'}}
</Button>
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='Invoices' />
</layout.Main>
<layout.Aside @label='Invoice details'>
<p class='text-sm'>Invoice #1042 · Paid</p>
</layout.Aside>
</AppLayout>
</DemoFrame>
</template>
}
Create an AppLayoutState where both sides can reach it, such as a service,
and pass it to the layout as @state. Each layout has its own state, so two
layouts on a page stay independent.
import Component from '@glimmer/component';
import type { TOC } from '@ember/component/template-only';
import { AppLayout, AppLayoutState, Button } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
const DetailsButton: TOC<{ Args: { state: AppLayoutState } }> = <template>
<Button @size='xs' @onPress={{@state.toggleAside}}>
{{if @state.asideOpen 'Hide details' 'Show details'}}
</Button>
</template>;
export default class SharedStateExample extends Component {
state = new AppLayoutState();
<template>
<div class='demo-stack demo-stack--wide'>
<DetailsButton @state={{this.state}} />
<DemoFrame @id='shared' @label='Shared state'>
<AppLayout @state={{this.state}} as |layout|>
<layout.Navbar>
<span class='text-sm font-medium'>Reports</span>
<layout.AsideToggle class='ms-auto' />
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='Monthly report' />
</layout.Main>
<layout.Aside @label='Report details'>
<p class='text-sm'>Generated on the 1st of each month.</p>
</layout.Aside>
</AppLayout>
</DemoFrame>
</div>
</template>
}
The layout sets its sizes as CSS custom properties on its root, for your own elements to line up with:
| Property | Value |
|---|---|
--app-navbar-height |
3rem |
--app-sidebar-width |
The sidebar column's width, including the inset gutter; 0px when none |
--app-bottomnav-height |
The bottom bar's height, including the safe-area inset; 0px when hidden |
The width and bottom-bar tokens are set after the first render, so give them a fallback. A toolbar fixed under the navbar and beside the sidebar:
<div
class='fixed end-0'
style='top: var(--app-navbar-height, 3rem); inset-inline-start: var(--app-sidebar-width, 0px);'
>
Filters
</div>
A workspace with grouped navigation, a folder, an account menu in the navbar, page actions and a details panel.
import { AppLayout, Avatar, Button } from 'frontile';
import { array } from '@ember/helper';
import DemoFrame from 'site/components/demo-frame';
import {
ViewIcon,
PackageIcon,
BookIcon,
StarIcon,
SettingsIcon,
SearchIcon,
PlusIcon,
SparklesIcon
} from 'site/components/icons';
<template>
<DemoFrame @id='dashboard' @label='Dashboard'>
<AppLayout as |layout|>
<layout.Sidebar @label='Workspace' as |sidebar|>
<sidebar.Header>
<SparklesIcon class='size-5 shrink-0 text-primary' />
<span class='truncate text-sm font-semibold' data-sidebar-label>
Acme
</span>
<sidebar.Toggle class='ml-auto' />
</sidebar.Header>
<sidebar.Body>
<sidebar.Item @href='#overview' @icon={{ViewIcon}} @isActive={{true}}>
Overview
</sidebar.Item>
<sidebar.Folder @label='Products' @icon={{PackageIcon}} as |folder|>
<folder.Item @href='#catalog'>Catalog</folder.Item>
<folder.Item @href='#pricing'>Pricing</folder.Item>
</sidebar.Folder>
<sidebar.Item @href='#orders' @icon={{BookIcon}} @badge={{4}}>
Orders
</sidebar.Item>
<sidebar.Group @label='Saved'>
<sidebar.Item @href='#q3' @icon={{StarIcon}}>Q3 review</sidebar.Item>
</sidebar.Group>
</sidebar.Body>
<sidebar.Footer>
<sidebar.Item @href='#settings' @icon={{SettingsIcon}}>
Settings
</sidebar.Item>
</sidebar.Footer>
</layout.Sidebar>
<layout.Navbar>
<layout.MenuToggle />
<Button @size='xs' @variant='outline' @class='ms-auto'>
<SearchIcon class='size-4' />
Search
</Button>
<layout.AsideToggle />
<Avatar @name='Sample User' @size='sm' />
</layout.Navbar>
<layout.Main>
<layout.PageHeader
@title='Overview'
@description='The last 30 days in your workspace.'
>
<:actions>
<Button @size='xs' @color='primary'>
<PlusIcon class='size-4' />
New order
</Button>
</:actions>
</layout.PageHeader>
<div class='grid gap-3 px-4 pb-4 sm:grid-cols-3'>
{{#each (array 'Revenue' 'Orders' 'Customers') as |metric|}}
<div class='rounded-lg border border-neutral-soft p-4'>
<p class='text-sm text-neutral-firm'>{{metric}}</p>
<p class='mt-1 text-xl font-semibold'>—</p>
</div>
{{/each}}
</div>
</layout.Main>
<layout.Aside @label='Activity'>
<h2 class='font-semibold'>Activity</h2>
<p class='mt-1 text-sm text-neutral-firm'>No new activity.</p>
</layout.Aside>
</AppLayout>
</DemoFrame>
</template>
Selecting a row opens its details in the aside, through layout.state. On a
phone the details open as a drawer.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { AppLayout, AppLayoutState, Listbox } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
const PEOPLE: Record<string, { role: string; team: string }> = {
'Avery Kim': { role: 'Administrator', team: 'Platform' },
'Blake Moreno': { role: 'Editor', team: 'Marketing' },
'Casey Lin': { role: 'Viewer', team: 'Finance' }
};
export default class ListDetailExample extends Component {
state = new AppLayoutState();
people = Object.keys(PEOPLE);
@tracked selected: string[] = [];
get person() {
const name = this.selected[0];
return name ? { name, ...PEOPLE[name] } : undefined;
}
select = (keys: string[]) => {
this.selected = keys;
this.state.asideOpen = keys.length > 0;
};
<template>
<DemoFrame @id='list-detail' @label='List and detail'>
<AppLayout @state={{this.state}} as |layout|>
<layout.Navbar>
<span class='text-sm font-medium'>People</span>
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title='People' />
<div class='px-2'>
<Listbox
@selectionMode='single'
@items={{this.people}}
@selectedKeys={{this.selected}}
@onSelectionChange={{this.select}}
/>
</div>
</layout.Main>
<layout.Aside @label='Person details'>
{{#if this.person}}
<h2 class='font-semibold'>{{this.person.name}}</h2>
<p class='mt-1 text-sm text-neutral-firm'>
{{this.person.role}}
·
{{this.person.team}}
</p>
{{/if}}
</layout.Aside>
</AppLayout>
</DemoFrame>
</template>
}
A mobile-first app: a rail of stacked items on desktop, and on a phone a top
bar, a bottom tab bar and a More sheet. The active page is a tracked value
so the demo needs no router; in an app, pass @route instead. The account
buttons are your own markup.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
import { AppLayout, Avatar } from 'frontile';
import DemoFrame from 'site/components/demo-frame';
import {
ViewIcon,
PackageIcon,
BookIcon,
StarIcon,
UserIcon,
SparklesIcon
} from 'site/components/icons';
const PAGES = [
{ id: 'overview', label: 'Overview', icon: ViewIcon, badge: 2 },
{ id: 'services', label: 'Services', icon: PackageIcon },
{ id: 'billing', label: 'Billing', icon: BookIcon },
{ id: 'market', label: 'Marketplace', short: 'Market', icon: StarIcon },
{
id: 'referrals',
label: 'Referral program',
short: 'Referrals',
icon: UserIcon
},
{ id: 'support', label: 'Support', icon: SparklesIcon }
];
export default class RailAppExample extends Component {
@tracked page = 'overview';
go = (id: string) => {
this.page = id;
};
get items() {
return PAGES.map((p) => ({ ...p, isActive: p.id === this.page }));
}
// The first four pages are tabs on a phone; the rest go in More.
get tabs() {
return this.items.slice(0, 4);
}
get more() {
return this.items.slice(4);
}
get title() {
return PAGES.find((p) => p.id === this.page)?.label;
}
<template>
<DemoFrame @id='rail-app' @label='Rail and bottom bar'>
<AppLayout @variant='attached' @mobileNav='bottom' as |layout|>
<layout.Sidebar @size='rail' @collapsible='none' as |sidebar|>
<sidebar.Header>
<SparklesIcon class='size-5 shrink-0 text-primary' />
</sidebar.Header>
<sidebar.Body>
{{#each this.items as |item|}}
<sidebar.Item
@layout='stacked'
@icon={{item.icon}}
@shortLabel={{item.short}}
@isActive={{item.isActive}}
@badge={{item.badge}}
{{on 'click' (fn this.go item.id)}}
>{{item.label}}</sidebar.Item>
{{/each}}
</sidebar.Body>
<sidebar.Footer>
<Avatar @name='Sample User' @size='sm' class='mx-auto' />
</sidebar.Footer>
</layout.Sidebar>
<layout.Navbar class='md:hidden'>
<SparklesIcon class='size-5 shrink-0 text-primary' />
<span class='text-sm font-medium'>{{this.title}}</span>
<Avatar @name='Sample User' @size='sm' class='ms-auto' />
</layout.Navbar>
<layout.Main>
<layout.PageHeader @title={{this.title}} />
</layout.Main>
<layout.BottomNav as |bar|>
{{#each this.tabs as |item|}}
<bar.Item
@icon={{item.icon}}
@badge={{item.badge}}
@isActive={{item.isActive}}
{{on 'click' (fn this.go item.id)}}
>{{item.label}}</bar.Item>
{{/each}}
<bar.More as |more|>
{{#each this.more as |item|}}
<more.Item
@icon={{item.icon}}
@isActive={{item.isActive}}
{{on 'click' (fn this.go item.id)}}
>{{item.label}}</more.Item>
{{/each}}
</bar.More>
</layout.BottomNav>
</AppLayout>
</DemoFrame>
</template>
}
Navbar is a header landmark and Main the main landmark. Render one
AppLayout per page so there is one of each.@skipLinkLabel, or remove it with
@skipLink={{false}} if the app has its own.@label.
Focus stays inside while it is open and returns to the element that opened
it.MenuToggle (default "Open menu"), SidebarToggle (default "Toggle
sidebar") and AsideToggle (default "Toggle details") are buttons named by
@label, with aria-expanded. aria-controls names what they open while
it is on the page, so a drawer is named only while it is open. Without a
Sidebar (or an Aside) in the layout, its toggles render nothing.Aside is an aside landmark named by @label (default Details); as a
drawer, the dialog has the same name.PageHeader renders @title as an h1.@mobileNav='bottom' on a phone, the BottomNav is the page's
navigation; see BottomNav.| Key | Action |
|---|---|
| Tab | From the top of the page, shows and focuses the skip link |
| Escape | Closes the open drawer |
Element: HTMLDivElement
The application shell. Its root is a grid that the yielded parts place themselves in, so they can be written in any order.
| Name | Type | Default | Description |
|---|---|---|---|
asideOpen
|
boolean
|
- |
Whether the aside is open. Passing it makes the value controlled.
Ignored when @state is passed.
|
breakpoint
|
enum
|
'md'
|
The viewport width (sm 40rem, md 48rem, lg 64rem) below which the
sidebar becomes a drawer opened by MenuToggle. Growing past it closes
an open drawer. Without window.matchMedia the layout stays desktop.
|
classes
|
SlotsToClasses<'base' | 'sidebar' | 'mobileSidebar' | 'drawerCloseButton' | 'sidebarHeader' | 'sidebarBody' | 'sidebarFooter' | 'sidebarGroup' | 'sidebarGroupLabel' | 'sidebarToggle' | ... 13 more ... | 'skipLink'>
|
- | Class names for each slot of the component, merged with the theme's. |
defaultAsideOpen
|
boolean
|
false
|
The initial asideOpen when uncontrolled.
|
defaultMobileSidebarOpen
|
boolean
|
false
|
The initial mobileSidebarOpen when uncontrolled.
|
defaultSidebarCollapsed
|
boolean
|
false
|
The initial sidebarCollapsed when uncontrolled.
|
mobileNav
|
enum
|
'drawer'
|
How navigation is shown below @breakpoint. drawer moves the sidebar
into a drawer opened by MenuToggle. bottom shows the yielded
BottomNav as a bar along the bottom instead, and renders no sidebar,
MenuToggle or SidebarToggle there. At or above @breakpoint both are
the desktop sidebar, and BottomNav renders nothing. Place BottomNav
after Main.
|
mobileSidebarOpen
|
boolean
|
- |
Whether the mobile sidebar drawer is open. Passing it makes the value
controlled. Ignored when @state is passed.
|
navbarPlacement
|
enum
|
'frame'
|
Where the navbar sits in the inset variant. frame puts it on the
tinted frame above the card; content makes it the card's first row.
|
onAsideOpenChange
|
function
|
- | Called with the new value when the aside opens or closes. |
onMobileSidebarOpenChange
|
function
|
- | Called with the new value when the mobile drawer opens or closes. |
onSidebarCollapsedChange
|
function
|
- | Called with the new value when the sidebar is collapsed or expanded. |
scrollMode
|
enum
|
'content'
|
content fills the viewport and scrolls Main on its own. page lets
the document scroll instead.
|
sidebarCollapsed
|
boolean
|
- |
Whether the desktop sidebar is collapsed. Passing it makes the value
controlled: toggles call @onSidebarCollapsedChange and wait for the
argument to change. Ignored when @state is passed.
|
sidebarSide
|
enum
|
'start'
|
Which side the sidebar sits on. Logical, so start is the right side in
a right-to-left document.
|
skipLink
|
boolean
|
true
|
Renders a "Skip to main content" link as the first focusable element. |
skipLinkLabel
|
string
|
'Skip to main content'
|
The text of the skip link. |
state
|
AppLayoutState
|
- |
A shared AppLayoutState to drive the layout from elsewhere in the app.
When passed, it is the only source of state: the individual state
arguments and their callbacks are ignored.
|
variant
|
enum
|
'inset'
|
inset draws the content in a rounded card on a tinted frame.
attached runs the content edge to edge, with no card.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLElement
The top bar of the content column, rendered as a header landmark. It
always sits to the sidebar's side, never above it.
| Name | Type | Default | Description |
|---|---|---|---|
layout
|
Object
|
- |
Supplied by AppLayout. Not part of the public API.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLElement
The page's main landmark. Inside AppLayout it fills the content area
and, by default, scrolls on its own; the skip link moves focus here.
| Name | Type | Default | Description |
|---|---|---|---|
layout
|
Object
|
- |
Supplied by AppLayout. Not part of the public API.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLDivElement
The title row at the top of a page: title, description and actions. Place
it inside Main.
| Name | Type | Default | Description |
|---|---|---|---|
description
|
string
|
- | A short line under the title. |
layout
|
Object
|
- |
Supplied by AppLayout. Not part of the public API.
|
title
|
string
|
- |
The page title, rendered as the page's h1.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- | Extra content under the title and description. |
actions
*
|
Array
|
- | Buttons or links for the page, aligned to the end of the row. |
Element: HTMLElement
A panel beside Main, shown while asideOpen is true. From lg (64rem)
up it is an in-flow column that narrows Main; below lg the same block
renders in a Drawer from the same side, whatever the layout's
@breakpoint is.
Open it with AsideToggle. Outside an AppLayout it needs @state and
is always inline.
| Name | Type | Default | Description |
|---|---|---|---|
label
|
string
|
'Details'
|
The accessible name of the aside landmark, also the name of the drawer
below lg.
|
layout
|
Object
|
- |
Supplied by AppLayout. Not part of the public API.
|
state
|
AppLayoutState
|
- |
The state that drives it. Only used when the aside is rendered outside
an AppLayout; inside one it follows the layout's state.
|
variant
|
enum
|
'attached'
|
The layout variant, inset or attached. Only used when the aside is
rendered outside an AppLayout; inside one it follows the layout.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLButtonElement
A button that opens and closes the Aside. Conventionally placed at the
end of the Navbar. aria-expanded is true while the aside is open.
Inside an AppLayout it renders nothing while the layout has no Aside,
so a page without one shows no toggle.
| Name | Type | Default | Description |
|---|---|---|---|
controls
|
string
|
- |
The id of the aside this button controls, for aria-controls. Inside an
AppLayout it defaults to the layout's aside.
|
label
|
string
|
'Toggle details'
|
The accessible name of the button. |
layout
|
Object
|
- |
Supplied by AppLayout. Not part of the public API.
|
state
|
AppLayoutState
|
- |
The state to toggle. Only used outside an AppLayout; inside one the
layout's state is toggled.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- | Replaces the default icon with your own content. |
Element: HTMLButtonElement
The mobile menu button: opens the sidebar drawer. It is shown only below
the layout's @breakpoint, typically at the top-left of the navbar. A
SidebarToggle also opens the drawer below the breakpoint, so a layout can
use either one there; with both in the navbar, both show on narrow
screens. Closing the drawer returns focus to the button that opened it.
With @mobileNav="bottom" there is no drawer, so below the breakpoint it
renders nothing.
| Name | Type | Default | Description |
|---|---|---|---|
controls
|
string
|
- |
The id of the sidebar this button opens, for aria-controls. Inside an
AppLayout it defaults to the layout's sidebar.
|
label
|
string
|
'Open menu'
|
The accessible name of the button. |
layout
|
Object
|
- |
Supplied by AppLayout. Not part of the public API.
|
state
|
AppLayoutState
|
- |
The state whose mobileSidebarOpen the button sets. Only used outside an
AppLayout; inside one the layout's state is used.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- | Replaces the default icon with your own content. |