Skip to main content

AppShell

Used to compose a fixed app shell with automatic layout offsets.

github
View source code
import { AppShell } from '@uhg-abyss/web/ui/AppShell';

AppShell is the layout primitive for a persistent application frame: a header, navigation rail, and footer that stay fixed in place while AppShell.Main scrolls independently. It handles the responsive behavior between regions — like collapsing a rail into a mobile drawer — so pages don't need to hand-roll fixed-position layout CSS. See it in action in the full page example.

Basic layout​

Use the root AppShell props to configure shell dimensions. AppShell.Main reads the active section sizes through internal CSS variables and applies the correct offsets automatically.

<AppShell
header={{ height: 64 }}
sidebar={{ width: { sm: 220, lg: 260 }, breakpoint: 'sm' }}
footer={{ height: 56 }}
padding={{ base: 16, sm: 24 }}
>
<AppShell.Header>{/* fixed header content */}</AppShell.Header>
<AppShell.Sidebar>{/* fixed or drawer navigation */}</AppShell.Sidebar>
<AppShell.Main>{/* page content */}</AppShell.Main>
<AppShell.Footer>{/* fixed footer content */}</AppShell.Footer>
</AppShell>
Important

Set spacing with the padding prop on AppShell, not by styling AppShell.Main directly. padding is read by every region to calculate its offset from AppShell.Main. Styling AppShell.Main on its own bypasses that calculation and the regions will not account for the extra space.

Layout modes​

Use the layout prop to control how the header and footer stack relative to the rails.

Default layout​

The default layout keeps the header and footer full-width while the sidebar and aside fill the remaining height between them.

<AppShell
layout="default"
header={{ height: 64 }}
sidebar={{ width: 240, breakpoint: 'sm' }}
footer={{ height: 56 }}
>
{/* shell regions */}
</AppShell>

Alt layout​

Use layout="alt" when the sidebar or aside needs to span the full viewport height. Header and footer become inset automatically.

<AppShell
layout="alt"
header={{ height: 64 }}
sidebar={{ width: 240, breakpoint: 'sm' }}
aside={{ width: 200, breakpoint: 'lg' }}
footer={{ height: 56 }}
>
{/* shell regions */}
</AppShell>

Use collapsed.desktop to collapse the desktop rail, and collapsed.mobile to close the mobile drawer.

const [mobileOpen, setMobileOpen] = useState(false);
// ...
<AppShell
sidebar={{
width: 240,
breakpoint: 'sm',
collapsed: {
desktop: false,
mobile: !mobileOpen,
},
}}
>
<AppShell.Sidebar onClose={() => setMobileOpen(false)}>
{/* navigation */}
</AppShell.Sidebar>
<AppShell.Main>{/* content */}</AppShell.Main>
</AppShell>;

Below the configured breakpoint, AppShell.Sidebar and AppShell.Aside render their content inside the existing Drawer component, controlled by collapsed.mobile.

Breakpoints​

The breakpoint value on sidebar and aside props, as well as the keys of any responsive size object (e.g. width, height, and padding), accept the same breakpoint names as your theme: xs, sm, md, and lg. These resolve against theme.breakpoints, so overriding breakpoints with createTheme also changes where AppShell switches modes.

base is the one key that is not a breakpoint. It is the unconditional value applied before any breakpoint override, i.e., the mobile-first default. Every other key (xs, sm, md, lg) layers an override on top of base starting at that breakpoint's width.

<AppShell
sidebar={{
// 240px by default, 288px once the viewport reaches the `lg` breakpoint
width: { base: 240, lg: 288 },
// switches to the mobile drawer below the `md` breakpoint
breakpoint: 'md',
}}
>
{/* shell regions */}
</AppShell>
Tip

Choose the breakpoint to match where your actual header/sidebar/aside content changes shape (for example, where a navigation row is added or removed), not an arbitrary value. AppShell does not measure content, so a height or width that does not match your real content at a given breakpoint will overlap adjacent regions.

Mobile drawer toggle​

The mobile drawer's open state is a plain controlled prop (collapsed.mobile), not something AppShell manages internally .

