Skip to main content
On this pageOverview

Toast

Overview

A stack of transient notifications anchored to a corner of the viewport. Each entry has its own enter and leave animation, its own auto-dismiss timer, and its own hover-to-pause behavior. One container lives at the app root; entries are added dynamically via Toast.show.

Toast is parameterized on a user-provided payload schema. The component owns only lifecycle and a11y fields — id, variant (drives ARIA role), transition, dismiss timer, hover state. Everything else lives in your payload and is rendered by your renderEntry callback. Ui.Toast.make(PayloadSchema) returns a module with Model, show, view, and the rest bound to your payload type.

See it in an app

Check out how Toast is wired up in a real Foldkit app.

Examples

Click a variant to push a toast onto the stack. Hover a toast to pause its auto-dismiss; move away and the timer restarts.

    // Pseudocode walkthrough of the Foldkit integration points. Each labeled
    // block below is an excerpt — fit them into your own Model, init, Message,
    // update, and view definitions.
    import { Option, Schema as S } from 'effect'
    import { Command, Ui } from 'foldkit'
    import { m } from 'foldkit/message'
    import { evo } from 'foldkit/struct'
    
    import { Class, Href, OnClick, a, button, div, p } from './html'
    
    // Define the payload shape for your toast. The Toast component owns only
    // lifecycle + a11y fields (id, variant, transition, dismiss timer, hover
    // state). The payload is yours — whatever you can encode in a Schema:
    const ToastPayload = S.Struct({
      bodyText: S.String,
      maybeLink: S.OptionFromSelf(S.Struct({ href: S.String, text: S.String })),
    })
    
    // Bind a Toast module to your payload schema:
    export const Toast = Ui.Toast.make(ToastPayload)
    
    // Add Toast.Model to your app Model:
    const Model = S.Struct({
      toast: Toast.Model,
      // ...your other fields
    })
    
    // In your init function, initialize it:
    const init = () => [
      {
        toast: Toast.init({ id: 'app-toast' }),
        // ...your other fields
      },
      [],
    ]
    
    // Embed the Toast Message in your parent Message, plus any domain Messages
    // that should push a toast:
    const GotToastMessage = m('GotToastMessage', { message: Toast.Message })
    const ClickedSave = m('ClickedSave')
    
    // Inside your update's M.tagsExhaustive({...}), delegate Toast's own Messages
    // and call Toast.show from any domain branch that should surface a notification:
    GotToastMessage: ({ message }) => {
      const [nextToasts, commands] = Toast.update(model.toast, message)
    
      return [
        evo(model, { toast: () => nextToasts }),
        commands.map(
          Command.mapEffect(Effect.map(message => GotToastMessage({ message }))),
        ),
      ]
    }
    
    ClickedSave: () => {
      const [nextToasts, commands] = Toast.show(model.toast, {
        variant: 'Success',
        payload: {
          bodyText: 'Changes saved',
          maybeLink: Option.some({ href: '/changes', text: 'View' }),
        },
      })
    
      return [
        evo(model, { toast: () => nextToasts }),
        commands.map(
          Command.mapEffect(Effect.map(message => GotToastMessage({ message }))),
        ),
      ]
    }
    
    // In your view, render the Toast container once at the app root. Provide a
    // renderEntry callback that lays out each entry from its payload — the
    // component handles the <li> wrapper, hover-to-pause, and enter/leave
    // animations.
    Toast.view({
      model: model.toast,
      position: 'BottomRight',
      toParentMessage: message => GotToastMessage({ message }),
      renderEntry: (entry, handlers) =>
        div(
          [Class('flex items-start gap-3 rounded-lg border bg-white p-3 shadow')],
          [
            div(
              [Class('flex-1')],
              [
                p([Class('font-semibold text-sm')], [entry.payload.bodyText]),
                ...Option.match(entry.payload.maybeLink, {
                  onNone: () => [],
                  onSome: ({ href, text }) => [
                    a([Class('text-sm underline'), Href(href)], [text]),
                  ],
                }),
              ],
            ),
            button([OnClick(handlers.dismiss)], ['Close']),
          ],
        ),
      entryClassName: 'w-80',
    })

    Styling

    Toast is headless. The container gets position: fixed and flex-column layout from the component (so entries stack correctly for each position); every other visual decision lives in your renderEntry callback and your entryClassName. Use data-variant on the entry to drive per-variant styling.

    AttributeCondition
    data-variantPresent on each entry, with the variant value (Info, Success, Warning, Error). Use for per-variant CSS.
    data-enterPresent on an entry while its enter animation runs.
    data-leavePresent on an entry while its leave animation runs.
    data-closedPresent on an entry at the closed extreme of its enter or leave animation. Pair with data-enter or data-leave to drive the starting and ending CSS states.
    data-transitionPresent on an entry while either animation runs.

    Accessibility

    The container is a role="region" with aria-live="polite", always rendered (even when empty) so screen readers observe the live region from page load. Individual entries receive role="status" for Info and Success variants, role="alert" for Warning and Error. Auto-dismiss pauses on pointer hover.

    API Reference

    InitConfig

    Configuration object passed to Toast.init().

    NameTypeDefaultDescription
    idstring-Unique ID for the toast container.
    defaultDurationDuration.DurationInputDuration.seconds(4)Auto-dismiss duration applied to any show() call that does not provide its own duration or pass sticky: true. Accepts any Effect Duration input; a bare number is interpreted as milliseconds.

    ShowInput

    Input shape for Toast.show(model, input).

    NameTypeDefaultDescription
    payloadA (your payload type)-Content for this entry, in whatever shape you supplied to Toast.make(). The component never reads it; it flows through to your renderEntry callback.
    variant'Info' | 'Success' | 'Warning' | 'Error''Info'Semantic category. Maps to data-variant for styling and to role=status (Info, Success) or role=alert (Warning, Error) for accessibility. The only content-adjacent field the component owns — everything else is in payload.
    durationDuration.DurationInput-Overrides the container's defaultDuration for this entry. Ignored when sticky: true.
    stickybooleanfalseWhen true, the entry never auto-dismisses. The user must close it manually.

    ViewConfig

    Configuration object passed to Toast.view().

    NameTypeDefaultDescription
    modelToast.Model-The toast container state from your parent Model.
    position'TopLeft' | 'TopCenter' | 'TopRight' | 'BottomLeft' | 'BottomCenter' | 'BottomRight'-Where the toast viewport is anchored on the screen.
    toParentMessage(childMessage: Dismissed | HoveredEntry | LeftEntry) => ParentMessage-Wraps the subset of Toast Messages that fire from DOM events in your parent Message type.
    renderEntry(entry: typeof Toast.Entry.Type, handlers: { dismiss: ParentMessage }) => Html-Renders each entry from its lifecycle fields (id, variant, transition) and its payload (your shape). The component wraps the return in an <li> with role, lifecycle handlers, and transition data attributes. Wire handlers.dismiss to a close button's OnClick.
    ariaLabelstring'Notifications'aria-label on the container region.
    classNamestring-CSS class for the container <ol>.
    entryClassNamestring-CSS class applied to every <li> entry.

    Programmatic Helpers

    Helper functions for driving toasts from parent update handlers, returning [Model, Commands].

    NameTypeDefaultDescription
    show(model: Model, input: ShowInput) => [Model, Commands]-Adds a new toast entry. Call this from any parent update handler that needs to surface a notification. Returns the next model plus commands for the enter animation and the auto-dismiss timer.
    dismiss(model: Model, entryId: string) => [Model, Commands]-Begins dismissing a specific entry. Safe to call for an entry that is already leaving or has been removed.
    dismissAll(model: Model) => [Model, Commands]-Begins dismissing every currently-visible entry.

    Stay in the update loop.

    New releases, patterns, and the occasional deep dive.


    Built with Foldkit.

    © 2026 Devin Jameson