diff --git a/.changeset/notification-display-types-present-distinctly.md b/.changeset/notification-display-types-present-distinctly.md
new file mode 100644
index 000000000..ad3cd0e4e
--- /dev/null
+++ b/.changeset/notification-display-types-present-distinctly.md
@@ -0,0 +1,58 @@
+---
+"@object-ui/react": minor
+"@object-ui/components": minor
+---
+
+feat(notifications): each spec `displayType` gets its own presentation instead of a toast (#3014)
+
+#3008 closed the **contract** half of this: `NotificationContext`'s union matched
+`NotificationTypeSchema`, and `notify()` materialized the declared type so a
+consumer *could* branch on it. Nothing did. `NotificationProvider` handed every
+item to the host's `onToast` delegate regardless of type, so an author picking
+`banner` or `inline` got a transient overlay — plausible output, wrong output.
+
+Each of the five spec types now has a presentation of its own:
+
+| `displayType` | Presentation | Rendered by |
+|---|---|---|
+| `toast` | transient overlay (unchanged) | the host's `onToast` delegate |
+| `snackbar` | bottom-anchored bar, one at a time, at most one action | `` |
+| `banner` | page-width strip **in the content flow** | `` |
+| `alert` | blocking acknowledgement dialog, FIFO queue | `` |
+| `inline` | in place, at the raising surface | `` |
+
+The four surface components ship from `@object-ui/components` and subscribe via
+`useNotificationsByPresentation(type, scope?)`.
+
+**Answers to the three questions the issue left open:**
+
+1. **Banner/inline placement is the host's.** They are not overlays: a banner takes
+ space at the top of the content area and an `inline` notification belongs next to
+ the thing that raised it. So the context exposes the items and the surfaces
+ subscribe, rather than one `onToast`-style delegate positioning everything. An
+ `inline` notification carries a `scope` that pairs it with its outlet, so two
+ forms on one page don't show each other's messages.
+2. **`alert` is modal-ish but NOT the action system's `ModalHandler`.** That handler
+ resolves a page/object, renders it, and reports an `ActionResult` back to the
+ `ActionRunner`; a notification alert has no schema, no target and no result.
+ Routing it there would mean synthesizing a page just to say "OK". It renders
+ through the `AlertDialog` primitive instead — no second action-modal path.
+3. **`snackbar` earns its own component.** It supersedes rather than stacks, anchors
+ bottom regardless of the toast position config, and takes at most one action.
+ Making it a sonner variant is what "presents as a toast" means.
+
+**Also fixed:** auto-dismiss now follows the presentation. `toast`/`snackbar` keep
+the transient timer; `banner`/`alert`/`inline` are persistent unless the raiser sets
+`duration` explicitly — a persistent banner used to evaporate on the shared 5s toast
+timer. `dismissible` is honored on the persistent surfaces (an `alert` always keeps
+its acknowledge button; `dismissible: false` only closes the Escape route).
+
+`onToast` now receives **only** `toast` items. A provider with no `onToast` remains
+the supported store-only mode (a bell reading `notifications`/`unreadCount`), but
+raising one of the other four types with its surface unmounted warns in dev, naming
+the component to mount — that failure used to be silent.
+
+`NOTIFICATION_PRESENTATIONS` is typed `Record`, so a new
+member in the spec enum fails type-check until its presentation is decided; a parity
+test additionally asserts the table covers `NotificationTypeSchema` exactly and that
+no two types share a surface.
diff --git a/content/docs/guide/meta.json b/content/docs/guide/meta.json
index eaeee8189..dbfc85af4 100644
--- a/content/docs/guide/meta.json
+++ b/content/docs/guide/meta.json
@@ -21,6 +21,7 @@
"designing-app-navigation",
"public-forms",
"record-edit-modes",
+ "notifications",
"dashboard-filters",
"slotted-pages",
"react-pages",
diff --git a/content/docs/guide/notifications.md b/content/docs/guide/notifications.md
new file mode 100644
index 000000000..dbfec1a64
--- /dev/null
+++ b/content/docs/guide/notifications.md
@@ -0,0 +1,125 @@
+---
+title: Notifications
+description: The five spec display types and the surface that renders each
+---
+
+# Notifications
+
+ObjectUI's notification system implements the spec `NotificationSchema`
+(`@objectstack/spec` → `ui/notification.zod.ts`). A notification carries two
+independent axes:
+
+- **`severity`** — `info` / `success` / `warning` / `error`. Picks the icon and tone.
+- **`displayType`** — `toast` / `snackbar` / `banner` / `alert` / `inline`. Picks the
+ **surface**: where and how it appears.
+
+`displayType` used to be stored and never read, so every type surfaced as a
+toast — an author asking for a `banner` got a transient overlay. Each type now
+has a presentation of its own.
+
+## The five presentations
+
+| `displayType` | Presentation | Rendered by | Persists |
+|---|---|---|---|
+| `toast` | Transient overlay | the host's `onToast` delegate (sonner in the console) | no — auto-dismiss |
+| `snackbar` | Bottom-anchored bar, one at a time, at most one action | `` | no — auto-dismiss |
+| `banner` | Page-width strip **in the content flow** | `` | yes — until dismissed |
+| `alert` | Blocking acknowledgement dialog, FIFO queue | `` | yes — until acknowledged |
+| `inline` | In place, at the surface that raised it | `` | yes — until dismissed |
+
+Auto-dismiss follows the presentation, not a single global timer: `toast` and
+`snackbar` are transient (`config.defaultDuration`, 5s by default), the other
+three stay until dismissed. An explicit `duration` always wins — including
+`duration: 0`, which makes a toast persistent.
+
+## Mounting the surfaces
+
+`toast` is delegated to the host; the other four are React components that
+subscribe to the provider. Placement is deliberately yours: a banner needs a
+slot in the content area and an inline notification belongs next to the thing
+that raised it, so neither can be positioned by a global overlay.
+
+```tsx
+import {
+ NotificationAlerts,
+ NotificationBanners,
+ NotificationInline,
+ NotificationSnackbar,
+} from '@object-ui/components';
+import { NotificationProvider } from '@object-ui/react';
+import { toast } from 'sonner';
+
+ toast[n.severity](n.title, { description: n.message })}
+>
+
+ {/* top of the content area */}
+
+
+
+
+
+```
+
+A provider with **no** `onToast` is a supported "notification centre" mode: items
+are collected in `notifications` / `unreadCount` for a bell or list, and nothing
+is overlaid. Raising one of the other four types with its surface unmounted is a
+mistake, though — dev builds warn, naming the component to mount.
+
+## Raising notifications
+
+```tsx
+const { notify } = useNotifications();
+
+notify({ title: 'Saved', severity: 'success' }); // toast (spec default)
+notify({ title: 'Row deleted', severity: 'info', displayType: 'snackbar',
+ actions: [{ label: 'Undo', onClick: undo }] }); // one action
+notify({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' });
+notify({ title: 'Session expired', severity: 'error', displayType: 'alert' });
+```
+
+### `inline` and `scope`
+
+An inline notification is rendered by the surface that raised it. `scope` is the
+routing key that pairs the two, so two forms on one page don't show each other's
+messages:
+
+```tsx
+notify({
+ title: 'Fix 2 fields', severity: 'error',
+ displayType: 'inline', scope: 'contact-form',
+});
+
+
+```
+
+Omit `scope` on both ends for a page-level inline outlet. `scope` is renderer-local
+routing metadata, not a spec field — the spec describes what a notification *is*,
+not which React subtree hosts it.
+
+### `dismissible`
+
+Defaults to `true`. On the persistent presentations, `dismissible: false` removes
+the dismiss control (a banner you must resolve rather than wave away). An `alert`
+always keeps its acknowledge button — `dismissible: false` only closes the Escape
+route, never the way out.
+
+## Notes
+
+- **`alert` is not the action system's modal.** `ModalHandler` resolves a page or
+ object, renders it, and reports an `ActionResult` back to the `ActionRunner`. A
+ notification alert has no schema, no target and no result — it is a message and
+ an acknowledgement, so it renders through the `AlertDialog` primitive instead.
+- **`snackbar` is not a toast variant.** It supersedes rather than stacks, anchors
+ to the bottom regardless of the toast position config, and carries at most one
+ action.
+- Adding a member to the spec `NotificationTypeSchema` fails type-check in
+ `NOTIFICATION_PRESENTATIONS` (`@object-ui/react`) until its presentation is
+ decided — new types cannot silently fall back to a toast.
+
+## Server-side notifications
+
+`useClientNotifications` bridges the `@objectstack/client` notifications API into
+the same provider (ADR-0030). Fetched items are persistent and default to `toast`,
+so a host that renders a bell from `notifications` needs no surface at all.
diff --git a/packages/components/README.md b/packages/components/README.md
index 2c7a2292e..240401c8d 100644
--- a/packages/components/README.md
+++ b/packages/components/README.md
@@ -150,6 +150,32 @@ function MyComponent() {
- `link` - Link component
- `breadcrumb` - Breadcrumb navigation
+## Notification Surfaces
+
+Direct-import React components (not schema blocks) that render the notifications
+raised through `NotificationProvider` from `@object-ui/react`. One per spec
+`displayType`, so a `banner` no longer presents as a toast:
+
+| Component | `displayType` | Where to mount it |
+| --- | --- | --- |
+| `` | `snackbar` | anywhere inside the provider — it anchors itself bottom-center |
+| `` | `banner` | top of the content area (it takes space in the flow) |
+| `` | `alert` | anywhere inside the provider — blocking dialog, FIFO queue |
+| `` | `inline` | in the surface that raises them |
+
+`toast` stays with the host's `onToast` delegate (sonner in the console). See the
+[notifications guide](https://objectui.org/docs/guide/notifications).
+
+```tsx
+import { NotificationBanners, NotificationAlerts } from '@object-ui/components';
+
+
+
+
+
+
+```
+
## Customization
### Override Styles
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index ff6d7e87d..16bdb802d 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -56,6 +56,9 @@ export {
export * from './ui';
export * from './custom';
+// Export the notification surfaces — one per spec `displayType` (#3014).
+export * from './notifications';
+
// Export hooks
export { useConfigDraft } from './hooks/use-config-draft';
export type { UseConfigDraftOptions, UseConfigDraftReturn } from './hooks/use-config-draft';
diff --git a/packages/components/src/notifications/NotificationAlerts.tsx b/packages/components/src/notifications/NotificationAlerts.tsx
new file mode 100644
index 000000000..dfc73b417
--- /dev/null
+++ b/packages/components/src/notifications/NotificationAlerts.tsx
@@ -0,0 +1,109 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `displayType: 'alert'` — a blocking acknowledgement.
+ *
+ * Why NOT the action system's `ModalHandler` (#3014, open question 2): that
+ * handler is `(schema, context) => Promise` — it resolves a page
+ * or object, renders it as a modal, and reports the outcome back to the
+ * `ActionRunner`. A notification alert has no schema, no target and no action
+ * result; it is a message plus an acknowledgement. Routing it there would mean
+ * synthesizing a page just to say "OK" and requiring an action runtime to
+ * present a notification. So `alert` renders through the Radix `AlertDialog`
+ * primitive — the blocking-acknowledgement primitive — which duplicates none of
+ * the action-modal machinery.
+ */
+
+import * as React from 'react';
+import { useNotifications, useNotificationsByPresentation } from '@object-ui/react';
+import { cn } from '../lib/utils';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '../ui/alert-dialog';
+import { notificationSeverityStyle } from './severity';
+
+export interface NotificationAlertsProps {
+ /** Extra classes for the dialog content. */
+ className?: string;
+ /** Label of the acknowledge button. Default `OK`. */
+ acknowledgeLabel?: string;
+}
+
+/**
+ * Renders `alert` notifications one at a time as a blocking dialog.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function NotificationAlerts({ className, acknowledgeLabel = 'OK' }: NotificationAlertsProps) {
+ const alerts = useNotificationsByPresentation('alert');
+ const { dismiss } = useNotifications();
+
+ // The list is newest-first; a blocking queue is FIFO, so the OLDEST unhandled
+ // alert is the one the user is currently being asked to acknowledge.
+ const notification = alerts[alerts.length - 1];
+ if (!notification) return null;
+
+ const { Icon, iconTone } = notificationSeverityStyle(notification.severity);
+ const acknowledge = () => dismiss(notification.id);
+ // `dismissible: false` removes the wave-it-away paths (Escape); it never
+ // removes the acknowledge button — an alert nobody can acknowledge is a trap.
+ const blocking = notification.dismissible === false;
+
+ return (
+ { if (!open && !blocking) acknowledge(); }}>
+ { if (blocking) event.preventDefault(); }}
+ >
+
+
+
+ {notification.title}
+
+ {notification.message ? (
+ {notification.message}
+ ) : null}
+
+
+ {notification.actions?.map((action) => (
+ { action.onClick(); acknowledge(); }}
+ >
+ {action.label}
+
+ ))}
+
+ {acknowledgeLabel}
+
+
+
+
+ );
+}
+
+NotificationAlerts.displayName = 'NotificationAlerts';
diff --git a/packages/components/src/notifications/NotificationBanners.tsx b/packages/components/src/notifications/NotificationBanners.tsx
new file mode 100644
index 000000000..8495fc1b7
--- /dev/null
+++ b/packages/components/src/notifications/NotificationBanners.tsx
@@ -0,0 +1,103 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `displayType: 'banner'` — a persistent, page-width strip.
+ *
+ * Unlike the overlay surfaces, a banner is IN FLOW: it takes vertical space and
+ * pushes the content down, which is what makes it read as a standing condition
+ * ("you are viewing a draft", "the connection dropped") rather than as an event
+ * that just happened. Placement is therefore the host's: mount this at the top
+ * of the content area, inside the `NotificationProvider`.
+ */
+
+import * as React from 'react';
+import { X } from 'lucide-react';
+import { useNotifications, useNotificationsByPresentation } from '@object-ui/react';
+import { cn } from '../lib/utils';
+import { Button } from '../ui/button';
+import { notificationSeverityStyle } from './severity';
+
+export interface NotificationBannersProps {
+ /** Extra classes for the banner stack container. */
+ className?: string;
+ /**
+ * Cap on simultaneously rendered banners (newest first). Banners are
+ * persistent, so an uncapped stack can swallow the viewport. Default 3.
+ */
+ max?: number;
+}
+
+/**
+ * Renders every active `banner` notification as a stacked strip.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function NotificationBanners({ className, max = 3 }: NotificationBannersProps) {
+ const banners = useNotificationsByPresentation('banner');
+ const { dismiss } = useNotifications();
+
+ if (banners.length === 0) return null;
+
+ return (
+
+ );
+}
+
+NotificationBanners.displayName = 'NotificationBanners';
diff --git a/packages/components/src/notifications/NotificationInline.tsx b/packages/components/src/notifications/NotificationInline.tsx
new file mode 100644
index 000000000..941075f1b
--- /dev/null
+++ b/packages/components/src/notifications/NotificationInline.tsx
@@ -0,0 +1,108 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `displayType: 'inline'` — rendered in place by the surface that raised it.
+ *
+ * The whole point of `inline` is proximity: a validation summary belongs above
+ * the form that failed, not in a corner of the viewport. So this component
+ * floats nothing and positions nothing — it renders where you put it, and a
+ * `scope` matches it to its raiser so two forms on one page don't show each
+ * other's messages.
+ */
+
+import * as React from 'react';
+import { X } from 'lucide-react';
+import { useNotifications, useNotificationsByPresentation } from '@object-ui/react';
+import { cn } from '../lib/utils';
+import { Button } from '../ui/button';
+import { notificationSeverityStyle } from './severity';
+
+export interface NotificationInlineProps {
+ /**
+ * Routing key — renders only notifications raised with the same `scope`.
+ * Omit on both ends for the page-level inline outlet.
+ */
+ scope?: string;
+ /** Extra classes for the stack container. */
+ className?: string;
+}
+
+/**
+ * Renders the `inline` notifications raised for `scope`, in place.
+ *
+ * @example
+ * ```tsx
+ * // in the form that raises them
+ * notify({ title: 'Fix 2 fields', severity: 'error', displayType: 'inline', scope: 'contact-form' });
+ *
+ *
+ * ```
+ */
+export function NotificationInline({ scope, className }: NotificationInlineProps) {
+ const items = useNotificationsByPresentation('inline', scope);
+ const { dismiss } = useNotifications();
+
+ if (items.length === 0) return null;
+
+ return (
+
+ );
+}
+
+NotificationInline.displayName = 'NotificationInline';
diff --git a/packages/components/src/notifications/NotificationSnackbar.tsx b/packages/components/src/notifications/NotificationSnackbar.tsx
new file mode 100644
index 000000000..10ed71782
--- /dev/null
+++ b/packages/components/src/notifications/NotificationSnackbar.tsx
@@ -0,0 +1,103 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `displayType: 'snackbar'` — a bottom-anchored transient with ONE action.
+ *
+ * Deliberately not a sonner variant. A snackbar is singular (a new one replaces
+ * the current, it does not stack), bottom-anchored regardless of the toast
+ * position config, and carries at most one action — "Undo" being the archetype.
+ * Routing it through the toast stack is exactly the collapse #3014 is about: it
+ * would look like a toast, so the spec's fifth member would mean nothing.
+ */
+
+import * as React from 'react';
+import { X } from 'lucide-react';
+import { useNotifications, useNotificationsByPresentation } from '@object-ui/react';
+import { cn } from '../lib/utils';
+import { Button } from '../ui/button';
+import { notificationSeverityStyle } from './severity';
+
+export interface NotificationSnackbarProps {
+ /** Extra classes for the snackbar container. */
+ className?: string;
+}
+
+/**
+ * Renders the most recent `snackbar` notification, anchored bottom-center.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function NotificationSnackbar({ className }: NotificationSnackbarProps) {
+ const snackbars = useNotificationsByPresentation('snackbar');
+ const { dismiss } = useNotifications();
+
+ // Newest-first — a snackbar supersedes rather than stacks.
+ const notification = snackbars[0];
+ if (!notification) return null;
+
+ const { Icon, iconTone } = notificationSeverityStyle(notification.severity);
+ // The spec frames a snackbar as carrying "an optional single action"; extra
+ // actions belong on a banner or an alert.
+ const action = notification.actions?.[0];
+
+ return (
+