const [sidebarOpen, setSidebarOpen] = useState(false);
// ...
<AppShell
sidebar={{
width: 240,
breakpoint: 'sm',
collapsed: { mobile: !sidebarOpen },
}}
>
<AppShell.Header>
<Button onClick={() => setSidebarOpen(true)}>Menu</Button>
</AppShell.Header>
<AppShell.Sidebar onClose={() => setSidebarOpen(false)}>
{/* navigation */}
</AppShell.Sidebar>
<AppShell.Main>{/* content */}</AppShell.Main>
</AppShell>;

AppShell.Section​

Use AppShell.Section to create fixed and growable regions inside AppShell.Sidebar or AppShell.Aside.

<AppShell.Sidebar>
<AppShell.Section>
<BrandLogo />
</AppShell.Section>
<AppShell.Section grow component={ScrollArea}>
<NavigationLinks />
</AppShell.Section>
<AppShell.Section>
<UserProfile />
</AppShell.Section>
</AppShell.Sidebar>

The grow props makes a section fill the remaining space among its sibling sections. This is useful for a scrollable middle section between a fixed header and footer inside the rail, as in the example above.

The component prop replaces AppShell.Section's own root element with the component you pass rather than rendering it as a child. This matters specifically for grow sections that need to scroll. AppShell.Section supplies the flex/min-height: 0 sizing that lets it correctly shrink to the rail's available space, and component puts that sizing directly on the swapped-in component's own root instead of leaving it to size itself as a nested child. This is why component={ScrollArea} above scrolls correctly; ScrollArea doesn't size itself to its container by default, so nesting it as a plain child would not fill the growing section.

Borders and shadows​

Use withBorder on AppShell to add a divider border to all regions by default. Override per region by passing withBorder directly on AppShell.Header, AppShell.Footer, AppShell.Sidebar, or AppShell.Aside.

// All regions get a border
<AppShell withBorder header={{ height: 64 }} sidebar={{ width: 240, breakpoint: 'sm' }}>
{/* shell regions */}
</AppShell>
// Only the aside gets a border; sidebar gets no border
<AppShell header={{ height: 64 }} sidebar={{ width: 240, breakpoint: 'sm' }}>
<AppShell.Sidebar withBorder={false}>{/* navigation */}</AppShell.Sidebar>
<AppShell.Aside withBorder>{/* context panel */}</AppShell.Aside>
<AppShell.Main>{/* content */}</AppShell.Main>
</AppShell>

Use withBoxShadow on AppShell.Sidebar or AppShell.Aside when a desktop rail should cast a directional shadow. Both rails default to no box shadow.

<AppShell.Aside withBoxShadow>{/* context panel */}</AppShell.Aside>

Z-index​

All regions share a zIndex value from AppShell by default. Override it per region by passing zIndex directly on AppShell.Header, AppShell.Footer, AppShell.Sidebar, or AppShell.Aside.

// All regions share the same z-index
<AppShell zIndex={200}>{/* shell regions */}</AppShell>
// Sidebar and Aside stack above Header
<AppShell>
<AppShell.Header>{/* header content */}</AppShell.Header>
<AppShell.Sidebar zIndex={300}>{/* navigation */}</AppShell.Sidebar>
<AppShell.Aside zIndex={300}>{/* context panel */}</AppShell.Aside>
</AppShell>

Disabling the shell​

Use the disabled prop to strip all fixed positioning and offsets from the shell. This is useful for embedded views, print layouts, or pages that should not have a persistent frame.

<AppShell
disabled
header={{ height: 64 }}
sidebar={{ width: 240, breakpoint: 'sm' }}
>
{/* Header, Sidebar, and Footer are hidden; Main renders inline */}
</AppShell>

Offset control​

By default, both the header and footer contribute to the top and bottom offset of AppShell.Main. Pass offset: false to a region config to exclude it from the offset calculation without hiding it. This is useful when a region overlaps the page intentionally.

<AppShell
header={{ height: 64, offset: false }}
footer={{ height: 56 }}
sidebar={{ width: 240, breakpoint: 'sm' }}
>
{/* AppShell.Main top padding does not include the header height */}
</AppShell>

