diff --git a/.changeset/toast.md b/.changeset/toast.md
new file mode 100644
index 0000000..c3c9596
--- /dev/null
+++ b/.changeset/toast.md
@@ -0,0 +1,39 @@
+---
+'@dunky.dev/toast': minor
+'@dunky.dev/react-toast': minor
+---
+
+Add the Toast primitive — a declarative, Radix-style toast (one component per
+toast, not an imperative toaster store), shipped as an agnostic core
+(`@dunky.dev/toast`) plus a React binding (`@dunky.dev/react-toast`).
+
+A toast announces as a `role="status"` live region (`assertive` for the
+`foreground` type, `polite` for `background`), auto-dismisses after its
+duration (`Infinity` = persistent), and pauses every timer while the viewport
+is hovered or focused — resuming restarts the full duration, a documented
+deviation from Radix that keeps the timer contract inside the state machine.
+Dismissing the toast that holds keyboard focus parks focus on the viewport
+(`tabindex="-1"`), so the user keeps their place and the hover/focus pause
+tracking stays truthful.
+
+```tsx
+import { Toast } from '@dunky.dev/react-toast'
+
+function App() {
+ return (
+
+ {/* app */}
+
+
+
+ Saved
+ Your changes are safe.
+ Undo
+ Dismiss
+
+
+
+
+ )
+}
+```
diff --git a/packages/core/toast/README.md b/packages/core/toast/README.md
new file mode 100644
index 0000000..5710994
--- /dev/null
+++ b/packages/core/toast/README.md
@@ -0,0 +1,28 @@
+# @dunky.dev/toast
+
+The framework-agnostic toast interaction, modeled as a state machine on
+`@dunky.dev/state-machine`. Pure logic — no substrate, no framework. Consumers
+pair it with a substrate driver rather than driving the machine directly.
+
+The behavior contract — scenarios, guarantees, driver obligations (including
+the timer wiring the substrate owns) — lives in [SPEC.md](./SPEC.md).
+
+## Install
+
+```sh
+npm install @dunky.dev/toast
+```
+
+## Usage
+
+```ts
+import { machine, connector } from '@dunky.dev/state-machine'
+import { toastMachine, toastConnect } from '@dunky.dev/toast'
+
+// `id` is substrate-minted (SSR-safe); the connect derives the per-part ids.
+const service = machine(toastMachine({ ...options, id: 'my-toast' }))
+connector(service, toastConnect, options) // wires the consumer callbacks
+service.start()
+// the substrate's timer sends this when `context.duration` elapses in `open`
+service.send({ type: 'timer.elapsed' })
+```
diff --git a/packages/core/toast/SPEC.md b/packages/core/toast/SPEC.md
new file mode 100644
index 0000000..d48dfdc
--- /dev/null
+++ b/packages/core/toast/SPEC.md
@@ -0,0 +1,161 @@
+# SPEC / Toast
+
+## Reference
+
+- **W3C pattern**: there is no APG toast pattern; the normative ground is
+ [WAI-ARIA 1.2 `status`](https://www.w3.org/TR/wai-aria-1.2/#status) (a live
+ region with implicit `aria-live`) plus the
+ [APG alert pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/) guidance
+ that time-based messages must not steal focus.
+- **State machine**: built on `@dunky.dev/state-machine`.
+- **Prior art**: API shape modeled on the Radix Toast (declarative, one
+ component per toast — not an imperative toaster store), cross-checked against
+ Base UI and Ark.
+
+## Overview
+
+A toast is a short, time-limited message that announces the outcome of an
+action or a background event without interrupting the user's flow: it appears
+in a dedicated region, is announced by assistive technology as a live update,
+and dismisses itself after a duration — or earlier, when the user acts on it.
+It is the opposite of a dialog: it must never take over interaction, never
+steal focus, and never require an answer.
+
+## Anatomy
+
+One machine per toast. The Provider and Viewport are shared coordination
+surfaces around the toasts — they carry configuration and the landmark, not
+machine state — and each substrate provides them (see [Design](#design)).
+
+```
+ — shared config: the default duration, the region's label
+ |_ — the landmark region listing the toasts; hovering or
+ | focusing it pauses every toast's timer
+ |_ — root; owns one toast's state, renders nothing of its own
+ |_ — the toast surface: the announced live element
+ |_ — names the toast
+ |_ — the toast's message body
+ |_ — an optional action; pressing it dismisses
+ |_ — the explicit dismissal affordance
+```
+
+## Behavior
+
+Using the toast is a walkthrough of intent, not a prop list:
+
+- The **root** owns one toast's open/close state, exposed controlled and
+ uncontrolled, mirroring the dialog: a controlled consumer drives it from
+ outside, and every open/close intent — including auto-dismiss — is reported
+ back so the consumer stays in sync. The controlled prop is a synced input,
+ not a gate: an internal dismissal (timer, Close, Action) still closes the
+ toast, and the report is what lets the consumer mirror it back. A rendered
+ uncontrolled toast is open by default: rendering it is the intent to show it.
+- A toast **auto-dismisses** after its duration. The duration comes from the
+ toast itself, falling back to the provider's default. A non-finite duration
+ (`Infinity`) makes the toast persistent — it only dismisses on user action or
+ from outside.
+- Hovering or focusing the **viewport pauses** every toast's timer — the user
+ is reading or about to act, so nothing may vanish under the pointer or
+ focus. Leaving/blurring resumes. Resuming restarts the full duration (the
+ [Design](#design) section records why).
+- **Title / Description** name and describe the toast; the root's ARIA
+ relationships always follow what is actually rendered — an omitted part never
+ leaves a dangling reference.
+- **Action** is the toast's optional interactive affordance (undo, retry).
+ Pressing it dismisses the toast; the consumer's own handler decides what the
+ action does.
+- **Close** dismisses from inside — the explicit affordance for a user who
+ won't wait out the timer, and what a persistent toast relies on.
+
+## States
+
+| State | Behavior |
+| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `closed` | Nothing is shown. An open intent (controlled prop, imperative open) moves to `open`. |
+| `open` | The toast is visible and its dismiss timer runs. The timer elapsing, a Close/Action press, or an outside close intent dismisses; a pause intent moves to `paused`. |
+| `paused` | The toast is visible with its timer suspended. A resume intent returns to `open` (restarting the duration); Close/Action and outside close intents still dismiss. |
+
+`open` and `paused` are both "shown" — parts expose `data-state="open"` for
+either, so pausing never disturbs styling or animation.
+
+### Timer
+
+The machine owns the timing _decision_ as explicit states: entering `open`
+means "the dismiss timer is running", `paused` means "it is suspended", and
+the elapse is an ordinary event that only `open` accepts. The substrate owns
+the _clock_: entering the timed state schedules a timeout for the toast's
+duration that sends the elapse event; leaving it cancels the timeout. A late
+or stray elapse can never dismiss a paused or closed toast — the state graph,
+not the scheduler, is the authority.
+
+## Accessibility
+
+Per WAI-ARIA `status` and the Radix mapping:
+
+- **Role**: the root is `role="status"` with an explicit politeness driven by
+ the toast's type — `foreground` (the direct result of a user action) maps to
+ `aria-live="assertive"`, `background` (a task the user didn't just perform)
+ maps to `aria-live="polite"`. `aria-atomic` announces the whole toast on
+ update.
+- **No focus steal**: showing a toast never moves focus. Its controls are
+ reached in the normal tab order through the viewport.
+- **Focus survives dismissal**: dismissing the toast that holds focus moves
+ focus to the viewport. Removing a focused element dispatches no blur, so
+ parking focus there keeps the keyboard user's place in the region and keeps
+ the viewport's focus-derived pause truthful.
+- **Name**: the root is labelled by the rendered Title and described by the
+ rendered Description, when present.
+- **Viewport**: a `role="region"` landmark with an accessible label (the
+ provider's `label`), so assistive-tech users can find pending toasts; the
+ toasts are a list inside it.
+- **Timing**: hover and focus pause the timer so the message stays readable
+ and operable for keyboard and pointer users alike.
+
+## Constraints
+
+- A toast never steals focus and never blocks interaction with the page.
+- Every open ⇄ close transition, whatever its cause (timer, Close, Action,
+ outside intent), is reported to the consumer; pause/resume is not an
+ open/close change and is never reported as one.
+- ARIA labelled-by / described-by must only reference elements that are
+ actually rendered.
+- A dismiss timer can only elapse in `open`: pausing or closing invalidates
+ any in-flight timer.
+- Out of scope for v0, recorded as deliberate exclusions: swipe-to-dismiss
+ gestures, an imperative `toast()` queue/store API, the F8 hotkey to jump to
+ the viewport landmark, Escape-to-dismiss of the focused toast,
+ toast stacking limits/queueing, and positioning — the consumer styles and
+ positions the viewport.
+- Also deferred, as a recorded limitation rather than a choice: the hidden
+ announcer. The live element mounts together with its content, and a live
+ region inserted pre-populated is announced unreliably by some screen
+ readers — the Radix-style off-screen announcer that guarantees the initial
+ announcement is a known v0 gap.
+
+## Internals
+
+- **One machine per toast; no store.** The Radix-style declarative shape wins
+ over an imperative toaster: each rendered toast owns exactly one machine and
+ the dialog's controlled/uncontrolled contract applies to it. A queue/store
+ API can be layered on later without touching this core.
+- **The substrate schedules, the machine decides.** The core has no clock: the
+ `open`/`paused` split is the machine's, while the substrate's effect wires
+ `setTimeout` to the state — enter `open`, schedule the duration; leave,
+ cancel. Any substrate inherits identical pause/dismiss semantics by wiring
+ one timer.
+- **Resume restarts the full duration.** Resuming with the remaining time
+ would need a clock inside the machine (or timing state inside the binding,
+ which a binding must not own). Restarting keeps the timer contract fully in
+ the state graph, and errs in the reader's favor — a paused toast gets its
+ whole duration back. This deviates from Radix, which resumes with the
+ remaining time.
+- **Provider and Viewport are substrate surfaces.** They coordinate _between_
+ machines — the default duration, the region landmark, broadcasting
+ pause/resume to every registered toast — so they hold no machine state and
+ make no decisions: a broadcast pause is just the pause intent delivered to
+ each machine, and each machine's state graph decides what it means. A toast
+ that starts (or opens) while the viewport is already hovered or focused
+ joins paused — the broadcast reaches late joiners too.
+- **Duration is read at toast creation.** Like every config option in this
+ repo's cores, `duration` and `type` seed the machine context at build time;
+ changing them on a live toast has no effect.
diff --git a/packages/core/toast/package.json b/packages/core/toast/package.json
new file mode 100644
index 0000000..56efe7b
--- /dev/null
+++ b/packages/core/toast/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@dunky.dev/toast",
+ "version": "0.0.0",
+ "description": "Framework-agnostic toast interaction, modeled as a state machine.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/core/toast"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "publishConfig": {
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsdown"
+ },
+ "dependencies": {
+ "@dunky.dev/state-machine": "^0.1.0",
+ "@dunky.dev/state-machine-bindings": "^0.1.0"
+ }
+}
diff --git a/packages/core/toast/src/connect.ts b/packages/core/toast/src/connect.ts
new file mode 100644
index 0000000..ed4e229
--- /dev/null
+++ b/packages/core/toast/src/connect.ts
@@ -0,0 +1,94 @@
+import { makeReaction, type Connect } from '@dunky.dev/state-machine'
+import type { AttrBindings, EventBindings } from '@dunky.dev/state-machine-bindings'
+import type {
+ ToastContext,
+ ToastIds,
+ ToastMachineEvent,
+ ToastOptions,
+ ToastStateName,
+ ToastType,
+} from './types'
+
+// Open and paused are both "shown": pausing never disturbs styling/animation.
+type ToastDataState = 'open' | 'closed'
+
+// The bindings a part carries, drawn from the shared agnostic vocabulary; the
+// index signature keeps parts assignable to the loose shape each substrate's
+// normalize() accepts. `data-state` is the styling/animation hook.
+export type ToastPartBindings = EventBindings &
+ AttrBindings & { 'data-state'?: ToastDataState } & Record
+
+// The cross-part ids all derive from the one base id, so Root's labelledby/
+// describedby and the title/description ids always agree.
+function toastIds(id: string): ToastIds {
+ return { root: `${id}-root`, title: `${id}-title`, description: `${id}-description` }
+}
+
+/** The view-facing surface a driver reads from the running toast machine. */
+export interface ToastApi {
+ open: boolean
+ type: ToastType
+ ids: ToastIds
+ setOpen: (open: boolean) => void
+ parts: {
+ root: ToastPartBindings
+ title: ToastPartBindings
+ description: ToastPartBindings
+ action: ToastPartBindings
+ close: ToastPartBindings
+ }
+}
+
+export const toastConnect: Connect<
+ ToastStateName,
+ ToastContext,
+ ToastMachineEvent,
+ ToastOptions,
+ ToastApi
+> = ({ state, context, send }) => {
+ const open = state !== 'closed'
+ const dataState: ToastDataState = open ? 'open' : 'closed'
+ const ids = toastIds(context.id)
+ const close = (): void => send({ type: 'close' })
+
+ return {
+ open,
+ type: context.type,
+ ids,
+ setOpen(next) {
+ if (open === next) return
+ send({ type: next ? 'open' : 'close' })
+ },
+ parts: {
+ root: {
+ // A status live region; the toast type only decides how assertively
+ // it is announced.
+ role: 'status',
+ live: context.type === 'foreground' ? 'assertive' : 'polite',
+ atomic: true,
+ id: ids.root,
+ labelledBy: context.parts.title ? ids.title : undefined,
+ describedBy: context.parts.description ? ids.description : undefined,
+ 'data-state': dataState,
+ },
+ title: { id: ids.title },
+ description: { id: ids.description },
+ // Action and Close both dismiss; what the action does is the consumer's
+ // own handler, composed by the substrate.
+ action: { onPress: close },
+ close: { onPress: close },
+ },
+ }
+}
+
+const reaction = makeReaction()
+
+// One reaction per consumer callback. Reactions fire in registration order within
+// a single setContext — that order is the callback-order contract. See SPEC.md.
+// The selector spans open+paused, so pause/resume never reads as an open change.
+toastConnect.reactions = [
+ reaction(
+ m => !m.matches('closed'),
+ (open, props) => props.onOpenChange?.(open),
+ ),
+]
diff --git a/packages/core/toast/src/index.ts b/packages/core/toast/src/index.ts
new file mode 100644
index 0000000..3469091
--- /dev/null
+++ b/packages/core/toast/src/index.ts
@@ -0,0 +1,12 @@
+export { toastMachine, type ToastMachine } from './machine'
+export { toastConnect, type ToastApi, type ToastPartBindings } from './connect'
+export type {
+ ToastCallbacks,
+ ToastContext,
+ ToastIds,
+ ToastMachineEvent,
+ ToastOptions,
+ ToastPart,
+ ToastStateName,
+ ToastType,
+} from './types'
diff --git a/packages/core/toast/src/machine.ts b/packages/core/toast/src/machine.ts
new file mode 100644
index 0000000..b720df2
--- /dev/null
+++ b/packages/core/toast/src/machine.ts
@@ -0,0 +1,59 @@
+import { setup, type Action, type Machine, type TransitionConfig } from '@dunky.dev/state-machine'
+import type { ToastContext, ToastMachineEvent, ToastOptions, ToastStateName } from './types'
+
+/** The running toast machine — what a substrate holds and sends events to. */
+export type ToastMachine = Machine
+
+type ToastAction = Action
+
+const setPartPresence: ToastAction = ({ event, context, setContext }) => {
+ if (event.type !== 'part.presence') return
+ setContext({ parts: { ...context.parts, [event.part]: event.present } })
+}
+
+export function toastMachine(
+ options: ToastOptions,
+): TransitionConfig {
+ // Annotated so createMachine infers Context as ToastContext, not the narrowed literal.
+ const context: ToastContext = {
+ type: options.type ?? 'foreground',
+ duration: options.duration ?? 5000,
+ // The substrate supplies a unique id; `toast` is only a bare fallback.
+ id: options.id ?? 'toast',
+ parts: { title: false, description: false },
+ }
+
+ return setup.as().createMachine({
+ // Rendering an uncontrolled toast is the intent to show it — open by default.
+ initial: (options.open ?? options.defaultOpen ?? true) ? 'open' : 'closed',
+ context,
+ // Top-level: parts report their presence from any state.
+ on: {
+ 'part.presence': { actions: setPartPresence },
+ },
+ states: {
+ closed: {
+ on: {
+ open: { target: 'open' },
+ },
+ },
+ // `open` means "the dismiss timer is running": the substrate's timer
+ // effect schedules on enter and cancels on exit.
+ open: {
+ on: {
+ close: { target: 'closed' },
+ 'timer.elapsed': { target: 'closed' },
+ 'timer.pause': { target: 'paused' },
+ },
+ },
+ // Shown with the timer suspended. No `timer.elapsed` here: a late elapse
+ // from the scheduler can never dismiss a paused toast.
+ paused: {
+ on: {
+ close: { target: 'closed' },
+ 'timer.resume': { target: 'open' },
+ },
+ },
+ },
+ })
+}
diff --git a/packages/core/toast/src/types.ts b/packages/core/toast/src/types.ts
new file mode 100644
index 0000000..7f16bf5
--- /dev/null
+++ b/packages/core/toast/src/types.ts
@@ -0,0 +1,75 @@
+// Public + machine-facing types for the framework-agnostic toast primitive.
+// The state machine is substrate-free: all event reading and timer scheduling
+// live in a per-substrate driver.
+
+export type ToastStateName = 'closed' | 'open' | 'paused'
+
+/**
+ * How assertively the toast is announced: `foreground` (the direct result of
+ * a user action) maps to `aria-live="assertive"`, `background` (a task the
+ * user didn't just perform) to `aria-live="polite"`.
+ */
+export type ToastType = 'foreground' | 'background'
+
+/** The parts whose presence drives the ARIA relationships on Root. */
+export type ToastPart = 'title' | 'description'
+
+/**
+ * The cross-part ids, derived from the one `id` on context: title/description
+ * render as `id` on their element and as an ARIA reference (labelledby /
+ * describedby) on Root, and the connect wires both sides.
+ */
+export interface ToastIds {
+ root: string
+ title: string
+ description: string
+}
+
+export interface ToastContext {
+ type: ToastType
+ // Auto-dismiss duration in ms; a non-finite value means persistent. The
+ // substrate's timer effect reads it when scheduling.
+ duration: number
+ // The base id (substrate-minted, SSR-safe); the connect derives the per-part
+ // ids from it.
+ id: string
+ // Which optional parts are currently present, so Root only references the
+ // ones actually rendered.
+ parts: Record
+}
+
+// The timer events are distinct from `close` so the state graph — not the
+// substrate's scheduler — stays the authority: only `open` accepts an elapse.
+export type ToastMachineEvent =
+ | { type: 'open' }
+ | { type: 'close' }
+ | { type: 'timer.elapsed' }
+ | { type: 'timer.pause' }
+ | { type: 'timer.resume' }
+ | { type: 'part.presence'; part: ToastPart; present: boolean }
+
+export interface ToastCallbacks {
+ /** Fired on every open/close transition — including auto-dismiss — with the
+ * new value. Pause/resume is never reported. */
+ onOpenChange?: (open: boolean) => void
+}
+
+/**
+ * The agnostic toast options — the behavior a consumer configures. A
+ * substrate's props extend this with its own concerns (e.g. `children`).
+ */
+export interface ToastOptions extends ToastCallbacks {
+ /** Base id for the toast's parts; the substrate supplies a unique, SSR-safe
+ * one. The per-part ids (root/title/description) are derived from it. */
+ id?: string
+ /** Controlled open state; every open/close intent is reported through `onOpenChange`. */
+ open?: boolean
+ /** Initial open state for the uncontrolled toast — rendering a toast is the
+ * intent to show it. @default true */
+ defaultOpen?: boolean
+ /** Announcement politeness. @default 'foreground' */
+ type?: ToastType
+ /** Auto-dismiss duration in ms; `Infinity` makes the toast persistent. The
+ * substrate defaults it from its provider surface. @default 5000 */
+ duration?: number
+}
diff --git a/packages/core/toast/tests/machine.test.ts b/packages/core/toast/tests/machine.test.ts
new file mode 100644
index 0000000..a345201
--- /dev/null
+++ b/packages/core/toast/tests/machine.test.ts
@@ -0,0 +1,198 @@
+// The agnostic core of the Toast — machine + connect, no DOM, no framework.
+import { describe, expect, it, vi } from 'vitest'
+import { connector, machine, type Connector, type Machine } from '@dunky.dev/state-machine'
+import { toastMachine, toastConnect } from '@dunky.dev/toast'
+import type {
+ ToastApi,
+ ToastContext,
+ ToastIds,
+ ToastMachineEvent,
+ ToastOptions,
+ ToastStateName,
+} from '@dunky.dev/toast'
+
+// The per-part ids the connect derives from a base id of `toast`.
+const ids: ToastIds = {
+ root: 'toast-root',
+ title: 'toast-title',
+ description: 'toast-description',
+}
+
+interface Harness {
+ service: Machine
+ connection: Connector
+}
+
+const build = (options: ToastOptions = {}): Harness => {
+ const service = machine(toastMachine({ id: 'toast', ...options }))
+ const connection = connector(service, toastConnect, options)
+ service.start()
+ return { service, connection }
+}
+
+describe('toast machine — open/close', () => {
+ it('starts open by default — rendering a toast is the intent to show it', () => {
+ const { service } = build()
+ expect(service.state).toBe('open')
+ })
+
+ it('starts closed when defaultOpen=false', () => {
+ const { service } = build({ defaultOpen: false })
+ expect(service.state).toBe('closed')
+ })
+
+ it('starts closed when controlled open=false', () => {
+ const { service } = build({ open: false })
+ expect(service.state).toBe('closed')
+ })
+
+ it('open/close events are one-directional (close while closed is a no-op)', () => {
+ const { service } = build({ defaultOpen: false })
+ service.send({ type: 'open' })
+ expect(service.state).toBe('open')
+ service.send({ type: 'close' })
+ expect(service.state).toBe('closed')
+ service.send({ type: 'close' })
+ expect(service.state).toBe('closed')
+ })
+})
+
+describe('toast machine — timer states', () => {
+ it('the timer elapsing dismisses an open toast', () => {
+ const { service } = build()
+ service.send({ type: 'timer.elapsed' })
+ expect(service.state).toBe('closed')
+ })
+
+ it('pause suspends the timer; resume restarts it', () => {
+ const { service } = build()
+ service.send({ type: 'timer.pause' })
+ expect(service.state).toBe('paused')
+ service.send({ type: 'timer.resume' })
+ expect(service.state).toBe('open')
+ })
+
+ it('a stray elapse never dismisses a paused toast', () => {
+ const { service } = build()
+ service.send({ type: 'timer.pause' })
+ service.send({ type: 'timer.elapsed' })
+ expect(service.state).toBe('paused')
+ })
+
+ it('pause is meaningless while closed', () => {
+ const { service } = build({ defaultOpen: false })
+ service.send({ type: 'timer.pause' })
+ expect(service.state).toBe('closed')
+ })
+
+ it('close dismisses a paused toast', () => {
+ const { service } = build()
+ service.send({ type: 'timer.pause' })
+ service.send({ type: 'close' })
+ expect(service.state).toBe('closed')
+ })
+})
+
+describe('toast machine — context seeding', () => {
+ it('defaults to foreground type and a 5000ms duration', () => {
+ const { service } = build()
+ expect(service.context.type).toBe('foreground')
+ expect(service.context.duration).toBe(5000)
+ })
+
+ it('seeds the duration override from options', () => {
+ const { service } = build({ duration: 200 })
+ expect(service.context.duration).toBe(200)
+ })
+
+ it('tracks title/description presence in context, in any state', () => {
+ const { service } = build({ defaultOpen: false })
+ service.send({ type: 'part.presence', part: 'title', present: true })
+ service.send({ type: 'part.presence', part: 'description', present: true })
+ expect(service.context.parts).toEqual({ title: true, description: true })
+
+ service.send({ type: 'part.presence', part: 'title', present: false })
+ expect(service.context.parts).toEqual({ title: false, description: true })
+ })
+})
+
+describe('toast connect — logical bindings', () => {
+ it('root is an assertive status live region for the default foreground type', () => {
+ const { connection } = build()
+ const root = connection.snapshot.parts.root
+ expect(root.role).toBe('status')
+ expect(root.live).toBe('assertive')
+ expect(root.atomic).toBe(true)
+ expect(root.id).toBe(ids.root)
+ })
+
+ it('background type announces politely', () => {
+ const { connection } = build({ type: 'background' })
+ expect(connection.snapshot.parts.root.live).toBe('polite')
+ })
+
+ it('labelledBy/describedBy only reference rendered parts', () => {
+ const { service, connection } = build()
+ expect(connection.snapshot.parts.root.labelledBy).toBeUndefined()
+ expect(connection.snapshot.parts.root.describedBy).toBeUndefined()
+
+ service.send({ type: 'part.presence', part: 'title', present: true })
+ service.send({ type: 'part.presence', part: 'description', present: true })
+ expect(connection.snapshot.parts.root.labelledBy).toBe(ids.title)
+ expect(connection.snapshot.parts.root.describedBy).toBe(ids.description)
+
+ expect(connection.snapshot.parts.title.id).toBe(ids.title)
+ expect(connection.snapshot.parts.description.id).toBe(ids.description)
+ })
+
+ it('action press dismisses through the machine', () => {
+ const { service, connection } = build()
+ connection.snapshot.parts.action.onPress?.()
+ expect(service.state).toBe('closed')
+ })
+
+ it('close press dismisses; setOpen drives both directions', () => {
+ const { service, connection } = build()
+ connection.snapshot.parts.close.onPress?.()
+ expect(service.state).toBe('closed')
+
+ connection.snapshot.setOpen(true)
+ expect(service.state).toBe('open')
+ connection.snapshot.setOpen(false)
+ expect(service.state).toBe('closed')
+ })
+
+ it('data-state stays "open" while paused — pausing never disturbs styling', () => {
+ const { service, connection } = build()
+ expect(connection.snapshot.parts.root['data-state']).toBe('open')
+
+ service.send({ type: 'timer.pause' })
+ expect(connection.snapshot.parts.root['data-state']).toBe('open')
+ expect(connection.snapshot.open).toBe(true)
+
+ service.send({ type: 'close' })
+ expect(connection.snapshot.parts.root['data-state']).toBe('closed')
+ })
+})
+
+describe('toast connect — reactions', () => {
+ it('fires onOpenChange on every open/close flip, not on subscribe', () => {
+ const onOpenChange = vi.fn()
+ const { service } = build({ defaultOpen: false, onOpenChange })
+ expect(onOpenChange).not.toHaveBeenCalled()
+
+ service.send({ type: 'open' })
+ expect(onOpenChange).toHaveBeenLastCalledWith(true)
+ service.send({ type: 'timer.elapsed' })
+ expect(onOpenChange).toHaveBeenLastCalledWith(false)
+ expect(onOpenChange).toHaveBeenCalledTimes(2)
+ })
+
+ it('never reports pause/resume as an open/close change', () => {
+ const onOpenChange = vi.fn()
+ const { service } = build({ onOpenChange })
+ service.send({ type: 'timer.pause' })
+ service.send({ type: 'timer.resume' })
+ expect(onOpenChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/react/toast/README.md b/packages/react/toast/README.md
new file mode 100644
index 0000000..f5dbd5d
--- /dev/null
+++ b/packages/react/toast/README.md
@@ -0,0 +1,40 @@
+# @dunky.dev/react-toast
+
+React binding for [`@dunky.dev/toast`](../../core/toast): a compound
+component — `Toast` plus its parts — that drives the framework-free toast
+machine. The root owns the machine; parts translate the core's logical
+bindings into DOM attributes and handlers, and wire the DOM-only concerns
+(the dismiss timer, the provider/viewport pause broadcast).
+
+Behavior contract: [`../../core/toast/SPEC.md`](../../core/toast/SPEC.md).
+React-specific surface: [SPEC.md](./SPEC.md).
+
+## Install
+
+```sh
+npm install @dunky.dev/react-toast
+```
+
+## Usage
+
+```tsx
+import { Toast } from '@dunky.dev/react-toast'
+
+function Notifications() {
+ return (
+
+ {/* app */}
+
+
+
+ Saved
+ Your changes are safe.
+ Undo
+ Dismiss
+
+
+
+
+ )
+}
+```
diff --git a/packages/react/toast/SPEC.md b/packages/react/toast/SPEC.md
new file mode 100644
index 0000000..592c82a
--- /dev/null
+++ b/packages/react/toast/SPEC.md
@@ -0,0 +1,131 @@
+# SPEC / React / Toast
+
+The React implementation of the [core spec](../../core/toast/SPEC.md).
+
+## Docs
+
+🔗 [`dunky.dev/ui/components/toast`](https://dunky.dev/ui/components/toast).
+
+## Install
+
+```sh
+npm install @dunky.dev/react-toast
+```
+
+## Usage
+
+```tsx
+import { Toast } from '@dunky.dev/react-toast'
+;
+ {/* app */}
+
+
+
+ Saved
+ Your changes are safe.
+ Undo
+ Dismiss
+
+
+
+
+```
+
+React-specific notes on top of the core contract:
+
+- **`Toast`** (the root) owns one toast's machine and renders no DOM;
+ **`Toast.Root`** is the rendered surface — the `
` announced to assistive
+ tech — and mounts only while the toast is open.
+- **`Toast.Provider`** and **`Toast.Viewport`** are the core's substrate
+ coordination surfaces: the provider carries the default duration and the
+ region label; the viewport is the `role="region"` landmark (``) that
+ pauses every toast's timer on hover/focus and resumes on leave/blur. Every
+ toast must live under a provider.
+- The dismiss timer is a substrate effect wired to the machine state:
+ entering `open` schedules a `setTimeout` for the toast's duration; leaving
+ cancels it. Resuming restarts the full duration, per the core design.
+- Everything ships headless — the consumer styles and positions the viewport;
+ `data-state` (`open`) on Root is the styling and animation hook.
+
+## API
+
+### `Toast.Provider`
+
+Shared configuration for every toast beneath it. Renders no DOM.
+
+| Prop | Type | Default | Description |
+| ---------- | ----------- | ----------------- | ---------------------------------------------------------------- |
+| `duration` | `number` | `5000` | Default auto-dismiss duration (ms) for toasts without their own. |
+| `label` | `string` | `'Notifications'` | Accessible label of the viewport region. |
+| `children` | `ReactNode` | — | The app and the viewport. |
+
+### `Toast.Viewport`
+
+The landmark region listing the toasts; renders an ``. Hovering (entering or moving the pointer) or focusing it
+pauses every toast's timer; leaving/blurring resumes. Dismissing the toast
+that holds focus parks focus here, per the core spec — programmatically
+focusable, never in the tab order.
+
+| Prop | Type | Default | Description |
+| ---------- | ---------------------- | ------- | --------------------------------- |
+| `...props` | `ComponentProps<'ol'>` | — | Forwarded to the rendered ``. |
+
+### `Toast`
+
+The root of one toast: owns its machine, renders no DOM. Accepts the core
+`ToastOptions`.
+
+| Prop | Type | Default | Description |
+| -------------- | ------------------------------ | -------------- | ----------------------------------------------------------------------------- |
+| `open` | `boolean` | — | Controlled open state. |
+| `defaultOpen` | `boolean` | `true` | Initial open state for the uncontrolled toast. |
+| `onOpenChange` | `(open: boolean) => void` | — | Fired on every open/close transition — including auto-dismiss. |
+| `type` | `'foreground' \| 'background'` | `'foreground'` | Announcement politeness: `assertive` for foreground, `polite` for background. |
+| `duration` | `number` | the provider's | Auto-dismiss duration (ms); `Infinity` makes the toast persistent. |
+| `id` | `string` | auto (`useId`) | Base id for the parts; per-part ids are derived from it. |
+| `children` | `ReactNode` | — | The toast's parts. |
+
+### `Toast.Root`
+
+The toast surface: the `role="status"` live element. Renders an `
`, only
+while the toast is open.
+
+| Prop | Type | Default | Description |
+| ---------- | ---------------------- | ------- | --------------------------------- |
+| `...props` | `ComponentProps<'li'>` | — | Forwarded to the rendered `
`. |
+
+### `Toast.Title`
+
+Names the toast (wires `aria-labelledby` on Root). Renders a `
` — a
+transient message carries no document-outline heading.
+
+| Prop | Type | Default | Description |
+| ---------- | ----------------------- | ------- | ---------------------------------- |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
+
+### `Toast.Description`
+
+The toast's message body (wires `aria-describedby` on Root).
+
+| Prop | Type | Default | Description |
+| ---------- | ----------------------- | ------- | ---------------------------------- |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
+
+### `Toast.Action`
+
+The toast's optional action (undo, retry); pressing it dismisses the toast.
+The consumer's `onClick` carries what the action does and runs first — calling
+`event.preventDefault()` in it keeps the toast open.
+
+| Prop | Type | Default | Description |
+| ---------- | -------------------------- | ------- | ------------------------------------- |
+| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered `