Skip to content

alouette

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

alouette-app-shell

457 linesSource

The chrome around every screen of an app. AppLayout is the shell at one call site: a header, an optional rail and a footer scrolling with the page, the screen in its main landmark with the safe-area insets already applied. AppShell with AppShellSidebar and AppShellMain is the same shell rendered once in a root layout, each route deciding whether it has a rail. AppSidebarLayout is for an application (a dashboard, a back office) navigated from a fixed AppSidebar, falling back to a header on small screens. AppHeader is the banner, filled from caller-composed slots: AppHeaderBrand with a BrandLogo, HeaderNav for text destinations on the bar, AppHeaderActions, and the session — AppHeaderAccount signed in, AppHeaderSignIn signed out, never a "Guest" account menu. Load when building an app header or top bar, a site or app shell, a sidebar layout, a brand logo, or the sign-in button or account menu of a header.

This skill builds on alouette-layout (the screen inside the shell) and alouette-navigation (the NavBar and SidebarNav it holds). Read them first.

alouette — Application shell

The shell around every screen: AppLayout at one call site, or AppShell + AppShellSidebar + AppShellMain composed per route; and AppSidebarLayout for an application whose navigation lives in a fixed sidebar. Each one is the scroll container and applies the safe-area insets, so the screen inside is a plain View — never a ScreenScrollView.

AppLayout

AppLayout is the shell around a screen: a header, an optional left sidebar beside the screen, and a footer, all scrolling together as one page — the bar comes back by scrolling up rather than being pinned chrome. Every slot is composed by the caller; the layout places them, puts the screen in a main landmark sized to what is left, and applies the safe-area insets around the body, so the screen inside needs no scroll container and no insets of its own.

tsx
import {
  AppHeader,
  AppHeaderBrand,
  AppLayout,
  NavBar,
  NavBarItem,
} from "alouette";

<AppLayout
  header={
    <AppHeader brand={<AppHeaderBrand title="Alouette" href="/" />}>
      <NavBar
        stretch
        aria-label="Main"
        value={pathname}
        onValueChange={router.push}
      >
        <NavBarItem href="/home" label="Home" />
      </NavBar>
    </AppHeader>
  }
  sidebar={
    <NavBar
      orientation="vertical"
      className="w-[220px] grow"
      aria-label="Sections"
      value={section}
      onValueChange={setSection}
    >
      …
    </NavBar>
  }
  footer={<Footer />}
>
  {screen}
</AppLayout>;

AppHeader and its slots

AppHeader is the banner: brand in the start slot, actions in the end slot, and its children are the navigation slot. From md on web the three sit on one boxed line in reading order, the navigation packed against the brand and the free space left on the actions side; below that — and on native at every width — brand and actions share the first line and the navigation spans the second (hence stretch on a NavBar). navAlign="center" is the alternative single-line layout: the start slot grows too, so the navigation lands in the middle, and it stays centered even without actions (the end slot is rendered empty to balance it). size is "xs" | "sm" | "md", variant is "bar" (default, its own ground plus a downward shadow) or "transparent" (for a landing hero), contentWidth is "boxed" (default, max 1200px) or "full". It pads its own top safe-area inset unless an ancestor SafeAreaScope already consumed the edge (withSafeAreaTop={false} opts out).

The navigation slot takes either material. HeaderNav + HeaderNavItem is the bar's own: text destinations sitting directly on it, the current one underlined in the group's accent, so it fits beside the brand — which is what the default navAlign="start" is for. NavBar (alouette-navigation/SKILL.md) is the segmented bar, a control of its own, so a header carrying one takes navAlign="center" — the alignment follows the material, never the design's mood.

tsx
<AppHeader
  brand={<AppHeaderBrand title="Alouette" href="/" />}
  actions={<AppHeaderSignIn label="Log in" href="/login" />}
>
  <HeaderNav aria-label="Main" value={pathname} onValueChange={router.push}>
    <HeaderNavItem href="/home" label="Home" />
    <HeaderNavItem
      href="/inbox"
      label="Inbox"
      aria-label="Inbox, 3 unread"
      badge={<Badge size="sm">3</Badge>}
    />
  </HeaderNav>
</AppHeader>

HeaderNav owns the value like every other selection group (value + onValueChange, or defaultValue) and its items match it against their own href — the same link + aria-current="page" semantics as NavBarItem, with the same icon / activeIcon / activeAccent, plus a badge rendered after the label. A badge is not part of the accessible name, so name the item with aria-label when the label alone no longer does. Every item is a 44px tap target whose affordance is the bar's soft fill; the underline is state, never the affordance.