CSS variables​

AppShell computes region sizes into CSS custom properties on .abyss-app-shell-root. These are an internal implementation detail rather than a documented public API, but referencing them can be useful for advanced consumer styling that needs to line up with the active shell dimensions.

VariableDescription
--abyss-app-shell-header-heightHeader height
--abyss-app-shell-header-offsetHeader's contribution to AppShell.Main's top offset
--abyss-app-shell-footer-heightFooter height
--abyss-app-shell-footer-offsetFooter's contribution to AppShell.Main's bottom offset
--abyss-app-shell-sidebar-widthSidebar width
--abyss-app-shell-sidebar-offsetSidebar's contribution to AppShell.Main's left offset
--abyss-app-shell-aside-widthAside width
--abyss-app-shell-aside-offsetAside's contribution to AppShell.Main's right offset

Transition customization​

Use transitionDuration (a number in milliseconds) and transitionTimingFunction to control how smoothly the shell animates between open and collapsed states.

<AppShell
transitionDuration={300}
transitionTimingFunction="ease-in-out"
sidebar={{ width: 240, breakpoint: 'sm' }}
>
{/* shell regions */}
</AppShell>

Full example​

See a complete application layout with AppShell orchestrating the header, sidebar, aside, main content, and footer regions. The full page example demonstrates mobile drawer navigation, rail toggles, borders, shadows, and layout switching.

AppShell Props​

NameTypeDefaultRequiredDescription
aside
AppShellRailConfiguration | undefined
--
Aside configuration.
children
React.ReactNode | undefined
--
AppShell content.
disabled
boolean | undefined
false
-
Hides all shell sections except AppShell.Main.
footer
AppShellOffsetConfiguration | undefined
--
Footer configuration.
header
AppShellOffsetConfiguration | undefined
--
Header configuration.
layout
'default' | 'alt' | undefined
'default'
-
Controls how the shell sections stack.
padding
number | string | AppShellResponsiveValue | undefined
0
-
Padding applied to AppShell.Main.
sidebar
AppShellRailConfiguration | undefined
--
Sidebar configuration.
transitionDuration
number | undefined
150
-
Transition duration in milliseconds.
transitionTimingFunction
string | undefined
'cubic-bezier(0.4, 0, 0.2, 1)'
-
Transition timing function.
withBorder
boolean | undefined
true
-
Removes borders from all shell sections when false.
zIndex
number | undefined
99
-
z-index applied to fixed shell sections.

Below are the link(s) to the relevant GitHub type files:

AppShell.types.ts

AppShell.Header Props​

NameTypeDefaultRequiredDescription
asElement
string | undefined
'header'
-
Overrides the rendered root element. Useful when nesting a component
that already provides its own semantic landmark (e.g. Header.Container)
to avoid duplicate/nested landmarks.
children
React.ReactNode | undefined
--
Header content.
withBorder
boolean | undefined
--
Overrides the root withBorder value for this section.
zIndex
number | undefined
--
Overrides the root z-index value for this section.

Below are the link(s) to the relevant GitHub type files:

AppShell.Sidebar Props​

NameTypeDefaultRequiredDescription
asElement
string | undefined
'nav'
-
Overrides the rendered root element for the fixed rail.
children
React.ReactNode | undefined
--
Sidebar content.
onClose
() => void | undefined
--
Called when the mobile drawer requests to close.
withBorder
boolean | undefined
--
Overrides the root withBorder value for this section.
withBoxShadow
boolean | undefined
false
-
Applies the rail box shadow on desktop fixed mode.
zIndex
number | undefined
--
Overrides the root z-index value for this section.

Below are the link(s) to the relevant GitHub type files:

AppShell.Aside Props​

NameTypeDefaultRequiredDescription
asElement
string | undefined
'aside'
-
Overrides the rendered root element for the fixed rail.
children
React.ReactNode | undefined
--
Aside content.
onClose
() => void | undefined
--
Called when the mobile drawer requests to close.
withBorder
boolean | undefined
--
Overrides the root withBorder value for this section.
withBoxShadow
boolean | undefined
false
-
Applies the rail box shadow on desktop fixed mode.
zIndex
number | undefined
--
Overrides the root z-index value for this section.

