---
name: alouette-actions
description: >
  Everything the user presses. Button and IconButton are the primary actions;
  ActionButton is the one to reach for when pressing starts an async operation —
  it runs the promise and shows the spinner, the success state and the failure
  message itself. ExternalLinkButton and InternalLinkButton are the
  link-shaped buttons, LinkText and ExternalLinkText the inline links inside a
  sentence, and Menu + MenuItem the dropdown that collects secondary actions
  behind one trigger. SplitButton is one button whose caret picks which of
  several alternative actions it runs (a merge method). PressableBox and PressableListItem are the bases for a
  custom pressable surface: they carry the hover, focus, press and disabled
  affordance so it never has to be rebuilt by hand. A disabled button says why
  with disabledReason, and Tooltip describes any other element. Load when
  adding buttons, text links, a menu of actions, a split button or button with
  options, a tooltip, or custom pressable elements.
sources:
  - packages/alouette/src/ui/actions/Button.tsx
  - packages/alouette/src/ui/actions/ActionButton.tsx
  - packages/alouette/src/ui/actions/IconButton.tsx
  - packages/alouette/src/ui/actions/PressableBox.tsx
  - packages/alouette/src/ui/actions/PressableListItem.tsx
  - packages/alouette/src/ui/actions/LinkText.tsx
  - packages/alouette/src/ui/actions/Menu.tsx
  - packages/alouette/src/ui/actions/MenuItem.tsx
  - packages/alouette/src/ui/actions/SplitButton.tsx
  - packages/alouette/src/ui/actions/SplitButtonOption.tsx
  - packages/alouette/src/ui/containers/Tooltip.tsx
  - packages/alouette/src/ui/containers/Tooltip.web.tsx
  - packages/alouette/src/ui/containers/mergeTooltipTriggerProps.ts
---

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

# alouette — Actions

Buttons and pressables carry interactive token states (hover/active/disabled,
plus the keyboard focus ring) automatically. `variant` is
`"tonal" | "filled" | "outlined" | "soft"`; `size` is
`"sm" | "md" | "lg"` (38/44/52px — `lg` is the prominent call to action of a
landing page or a hero, never a size for an action among others); `accent`
is `"brand" | "danger" | "info" | "success" | "warning" | "neutral"`. Unset,
a `Button` takes the accent of the nearest accent scope (a `Modal`, `Message`,
`Box` or `AccentScope` with an `accent`), and `"brand"` outside one.

**The secondary action is neutral `soft`.** `tonal` (the default) is the
material an action button is made of; the accent says how loud it is. The
secondary action beside a call to action is `accent="neutral" variant="soft"` —
a neutral text button that gives way to the primary — never a neutral `tonal`
twin of it, and never `outlined`.

## Setup

```tsx
import { Button } from "alouette";
import { CheckRegularIcon } from "alouette-icons/phosphor-icons/Check";

<Button text="Save" icon={<CheckRegularIcon />} onPress={save} />;
```

## Core Patterns

### Button accents

```tsx
<Button text="Save" />                                 {/* tonal, brand */}
<Button accent="neutral" text="Menu" />                   {/* tonal, neutral */}
<Button accent="neutral" variant="soft" text="Cancel" />  {/* the secondary action */}
<Button accent="danger" text="Delete" />
<Button variant="filled" text="Publish" />              {/* the accent's fill */}
<Button size="sm" text="Small" />
<Button size="lg" variant="filled" text="Get started" />  {/* the hero call to action */}
```

`tonal` is a ground **lighter than the page it sits on**, lifted by
`shadow-s`: the lightest card step when neutral, the accent's pale tints in light
mode and its own dark ground in dark mode. The hue is carried by the
`text-on-tonal` ink (label and icon) — the accent itself on a light ground,
the sharp ink on a dark one — because a tint pale enough for dark ink cannot
carry it (a pale red reads as pink).