The slot components: AppHeaderBrand (title, optional subtitle and brandLogo; given href or onPress it becomes a real pressable instead of a row wrapped in a link), BrandLogo (an icon on an accent disc; accent="neutral" over a ground of its own accent, such as a transparent header's hero, where the accented disc fades in dark mode), AppHeaderActions (spaces the end-slot controls) and AppHeaderAccount — the signed-in account as one Avatar trigger opening a Menu of MenuItems, which is where session actions belong rather than in the bar itself.

Signed out, the session is AppHeaderSignIn instead: a Button with the bar's sizing, in the bar itself, because a visitor has exactly one action and it must stay one press away. Pass it straight as actions, or beside a secondary neutral soft "Sign up" (accent="neutral" variant="soft": a neutral tonal ground is the bar's own white) inside an AppHeaderActions. It takes the Button props (label in place of text, icon, accent, variant, disabled) plus href, the in-app destination — a real <a> on web, ignored on native, where expo Router's <Link asChild> supplies the onPress. A destination outside the app on native takes an ExternalLinkButton (alouette-external-links/SKILL.md) in the slot instead.

tsx
<AppHeader
  brand={<AppHeaderBrand title="Alouette" href="/" />}
  actions={<AppHeaderSignIn label="Log in" href="/login" />}
>
  {navigation}
</AppHeader>

A light/dark switch goes in the same actions slot as a ColorModePicker (alouette-inputs/SKILL.md) — one pill of icon-only chips, never two loose IconButtons. It reports the stored ColorModePreference only; the app applies it by passing it to a ScopedTheme (alouette-theming/SKILL.md) and persists it.

tsx
<AppHeader
  brand={
    <AppHeaderBrand
      title="Alouette"
      brandLogo={<BrandLogo icon={<BirdRegularIcon />} />}
      href="/"
    />
  }
  actions={
    <AppHeaderActions>
      <ColorModePicker value={preference} onValueChange={setPreference} />
      <IconButton
        icon={<BellRegularIcon />}
        aria-label="Notifications"
        variant="soft"
      />
      <AppHeaderAccount name="Ada Lovelace">
        <MenuItem label="Profile" onPress={openProfile} />
        <MenuItem label="Log out" accent="danger" onPress={logout} />
      </AppHeaderAccount>
    </AppHeaderActions>
  }
>
  {navigation}
</AppHeader>

Shell composed per route

AppLayout decides the whole shell at one call site. When the shell is rendered once for a whole app — a root layout around a router outlet — but the rail belongs to one section of it, compose the same shell from its parts instead: AppShell (scroll container, header, footer, and the row the body sits in) plus a per-route AppShellSidebar and AppShellMain.

tsx
// app/_layout.tsx — the shell, once
<AppShell header={<AppHeader … />} footer={<Footer />}>
  <Slot />
</AppShell>

// app/(reports)/_layout.tsx — this section, and only it, owns a rail
<>
  <AppShellSidebar>
    <NavBar
      orientation="vertical"
      className="w-[220px] grow"
      aria-label="Sections"
      value={pathname}
    >
      <NavBarItem href="/reports/weekly" label="Weekly" />
    </NavBar>
  </AppShellSidebar>
  <AppShellMain>
    <Slot />
  </AppShellMain>
</>

// a route with no rail
<AppShellMain>{screen}</AppShellMain>

AppShell renders no landmark of its own: each route composing the body brings its own AppShellMain, one per rendered shell. The rail is a sibling placed before the main, never inside it — inside would put the navigation in the main landmark. Everything else is what AppLayout does, since that is the same component underneath: one scroll container, every safe-area edge declared consumed for the body, and a web:sticky rail slot.

Source: packages/alouette/src/ui/layout/AppLayout.tsx; ui/layout/AppShell.tsx; ui/layout/AppHeader.tsx

AppSidebarLayout — an application with a sidebar

For an application rather than a site: from sidebarBreakpoint the frame is fixed to the viewport, the sidebar stands on its lowered ground, and the screen sits in a raised bg-screen panel inset in it — the one scroll container, so the sidebar never moves. Below it the sidebar is hidden and header takes over, scrolling with the screen exactly as in an AppShell, so phones keep the page they have. Both are one tree switched by breakpoint classes: crossing the breakpoint keeps the screen mounted.

sidebarBreakpoint is "md" | "lg" | "xl" (768 / 1024 / 1280px), lg by default: at md a 280px sidebar leaves the screen under 500px, so lower it only for a screen that holds up at that width.

tsx
import {
  AppHeader,
  AppHeaderAccount,
  AppHeaderActions,
  AppHeaderBrand,
  AppSidebar,
  AppSidebarAccount,
  AppSidebarLayout,
  ColorModePicker,
  IconButton,
  MenuItem,
  NavBar,
  NavBarItem,
  Select,
  SidebarNav,
  Text,
  View,
} from "alouette";

