Skip to content

alouette

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

alouette-layout

362 linesSource

Build a screen's structure. A raised card is a Box with the surface utilities, never the removed Surface component; a View with flex classes plus Separator arranges children. page-container is the boxed column of a page; SectionTitle heads a section standing on the screen ground, where a list of pressable rows goes instead of inside a card. ScreenScrollView, ScreenFlatList and ScreenSectionList are a screen's scrollable body, padding the safe-area edges no SafeAreaScope has consumed; ScreenCenterLayout frames a sign-in or splash screen. GradientBackground and GradientScrollView tint the ground, GlowBackground bleeds soft accent glows over it. Spacing, radius and shadow come from the alouette scale, never raw Tailwind numbers. The shell around every screen is alouette-app-shell. Load when building screen structure, cards, a section heading, spacing, a scrollable screen, or a background.

This skill builds on alouette-theming. Read it first for the token model.

alouette — Layout

Compose structure from View/Box, stacks and Separator, raised with the surface utilities and sized with the alouette spacing/radius/shadow scale (not the raw Tailwind numeric scale).

Setup

tsx
import { Box, View, Text } from "alouette";

<Box className="surface">
  <View className="gap-m">
    <Text className="font-heading-bold text-xl">Card title</Text>
    <Text>Body</Text>
  </View>
</Box>;

Core Patterns

Rows and columns

Arrange children with a View and flex classes. A View is already a column on React Native and react-native-web, so a column needs no direction class; a row is flex-row, a wrapping row flex-row flex-wrap. Add gap-* for spacing, and switch direction at a breakpoint with a prefix.

tsx
<View className="gap-xs">…</View>
<View className="flex-row items-center gap-m">…</View>
<View className="flex-col md:flex-row gap-m">…</View>

HStack, VStack and Stack were removed in 23.0.0; npx alouette-codemod surface-and-stacks src rewrites them.

Surfaces (raised containers)

A raised card is a Box with the surface utility — overflow-hidden bg-surface shadow-s surface-md transition-colors duration-fast — plus accent on the Box when it takes one. Every class written after surface overrides its own part (the utility sorts ahead of them), and every class takes a breakpoint prefix:

  • size (padding + radius, one class): surface-xxs (8px / 8px) · surface-xs (12 / 8) · surface-sm (16 / 16) · surface-md (32 / 16, default) · surface-lg (48 / 32)
  • ground: bg-surface · bg-highlight · bg-highlight-accent · bg-translucent · lowered (utility: the lowered ground and its inset shadow, never split)
  • elevation: shadow-s · shadow-m · shadow-l
  • roles, used instead of surface: surface-flat — the card with no elevation, a border-border-muted hairline in place of the shadow (same ground, clip, size and transition), for a card inside a card, where a second shadow reads as a card stacked on a card, and for a dense grid of cards · surface-popover — the panel a popover list opens in (overflow, ground, shadow, padding and radius together)
tsx
<Box className="surface shadow-m surface-sm md:surface-lg">Elevated</Box>
<Box className="surface lowered surface-sm">Sunken</Box>
<Box accent="info" className="surface">Info card</Box>
<Box className="surface">
  <Box className="surface-flat surface-sm">Card inside a card</Box>
</Box>
<Box className="surface-popover">{menuRows}</Box>

Never write shadow-none border border-border-muted after surface by hand: that is surface-flat. The hairline is 1px inside the box, so a flat card and a raised one of the same content differ by 2px.

Size a surface with a surface-* class, not a hand-picked p-* + rounded-*, so every surface of a size matches. Override one side after it when a layout needs it (surface-sm py-0). The Surface component was removed in 23.0.0: it was this utility behind props that cannot take a breakpoint prefix.

A read-only section behind one edit button is EditableSection (alouette-data/SKILL.md), not a hand-built heading row on a bare surface.

Sections on the screen ground

Never nest raised material: a surface card and a PressableListItem / PressableBox row each carry their own ground, shadow and radius, so one inside the other reads as a double elevation. A list of pressable rows therefore stands on the screen ground, headed by a SectionTitle — a heading (level 2 by default) with an optional icon, tinted by accent. Keep surface for static content.

tsx
import { SectionTitle } from "alouette";

<View className="gap-xs">
  <SectionTitle icon={<EyeRegularIcon />} accent="info">
    Waiting for your review
  </SectionTitle>
  {rows}
</View>;

Boxes

Box is a themed View (accepts accent). InteractiveBox adds press/hover transitions. SafeAreaBox pads by the device safe-area insets.

tsx
import { Box, SafeAreaBox } from "alouette";

<SafeAreaBox>
  <Box accent="brand">…</Box>
</SafeAreaBox>;

Separator

tsx
import { Separator } from "alouette";

<Separator />            {/* horizontal */}
<Separator vertical />   {/* vertical */}

Background gradient

tsx
import { GradientBackground, GradientScrollView } from "alouette";

<GradientBackground accent="brand" />

<GradientScrollView accent="brand">
  {content}
</GradientScrollView>;

GlowBackground keeps the bg-screen ground and bleeds radial glows from the top corners instead: accent from the top-left, the optional secondaryAccent from the top-right. It is an absolute, non-interactive overlay, so it goes first inside a relative (or full-height) container, before the content.

tsx
import { GlowBackground } from "alouette";

<View className="h-screen bg-screen">
  <GlowBackground accent="brand" secondaryAccent="info" />
  <ScrollView className="flex-1">{content}</ScrollView>