`accent="neutral"` resolves to the plain mode theme (alouette-theming/SKILL.md), so
the button keeps the tonal material — ground, shadow, hover/focus/press
states — on the grayscale palette: a white card with sharp ink. That is a lone
action that must not carry the accent: a `Menu` trigger, the Add of a field
array, a `Modal` footer's single "Close". Beside a primary action it becomes
`variant="soft"`: a neutral `tonal` twin reads as a second equal choice, and on
a white `bg-highlight` ground — a `Modal` or `AlertDialog` panel, the `AppHeader`
bar — its ground is the ground itself and dissolves. The form editor modals'
Cancel and the header's "Sign up" beside "Log in" are neutral `soft`.

`filled` is the accent's own fill, flat, under the `text-on-accent` label —
for the one action that must dominate its neighbours. The neutral theme is an
accent, not the absence of one: its fill is the sharp ink turned into a ground,
so a neutral `filled` button is black with a white label in light mode and
white with a dark label in dark mode. Beside a `filled` call to action, the
other action is neutral `soft`.

**On an accented surface** (`<Box accent className="surface">`, a
`GradientBackground`), a lone `tonal` button is always `accent="neutral"`: in light
mode an accented `tonal` ground is the accented surface's own step, so it
dissolves into it. The accented action there is `filled`, or `soft` for chrome.
What counts is the ground, not the theme: a `bg-highlight` panel stays neutral
inside an accented theme, so an accented `Modal` or `AlertDialog` keeps a
`tonal` confirm (with a neutral `soft` Cancel). On web, `PressableBox` warns
outside production when an accented `tonal` pressable has the same ground as the
surface behind it.

```tsx
<Box accent="danger" className="surface">
  <Button accent="neutral" variant="soft" text="Keep" onPress={keep} />
  <Button variant="filled" accent="danger" text="Delete" onPress={remove} />
</Box>
```

### `soft` beyond the secondary action, and `outlined`

```tsx
<Button variant="soft" text="Docs" />
<IconButton variant="soft" icon={<XRegularIcon />} aria-label="Close" />
```

`soft` has no ground and no border at rest: the affordance is a fill arriving on
hover/focus/press, and that fill is a tone of the surrounding surface rather than
the accent. A `PressableBox` row composed on it (a header pressable, a menu row)
therefore keeps its own label color — never flip it to `text-on-accent`. It is
what a header pressable or a menu row wants, where a border tint reads as noise,
and the icon-only affordance inside a component's own frame — the modal close,
the message dismiss, the remove button of a field-array row — where a second
ground inside the frame would read as a nested card. With no ground to carry the
accent, the soft `Button` carries it in its label (`text-accent`, underlined —
a text button), and the soft `IconButton` glyph turns `text-accent` on
hover/focus/press.

`outlined` has no default use. Reach for it only for a pressable that must not
carry a ground and is not chrome — a row of equal-weight actions in a dense
toolbar. A secondary action is `accent="neutral" variant="soft"`, not
`outlined`.

### Async action — prefer ActionButton

For a direct action whose `onPress` is async (delete, retry, publish — not a
form submit, which uses `FormSubmitButton`), use `ActionButton`. It runs the
promise, derives the button `state` for you (spinner while pending, terminal
icon on settle), and renders an inline `ErrorMessage` on rejection. Map the
error to a string via the required `errorToMessage`.

```tsx
import { ActionButton } from "alouette";

<ActionButton
  accent="danger"
  text="Delete"
  onPress={async () => deleteItem(id)}
  errorToMessage={(error) =>
    error instanceof Error ? error.message : "Failed"
  }
/>;
```

`ActionButton` omits `onPress`/`state` from `ButtonProps` and manages them
itself — don't pass `state` to it.

The failure message is a raised `ErrorMessage`. When the button already sits
inside a raised surface (a `surface` card, a modal panel), pass
`errorMessageVariant="flat"` so the message doesn't read as a card on a card:

```tsx
<Box className="surface">
  <ActionButton
    text="Delete"
    errorMessageVariant="flat"
    onPress={remove}
    errorToMessage={toMessage}
  />
</Box>
```

Leave it unset everywhere else — see alouette-feedback for when `flat` is
warranted.

### Manual loading state

Reach for `Button`'s `state` prop directly only when `ActionButton` doesn't fit
(state driven externally, e.g. by a form or store). `state` drives a transient
overlay and disables the button: `"loading"` shows an indeterminate spinner,
`"success"` a check, `"failed"` a warning icon (the spinner plays its finish
animation before the terminal icon). Any non-`undefined` `state` disables presses.

