Skip to content

alouette

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

alouette-feedback

365 linesSource

Tell the user what happened, in place. Message is the semantic banner — InfoMessage, ConfirmationMessage, WarningMessage and ErrorMessage are its ready-made meanings — optionally dismissible, and flattened when it sits inside an already-raised surface. ConnectionState is the banner pinned at the top of the screen reporting the network. LinearProgress and CircularProgress show how far an operation with a known percentage has got; their Indeterminate twins show that one is running when no percentage exists. The skeleton utility is the pulsing placeholder of content still loading. EmptyState stands in for a list or a section with nothing to show. A failure coming out of a button or a form is already rendered by that component, so reach for these for status the screen itself has to state. Load when showing inline status, alerts, dismissible notices, connection status, a progress bar or a loading indicator.

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

alouette — Feedback messages

Message is an accent-themed banner with an icon and optional dismiss button. Prefer the presets InfoMessage / ConfirmationMessage / WarningMessage / ErrorMessage, which set the accent and icon for you.

Setup

tsx
import { InfoMessage } from "alouette";

<InfoMessage>Your changes were saved.</InfoMessage>;

Core Patterns

Presets

tsx
import { InfoMessage, ConfirmationMessage, WarningMessage, ErrorMessage } from "alouette";

<InfoMessage>Heads up.</InfoMessage>           {/* accent="info"    */}
<ConfirmationMessage>Saved.</ConfirmationMessage> {/* accent="success" */}
<WarningMessage>Careful.</WarningMessage>       {/* accent="warning" */}
<ErrorMessage>Payment failed.</ErrorMessage>   {/* accent="danger"  */}

Dismissible message

onDismiss and dismissIconAriaLabel must be provided together:

tsx
<WarningMessage onDismiss={hide} dismissIconAriaLabel="Dismiss">
  Low disk space.
</WarningMessage>

Elevation — variant

variant is "surface" (default) or "flat". surface carries shadow-m: the banner is its own raised layer above the screen background, which is what you want almost everywhere.

flat only drops the shadow. Reach for it only when the message is already inside a raised surface — a Modal/AlertDialog panel, a surface card, a PressableListItem row — where a second elevation reads as a card stacked on a card. Even there, prefer laying the message out on the screen background (its own surface banner above or below the card) when the layout allows it; flat is the allowance for when it cannot. It is the message's own variant, not the surface-flat utility (alouette-layout): a banner is not a surface — its ground is bg-highlight-accent and its sizes carry a gap.

tsx
<ErrorMessage>Payment failed.</ErrorMessage>              {/* on the page */}

<Box className="surface">
  <Text>Billing</Text>
  <ErrorMessage variant="flat">Payment failed.</ErrorMessage> {/* inside a card */}
</Box>

AlertDialog already renders its errorToMessage failure flat, and ActionButton exposes errorMessageVariant for the same reason (see alouette-actions).

Custom Message (other accents / icons)

The four presets cover info / success / warning / danger. For a different accent or a custom icon, use the base Message with an explicit accent and icon. size is "sm" | "md" | "lg".

tsx
import { Message } from "alouette";
import { XCircleRegularIcon } from "alouette-icons/phosphor-icons/XCircle";

<Message accent="brand" icon={<XCircleRegularIcon />} size="lg">
  Custom banner.
</Message>;

Connection status banner

ConnectionState is a thin banner pinned to the top of its nearest positioned ancestor. It stays hidden while connected and slides down to reveal a pill when connecting or disconnected. It sets its own accent (success when connected, danger otherwise), so no accent prop.

tsx
import { ConnectionState } from "alouette";

<ConnectionState state={status}>
  {status === "connected" ? "Connected" : "Reconnecting…"}
</ConnectionState>;

state is "connected" | "connecting" | "disconnected" | null (null renders nothing). children is the required pill label. On reconnection it turns green and holds briefly before sliding out. forceVisible keeps it on-screen even when connected (demos); forceHidden forces it off-screen regardless of state.

The banner is absolutely positioned, so its parent must establish a positioning context and clip the off-screen pill — wrap it in relative overflow-hidden.

Progress indicators

