Skip to content

alouette

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

alouette-dialogs

433 linesSource

Content above the screen. Modal is the general dialog: the caller controls whether it is open, and it is dismissed by the backdrop, its close button, Escape or the Android back gesture, with its footer pinned below a scrolling body. AlertDialog is the confirmation built on it — ask, acknowledge, or demand a decision that cannot be dismissed — with icon-fixed presets (QuestionAlertDialog, WarningAlertDialog, InfoAlertDialog, SuccessAlertDialog); confirming may be async, and the dialog stays open, locked, until it settles, rendering a failure in place. Popover is the chrome-less escape hatch for content that must escape a clipping ancestor — but a set of actions off a trigger is Menu + MenuItem, never a hand-built Popover. Load when adding a modal, confirmation, alert dialog, or a dropdown escaping a clipping container.

This skill builds on alouette-theming (accents) and alouette-actions (the footer buttons). Read them first.

alouette — Dialogs

Modal is a themed, accessible overlay you control with visible / onClose. AlertDialog builds on it for the confirm/alert/required decision pattern with a prebuilt footer. Both render through react-native's Modal, animate a fade, and dismiss on backdrop press, the close button, the Android back button, and Escape (web).

Setup

tsx
import { Button, DecisionBar, Modal } from "alouette";

function Example() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button text="Open" onPress={() => setOpen(true)} />
      <Modal
        visible={open}
        onClose={() => setOpen(false)}
        title="Details"
        footer={
          <DecisionBar
            primary={<Button text="Done" onPress={() => setOpen(false)} />}
          />
        }
      >
        <Text>Body content.</Text>
      </Modal>
    </>
  );
}

Core Patterns

title is required: it renders as the heading and labels the dialog for assistive tech (aria-labelledby). size is "sm" | "md" | "lg" (panel max-width + padding; default md). Optional icon tints with the accent before the title. Use hideCloseButton to drop the corner close button (the modal stays dismissible via backdrop/Escape/back), role="alertdialog" for an interruption that requires an explicit response, and aria-describedby to point at the element describing the dialog.

The header sits outside the scroll box, so the title and the close button stay put while the body scrolls under them (the body scrolls at ~70% viewport height). footer is the actions row and stays pinned to the bottom of that body — position: sticky inside the scroll content on web, laid out below the scroll box on native, since Yoga has no sticky. It grows a top border only while content is scrolled under it, and loses it at the end of the scroll. That is built in: don't rebuild it with an absolutely-positioned bar or a scroll listener.

The footer always carries one primary action with button material — an accented tonal button by default, a neutral filled one when backing out is the expected answer. A neutral soft button is only ever the secondary action beside it (Cancel, Discard, Decline): a footer holding a lone soft button, even a plain "Close", has no action with any weight. The footer's actions go in a DecisionBar (alouette-actions), which fixes their order: dismiss, alternative, primary — a lone "Close" is a DecisionBar with only a primary. footer itself takes any content, for a footer that is not a decision (a hint, a link beside the actions).

tsx
<Modal
  visible={open}
  onClose={close}
  title="Save changes?"
  footer={
    <DecisionBar
      dismissText="Discard"
      onDismiss={discard}
      primary={
        <ActionButton text="Save" onPress={save} errorToMessage={toMessage} />
      }
    />
  }
>
  …
</Modal>

Accent across the portal

accent themes the whole panel. A modal renders through a portal, i.e. outside the themed DOM subtree, so Modal rebuilds the scope inside with PortalAccentScope (also exported, for any other portalled overlay). An AccentScope placed around the <Modal> element in your tree does not reach the panel — pass accent to the modal itself.

AlertDialog — confirmations

Prefer the icon-fixed presets; each takes every AlertDialog prop except icon. The variant selects the footer:

  • "confirm" (default) — onConfirm + onCancel, a Cancel and a Confirm button.
  • "alert" — onClose, a single acknowledge button.
  • "required" — onConfirm, a single action; cannot be dismissed by backdrop/Escape/back (the user must respond).

accent defaults to "danger" (themes the icon and primary button).

tsx
import { QuestionAlertDialog, WarningAlertDialog } from "alouette";

<QuestionAlertDialog
  visible={confirming}
  title="Delete project?"
  confirmText="Delete"
  onConfirm={handleDelete}
  onCancel={() => setConfirming(false)}
