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 payload Schema that you provide. The component owns its id, semantic variant, transition, dismiss timer, and hover state. Everything else lives in your payload and is rendered by your entryToView callback. Toast.make(PayloadSchema) returns a module whose Model, helpers, and view are bound to that 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.

No toasts dismissed yet

    // 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 } from 'effect'
    import { Update } from 'foldkit'
    import type { HtmlBuilder } from 'foldkit/html'
    import { defineMessageUnion } from 'foldkit/message'
    import { evo } from 'foldkit/struct'
    
    import { Toast as UiToast } from '@foldkit/ui'
    
    // 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 = Schema.Struct({
      bodyText: Schema.String,
      maybeLink: Schema.Option(
        Schema.Struct({ href: Schema.String, text: Schema.String }),
      ),
    })
    
    // Bind a Toast module to your payload schema. The factory returns Model,
    // Message, OutMessage, update, view, show/dismiss/dismissAll, and the
    // DismissedToast OutMessage variant:
    export const Toast = UiToast.make(ToastPayload)
    
    // Add Toast.Model to your app Model. Track anything you want to lift from
    // a toast's lifecycle alongside it. Here, the last dismissed bodyText so
    // the UI can show "just dismissed: ..." after a toast goes away:
    const Model = Schema.Struct({
      toast: Toast.Model,
      maybeLastDismissedBody: Schema.Option(Schema.String),
      // ...your other fields
    })
    type Model = typeof Model.Type
    
    // In your init function, initialize it:
    const init = () => ({
      model: {
        toast: Toast.init({ id: 'app-toast' }),
        maybeLastDismissedBody: Option.none(),
        // ...your other fields
      },
    })
    
    // Embed the Toast Message in your parent Message, plus any domain Messages
    // that should push a toast:
    const Message = defineMessageUnion({
      GotToastMessage: { message: Toast.Message },
      ClickedSave: {},
    })
    type Message = typeof Message.Type
    
    // At module scope, fold the OutMessage into your own Model, lifting the
    // DismissedToast event into domain state. The arm returns an Update.Step over
    // the parent Model, which already has the next Toast Model written back:
    const foldToastOutMessage = Toast.OutMessage.match<Update.Step<Model, Message>>(
      {
        DismissedToast:
          ({ payload }) =>
          model => ({
            model: evo(model, {
              maybeLastDismissedBody: () => Option.some(payload.bodyText),
            }),
          }),
      },
    )
    
    // Update.foldChild wires the child into the parent: it delegates Toast's own
    // Messages to Toast.update, writes the next Toast Model back, maps the
    // Submodel's Commands into your Message type, and hands any OutMessage to
    // foldOutMessage.
    const foldToast = Update.foldChild({
      update: Toast.update,
      read: (model: Model) => Option.some(model.toast),
      write: (model, nextToast) => evo(model, { toast: () => nextToast }),
      toParentMessage: message => Message.GotToastMessage({ message }),
      foldOutMessage: foldToastOutMessage,
    })
    
    const foldToastShow = Update.foldChild({
      update: Toast.show,
      read: (model: Model) => Option.some(model.toast),
      write: (model, nextToast) => evo(model, { toast: () => nextToast }),
      toParentMessage: message => Message.GotToastMessage({ message }),
      foldOutMessage: foldToastOutMessage,
    })
    
    // In the corresponding Message.match handler, call the fold:
    GotToastMessage: ({ message }) => foldToast(model, message)
    
    ClickedSave: () =>
      foldToastShow(model, {
        variant: 'Success',
        payload: {
          bodyText: 'Changes saved',
          // Generate the href via your app's router (Foldkit's biparser-based
          // routing builds URLs from typed values, e.g. `changesRouter()`),
          // not a string literal, so renames flow through.
          maybeLink: Option.some({ href: changesRouter(), text: 'View' }),
        },
      })
    
    // In your view, embed Toast via h.submodel once at the app root. The
    // entryToView callback lays out each entry from its payload. The
    // component handles the <li> wrapper, hover-to-pause, and enter/leave
    // animations.
    const view = (h: HtmlBuilder<Message>) =>
      h.submodel({
        slotId: 'app-toast',
        model: model.toast,
        view: Toast.view,
        viewInputs: {
          position: 'BottomRight',
          entryClassName: 'w-80',
          entryToView: (entry, handlers) =>
            h.div(
              [
                h.Class(
                  'flex items-start gap-3 rounded-lg border bg-white p-3 shadow',
                ),
              ],
              [
                h.div(
                  [h.Class('flex-1')],
                  [
                    h.p(
                      [h.Class('font-semibold text-sm')],
                      [entry.payload.bodyText],
                    ),
                    ...Option.match(entry.payload.maybeLink, {
                      onNone: () => [],
                      onSome: ({ href, text }) => [
                        h.a([h.Class('text-sm underline'), h.Href(href)], [text]),
                      ],
                    }),
                  ],
                ),
                h.button([...handlers.dismiss], ['Close']),
              ],
            ),
        },
        toParentMessage: message => Message.GotToastMessage({ message }),
      })

    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 entryToView callback and your entryClassName. Use data-variant on the entry to drive per-variant styling.

    Each entry’s enter/leave animations flow through the Animation module. Style with CSS transitions or CSS keyframe animations. Animation advances once every animation on the element has settled.

    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
    idstringUnique ID for the toast container.
    defaultDurationDuration.InputDuration.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 entryToView 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.InputOverrides 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.ModelThe 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) => ParentMessageWraps the subset of Toast Messages that fire from DOM events in your parent Message type.
    entryToView(entry: typeof Toast.Entry.Type, handlers: { dismiss: ReadonlyArray<ChildAttribute> }) => HtmlRenders each entry from its lifecycle fields (for example id, variant, and animation) and its payload (your shape). The component wraps the return in an <li> with role, lifecycle handlers, and transition data attributes. Spread handlers.dismiss onto a close button (h.button([...handlers.dismiss], [...])) so users can dismiss the entry manually.
    ariaLabelstring'Notifications'aria-label on the container region.
    containerClassNamestringCSS class for the container <ol>.
    entryClassNamestringCSS class applied to every <li> entry.

    Programmatic Helpers

    Toast helpers are child entry points. Fold show and dismiss with Update.foldChild because they take additional input. Fold dismissAll with Update.foldChildStep.

    NameTypeDefaultDescription
    show(model: Model, input: ShowInput) => Update.ReturnWithOutMessage<Model, Message, OutMessage>Adds a new toast entry. Fold it from any parent 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) => Update.ReturnWithOutMessage<Model, Message, OutMessage>Begins dismissing a specific entry. Calling it for an entry that is already leaving or has been removed is a no-op.
    dismissAll(model: Model) => Update.ReturnWithOutMessage<Model, Message, OutMessage>Begins dismissing every currently-visible entry.

    OutMessage

    Messages emitted to the parent through the optional outMessage field. Fold the OutMessage in the foldOutMessage of your Update.foldChild config.

    NameTypeDefaultDescription
    DismissedToast{ payload: Payload }Emitted once an entry has finished its leave animation and is being removed from the Model. Carries the toast's payload typed as your Payload Schema. Fold it in the foldOutMessage of your Toast fold to lift the dismissal into domain state, for example to resolve a pending action or fire analytics. It fires only after TransitionedOut, so it represents the actual removal, not the initial dismiss request.