Terracotta
GitHub
  • ToasterStore is a framework-agnostic queue. Create it outside your components and push messages into it from anywhere.
  • useToaster(store) subscribes to a store and returns a Solid accessor.
  • <Toaster> and <Toast> are the markup. The Toaster is the live region.

The queue lives outside the component tree, so any code can raise a toast without prop drilling: a fetch handler, a router guard, a worker callback.

import { Toast, Toaster, ToasterStore, useToaster } from 'terracotta/toast';

Anatomy

<Toaster>  {/* role="status" aria-live="polite"; holds the queue */}
  <Toast/> {/* one message; no live role of its own */}
</Toaster>

Keep the Toaster mounted, even while the queue is empty. Screen readers only announce changes inside a live region that already exists. A toast added to it is then announced.

Examples

The store and the region

Anywhere else in the app:

Auto-dismiss

The store has no timing policy. Add one where you create the toast:

export function notify(data: Notice, ms = 4000): string {
  const id = notices.create(data);
  setTimeout(() => notices.remove(id), ms);
  return id;
}

Animating entry and exit

The queue removes an item immediately. To animate a toast out, wrap it in a Transition:

<For each={queue()}>
  {item => (
    <Transition
      show={true}
      enter="toast-enter"
      enterFrom="toast-enter-from"
      enterTo="toast-enter-to"
    >
      <Toast class="toast">{item.data.title}</Toast>
    </Transition>
  )}
</For>
.toast-enter { transition: opacity 150ms ease-out, translate 150ms ease-out; }
.toast-enter-from { opacity: 0; translate: 0 0.5rem; }
.toast-enter-to   { opacity: 1; translate: none; }

For a leave animation, keep the item in the queue until the transition finishes. Flip a local signal, then call notices.remove(item.id) from afterLeave.

Clearing the queue

<button type="button" onClick={() => notices.clear()}>
  Dismiss all
</button>

Several independent regions

Each store is its own queue, so one page can have several:

export const systemNotices = new ToasterStore<Notice>();
export const uploadNotices = new ToasterStore<UploadProgress>();
<Toaster class="toaster toaster-top">{/* systemNotices */}</Toaster>
<Toaster class="toaster toaster-bottom">{/* uploadNotices */}</Toaster>

Reading the queue outside a component

notices.getQueue().length; // non-reactive snapshot

const unsubscribe = notices.subscribe(queue => {
  console.log(`${queue.length} notice(s) pending`);
});

useToaster is the reactive equivalent. It unsubscribes with the component.

State attributes

Neither component carries dynamic state. A toast is either in the queue or it is not, so only the markers are written.

Element Attribute Present when
Toaster tc-toaster Always
Toast tc-toast Always

Anything that varies per toast is your own data: severity, position, whether it has an action. Pass a data-* attribute and select on it, as in the example above.

Styling

[tc-toaster] {
  position: fixed;
  inset-block-end: 1rem;
  inset-inline-end: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

[tc-toast] {
  border-radius: 0.5rem;
  background: #ffffff;
  padding: 0.75rem 1rem;
  box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
}

/* Stack newest on top without touching the markup */
[tc-toaster] { flex-direction: column-reverse; }

Reading the state in code

There is no component state. The queue is the state, and you read it with useToaster(store):

Member Type Description
useToaster(store) () => ToastData<T>[] Reactive accessor for the queue.
store.getQueue() () => ToastData<T>[] Non-reactive snapshot.
store.subscribe(cb) (cb) => () => void Manual subscription. Returns an unsubscribe function.

Keyboard

Neither component handles keys. A toast is not a focus stop, and that is the point: it must not interrupt what the user is typing. For a dismiss or undo action, put a real <button> inside the toast. Tab reaches it the normal way.

If the message must be acted on, it is not a toast. Use AlertDialog.

API

ToasterStore<T>

A subscribable queue of { id, data } entries. T is whatever shape you want a toast to carry.

Member Type Description
create(data) (data: T) => string Appends a toast and returns its generated id.
remove(id) (id: string) => void Removes the toast with that id.
clear() () => void Empties the queue.
getQueue() () => ToastData<T>[] The current queue. Not reactive.
subscribe(callback) (cb: (queue: ToastData<T>[]) => void) => () => void Subscribes to changes and returns an unsubscribe function. Each notification receives a fresh array.

ToastData<T> is { id: string; data: T }.

useToaster(store)

Signature Description
useToaster<T>(toaster: ToasterStore<T>): () => ToastData<T>[] Subscribes for the lifetime of the calling component and returns an accessor for the queue.

<Toaster>

The container that owns the toast context. <Toast> must be rendered inside one, and throws otherwise. Renders a <div> by default. Does not take a ref.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
children JSX.Element none The toasts. Not a render prop.
…rest props of as none Forwarded to the rendered element.

Rendered attributes

Attribute Value
role "status"
aria-live "polite"
aria-atomic "false"
aria-relevant "additions text"
tc-toaster ""

The region is polite, so a toast is announced without interrupting the user. aria-atomic="false" reads each new toast on its own, not the whole queue again. Your own props are applied last, so you can change any of these. For example, pass aria-live="assertive" for a region of urgent messages.

<Toast>

A single notification. Renders a <div> by default. Does not take a ref.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
children JSX.Element none The message. Not a render prop.
…rest props of as none Forwarded to the rendered element.

Rendered attributes

Attribute Value
tc-toast ""

A toast has no live role. The Toaster around it announces it. For a message that must interrupt, use Alert instead.