>
  This permanently removes the project and its data.
</QuestionAlertDialog>

<WarningAlertDialog
  variant="alert"
  visible={notice}
  title="Session expired"
  onClose={() => setNotice(false)}
>
  Please sign in again.
</WarningAlertDialog>

The confirm variant's footer is a neutral soft Cancel beside an accented tonal confirm. primaryAction="cancel" puts the weight on Cancel instead — a neutral filled Cancel beside an accented soft confirm — for an irreversible action where backing out is the expected answer (delete an account, discard work, stop a running process). Cancel is filled rather than tonal because a neutral tonal ground is the white of the bg-highlight panel and dissolves into it. The order stays Cancel then confirm, so the buttons never trade places between dialogs.

tsx
<WarningAlertDialog
  primaryAction="cancel"
  visible={confirming}
  title="Delete account"
  confirmText="Delete"
  onConfirm={deleteAccount}
  onCancel={() => setConfirming(false)}
>
  Your account and its data are erased immediately.
</WarningAlertDialog>

confirmDisabled disables the confirm button (e.g. while a form is invalid). Button labels default to Confirm/Cancel (confirm), OK (alert/required).

Content beside the description

children is the description: it is wrapped in a Text and announced as the dialog's aria-describedby, so it takes copy only. What the decision needs besides it — a field completing what is confirmed, the list of items affected — goes in content, rendered under the description and outside it. The caller owns the field's state and reads it in onConfirm.

tsx
<QuestionAlertDialog
  visible={confirming}
  accent="success"
  title="Merge this pull request?"
  confirmText="Squash and merge"
  confirmDisabled={commitTitle.trim() === ""}
  content={
    <FormItem
      label="Commit message"
      render={({ labelId, describedBy, invalid, required }) => (
        <InputText
          aria-labelledby={labelId}
          aria-describedby={describedBy}
          aria-required={required}
          invalid={invalid}
          value={commitTitle}
          onChangeText={setCommitTitle}
        />
      )}
    />
  }
  onConfirm={() => merge(commitTitle)}
  onCancel={close}
>
  #42 goes into main.
</QuestionAlertDialog>

A field never goes in children: inside a Text it is invalid on native and read out as the description.

Async confirmations

onConfirm (confirm and required variants) may return a promise. The dialog then runs the same state machine as ActionButton: the button shows a spinner, the dialog locks (Cancel disabled, backdrop/Escape/Android-back stop dismissing) and unlocks when the promise settles. Do not hand-roll this with confirmDisabled and local state.

Pass errorToMessage to turn a rejection into a message shown full width in the footer (a flat ErrorMessage, since the dialog panel is already raised), with the dialog left open so the action can be retried; without it a failure only flips the button to its failed state. There is no default formatter — a library one could only hardcode an English string.

Close the dialog yourself once the promise resolves — the dialog never closes itself on success.

tsx
<WarningAlertDialog
  visible={confirming}
  title="Delete project"
  confirmText="Delete"
  errorToMessage={(error) =>
    error instanceof Error ? error.message : t("unknownError")
  }
  onConfirm={async () => {
    await deleteProject();
    setConfirming(false);
  }}
  onCancel={() => setConfirming(false)}
>
  This permanently removes the project and its data.
</WarningAlertDialog>

Popover — content above a clipping ancestor

Popover is the low-level overlay behind Select and InputTextAutocomplete: it renders children above everything, outside the clipping of any overflow-hidden ancestor (a surface is one by design). It brings no panel chrome — no title, no close button, no padding — so wrap the content in a surface-popover Box yourself.

tsx
import { Box, IconButton, Popover } from "alouette";

const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);

<View ref={anchorRef}>
  <IconButton aria-label="More" onPress={() => setOpen(true)} … />
</View>
<Popover
  open={open}
  anchorRef={anchorRef}
  aria-label="Actions"
  onClose={() => setOpen(false)}
>
  <Box className="surface-popover">{menu}</Box>
</Popover>;

