Headless toast host. NakedToastScope owns a queue of nonmodal notifications, their timers, pause rules, keyboard dismissal, and semantics. You render every toast through toastBuilder, so the visuals are entirely yours.
- Confirmations: "Draft saved", "Link copied"
- Undo affordances: A short-lived toast with an action button
- Background results: An upload or sync that finished while the user kept working
- Urgent notices: Failures that must be announced immediately (
NakedToastPriority.assertive)
Use a dialog instead when the user must respond before continuing.
Working example: packages/example/lib/api/naked_toast.0.dart.
Place one scope below the app's Overlay, in a subtree that stays mounted.
import 'package:flutter/widgets.dart';
import 'package:naked_ui/naked_ui.dart';
class Message {
const Message(this.text, {this.actionLabel, this.onAction});
final String text;
final String? actionLabel;
final VoidCallback? onAction;
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return WidgetsApp(
color: const Color(0xFF000000),
builder: (context, _) => Overlay.wrap(
child: NakedToastScope<Message>(
toastBuilder: (context, toast, animation) => FadeTransition(
opacity: animation,
child: Container(
width: 320,
padding: const EdgeInsets.all(12),
color: const Color(0xFF222222),
child: Row(
children: [
// The scope announces semanticLabel; hide the repeated text.
Expanded(
child: ExcludeSemantics(child: Text(toast.data.text)),
),
if (toast.data.actionLabel case final label?)
NakedButton(
onPressed: () {
toast.data.onAction?.call();
toast.dismiss(NakedToastDismissReason.action);
},
child: Text(label),
),
],
),
),
),
child: const Home(),
),
),
);
}
}
// From any event callback below the scope:
final handle = NakedToastScope.of<Message>(context).show(
const NakedToastRequest(
data: Message('Draft saved'),
semanticLabel: 'Draft saved',
),
);
final reason = await handle.closed;With MaterialApp, put the scope in home (or a persistent router shell) so it sits below the Navigator's Overlay.
| Parameter | Default | Description |
|---|---|---|
toastBuilder | required | (context, NakedToastState<T> toast, Animation<double> animation). Builds each visible toast. |
child | required | The app content the toasts appear over. |
controller | null | A caller-owned NakedToastController<T>. The scope creates and disposes its own when null. |
placement | bottomEnd | One of six NakedToastPlacement values. Start and end follow Directionality. The newest toast sits nearest the edge. |
maxVisible | 3 | Toasts on screen at once. |
maxQueued | 20 | Requests waiting for a slot. When full, the oldest waiting request closes with queueOverflow. |
inset | EdgeInsetsDirectional.all(24) | Space from the safe area. The bottom edge also clears the keyboard. |
gap | 12 | Space between stacked toasts. |
animationStyle | 180 ms in, 120 ms out | Drives animation. Skipped when MediaQuery.disableAnimationsOf is true. |
NakedToastScope.of<T>(context) and maybeOf<T>(context) return the controller without registering a dependency, so they are safe in event callbacks.
| Field | Default | Description |
|---|---|---|
data | required | Opaque payload for your presenter. |
semanticLabel | required | Announced once. Must not be blank. |
id | null | A request with an equal id replaces the visible or queued toast in place and restarts its lifetime. |
duration | 4 seconds | null persists until dismissed and requires interactive: true. |
priority | polite | polite maps to SemanticsRole.status, assertive to SemanticsRole.alert. |
interactive | false | Set when the presenter renders an action or close control. |
show(request)returns aNakedToastHandle. It throws aStateErrorwhen the controller is not attached to a mounted scope, and aFlutterErrorwhen called during build.dismiss(id, [reason])returns whether a toast was dismissed.clear()dismisses everything withprogrammatic.visibleCount,pendingCount,isAttached.
id,closed(aFuture<NakedToastDismissReason>that completes exactly once),isClosed,dismiss([reason]).- Dismiss reasons:
timeout,action,close,programmatic,replaced,queueOverflow,scopeDisposed.
Handed to toastBuilder and available through NakedToastState.of<T>(context): data, id, priority, duration, isHovered, isFocused, isPaused, isExiting, and dismiss([reason]) (defaults to close).
- One portal. All toasts render through a single
OverlayPortal, so they inheritDirectionality,MediaQuery, and theme state from the scope's position and update live. - Queue. Requests are admitted first-in, first-out. Waiting requests have no widget, semantics node, or timer; each countdown starts only when its toast becomes visible.
- Pausing. A toast's countdown stops while it is hovered, while focus is inside it, and while the app is not resumed. Resuming restarts the full duration.
- Accessible navigation. When
MediaQuery.accessibleNavigationOfis true, interactive toasts never auto-dismiss. - Focus. Showing a toast never moves focus. Escape dismisses the toast that contains focus. If a focused toast is dismissed, focus returns to the node that was focused when it appeared.
- Pointer input. Only toast surfaces receive hits; the rest of the region passes input through to the app.
- Exit transition.
closedcompletes at dismissal. The toast then plays its exit transition without pointer input, focus, or semantics.
Each visible toast is one node with SemanticsRole.status or SemanticsRole.alert and the request's semanticLabel. It never sets liveRegion, which Flutter rejects on these roles. In your presenter:
- Wrap visual text that repeats
semanticLabelinExcludeSemanticsso it is announced once. - Keep action and close controls as their own button nodes; do not merge them into the message.