Below are the link(s) to the relevant GitHub type files:

AppShell.Footer Props​

NameTypeDefaultRequiredDescription
asElement
string | undefined
'footer'
-
Overrides the rendered root element. Useful when nesting a component
that already provides its own semantic landmark (e.g. Footer) to
avoid duplicate/nested landmarks.
children
React.ReactNode | undefined
--
Footer content.
withBorder
boolean | undefined
--
Overrides the root withBorder value for this section.
zIndex
number | undefined
--
Overrides the root z-index value for this section.

Below are the link(s) to the relevant GitHub type files:

AppShell.Main Props​

NameTypeDefaultRequiredDescription
asElement
string | undefined
'main'
-
Overrides the rendered root element.
children
React.ReactNode | undefined
--
Main content.

Below are the link(s) to the relevant GitHub type files:

AppShell.Section Props​

NameTypeDefaultRequiredDescription
children
React.ReactNode | undefined
--
Section content.
component
React.ElementType | undefined
--
Optional custom component, such as ScrollArea.
grow
boolean | undefined
false
-
Grows to fill the remaining Sidebar or Aside height.

Below are the link(s) to the relevant GitHub type files:

AppShell Classes​

Class NameDescription
.abyss-app-shell-rootAppShell root element

AppShell.Header Classes​

Class NameDescription
.abyss-app-shell-headerAppShell header element

AppShell.Sidebar Classes​

Class NameDescription
.abyss-app-shell-sidebarAppShell sidebar element
.abyss-app-shell-sidebar-drawerAppShell sidebar drawer wrapper

AppShell.Aside Classes​

Class NameDescription
.abyss-app-shell-asideAppShell aside element
.abyss-app-shell-aside-drawerAppShell aside drawer wrapper

AppShell.Footer Classes​

Class NameDescription
.abyss-app-shell-footerAppShell footer element

AppShell.Main Classes​

Class NameDescription
.abyss-app-shell-mainAppShell main element

AppShell.Section Classes​

Class NameDescription
.abyss-app-shell-sectionAppShell section element

AppShell is a layout utility and does not add its own keyboard model. Interactive children inside each region keep their native keyboard behavior.

Keyboard interactions​

KeyDescription
TabMoves focus through interactive elements rendered inside the shell regions.
Shift + TabMoves focus to the previous interactive element rendered inside the shell regions.
EscCloses mobile sidebar or aside drawers when the consumer provides the close handler required by Drawer.

Semantic HTML​

  • AppShell.Header renders a header
  • AppShell.Sidebar renders a nav
  • AppShell.Aside renders an aside
  • AppShell.Footer renders a footer
  • AppShell.Main renders a main

Each region renders its element unconditionally, regardless of what you place inside it. Nesting a component that provides the same landmark produces a duplicate/nested landmark, which most screen readers surface as confusing, separate regions rather than one.

Warning

Do not render another main element inside AppShell.Main — only one main is allowed per page. The same applies to the other regions: don't nest another header inside AppShell.Header, nav inside AppShell.Sidebar, aside inside AppShell.Aside, or footer inside AppShell.Footer.

If you're composing a component that already provides its own landmark — for example the standalone Header or Footer components — override the region's element with asElement="div" so only one landmark exists:

<AppShell.Header asElement="div">
{/* Header.Container renders its own <header role="banner">, so
AppShell.Header steps aside and renders a plain <div> instead */}
<Header.Container>{/* ... */}</Header.Container>
</AppShell.Header>

Component tokens​

Tip

Click on a token row to copy the token to your clipboard.

Token NameValue
app-shell.color.border.separator
#E5E5E6
app-shell.color.surface.content
#FFFFFF
app-shell.color.surface.rail
#FFFFFF
app-shell.border-width.separator
1px
app-shell.box-shadow.aside
-6px 0px 8px -2px rgba(0,0,0,0.16)
app-shell.box-shadow.sidebar
6px 0px 8px -2px rgba(0,0,0,0.16)
Table of Contents