Notifications
Overview
NotificationComponent renders transient toast-style feedback — a compact card with an icon, optional title, message body, and a dismiss control. Notifications are pushed into a fixed container rendered in the browser's top layer via the Popover API, so they appear above modals, dialogs, and any z-index stack without any z-index management.
The container holds a Turbo Frame. A single turbo_stream.prepend call from any controller action delivers a new notification to every tab or browser window connected to the same page — no polling, no custom JavaScript.
Setup
1. Add the container to your layout
Place midwest_notifications_container once in your application layout, before the closing </body> tag. It renders as a hidden popover that becomes visible automatically when the first notification arrives and hides itself once all notifications are dismissed.
<%# app/views/layouts/application.html.erb %><body> <%= yield %> <%= midwest_notifications_container %></body>The container defaults to the top-right corner. Pass placement: to reposition it:
<%= midwest_notifications_container(placement: :bottom_right) %>| Placement | Description |
|---|---|
:top_right |
Top-right corner (default) |
:top_left |
Top-left corner |
:top_center |
Top-center, horizontally centred |
:bottom_right |
Bottom-right corner |
:bottom_left |
Bottom-left corner |
:bottom_center |
Bottom-center, horizontally centred |
Sending notifications from a controller
Use turbo_stream.prepend targeting "midwest-notifications" in any Turbo Stream response. The new notification slides in at the top of the stack; any existing notifications shift back behind it.
Inline in an action
# app/controllers/posts_controller.rbdef create @post = Post.new(post_params) if @post.save render turbo_stream: turbo_stream.prepend('midwest-notifications') do render_to_string( Midwest::NotificationComponent.new( title: 'Post created', state: :success, countdown: 5 ), layout: false ) end else render :new, status: :unprocessable_entity endendWith a dedicated Turbo Stream template
For actions that need to update multiple parts of the page, use a .turbo_stream.erb template:
<%# app/views/posts/create.turbo_stream.erb %><%= turbo_stream.prepend 'midwest-notifications' do %> <%= render Midwest::NotificationComponent.new(title: 'Post created', state: :success, countdown: 5) do %> Your post is now live. <% end %><% end %><%= turbo_stream.replace 'posts-list' do %> <%= render @posts %><% end %>From a concern or service
Extract notification rendering into a helper for reuse across controllers:
# app/helpers/notification_helper.rbmodule NotificationHelper def notification_stream(title:, state: :default, body: nil, countdown: nil) turbo_stream.prepend('midwest-notifications') do render Midwest::NotificationComponent.new( title: title, state: state, countdown: countdown ) { body } end endendclass PostsController < ApplicationController include NotificationHelper def destroy @post.destroy render turbo_stream: notification_stream( title: 'Post deleted', state: :error, countdown: 4 ) endendComponent options
<%= render Midwest::NotificationComponent.new( state: :success, # :default, :success, :error, :warning, :info title: 'Saved!', # optional heading icon: nil, # override the default state icon closable: true, # show the dismiss ✕ button animated: true, # slide-in / slide-out animation countdown: 5 # auto-dismiss after N seconds) do %> Your record was saved successfully.<% end %>| Option | Default | Description |
|---|---|---|
state: |
:default |
Sets the theme color and default icon |
title: |
nil |
Bold heading rendered above the message body |
icon: |
auto | Override the state-derived icon; pass any icon name or false to suppress |
closable: |
true |
Renders a dismiss button; ignored when countdown: is set |
animated: |
true |
Slide-in from the right on appear, slide-out on dismiss |
countdown: |
nil |
Auto-dismiss after N seconds; replaces the close button with an animated ring timer |
States
Each state maps to a theme color and a contextual default icon. Pass state: to communicate intent without selecting an icon manually.
| State | Color | Default icon |
|---|---|---|
:default |
Primary | (none) |
:success |
Green | checkmark-circle |
:error |
Red | warning |
:warning |
Orange | warning |
:info |
Cyan | information-circle |
<%= render Midwest::NotificationComponent.new(state: :success, title: 'Payment received') do %> $49.00 was charged to your card on file.<% end %>Auto-dismiss with countdown
Pass countdown: with an integer number of seconds. The dismiss button is replaced with an animated SVG ring that drains over the countdown period and auto-closes the notification when it completes.
<%= render Midwest::NotificationComponent.new( state: :info, title: 'Session expiring', countdown: 10) do %> You will be signed out in 10 seconds.<% end %>When notifications are stacked, only the topmost notification's countdown runs. Countdowns for notifications beneath the stack are paused and resume accurately — including the visual ring — once they reach the top position.
Stacking
Up to three notifications are visible at once. When a fourth arrives the oldest (bottom of the stack) is hidden until one above it is dismissed. Notifications stack in the direction of their placement — top placements stack downward, bottom placements stack upward — so older notifications always peek toward the interior of the screen rather than off the edge.
When the topmost notification is dismissed, the notification beneath it slides forward and resumes its countdown from exactly where it was paused.
Accessibility
- The container has
aria-live="polite"andaria-atomic="false"so screen readers announce each new notification individually without interrupting current speech. - Every notification has
role="alert"for immediate announcement regardless of the live region. - The dismiss button includes a visually-hidden
<span class="sr-only">label. - The countdown button includes screen-reader text: "Dismisses in N seconds".