<AppSidebarLayout
  className="h-screen"
  sidebar={
    <AppSidebar
      brand={<AppHeaderBrand href="/" title="Alouette" />}
      actions={<IconButton aria-label="Search" icon={…} size="sm" variant="soft" />}
      header={
        <Select
          variant="tonal"
          aria-label="Club"
          icon={<FeatherRegularIcon />}
          options={clubs}
          value={clubId}
          onValueChange={setClubId}
        />
      }
      footer={
        <AppSidebarAccount
          name={user.name}
          description={user.email}
          header={
            <View className="flex-row items-center justify-between gap-sm">
              <Text className="text-sm text-muted">Color mode</Text>
              <ColorModePicker value={preference} onValueChange={setPreference} />
            </View>
          }
        >
          <MenuItem label="Log out" accent="danger" onPress={logOut} />
        </AppSidebarAccount>
      }
    >
      <SidebarNav aria-label="Main" value={pathname} onValueChange={router.push}>
        …
      </SidebarNav>
    </AppSidebar>
  }
  header={
    <AppHeader
      brand={…}
      actions={
        <AppHeaderActions>
          <ColorModePicker value={preference} onValueChange={setPreference} />
          <AppHeaderAccount name={user.name}>…</AppHeaderAccount>
        </AppHeaderActions>
      }
    >
      <NavBar stretch aria-label="Primary" value={pathname} onValueChange={router.push}>
        …
      </NavBar>
    </AppHeader>
  }
>
  <View className="gap-l p-m md:p-l">{screen}</View>
</AppSidebarLayout>;
  • children is plain content in the main landmark, on the screen ground, so a screen written for AppLayout renders unchanged — never a ScreenScrollView inside, which would nest a second scroll view. The layout applies every safe-area inset.
  • The frame fills its parent (flex-1): a web root with no height of its own passes className="h-screen".
  • header is the navigation under the breakpoint, where the sidebar's destinations are out of reach, in a form fitting their number: a few in a NavBar (or an HeaderNav); a long navigation behind a menu button opening a drawer.
  • AppSidebar pins brand + actions (one row) and header at the top, footer at the bottom, and scrolls only children. Its width is 280px; className overrides it.
  • What the navigation applies to (a team, a site) is a Select variant="tonal" with a leading icon in header — a pill lifted off the sidebar rather than a form field (alouette-inputs/SKILL.md).
  • AppSidebarAccount is the signed-in footer row: avatar, name and a second line, opening its MenuItems above it, as wide as the row.
  • The light/dark switch is a ColorModePicker in each tree: from the breakpoint in the header of the AppSidebarAccount menu (the brand row has no room for it beside its actions), below it in the AppHeader actions as in AppLayout. Only the visible one is exposed. Both take the same stored preference, which the app applies by passing it to a ScopedTheme around the layout (alouette-theming/SKILL.md). A press in the menu's header does not close the menu. Keyboard users reach the picker with Shift+Tab from the first item, since the menu takes the focus as it opens.

Source: packages/alouette/src/ui/layout/AppSidebarLayout.tsx

Common Mistakes

HIGH Signed-out header hidden behind a "Guest" account menu

Wrong:

tsx
<AppHeaderAccount name="Guest" icon={<UserRegularIcon />}>
  <ExternalLink as={MenuItem} href={`${authOrigin}/login`} label="Login" />
</AppHeaderAccount>

Correct:

tsx
<AppHeaderSignIn label="Login" href="/login" />

AppHeaderAccount is the signed-in session: an avatar standing for a person, opening the several actions that person has. A signed-out visitor has one, so wrapping it in an account menu invents an identity nobody has and buries the only call to action behind a second press. AppHeaderSignIn is the signed-out counterpart: a button in the bar, one press. When the destination is outside the app, an ExternalLinkButton goes in the slot the same way — still a button in the bar, never a menu.

Source: packages/alouette/src/ui/layout/AppHeaderSignIn.tsx

MEDIUM Putting a screen scroll container inside AppLayout

Wrong:

tsx
<AppLayout header={<AppHeader … />}>
  <ScreenScrollView contentContainerClassName="p-m">{content}</ScreenScrollView>
</AppLayout>

Correct:

tsx
<AppLayout header={<AppHeader … />}>
  <View className="p-m gap-m">{content}</View>
</AppLayout>

AppLayout is itself the scroll container — the header and footer scroll with the page — and it declares every safe-area edge consumed for its body. A nested scroll container gives a second scrollable inside the first and pads insets that are already applied.

Source: packages/alouette/src/ui/layout/AppLayout.tsx

MEDIUM An AppLayout per route to give one section a sidebar

Wrong:

tsx
// every route rebuilds the whole shell to change the rail
<AppLayout header={<AppHeader … />} sidebar={<NavBar …/>} footer={<Footer />}>
  {screen}
</AppLayout>

Correct:

tsx
// the shell once, in the root layout
<AppShell header={<AppHeader … />} footer={<Footer />}>
  <Slot />
</AppShell>

// the section that owns a rail composes the body
<>
  <AppShellSidebar>
    <NavBar orientation="vertical" className="w-[220px] grow" …/>
  </AppShellSidebar>
  <AppShellMain>
    <Slot />
  </AppShellMain>
</>

A shell per route remounts the scroll container, the header and the footer on every navigation — the scroll position, the header state and the safe-area padding all reset. AppShell keeps one shell and lets the route decide the body.

Source: packages/alouette/src/ui/layout/AppShell.tsx