```tsx
import { Button, type ButtonState } from "alouette";

<Button text="Save" state={submitState} onPress={save} />;
// submitState: ButtonState | undefined = "loading" | "success" | "failed"
```

Don't drive loading UI yourself (spinner + manual `disabled`) — use
`ActionButton`, or set `state`.

### Icon-only button

```tsx
import { IconButton } from "alouette";
import { XRegularIcon } from "alouette-icons/phosphor-icons/X";

<IconButton icon={<XRegularIcon />} aria-label="Close" onPress={close} />;
```

`size` is `"sm" | "md"` or a number (custom diameter in px); `iconSize="fill"`
makes the icon take 80% of the button. `variant` is `"tonal" | "filled" |
"soft"` — an icon-only button has no `outlined`.

### Disabled with a reason

When an action is unavailable for a reason the user should know, pass
`disabledReason` to `Button`, `IconButton` or `ActionButton` rather than
`disabled`. A non-empty reason disables the button, adds the reason to its
accessible description, and on web shows it in a `Tooltip` on hover. Like any
disabled button it leaves the tab order (react-native-web sets the native
`disabled` attribute), so a screen reader gets the reason from the
description.

```tsx
<Button
  text="Archive"
  disabledReason={
    isDeploying ? "A deployment of this project is still running" : null
  }
  onPress={archive}
/>
```

