@relictombs/opentui-toast
v0.1.0
Published
Sonner-style animated toasts for OpenTUI
Downloads
39
Maintainers
Readme
@relictombs/opentui-toast
Sonner-style toast notifications for OpenTUI, animated by @relictombs/opentui-motion.
- Mount one toaster, call
toast()from anywhere - Success, info, warning, error, loading, and promise states
- Update in place with stable IDs
- Sonner-style layered stacks that fan out smoothly on hover
- Stack-wide hover-to-pause, close buttons, and mouse actions
- Six edge positions and independent toaster IDs
- Bounded queues whose timers start only when a toast is visible
- Core, Effect, React, and Solid entry points
Install
bun add @relictombs/opentui-toast @relictombs/opentui-motion @opentui/core@relictombs/opentui-motion is a required peer dependency. Keeping one shared motion runtime lets toast transitions retarget safely
without competing property owners or duplicated animation engines.
OpenTUI core
import { createCliRenderer } from "@opentui/core"
import { ToasterRenderable, toast } from "@relictombs/opentui-toast"
const renderer = await createCliRenderer({ exitOnCtrlC: true })
renderer.root.add(
new ToasterRenderable(renderer, {
placement: "bottom-right",
visibleToasts: 3,
}),
)
toast.success("Saved", {
description: "Your changes are on disk",
})The host is an overlay, but it does not claim the terminal hit grid outside visible toast cards. The rest of your app keeps receiving mouse input normally. Older cards collapse behind the newest toast; hover any part of the stack to fan it out and reveal every visible card.
Effect
Use @relictombs/opentui-toast/effect when toast settlement belongs to an Effect workflow. Ordinary operations report
schema-backed Toast.OperationError failures, and promise toasts are interrupted with their owning scope.
import { Effect } from "effect"
import { ToastStore, ToasterRenderable } from "@relictombs/opentui-toast"
import { Toast } from "@relictombs/opentui-toast/effect"
const store = new ToastStore()
renderer.root.add(new ToasterRenderable(renderer, { store }))
const upload = Effect.scoped(
Effect.gen(function* () {
const notify = yield* Toast.make(store)
const pending = yield* notify.promise(uploadRelease, {
loading: "Uploading release",
success: (release) => Effect.succeed(`Uploaded ${release.version}`),
error: (cause) => Effect.succeed(`Upload failed: ${cause.message}`),
})
return yield* pending.await()
}),
)Closing the scope interrupts pending work and dismisses its loading toast. Exact record ownership prevents stale scope cleanup from removing or resurrecting a newer update with the same ID.
React
/** @jsxImportSource @opentui/react */
import { Toaster, toast } from "@relictombs/opentui-toast/react"
export function App() {
return <Toaster placement="bottom-right" />
}
toast.success("Deployed")Solid
/** @jsxImportSource @opentui/solid */
import { Toaster, toast } from "@relictombs/opentui-toast/solid"
export function App() {
return <Toaster placement="bottom-right" />
}
toast.info("Listening on :3000")Both adapters also export an explicit, idempotent registerToast() and the <toaster /> intrinsic for lower-level use.
API
The default call creates a toast and returns its ID:
const id = toast("Draft created")
toast.success("Saved")
toast.info("A new version is available")
toast.warning("Disk space is low")
toast.error("Deploy failed")
toast.loading("Uploading")Pass an existing ID to update in place. Rapid updates retarget from the current visual state:
const id = toast.loading("Uploading release")
toast.success("Release uploaded", {
id,
description: "v1.4.0 is ready",
})Or use the explicit update helper:
toast.update(id, {
message: "Processing 42%",
type: "info",
})Dismiss one toast or all active toasts:
toast.dismiss(id)
toast.dismiss()Promises
toast.promise keeps one ID through loading, success, and error states. Its stable handle exposes the original promise
without creating an unhandled rejection for fire-and-forget UI use.
const upload = toast.promise(sendRelease(), {
loading: "Uploading release",
success: (release) => ({
message: `Uploaded ${release.version}`,
description: "Ready to deploy",
}),
error: (error) => `Upload failed: ${String(error)}`,
finally: () => cleanup(),
})
const release = await upload.unwrap()
upload.dismiss()A dismissed or superseded promise toast cannot reappear when stale work settles.
Actions
Actions run on primary mouse press and dismiss after a successful handler by default:
toast("File deleted", {
action: {
label: "Undo",
onClick: async ({ preventDismiss }) => {
await restoreFile()
preventDismiss()
toast.success("File restored")
},
},
cancel: {
label: "Dismiss",
onClick: () => {},
},
onActionError: (error) => {
toast.error(`Undo failed: ${String(error)}`)
},
})Hovering pauses only the dwell timer; enter, exit, and reflow animations remain responsive.
Placement and multiple hosts
The host default is configured with placement. An individual toast can override it with position:
new ToasterRenderable(renderer, {
toasterId: "builds",
placement: "top-right",
})
toast.error("Build failed", {
toasterId: "builds",
position: "bottom-center",
})Supported positions are top-left, top-center, top-right, bottom-left, bottom-center, and bottom-right.
Isolated stores
The exported toast uses the default process-wide store for the simplest setup. Tests, embedded apps, and multiple
renderer domains can create isolated APIs explicitly:
import { ToastStore, ToasterRenderable, createToast } from "@relictombs/opentui-toast"
const store = new ToastStore({ maxToasts: 50 })
const notify = createToast(store)
const toaster = new ToasterRenderable(renderer, { store })
renderer.root.add(toaster)
notify.success("Isolated")Toaster options
| Option | Default | Purpose |
| --------------- | -------------: | --------------------------------------------- |
| placement | bottom-right | Default stack edge |
| visibleToasts | 3 | Visible cards per position |
| duration | 4000 | Dwell time in milliseconds |
| gap | 1 | Rows between cards |
| offset | 1 | Rows/columns from the viewport edge |
| toastWidth | 42 | Preferred card width, clamped to the terminal |
| expand | false | Keep stacks fanned out without hover |
| closeButton | true | Show a mouse-dismiss button |
| pauseOnHover | true | Pause every stack timer while hovered |
| reducedMotion | false | Resolve visual transitions immediately |
Use duration: Infinity for persistent non-loading notifications. Loading toasts are persistent until updated or
dismissed.
Design notes
The API and defaults are inspired by Sonner: one host, an imperative callable API,
excellent defaults, stable ID updates, promise state transitions, and invisible edge-case handling. The renderer is
OpenTUI-native—no DOM, hooks, browser-only globals, or motion/react bridge.
Development
From a repository checkout, install dependencies at the workspace root and run package commands from
packages/opentui/toast:
cd packages/opentui/toast
bun run test
bun run check
bun run build
bun run test:packedLicense
MIT