</View>;

Page column

page-container is the boxed column of a page: full width up to max-w-page (1200px), centered, with the px-m md:px-l gutter of AppHeader's boxed row at its default size — so the content starts under the brand at every width. Never rewrite it as mx-auto max-w-[1200px] px-…: the copy drifts the day the header changes. max-w-page alone is the width without the gutter.

tsx
<AppLayout header={<AppHeader brand={brand} />}>
  <View className="page-container gap-l py-l">{screen}</View>
</AppLayout>

Screen scroll containers

ScreenScrollView, ScreenFlatList and ScreenSectionList are the scrollable body of a screen: a bg-screen min-h-full frame, a grow content container (both extendable — the incoming className / contentContainerClassName are merged with tailwind-merge), and the safe-area insets applied to the content, so the background bleeds under the system bars while the content clears them.

tsx
import { ScreenScrollView, ScreenFlatList } from "alouette";

<ScreenScrollView contentContainerClassName="p-m gap-m">{content}</ScreenScrollView>

<ScreenFlatList<Contact>
  data={contacts}
  contentContainerClassName="p-m gap-xxs"
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <ContactRow {...item} />}
/>

Which edges get padded is decided by context, not by each call site: they pad every edge an ancestor SafeAreaScope has not declared consumed. Chrome that already applies an inset declares it once, and nesting scopes merge:

tsx
{
  /* header applies insets.top itself */
}
<SafeAreaScope consumedEdges={["top"]}>
  {/* bottom bar applies insets.bottom */}
  <SafeAreaScope consumedEdges={["bottom"]}>
    {/* pads left/right only */}
    <ScreenScrollView>…</ScreenScrollView>
  </SafeAreaScope>
</SafeAreaScope>;

edges overrides the scope for one screen (edges={["bottom"]}, edges={[]}), and useScreenSafeAreaPadding(edges?) exposes the same resolution for a custom container. Insets are native-only — useSafeAreaInsets is stubbed to zeros on web — and are only passed as contentContainerStyle when non-zero, because on native an inline style wins over the className, so an inset edge would override a same-edge padding class.

Centered single-purpose screens

ScreenCenterLayout is the sign-in / splash / empty-state frame: three slots in a full-height column, content centered in the space the other two leave.

tsx
import { ScreenCenterLayout } from "alouette";

<ScreenCenterLayout
  header={<BrandLogo icon={<BirdRegularIcon />} />}
  content={<SignInForm />}
  footer={<Text className="text-sm text-muted">v2.4.0</Text>}
/>;

All three slots are required — pass null for one that has nothing in it. It scrolls nothing and applies no insets, so put it inside a screen scroll container or a SafeAreaBox when it has to clear the system bars.

Inside the application shell

The header, rail and footer around every screen — AppLayout, AppShell, AppSidebarLayout, AppHeader and its slots — are alouette-app-shell/SKILL.md. Each of them is the scroll container and applies the insets itself, so the screen inside is a plain View (page-container for its column), never a screen scroll container.

Common Mistakes

HIGH Reaching for the removed Surface component

Wrong:

tsx
<Surface variant="lowered">Sunken</Surface>
<Surface variant="highlight" shadow="l" size="sm" className="py-xs">…</Surface>

Correct:

tsx
<Box className="surface lowered">Sunken</Box>
<Box className="surface bg-highlight shadow-l surface-sm py-xs">…</Box>

Surface was removed in 23.0.0: its variant / shadow / size props could not take a breakpoint prefix. Write the utilities on a Box: surface is the card, lowered carries the lowered ground with its inset shadow (bg-lowered alone loses the shadow), and surface-sm is the old size="sm" padding + radius as one class. A popover list panel is surface-popover, not the classes rebuilt.

Source: packages/alouette/scripts/build-css.ts

MEDIUM Using the raw Tailwind numeric scale instead of tokens

Wrong:

tsx
<View className="gap-2 p-4 rounded-lg">…</View>

Correct:

tsx
<View className="gap-xs p-m rounded-sm">…</View>

p-4 / gap-2 use the default Tailwind scale, not the alouette spacing/radius scale, so layouts drift from the design-system rhythm.

Source: src/config/tokens.stories.tsx (see references/spacing-radius-shadow.md)

MEDIUM Reaching for expo-linear-gradient for backgrounds

Wrong:

tsx
import { LinearGradient } from "expo-linear-gradient";
<LinearGradient colors={["#fff", "#eee"]} />;

Correct:

tsx
import { GradientBackground } from "alouette";
<GradientBackground accent="brand" />;

alouette gradients are pure NativeWind classes; GradientBackground / GradientScrollView need no gradient library, and expo-linear-gradient is not a dependency.

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

MEDIUM Reaching for the removed stacks

Wrong:

tsx
<VStack className="gap-m">…</VStack>
<HStack className="items-center">…</HStack>
<Stack className="gap-m">…</Stack>

Correct:

tsx
<View className="gap-m">…</View>
<View className="flex-row items-center">…</View>
<View className="flex-row flex-wrap gap-m">…</View>

HStack, VStack and Stack were wrappers around those classes, removed in 23.0.0. A navigation Stack (expo-router) is unrelated.

Source: packages/alouette/src/ui/primitives/View.tsx

References

See also: alouette-responsive/SKILL.md — switch between layout variants by breakpoint.