ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Toast

Notification toaster + Toast. Sonner-style stacking, group pause-on-hover, swipe-to-dismiss, Turbo-Stream friendly.

Wabi's Toast uses a vanilla JS controller — @zag-js/toast group machine is deferred to v0.6.

Installation

bin/rails g wabi:add toast

Layout setup

# In your application layout (once, near end of <body>):
render Components::UI::Toaster.new

Example

Toasts are dispatched from a Rails action via Turbo Stream. Click a button several times to build a stack — toasts collapse into a peek pile, expand when you hover the group (which also pauses auto-dismiss), and can be swiped away.

Source

app/components/ui/toast.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # A single notification. Render via Turbo Stream into the Toaster container,
    # or inline in a Phlex view for static / story examples. Has its own
    # `wabi--toast` Stimulus controller that handles auto-dismiss with
    # pause-on-hover -- v0.1 skips Zag's `@zag-js/toast` group machinery in
    # favor of a self-contained vanilla timer. Cross-toast coordination (max
    # visible, advanced stacking) is a v0.2 follow-up.
    class Toast < Wabi::Base
      variants do
        base "pointer-events-auto w-full overflow-hidden rounded-md border border-input p-4 shadow-md " \
             "transition-all duration-300 ease-out motion-reduce:transition-none motion-reduce:opacity-100 " \
             "data-[state=open]:opacity-100 data-[state=closed]:opacity-0"

        variant :appearance, {
          info:        "bg-background text-foreground",
          success:     "bg-primary text-primary-foreground",
          destructive: "bg-destructive text-destructive-foreground",
        }, default: :info
      end

      def initialize(title:, description: nil, appearance: nil, duration_ms: 5000, **attrs)
        @title       = title
        @description = description
        @appearance  = appearance
        @duration_ms = duration_ms
        @attrs       = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        # No per-<li> live-region attrs: announcement comes from the Toaster <ol>
        # (the pre-existing aria-live region) when this <li> is appended into it.
        li(
          "data-state": "open",
          data: {
            controller: "wabi--toast",
            "wabi--toast-duration-ms-value": @duration_ms.to_s,
          },
          class: merge_class(tokens(appearance: @appearance), user_class)
        ) do
          div(class: "flex items-start justify-between gap-3") do
            div(class: "grid gap-1") do
              div(class: "text-sm font-semibold") { @title }
              div(class: "text-sm opacity-90") { @description } if @description
            end
            button(
              type: "button",
              "aria-label": "Dismiss",
              data: { action: "click->wabi--toast#dismiss" },
              class: "shrink-0 rounded-md p-1 text-current opacity-70 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
            ) { "×" }
          end
        end
      end
    end
  end
end

app/components/ui/toaster.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Singleton container for toasts. Render ONCE near the end of <body> in your
    # layout. Toasts are appended to this <ol> -- typically via Turbo Stream:
    #
    #   turbo_stream.append "wabi-toaster",
    #     Components::UI::Toast.new(title: "Saved", appearance: :success)
    #
    # The list is `pointer-events-none` so the empty toaster doesn't block clicks
    # behind it; individual toasts override with `pointer-events-auto`.
    class Toaster < Wabi::Base
      def initialize(id: "wabi-toaster", placement: :top_right, visible_count: 3, gap: 14, **attrs)
        @id            = id
        @placement     = placement
        @visible_count = visible_count
        @gap           = gap
        @attrs         = attrs
      end

      PLACEMENT_CLASSES = {
        top_left:      "top-4 left-4",
        top_center:    "top-4 left-1/2 -translate-x-1/2",
        top_right:     "top-4 right-4",
        bottom_left:   "bottom-4 left-4",
        bottom_center: "bottom-4 left-1/2 -translate-x-1/2",
        bottom_right:  "bottom-4 right-4",
      }.freeze

      def view_template
        user_class = @attrs.delete(:class)
        # The <ol> is the containing block for its absolutely-positioned toast
        # <li> children: the wabi--toaster controller sets each toast to
        # position:absolute and assigns its translateY/scale transform. Without
        # JS the <li>s fall back to normal block flow (a plain vertical list),
        # so the no-JS experience still works. `w-96`/`h-fit` keep the empty
        # container sized correctly; `position: fixed` is itself the containing
        # block for the absolute toast children.
        ol(
          id: @id,
          role: "region",
          "aria-label": "Notifications",
          # The <ol> is the live region: it pre-exists, so toast <li>s appended
          # via Turbo Stream are announced. (Per-<li> live-region attrs do NOT
          # announce — AT only reacts to mutations inside an existing live region.)
          "aria-live": "polite",
          "aria-atomic": "false",
          data: {
            controller: "wabi--toaster",
            # @id must be a simple alphanumeric/hyphen string; no CSS escaping is applied.
            "wabi--toaster-wabi--toast-outlet": "##{@id} > [data-controller~='wabi--toast']",
            "wabi--toaster-placement-value": @placement.to_s,
            "wabi--toaster-visible-count-value": @visible_count.to_s,
            "wabi--toaster-gap-value": @gap.to_s,
          },
          class: merge_class(
            "fixed z-50 w-96 max-w-[calc(100vw-2rem)] h-fit pointer-events-none list-none p-0 m-0",
            PLACEMENT_CLASSES.fetch(@placement),
            user_class,
          )
        )
      end
    end
  end
end

Accessibility

  • role="status" + aria-live="polite" + aria-atomic="true" on every toast — appearance is purely visual.
  • auto-dismiss after duration_ms (default 5s); hover pauses the timer so users have time to read.
  • each toast has a manual close button (×) for keyboard / screen-reader users.
  • for urgent / destructive messages pair toasts with an inline error message in those flows, or wait for the @zag-js/toast group machine in v0.6.