The application shell around every page, with slots for banners, a header, a resizable side navigation, the scrolling content and trailing sidebars.

AppLayout is the shell an application page sits in: banners across the top, a header, a side navigation on the leading edge, the scrolling content area with an optional footer under it, and room for sidebars on the trailing edge. It fills its parent, so give the parent a height (usually the viewport).

Importing

import {
  AppLayout,
  AppLayoutAside,
  AppLayoutBanners,
  AppLayoutContent,
  AppLayoutFooter,
  AppLayoutHeader,
  AppLayoutNav,
  AppLayoutNavToggle,
} from '@customerio/pluma-components/react';

Basic usage

Wrap the layout in a SideNavProvider and put a SideNav in the nav slot. Drag the edge just outside the nav to resize it. On screens narrower than 1024px the nav opens from the toggle in the header instead; see Small screens.

Loading editor

Slots

Every slot is optional and renders a div; pass as for a different element, such as as="main" on the content.

  • Banners (AppLayoutBanners / PlumaAppLayoutBanners) stacks full-width banners above the header.
  • Header (AppLayoutHeader / PlumaAppLayoutHeader) holds the top navigation bar. Its height is the headerHeight variable below.
  • Nav (AppLayoutNav / PlumaAppLayoutNav) hosts a SideNav.
  • Nav toggle (AppLayoutNavToggle / PlumaAppLayoutNavToggle) is the button that opens the nav on small screens. Put it in the header, inside a SideNavProvider. It is hidden from 1024px up.
  • Content (AppLayoutContent / PlumaAppLayoutContent) is the scrolling main area, with rounded top corners. A Page inside it keeps its minimum width and scrolls horizontally when the content area is narrower.
  • Footer (AppLayoutFooter / PlumaAppLayoutFooter) sits under the content, in the content column and outside the scroller, so whatever goes in it takes its own height instead of covering the content.
  • Aside (AppLayoutAside / PlumaAppLayoutAside) is a trailing region for sidebars. It comes after the content, so a sidebar in it paints over the content at the same z-index. Below 1024px it opens over the content when you pass isOpen; see Small screens.

To hide the content while a route changes, put a style or className on the slot's child.

Resizing the nav

The nav slot reads and writes the collapsed state of the surrounding SideNavProvider, so the provider's defaultIsCollapsed, isCollapsed and onCollapseChange work as they do for a SideNav on its own. The edge to the right of the nav:

  • follows the pointer while dragging, then settles on the collapsed rail or the expanded width, whichever is nearer;
  • toggles the nav when clicked without moving;
  • moves between the two widths with the arrow keys, Home and End when focused.

The edge only renders inside a SideNavProvider whose isCollapsible is true (the default).

The provider's onCollapseChange says which of these changed the state, and whether a drag's change is a preview or final. See "What changed the state" in the SideNav docs.

Small screens

Put an AppLayoutNavToggle in the AppLayoutHeader of a layout inside a SideNavProvider, and below the lg breakpoint (1024px) the nav leaves the layout and the content takes the full width. The nav opens over the content from the toggle and closes on Escape, on a click outside it, or from the toggle again. Opening it moves focus to its first item; closing it returns focus to the toggle. From 1024px up the layout is unchanged and the toggle is hidden.

A layout without the toggle keeps the nav in the layout at every width. The toggle needs the provider and renders nothing without one.

The open nav always shows expanded, without its collapse button or the resize edge. A collapsed state set on a wide screen comes back once the screen is wide again. The nav starts closed, and it closes whenever the viewport crosses 1024px.

The open state lives in the SideNavProvider, beside the collapsed state: isOpen, defaultIsOpen and onOpenChange work like their collapse counterparts. Close the nav when the route changes, so a link in it doesn't leave it covering the new page:

import { useEffect } from 'react';
import { useSetSideNavOpen } from '@customerio/pluma-components/react';

// Render inside the SideNavProvider. `useLocation` is your router's.
function CloseNavOnRouteChange() {
  const setSideNavOpen = useSetSideNavOpen();
  const { pathname } = useLocation();

  useEffect(() => {
    setSideNavOpen?.(false);
  }, [pathname, setSideNavOpen]);

  return null;
}

The aside can open over the content below 1024px too. Pass AppLayoutAside an isOpen prop and an onDismiss callback, and open it from a button in the header. It opens from the trailing edge and calls onDismiss on Escape, on a click outside it, and when the nav opens; set isOpen to false there. Opening it moves focus to its first focusable item and closes the nav, and closing it returns focus to the button that opened it. Without isOpen the aside keeps its column at every width. From 1024px up the aside stays in view at the width of its content, and both props are ignored.

import { useState } from 'react';
import { AppLayout, AppLayoutAside, AppLayoutHeader, Button } from '@customerio/pluma-components/react';

function Layout() {
  const [isDetailsOpen, setIsDetailsOpen] = useState(false);

  return (
    <AppLayout>
      <AppLayoutHeader>
        {/* ... */}
        <Button
          variant="tertiary"
          aria-expanded={isDetailsOpen}
          onClick={() => setIsDetailsOpen(!isDetailsOpen)}
        >
          Details
        </Button>
      </AppLayoutHeader>
      {/* Nav, content and footer */}
      <AppLayoutAside
        as="aside"
        aria-label="Details"
        isOpen={isDetailsOpen}
        onDismiss={() => setIsDetailsOpen(false)}
      >
        {/* Details panel */}
      </AppLayoutAside>
    </AppLayout>
  );
}

Responsive header