LinearProgress (a thin bar pinned to the top of its positioned ancestor) and CircularProgress (a ring) show a known completion percentage. Both take progress (0-100), accent (defaults to the nearest accent scope's, brand outside one), size ("xs" | "sm" | "md" | "lg"), and hidden (fades out without unmounting). The fill animates on progress change.

tsx
import { LinearProgress, CircularProgress } from "alouette";

<CircularProgress progress={uploaded} accent="brand" size="md" />

<Box className="relative overflow-hidden">
  <LinearProgress progress={uploaded} hidden={uploaded >= 100} />
</Box>

For an unknown percentage (reconnecting, a page transition, a background fetch), use IndeterminateLinearProgress / IndeterminateCircularProgress. They take loading instead of progress and hidden: while true the fill creeps toward 100% without reaching it; once false it completes, then fades out. Keep them mounted and toggle loading — unmounting on completion cuts the finish animation off.

tsx
import { IndeterminateLinearProgress } from "alouette";

<Box className="relative overflow-hidden">
  <IndeterminateLinearProgress loading={isFetching} size="sm" />
</Box>;

A pending action needs neither: a Button with state="loading" (or ActionButton, which derives it from the promise) already renders the indeterminate ring (see alouette-actions).

Source: packages/alouette/src/ui/feedback/LinearProgress.tsx; ui/feedback/useSimulatedProgress.ts

Loading placeholders

Content whose shape is known but whose data is not there yet (a first fetch) renders skeleton blocks in its place: animate-pulse rounded-sm bg-lowered, sized with w-* / h-* to the content they stand in for. A bar is mute, so the region carries role="status" and an aria-label. The pulse stops under reduced motion.

tsx
<View role="status" aria-label="Loading pull requests" className="gap-xs">
  <View className="skeleton h-[54px]" />
  <View className="skeleton h-[54px]" />
</View>

A placeholder sits where the content will: on the screen ground for rows that are raised on their own, inside the surface for a card's lines. Once data has been shown, a refetch keeps it and uses an IndeterminateLinearProgress instead of swapping back to skeletons.

Source: packages/alouette/scripts/build-css.ts; ui/feedback/Skeleton.stories.tsx

Empty state

A list or a section that loaded and has nothing to show renders an EmptyState where the content would have been, on the screen ground: a muted 48px icon, a title, and optional muted prose as children saying why it is empty or what would fill it. It is not a Message: nothing went wrong, so it carries no accent.

tsx
import { EmptyState } from "alouette";
import { ConfettiRegularIcon } from "alouette-icons/phosphor-icons/Confetti";

<EmptyState icon={<ConfettiRegularIcon />} title="Nothing to review">
  No pull request is waiting for you.
</EmptyState>;

A whole screen with a single purpose (sign-in, splash) is ScreenCenterLayout (alouette-layout), not an EmptyState.

Source: packages/alouette/src/ui/feedback/EmptyState.tsx

Common Mistakes

MEDIUM onDismiss without dismissIconAriaLabel

Wrong:

tsx
<InfoMessage onDismiss={hide}>Saved</InfoMessage>

Correct:

tsx
<InfoMessage onDismiss={hide} dismissIconAriaLabel="Dismiss">
  Saved
</InfoMessage>

Message types onDismiss and dismissIconAriaLabel as a required pair; providing one without the other is a type error and leaves the dismiss button without an accessible label.

Source: packages/alouette/src/ui/feedback/Message.tsx

MEDIUM Rendering the base Message without accent/icon

Wrong:

tsx
<Message>Heads up</Message>

Correct:

tsx
<WarningMessage>Heads up</WarningMessage>

The base Message requires both accent and icon. For the common cases use a preset, which sets them.

Source: packages/alouette/src/ui/feedback/Message.tsx

MEDIUM Reaching for variant="flat" outside a surface

Wrong:

tsx
<View>
  <ErrorMessage variant="flat">Payment failed.</ErrorMessage>
</View>

Correct:

tsx
<View>
  <ErrorMessage>Payment failed.</ErrorMessage>
</View>

flat is not "the quiet look" — it is the fix for a banner nested in a raised surface. On the screen background it loses the elevation that separates the banner from the page. Default to surface; use flat only inside a Modal / AlertDialog panel or a surface card, and prefer restructuring so the message can sit on the page instead.

Source: packages/alouette/src/ui/feedback/Message.tsx

MEDIUM Hand-building a colored banner instead of Message

Wrong:

tsx
<Box className="bg-yellow-100">
  <Text>Warning</Text>
</Box>

Correct:

tsx
<WarningMessage>Warning</WarningMessage>

A manual banner loses accent theming, the leading icon, dark-mode support and the dismiss a11y that Message provides.

Source: packages/alouette/src/ui/feedback/Message.tsx

MEDIUM ConnectionState in a non-positioned, non-clipping parent

Wrong:

tsx
<Box>
  <ConnectionState state={status}>Reconnecting…</ConnectionState>
</Box>

Correct:

tsx
<Box className="relative overflow-hidden">
  <ConnectionState state={status}>Reconnecting…</ConnectionState>
</Box>

The banner is absolute inset-x-0 top-0 and slides in from off-screen. Without a positioned (relative) ancestor it anchors to the wrong element, and without overflow-hidden the hidden/pre-slide pill leaks above the container.

Source: packages/alouette/src/ui/feedback/ConnectionState.tsx

LOW Passing accent to ConnectionState

ConnectionState derives its accent from state (success when connected, danger otherwise) — there is no accent prop. To theme it, change state.

Source: packages/alouette/src/ui/feedback/ConnectionState.tsx

MEDIUM Using LinearProgress/CircularProgress for an unknown-duration wait

Wrong:

tsx
<CircularProgress progress={0} /> {/* as a spinner */}
{isLoading && <CircularProgress progress={50} />}

Correct:

tsx
<IndeterminateCircularProgress loading={isLoading} />

LinearProgress / CircularProgress render the progress value you pass, so a fixed value reads as a stalled operation. The Indeterminate twin owns the creep, the completion and the fade-out from a single loading flag. When the wait belongs to a button press, use Button state="loading" / ActionButton instead.

Source: packages/alouette/src/ui/feedback/CircularProgress.tsx; ui/actions/Button.tsx

See also: alouette-animation/SKILL.md — render messages in PresenceList for animated add/remove; alouette-data/SKILL.md — Badge, for an inline status pill rather than a banner.