Don't wrap a disabled button in a DOM element carrying `title`:
react-native-web forwards no `title`, the wrapper is web-only, and the reason
then has to be repeated in `aria-label`. For any other element (an icon-only
button's label, a truncated value), `Tooltip` takes a `render` prop and hands
it the props to spread on the described element:

```tsx
import { Tooltip } from "alouette";

<Tooltip
  content="Archive the project"
  render={(triggerProps) => (
    <IconButton
      {...triggerProps}
      icon={<ArchiveRegularIcon />}
      aria-label="Archive"
    />
  )}
/>;
```

The trigger props carry the hover and focus handlers as well as
`aria-describedby`, so spread them last, or keep the element's own with
`mergeTooltipTriggerProps(ownProps, triggerProps)`, which joins the
descriptions and runs both handlers. `content={null}` renders the element
alone in the same tree, so toggling it never remounts the element. The panel
opens on hover (not on a tap) and on keyboard focus, above the element or below
it when there is no room above, and stays open while the pointer is on it.
Don't nest two tooltips around one element.

Native has no hover: `Tooltip` shows no panel there and only announces the
content as the element's accessibility hint.

### Icon weight on interaction

`Button`, `IconButton` and `MenuItem` take an optional `activeIcon` next to
`icon` — usually the duotone twin of the same glyph — rendered while the control
is hovered, focused or pressed. It is an accent on top of the affordance, not a
replacement: the background and border still come from the `interactive-*`
tokens, and a disabled control never swaps.

```tsx
import {
  ArrowLeftDuotoneIcon,
  ArrowLeftRegularIcon,
} from "alouette-icons/phosphor-icons/ArrowLeft";

<Button
  text="Back"
  icon={<ArrowLeftRegularIcon />}
  activeIcon={<ArrowLeftDuotoneIcon />}
  onPress={goBack}
/>;
```

### Pressable surfaces

`PressableBox` is a themed, pressable container (`variant`, `accent`,
`forceStyle`; `tonal` by default; `raised={false}` drops the tonal shadow for a
segment of a group that casts one). `PressableListItem` is a row with a
trailing caret, on the same `tonal` material as a button: a card lifted off
the surface whose ground is a tone of the theme, never the accent's fill. Its
label and caret are `text-on-tonal`, the ink that carries the accent where
the ground cannot. These, and the
link components below, are how something becomes interactive — never by wrapping
a display-only component (`Badge`, `Bullet`, `Text`) in a `Link` or `Pressable`
(alouette-styling/SKILL.md).

```tsx
import { PressableBox, PressableListItem, Text } from "alouette";

<PressableBox onPress={open}>
  <Text>Custom card</Text>
</PressableBox>

<PressableListItem onPress={open}>
  <Text>Row label</Text>
</PressableListItem>;
```

A `PressableListItem` holding more than a title needs `aria-label` ("Open Add
dark mode"), else the row is announced as every label, badge and date it holds.
`actions` pins buttons to the row's bottom end; each is a nested pressable that
takes its own press (the row's `onPress` does not fire), so give each its own
handler. On web a `role="button"` row is a `<button>`, so actions nest a button
in a button: pass `role="listitem"` (inside a `role="list"`) or `"menuitem"` for
valid markup. `href` makes the row a real `<a>` — only on a row with no
`actions` and no link in its children, which would nest inside the anchor.
The row has no outer margin, so it lines up with the content around it: the
list holding the rows spaces them (`<View className="gap-xs">`).

```tsx
<PressableListItem
  aria-label="Open Add dark mode"
  actions={<Button size="sm" text="Merge" onPress={merge} />}
  onPress={open}
>
  <Text className="font-body-bold">Add dark mode</Text>
</PressableListItem>
```

Source: packages/alouette/src/ui/actions/PressableListItem.tsx

`PressableBox` carries `group`, so a child can style itself from the pressable's
state (`group-hover:`, `group-active:`) — that is how `InteractiveIcon` swaps a
glyph. It also forwards its `ref`, as `Button` and `IconButton` do, so a button
can anchor a `Popover` or a `Menu`.

`href` makes it a link: react-native-web renders a real `<a>` (native ignores it
and routes from `onPress` — expo Router's `<Link asChild>` injects both), and the
default `role` becomes `"link"` instead of `"button"`. Every pressable built on
it inherits this — `Button`, `IconButton`, `AppHeaderSignIn` — and a component
needing another role passes its own (`MenuItem` stays a `menuitem`).

```tsx
<PressableBox href="/login">
  <Text>Log in</Text>
</PressableBox>
```

The keyboard focus ring is the `focus-ring` utility in the accent ink, on
`focus-visible` only — a mouse click never leaves a ring or a changed ground
behind. A `tonal` or `soft` pressable rings 2px inside its edge, `filled` and
`outlined` 2px outside it. `withFocusVisibleOutline="inset"` moves the ring inside for a
pressable whose parent clips (a `surface` is `overflow-hidden`), and
`withFocusVisibleOutline={false}` drops it for a row of a list that already
paints its cursor (a menu item, a listbox option), where the outline would ring
whatever the pointer crosses. `false` emits `outline-solid outline-0`, not
`outline-none` — react-native-css drops `outline-style: none`, so the UA ring
would survive.

### Link buttons

```tsx
import { ExternalLinkButton, ExternalLinkText, InternalLinkButton, LinkText } from "alouette";

<ExternalLinkButton href="https://example.com" text="Open" />
<InternalLinkButton href="/settings" text="Settings" />
```

`LinkText` and `ExternalLinkText` are the inline counterparts — one underlined
text link treatment, for a link inside a text flow rather than a call to action.
`LinkText` goes to an in-app destination (`href` renders a real `<a>` on web,
native routes from `onPress`; `icon` is leading), `ExternalLinkText` leaves the
app and adds the trailing arrow. `size` is `"sm" | "md"` on both. Primary
navigation between screens is a `NavBar`, not a text link
(alouette-navigation/SKILL.md); for full external-link control (in-app browser vs
new tab), see alouette-external-links/SKILL.md.

```tsx
<LinkText href="/settings" text="Account settings" />
```

### Menu of secondary actions

`Menu` is a pressable that opens a list of actions: anchored under its trigger on
web, an overlay on native. Prefer it over a row of buttons for actions that are
secondary, rare or destructive. The trigger is composed through `render` — spread
the params it gives you (they carry the `ref`, `onPress`, `aria-haspopup` and
`aria-expanded`) onto any pressable. `label` names the menu; `header` renders
above the items, outside the `menu` element.

```tsx
import { IconButton, Menu, MenuItem, Separator } from "alouette";

<Menu
  label="Document actions"
  render={(triggerProps) => (
    <IconButton
      icon={<DotsThreeRegularIcon />}
      aria-label="Document actions"
      variant="soft"
      {...triggerProps}
    />
  )}
>
  <MenuItem label="Rename" icon={<PencilRegularIcon />} onPress={rename} />
  <Separator />
  <MenuItem label="Delete" accent="danger" onPress={remove} />
</Menu>;
```

A `MenuItem` closes the menu after its `onPress`. `accent` colors its label and
icon (`danger` for a destructive action — the row itself stays a plain surface).
`href` renders a real anchor on web, so expo Router's `<Link asChild>` composes
with it, and a disabled item drops the `href`. `description` adds a line under
the label; `checked` makes the rows a set of choices (`menuitemradio`), and the
menu then opens on the checked one. One button choosing which of several actions
it runs is a `SplitButton`: [references/split-button.md](references/split-button.md).

On web the panel hangs from the trigger's end edge and sizes to its items. A
full-width trigger takes `align="start"`, or `width="anchor"` to make the panel
exactly as wide; a trigger pinned at the bottom of the viewport (a sidebar's
account row) takes `side="top"`.

## Common Mistakes

### HIGH Passing the button label as children instead of text

Wrong:

```tsx
<Button>Save</Button>
```

Correct:

```tsx
<Button text="Save" />
```

`Button` renders its label from the required `text` prop (plus an optional `icon`
prop); children are ignored, so `<Button>Save</Button>` shows no label.

Source: packages/alouette/src/ui/actions/Button.tsx

### HIGH A tonal or `outlined` secondary button

Wrong:

```tsx
<Button accent="neutral" text="Cancel" onPress={close} />
<Button text="Save" onPress={save} />

<Button variant="outlined" text="Cancel" onPress={close} />
<Button text="Save" onPress={save} />
```

Correct:

```tsx
<Button accent="neutral" variant="soft" text="Cancel" onPress={close} />
<Button text="Save" onPress={save} />
```

The secondary action gives way to the primary one. A neutral `tonal` Cancel keeps
the ground and the shadow, so the pair reads as two equal choices — and on a
white `bg-highlight` panel (a `Modal` footer) its ground is the panel's white and
dissolves. An `outlined` one draws a frame that competes, and on a `surface`
card its `bg-highlight` fights the card it sits on. Neutral `soft` is a text
button in the ambient ink: present, and plainly second.

Source: packages/alouette/src/ui/containers/AlertDialog.tsx, ui/actions/Button.tsx

### HIGH Using non-existent variant names

Wrong:

```tsx
<Button variant="contained" text="Cancel" />
<Button variant="primary" text="Save" />
```

Correct:

```tsx
<Button accent="neutral" variant="soft" text="Cancel" />
<Button accent="brand" text="Save" />
```

`variant` is only `"tonal" | "filled" | "outlined" | "soft"` (`IconButton`:
no `outlined`); `contained`, `list` and `ghost` no longer exist. The accent is
chosen via the `accent` prop — `"brand" | "danger" | "info" | "success" |
"warning" | "neutral"`.

Source: packages/alouette/src/ui/actions/PressableBox.tsx, ui/actions/Button.tsx

### MEDIUM IconButton without aria-label

Wrong:

```tsx
<IconButton icon={<XRegularIcon />} onPress={close} />
```

Correct:

```tsx
<IconButton icon={<XRegularIcon />} aria-label="Close" onPress={close} />
```

`IconButton` types `aria-label` as required because it has no text label;
omitting it is an accessibility failure and a type error.

Source: packages/alouette/src/ui/actions/IconButton.tsx

### HIGH IconButton triggering an async action instead of ActionButton

Wrong:

```tsx
<IconButton
  icon={<TrashRegularIcon />}
  aria-label="Delete"
  onPress={async () => deleteItem(id)}
/>
```

Correct:

```tsx
<ActionButton
  accent="danger"
  text="Delete"
  icon={<TrashRegularIcon />}
  onPress={async () => deleteItem(id)}
  errorToMessage={(error) =>
    error instanceof Error ? error.message : "Failed"
  }
/>
```

An icon alone doesn't communicate intent reliably — a visible text label is
required for any action with consequences. An `IconButton` also has no async
affordance: it won't show a spinner while the promise is pending or surface a
rejection. Use `ActionButton` (text + optional `icon`) so the intent is explicit
and the pending/error states are handled.

Source: packages/alouette/src/ui/actions/ActionButton.tsx, ui/actions/IconButton.tsx