Below lg the header has less room. Start it with AppLayoutNavToggle, then decide for each item how it adapts:

  • To show or hide an item, give it a per-breakpoint display, such as a workspace name that only shows from lg up.
  • When the item's props change, read the breakpoint with useMediaQuery in React or the MediaQuery resource in Ember. A "Create" button, for example, becomes icon-only below lg. Keep its aria-label and add a tooltip, so the action keeps its name.

Below md, move the header's icon buttons into a "More" popover unchanged. Each one keeps its own popover or menu, and a status indicator such as an unread dot travels with its button. Render the same buttons in both places, so they stay in sync.

import {
  AppLayoutNavToggle,
  Box,
  Button,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuTriggerButton,
  InlineStack,
  Popover,
  PopoverContent,
  PopoverTriggerButton,
  Text,
  Tooltip,
  useMediaQuery,
} from '@customerio/pluma-components/react';
import { mediaQueries } from '@customerio/pluma-components/css';

function HeaderActions() {
  return (
    <>
      <Button variant="tertiary" icon="help" isIconOnly={true} aria-label="Help" />
      <Popover>
        <PopoverTriggerButton variant="tertiary" icon="notifications" isIconOnly={true} aria-label="Notifications" />
        <PopoverContent>{/* Notifications */}</PopoverContent>
      </Popover>
      <DropdownMenu>
        <DropdownMenuTriggerButton variant="tertiary" icon="manage" isIconOnly={true} aria-label="Settings" />
        <DropdownMenuContent>{/* Settings items */}</DropdownMenuContent>
      </DropdownMenu>
    </>
  );
}

function Header() {
  const isLargeUp = useMediaQuery(mediaQueries.lg);

  return (
    <Box display="flex" alignItems="center" gap="100" px="150" py="050" flex={1}>
      <AppLayoutNavToggle />
      <Text display={{ base: 'none', lg: 'inline' }}>Acme</Text>
      <Tooltip isCustom={true} content="Create" isDisabled={isLargeUp}>
        {(tooltip) => (
          <Button
            {...tooltip.reference.getReferenceProps()}
            ref={tooltip.reference.setReference}
            icon="add"
            isIconOnly={!isLargeUp}
            aria-label="Create"
          >
            Create
          </Button>
        )}
      </Tooltip>
      <Box display={{ base: 'none', md: 'flex' }}>
        <HeaderActions />
      </Box>
      <Box display={{ base: 'flex', md: 'none' }}>
        <Popover>
          <PopoverTriggerButton variant="tertiary" icon="more" isIconOnly={true} aria-label="More" />
          <PopoverContent p="100">
            <InlineStack gap="075">
              <HeaderActions />
            </InlineStack>
          </PopoverContent>
        </Popover>
      </Box>
    </Box>
  );
}

Mirroring the nav width

onNavWidthChange reports the nav's width in pixels, for something outside the layout that has to line up with it, such as fixed-position chrome or an embedded app. It is called once on mount, before the layout is first painted, whenever the nav collapses or expands, and for every width a drag of the edge shows. The second argument's isDragging is true for a drag's live widths, and false for the resting width, including the one a drag settles on. Turn off any easing of your own while it is true, since the nav follows the pointer without easing.

The width is the one AppLayout drives: the drag width, or the collapsed or expanded width the SideNavProvider's state picks (the expanded width when isCollapsible is false). Below 1024px, where the nav opens over the content, it is 0. It does not reflect an override of the navWidth variable.

The width comes from the SideNavProvider's state, not from the SideNav, so when mirroring it, render the layout inside a provider and set isCollapsible on the provider rather than on the SideNav.

<AppLayout
  onNavWidthChange={(width) => {
    document.documentElement.style.setProperty('--main-nav-width', `${width}px`);
  }}
>

Embedding an app

An id on the content slot lands on the scrolling element, so another app can mount into it.

<AppLayoutContent id="embedded-app-container" />

CSS variables

AppLayout sets these on its root element, so anything inside it can read them. Override one by setting its public variable on the AppLayout or an ancestor.

table
KeyDefaultWhat it sizes
navWidth220px expanded, 60px collapsed, and the live width while dragging; 0px below 1024pxThe nav
headerHeight44pxThe header row
contentRadiusradius['10']The content's top corners

vars holds the public variables to override. resolvedVars holds the values in effect, which is what to read when lining something else up with the layout, such as a fixed-position element that has to clear the nav:

import { style } from '@vanilla-extract/css';
import { PlumaAppLayout } from '@customerio/pluma-components/css/components';

export const toolbar = style({
  position: 'fixed',
  insetInlineStart: PlumaAppLayout.resolvedVars.navWidth,
});

defaultVars holds the default values (navWidthExpanded, navWidthCollapsed, navWidthOverlay, headerHeight, contentRadius).

API

Default:'div'

What element or component should be rendered.

Called with the nav's width in pixels: once when the layout mounts, before it is first painted, whenever the resting width changes, and for every width a drag of the nav edge shows. The resting width is the collapsed or expanded width the SideNavProvider's state picks, and 0 below the lg breakpoint, where the nav opens over the content. Use it to line up something outside the layout with the nav.

Whether the aside is open over the content, below the lg breakpoint (1024px). Passing it, true or false, makes the aside an overlay below lg; leave it out and the aside keeps its column at every width. Opening it moves focus into it and closes the navigation if that is open. It has no effect from lg up, where the aside is always in view at the width of its content.

Called below the lg breakpoint when the open aside asks to close: on Escape, on a click outside it, and when the navigation opens. Set isOpen to false in it.