# Overlays
Three app-level singletons that float above the stage: a **tooltip**, a
**modal**, and a **toast** stack. You call them imperatively from anywhere
(`stageModal.open(...)`, `stageToast.push(...)`) — no ref threading, no context.
The modal and toast each need a **one-time host component** mounted inside your
`.stage-root`. The tooltip mounts itself.
```tsx
function App() {
return (
{/* once */}
{/* once */}
);
}
```
---
## stageTooltip
A single floating speech-bubble, positioned with `@floating-ui/dom`. It
**mounts itself** — no host component. Mostly you don't call it directly: a
field's `info` prop and its validation both drive it for you. Call it manually for
custom anchored hints.
```ts
stageTooltip.show({
anchor: myEl, // the element to point at
level: 'info', // 'info' | 'success' | 'warn' | 'error'
content: 'Press ⌘S to save', // string or JSX
owner: myEl, // optional identity for owner-gated hide
});
stageTooltip.hide(myEl); // gated: only hides if this owner holds it
```
| Method | Signature | Notes |
|---|---|---|
| `show` | `(opts: TooltipShowOptions) => void` | `{ anchor, level, content, owner?, keepOpen?, placement?, interactive?, onHide? }` |
| `hide` | `(owner?) => void` | No-op unless that owner holds the bubble. |
| `requestHide` | `(owner?) => void` | Immediate, owner-gated. |
| `owns` | `(owner) => boolean` | |
Auto-hide is automatic: the bubble closes the moment the pointer leaves the
combined anchor+bubble box. `keepOpen?: () => boolean` suppresses that (a focused
field passes `() => focused`). Four level skins tint the bubble.
The bubble re-parents into the anchor's nearest `.stage-root` so tokens cascade. It
hides by itself if its anchor is removed from the document or becomes inert, such as
when the panel or section holding it collapses. `keepOpen` doesn't prevent either.
Three more options suit tooltips on tightly packed targets, such as a grid of tiles:
| Option | Default | Notes |
|---|---|---|
| `placement` | `'top'` | `'top' \| 'bottom' \| 'left' \| 'right'`. The preferred side; it still flips and shifts to stay on screen. |
| `interactive` | `true` | `false` lets the pointer pass through the bubble to what's beneath, and the hover area becomes the anchor alone. Links in the content stop working. |
| `onHide` | none | Called once per show session, as hiding starts rather than after the fade. Any hide ends a session, and so does another `show()`, even for the same owner. The old callback runs before the new session is set up, so you can clear one target's state and then set another's. |
`owns()` stays true during the fade, so use `onHide` to keep state in step with the
bubble.
**Moving the bubble between anchors.** An outside `pointerdown` starts the fade, so
a tap on another anchor that shows from `click` dips visibly for as long as the press
lasts. To move the bubble without a dip, call `show()` from that anchor's
`pointerdown`, which runs before anything paints. If a second tap on the same anchor
means something, as in tap-to-activate, read "was it already active" at `pointerdown`
too, or that tap's own `click` will see it as active. Moving between anchors by hover
needs nothing extra, as long as the gap between them is no wider than 6px, the hover
area's padding.
---
## stageModal
One app-level modal for settings-style flows. Needs `` mounted
once in your `.stage-root`.
```ts
stageModal.open(() => stageModal.close()} />, { label: 'Settings' });
```
> **`open` takes a *thunk* — `() => JSX.Element`, not pre-built JSX.** It's called
> from event handlers (outside a Solid owner), so eager JSX would orphan its
> effects. The host instantiates the thunk under its own owner.
| Method | Signature |
|---|---|
| `open` | `(content: () => JSX.Element, options?: { label?: string; class?: string; closeOnInteractOutside?: boolean }) => void` |
| `close` | `() => void` |
| `isOpen` | `() => boolean` |
`open` options:
| Field | Type | Notes |
|---|---|---|
| `label?` | `string` | Accessible name for the dialog (typically the hosted section's title). |
| `class?` | `string` | Class on the dialog container, for example to widen a content-heavy modal. |
| `closeOnInteractOutside?` | `boolean` | Default `true` — a click on the backdrop closes the modal. Pass `false` for a **persistent** modal that only closes via the ×, Escape, or `stageModal.close()` (e.g. a flow with unsaved changes). |
The host renders a dimming backdrop (clicks never pass through to content beneath)
around a centred container. By default a click on the backdrop closes the modal;
`closeOnInteractOutside: false` makes it persistent. The container hosts a single
dismissable `Section` (its × calls close). It owns a11y: focus trap (Tab wraps
within the modal), Escape closes, focus restored to the opener on close,
`role=dialog` + `aria-modal`, and `aria-label` from `options.label`.
Fields opened *inside* the modal portal into the modal container (so their select
popovers/tooltips stack above the dim correctly) — this works automatically.
```tsx
function SettingsDialog(props: { onDone: () => void }) {
return (
);
}
```
---
## stageToast
A corner stack of transient notifications. Needs `` mounted
once in your `.stage-root`.
```ts
const id = stageToast.push({ message: 'Saved', level: 'success' });
stageToast.push({ message: 'Could not connect', level: 'error', autoDismissMs: null }); // pinned
stageToast.dismiss(id);
stageToast.clear();
```
| Method | Signature | Notes |
|---|---|---|
| `push` | `(opts: ToastOptions) => string` | Returns the toast id. |
| `dismiss` | `(id: string) => void` | Animated fade-out. |
| `clear` | `() => void` | Remove all. |
`ToastOptions`:
| Field | Type | Notes |
|---|---|---|
| `message` | `string \| (() => JSX.Element)` | A thunk renders rich content (a link/button). |
| `level?` | `'info' \| 'success' \| 'warn' \| 'error'` | Default `'info'`. Tints the left border. |
| `autoDismissMs?` | `number \| null` | Default `5000`; `null` pins it (no auto-dismiss). |
A **thunk message** wires the toast into your app — its `onClick` can deep-link:
```ts
stageToast.push({
level: 'warn',
message: () => (
Unsaved changes. stageModal.open(SaveDialog)}>Review
),
autoDismissMs: null,
});
```
Hovering any toast pauses the whole stack's auto-dismiss (so reading one never
drops its neighbours). The × removes immediately; auto-dismiss and the API fade
out.
### Host props
```tsx
```
| Prop | Type | Notes |
|---|---|---|
| `placement?` | `'top-left' \| 'top-right' \| 'bottom-left' \| 'bottom-right'` | Default `bottom-right`. |
| `limit?` | `number` | Max visible; the excess collapses into a "+N more…" indicator. Default `5`. |
| `hide?` | `'oldest' \| 'newest'` | Which end collapses on overflow. Default `oldest`. |
| `contained?` | `boolean` | Use `position: absolute` instead of `fixed` — scope + clip toasts to a positioned region (e.g. a `CanvasStage` viewport) instead of the screen corner. |
---
## Z-order
`--stage-z-popover` (1000, select/search dropdowns) < `--stage-z-modal` (1050) <
`--stage-z-toast` (1060, so a toast fired from a modal shows over the dim) <
`--stage-z-tooltip` (1100, always on top). A modal field's own tooltip still wins.