The two platforms present it differently, and that is deliberate:

  • web — portals into document.body and positions itself under anchorRef. align picks the anchor edge it lines up with ("start" by default, "end" for a panel hanging off a small trigger near the right edge) and width its sizing: "anchor" (default, as wide as the anchor — what a field dropdown wants) or "content", sizing to its own content for a menu whose trigger is narrower than its items. side="top" opens it above the anchor, for a trigger pinned at the bottom of the viewport. All three are anchored-web only, like anchorRef itself. It follows the anchor through page and nested scrolling, sits above react-native-web's own Modal layer (so a popover inside a dialog is not hidden behind it), and closes on Escape or a press outside the content and the anchor.
  • native — anchorRef is ignored and the content is presented as an overlay in a transparent Modal, because a Modal resigns the keyboard of whatever is behind it, so an anchored dropdown over a focused input is not renderable. placement picks the presentation: "center" (default) for content of fixed height, "top" to pin it below the status bar so the first row stays put while the content resizes. Web falls back to the same overlay when no anchorRef is given.

accent themes the content through PortalAccentScope; pass accent="neutral" to render it on the neutral mode tokens under an accented ancestor. aria-label names the overlay.

For a list of actions hanging off a trigger, use Menu + MenuItem (alouette-actions/SKILL.md) rather than assembling this yourself: it is this Popover plus the menu/menuitem roles, the trigger's aria-haspopup / aria-expanded, arrow-key roving focus and close-on-select.

Common Mistakes

HIGH Labelling a Modal with aria-label instead of title

Wrong:

tsx
<Modal visible={open} onClose={close} aria-label="Image preview"><Image … /></Modal>

Correct:

tsx
<Modal visible={open} onClose={close} title="Image preview"><Image … /></Modal>

title is a required ModalProps prop and there is no aria-label: every modal gets a visible heading, which is also its accessible name (aria-labelledby). aria-describedby is the only aria prop Modal takes.

Source: packages/alouette/src/ui/containers/Modal.tsx

Wrong:

tsx
<Modal visible={open} onClose={close} title="Details">
  <Text>…</Text>
  <View className="flex-row justify-end gap-m">
    <Button text="Done" onPress={close} />
  </View>
</Modal>

Correct:

tsx
<Modal
  visible={open}
  onClose={close}
  title="Details"
  footer={<DecisionBar primary={<Button text="Done" onPress={close} />} />}
>
  <Text>…</Text>
</Modal>

A row placed in children scrolls away with the body. footer stays pinned at the bottom of the scroll box on both platforms and takes on the separator border while content scrolls under it; the DecisionBar in it orders and right-aligns the actions.

Source: packages/alouette/src/ui/containers/Modal.tsx

MEDIUM Wrapping the Modal element in AccentScope

Wrong:

tsx
<AccentScope accent="danger">
  <Modal visible={open} onClose={close} title="Delete project">
    …
  </Modal>
</AccentScope>

Correct:

tsx
<Modal accent="danger" visible={open} onClose={close} title="Delete project">
  …
</Modal>

The panel renders through a portal, outside the themed subtree, so a surrounding scope never reaches it. Modal rebuilds the theme inside with PortalAccentScope from its own accent prop.

Source: packages/alouette/src/ui/containers/PortalAccentScope.tsx

HIGH Building a confirmation out of a raw Modal

Wrong: a Modal with hand-placed Cancel/Confirm buttons and manual role.

Correct:

tsx
<QuestionAlertDialog
  visible={open}
  title="Discard changes?"
  onConfirm={discard}
  onCancel={close}
/>

AlertDialog sets role="alertdialog", wires the description to aria-describedby, hides the close button, and builds the footer for the chosen variant. Reach for Modal directly only for non-decision content.

Source: packages/alouette/src/ui/containers/AlertDialog.tsx

MEDIUM Expecting a required dialog to close on backdrop/Escape

variant="required" intentionally ignores backdrop, Escape, and the Android back button — only its explicit action closes it. Use it for must-respond interruptions (accept terms, forced sign-out), not for ordinary dialogs, which should stay dismissible (confirm / alert).

Source: packages/alouette/src/ui/containers/AlertDialog.tsx

LOW Passing icon to a preset dialog

The presets (QuestionAlertDialog, WarningAlertDialog, InfoAlertDialog, SuccessAlertDialog) fix the icon; their props are AlertDialogUsageProps = every AlertDialog prop except icon. Use the base AlertDialog when you need a custom icon.

Source: packages/alouette/src/ui/containers/AlertDialog.tsx