[Feature] Toast: match shadcn (Base UI + vega)
Merged
CI
2d ago+1041+1041+0123456789012345678901234567890123456789-422-422-012345678901234567890123456789
RubyUI's Toast started as a port of sonner. shadcn now ships its own Toast built on Base UI (bases/base/ui/toast.tsx, July 2026), so this PR moves RubyUI to that reference with the vega style, following the shadcn audit of the component.
What changes
- The viewport sits 16px from the corner and is
max-w-smwide (full width minus 16px on mobile). Toasts arerounded-2xlwithshadow-lg, the content row isp-4 gap-3, and title and description aretext-sm. The action is an outlinesmButton and the X a ghosticon-smButton in the row, shown by default. - Each toast is a
role="dialog"(aria-modal="false"), named by its<h2>title and described by its<p>description. The region is the only live region (polite).priority: :highgivesrole="alertdialog"plus a visually hiddenrole="alert", as Base UI does. - Collapsed toasts peek 12px and shrink 10% per step with their content hidden; expanded ones sit 12px apart. Toasts past
maxstay mounted but hidden (data-limited) and come back when a front toast closes, where before they were thrown away. - Hover, keyboard focus and window blur pause the timers. The default is 5000ms, and the region's
duration:now reaches spawned toasts (it didn't before). - F6 focuses the region and Tab moves to the newest toast. Escape closes the focused toast. When a focused toast goes away, focus moves to the next one or back to where it was.
- Swipe dismisses down or right for bottom-right (derived from
position:, or set withswipe_directions:;[]turns it off). - Toasts slide in from and out to
translateY(150%)over 500ms, and the node is removed ontransitionendinstead of after a fixed delay. - The region is a
popover="manual". A toast fired while a RubyUI Dialog, AlertDialog or Sheet is open (they useshowModal()) now shows above it. It can't be clicked until the modal closes, see Follow-ups. - ToastItem gains
priority:andon_remove:.on_dismiss:/on_remove:take Stimulus action descriptors bound toruby-ui:toast:close/ruby-ui:toast:remove. The JS API takesonClose,onRemove,priorityandswipeDirections, and hasRubyUI.toast.closeas an alias ofdismiss. - The Toast now depends on Button (
dependencies.yml), and the MCP registry is rebuilt.
Breaking
- Removed, because they never did anything:
ToastRegionoffset:,theme:,rich_colors:,dir:andToastIteminvert:,on_auto_close:. Passing them raisesArgumentErrorwith the reason, instead of rendering stray HTML attributes. data-slotnames are now shadcn's (toast,toast-title,toast-description,toast-icon,toast-action,toast-cancel,toast-close,toast-viewport). The list is a<div>, no longer an<ol>, and still has the idruby-ui-toasterfor Turbo Streams.- Error toasts are no longer
role="alert". Urgency comes frompriority: :high, as in Base UI.
Registered divergences (audit/divergences/toast.yml): ToastCancel stays; clicking the action still closes the toast; the X stays exposed to assistive tech (Base UI hides it until the toast is focused); one global manager instead of toastManager; and the extra positions and props.
Before / after





Audit on main (before)
shadcn audit ruby-ui/ruby_ui
base 1998c5e8 head 1998c5e8 shadcn 8a353667
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment fixture shims (local, uncommitted map + fixtures): shadcn.tsx exposes the toast manager as window.__auditToast; RubyUI spawns with duration: Infinity + closeButton: true, shadcn with timeout: 0 (its ToastList always renders the close)
✅ Theme neutral tokens match shadcn
Toast (ref: bases/base/ui/toast.tsx + style-vega.css @ 8a353667)
❌ Look padding-bottom: 24px; padding-left: 24px; padding-right: 24px; padding-top: 24px
vega: padding-bottom: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px
ToastRegion — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark. RubyUI places the list with the region's p-4 sm:p-6 (24px) and a fixed w-[356px] toast; shadcn's toast-viewport is fixed bottom-4 right-4 (16px) with max-w-sm (384px). Toast box: RubyUI 356×74 at (900,702), shadcn 384×78 at (880,706). Requested: viewport like shadcn (fixed inset-x-4 bottom-4 sm:right-4 sm:left-auto max-w-sm sm:w-full, no region padding; toast w-full). Requested: run `baseline toast 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR.
↳ bases/base/ui/toast.tsx:24
❌ Look border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; border-top-left-radius: 10px; border-top-right-radius: 10px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 4px 12px 0px; column-gap: 6px; display: flex; gap: 6px; padding-bottom: 16px; padding-left: 16px; padding-right: 40px; padding-top: 16px; row-gap: 6px
vega: border-bottom-left-radius: 18px; border-bottom-right-radius: 18px; border-top-left-radius: 18px; border-top-right-radius: 18px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.1) 0px 4p…; column-gap: normal; display: block; gap: normal; padding-bottom: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px; row-gap: normal
ToastItem — open/light, after-esc/light, open/dark, after-esc/dark. Radius: rounded-lg 10px vs vega .cn-toast rounded-2xl (18px here). Shadow: shadow-[0_4px_12px_rgba(0,0,0,0.1)] vs shadow-lg. Layout: RubyUI puts p-4 + gap-1.5 (6px) on the <li>; shadcn puts p-4 + gap-3 (12px) on toast-content (bases/base/ui/toast.tsx:68). The extra transparent box-shadow layers are Tailwind placeholders on both. Requested: ToastItem rounded-2xl shadow-lg; content p-4 gap-3.
↳ style-vega.css:1245
❌ Look border-bottom-color: oklch(0.922 0 0); border-bottom-color: oklch(1 0 0 / 0.1); border-bottom-width: 0px; border-left-color: oklch(0.922 0 0); border-left-color: oklch(1 0 0 / 0.1); border-left-width: 0px; border-right-color: oklch(0.922 0 0); border-right-color: oklch(1 0 0 / 0.1); border-right-width: 0px; border-top-color: oklch(0.922 0 0); border-top-color: oklch(1 0 0 / 0.1); border-top-width: 0px; color: oklab(0.145 0 0 / 0.6); color: oklab(0.985 0 0 / 0.6)
vega: border-bottom-color: rgba(0, 0, 0, 0); border-bottom-width: 1px; border-left-color: rgba(0, 0, 0, 0); border-left-width: 1px; border-right-color: rgba(0, 0, 0, 0); border-right-width: 1px; border-top-color: rgba(0, 0, 0, 0); border-top-width: 1px; color: oklch(0.556 0 0); color: oklch(0.708 0 0)
ToastClose — open/light, after-esc/light, open/dark, after-esc/dark. RubyUI ToastClose: absolute right-2 top-2, size-6 (24px), text-foreground/60, and the toast gets pr-10. shadcn: Button ghost icon-sm, size-8 (32px), in the flex row, vertically centered, text-muted-foreground. Border: 0 vs 1px transparent (renders the same). Requested: ToastClose as Button ghost icon-sm in the flex row, rendered by default (close_button default true); keep it exposed to AT (don't copy Base UI's conditional aria-hidden).
↳ bases/base/ui/toast.tsx:111
❌ Look font-size 13px (text-[13px] on the item): title 13px/19.5px, description 13px/18.2px (leading-[1.4])
vega: title text-sm 14px/20px font-medium, description text-sm 14px/20px
ToastRegion, ToastItem, ToastTitle, ToastDescription, ToastClose — explained by environment diff: font. Override: only the font-family fallback list differs by environment (Inter Variable loads on both). font-size/line-height come from the component classes; width/height follow from them and from the 356 vs 384px width. Requested: drop text-[13px]/leading-*; ToastTitle text-sm font-medium, ToastDescription text-sm text-muted-foreground.
↳ bases/base/ui/toast.tsx:79
✅ Look ToastDescription text-muted-foreground: oklch(0.556) light, oklch(0.708) dark
Computed on both sides, light and dark.
↳ bases/base/ui/toast.tsx:91
❌ Look ToastAction: h-6, text-xs, rounded (4px), solid bg-foreground text-background
vega: toast-action: Button outline size sm (h-8, text-sm, border-border, bg-background, shadow-xs, rounded-[min(var(--radius-md),10px)])
toast.success with an action on both sides (screens/toast-rich-*-light.png). ToastCancel has no shadcn counterpart (RubyUI-only). Requested: ToastAction renders Button(variant: :outline, size: :sm). Suggested divergence: ToastCancel is RubyUI-only (Button ghost sm).
↳ bases/base/ui/toast.tsx:100
✅ Look ToastIcon (success): 16×16, color foreground
Position differs only through the item layout (gap/margins on the Look line above). Same Lucide icons. shadcn colors the error icon text-destructive (bases/base/ui/toast.tsx:174); RubyUI keeps text-foreground for every type.
↳ bases/base/ui/toast.tsx:211
❌ Look collapsed stack: back toasts fade (opacity .8/.6) with content visible, peek 16px, scale steps .95/.9
vega: collapsed stack: back cards stay opaque, toast-content hidden (data-behind:opacity-0), peek --peek 0.75rem (12px), scale steps .9/.8
4 toasts, no timeout (screens/toast-stack-*-light.png). Requested: collapsed stack like shadcn (opaque back cards, content hidden, peek 12px, scale -0.1 per level).
↳ bases/base/ui/toast.tsx:40
❌ Look expanded stack gap 14px (gap: 14)
vega: expanded stack gap --gap 0.75rem (12px)
Expanded y steps: RubyUI 88px (74+14), shadcn 90px (78+12). Requested: expanded gap default 12.
↳ bases/base/ui/toast.tsx:40
❌ HTML div[role=region] > ol > li > div.flex-col > div[data-slot=title] + div[data-slot=description]; no content wrapper
shadcn: div[role=region][data-slot=toast-viewport] > div[role=dialog][data-slot=toast] > div[data-slot=toast-content] > span[toast-icon], div > h2[toast-title] + p[toast-description], action, close
Title is a <div> vs Base UI's <h2>, description <div> vs <p>; RubyUI keeps an <ol>/<li> list. Roles graded on the ARIA lines. Requested: Base UI semantics (div role=dialog aria-modal=false, aria-labelledby/-describedby, ToastTitle h2, ToastDescription p, single polite region with aria-relevant="additions text").
↳ sandbox DOM
❌ HTML close button only with close_button: true / closeButton: true
shadcn: ToastList always renders ToastClose
The fixture passes closeButton: true on RubyUI to compare the part. Requested: close button rendered by default.
↳ bases/base/ui/toast.tsx:229
❌ Props ToastRegion: no toastManager; ToastItem: no priority, onRemove, actionProps kwarg
shadcn: Toaster/Provider toastManager; toast.add priority, onRemove, actionProps
RubyUI exposes one global manager (window.RubyUI.toast) and actions only through the JS API detail. rubyui_only: position, expand, gap, hotkey, close_button, flash (region), dismissible (item), label/on, variant (note only). Requested: add priority (high → assertive) and on_remove. Suggested divergence: toastManager and actionProps (single global manager; action: via the JS API).
↳ @base-ui/react 1.9.0 toast (useToastManager.d.ts)
❌ Props accepted but ignored: ToastRegion duration: (shadcn timeout; not applied to spawned toasts), offset:, theme:, rich_colors:, dir:; ToastItem on_dismiss: (shadcn onClose), invert:, on_auto_close:
shadcn: timeout and onClose change behavior
Verified in the browser: with the region's duration 800, offset 80, rich-colors true, dir rtl, theme dark, a toast.success still stayed 1.5s+, sat 24px from the corner, white bg, ltr. The Stimulus values are declared but never read (toaster_controller.js:30-41, toast_controller.js:9-14); the region duration is read only in _mutate (promise). Requested: remove offset:, theme:, rich_colors:, dir:, invert:, on_auto_close: now (breaking, changelog). Requested: make region duration: (timeout) and on_dismiss: (onClose) work.
↳ gem/lib/ruby_ui/toast/toaster_controller.js:30
✅ Props max (limit), duration per toast (timeout), variant (type), id
Read by the controllers and change behavior.
↳ gem/lib/ruby_ui/toast/toast_region.rb:7
⚠️ ARIA snapshot: only here: - list: | - status: | - text: Event created Sunday, December 3 at 9:00 AM | - button "Close toast": | - img | - text: Close; snapshot: only here: - region "Notifications": | - list
shadcn: snapshot: only here: - dialog "Event created": | - heading "Event created" [level=2] | - paragraph: Sunday, December 3 at 9:00 AM; snapshot: only here: - region "Notifications"
ToastRegion — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark Requested: Base UI semantics (see HTML line): role=dialog toasts, one live region.
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA aria-describedby: (none); snapshot: only here: - status: | - text: Event created Sunday, December 3 at 9:00 AM | - button "Close toast": | - img | - text: Close
shadcn: aria-describedby: Sunday, December 3 at 9:00 AM; snapshot: only here: - dialog "Event created": | - heading "Event created" [level=2] | - paragraph: Sunday, December 3 at 9:00 AM
ToastItem — open/light, after-esc/light, open/dark, after-esc/dark Requested: Base UI semantics (see HTML line).
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - text: Event created
shadcn: snapshot: only here: - heading "Event created" [level=2]
ToastTitle — open/light, after-esc/light, open/dark, after-esc/dark Requested: ToastTitle as h2 (see HTML line).
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - text: Sunday, December 3 at 9:00 AM
shadcn: snapshot: only here: - paragraph: Sunday, December 3 at 9:00 AM
ToastDescription — open/light, after-esc/light, open/dark, after-esc/dark Requested: ToastDescription as p (see HTML line).
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA snapshot: only here: - button "Close toast": | - img | - text: Close
shadcn: snapshot: (none)
ToastClose — open/light, after-esc/light, open/dark, after-esc/dark Requested: keep the close exposed to AT (kept as is on aria-hidden).
↳ accessibility tree (shadcn-audit collect)
⚠️ ARIA error toast: li role=alert (assertive) inside the polite region; other types role=status
shadcn: every toast is role=dialog aria-modal=false, labelled by the title and described by the description; type only sets data-type (priority controls urgency)
RubyUI nests a live region per toast inside a region that is already aria-live=polite (possible double announcement). Base UI uses one polite region (aria-relevant="additions text") and a non-modal dialog per toast. Requested: priority instead of role=alert; no per-toast live region.
↳ sandbox DOM
✅ Behavior hover pauses and resumes; close click dismisses; Tab reaches the toast
behavior.json; toast spawned under a native modal <dialog> is covered on both sides too (see Native).
↳ sandbox DOM
⚠️ Behavior keyboard focus on the toast doesn't pause the timer (gone at 6.8s)
shadcn: focus inside the toast pauses it (still there at 6.8s)
Tab from the trigger onto the toast, default timeout, wait 6.5s. Requested: pause on focusin, resume on focusout inside the region.
↳ sandbox DOM
⚠️ Behavior default duration 4000ms
shadcn: default timeout 5000ms (present at 4.5s, gone at 5.5s)
RubyUI: present at 3.5s, gone at 4.5s. Requested: default duration 5000.
↳ @base-ui/react 1.9.0 toast provider
✅ Behavior Esc with focus on a toast dismisses it
Override after the Codex review: Base UI closes the focused toast on Escape. The first run waited 600ms after Escape while Base UI was still animating out, and its F6 had focused the viewport, not the toast, so the toast looked kept. With Tab onto the toast, Escape removes it on both sides. The earlier decision ("remove the Escape handler") is withdrawn; RubyUI keeps closing on Escape.
↳ @base-ui/react 1.9.0 toast/root/ToastRoot.js:383
⚠️ Behavior hotkey alt+T focuses the first toast
shadcn: F6 focuses the toast viewport; Tab then goes to the front toast
Requested: default hotkey F6, keep hotkey:. Requested: F6 focuses the region; Tab moves to the front toast (Base UI ToastViewport).
↳ sandbox DOM
⚠️ Behavior action click always dismisses the toast
shadcn: action click leaves the toast open; the onClick closes it (toast.close(id) in toast-example.tsx)
RubyUI force-dismisses after onClick (toaster_controller.js:139). Kept as is. Suggested divergence: action click closes the toast (common case, avoids breaking callers).
↳ bases/base/examples/toast-example.tsx:42
⚠️ Behavior swipe dismisses in any direction (left 120px dismissed)
shadcn: swipe out only down or right (left 120px kept)
Base UI swipeDirection default ['down','right']. Requested: default swipe down/right (from position) + swipe_directions: prop.
↳ sandbox DOM
⚠️ Behavior more than max (3): the oldest is force-dismissed and removed
shadcn: more than limit (3): the oldest stays mounted with data-limited (opacity 0) and returns when a front toast closes
4 toasts: RubyUI 3 nodes, shadcn 4 (toaster_controller.js:229). Requested: hide extra toasts (data-limited, opacity-0, no pointer events, timer paused) and re-show when space frees.
↳ bases/base/ui/toast.tsx:40
⚠️ Animation enter: opacity 0→1 over ~300ms, no movement
shadcn: enter: slides up from translateY(150%), transform 500ms cubic-bezier(0.22,1,0.36,1), no fade
Sampled 0-700ms: shadcn y 823→706, opacity 1 throughout. Requested: slide from translateY(150%), 500ms cubic-bezier(0.22,1,0.36,1).
↳ bases/base/ui/toast.tsx:43
⚠️ Animation exit: fade for 200ms, node removed mid-transition (transition 300ms, TIME_BEFORE_UNMOUNT 200ms), no movement
shadcn: exit: slides down translateY(150%) over ~500ms, opacity 1
Close click, sampled 0-400ms (toast_controller.js:4,73). Requested: slide out to translateY(150%); remove the node on transitionend, not a fixed timeout.
↳ bases/base/ui/toast.tsx:44
⚠️ Native region is position:fixed z-[100], not in the top layer
shadcn: toast-viewport fixed z-50, not in the top layer either
RubyUI's Dialog, AlertDialog and Sheet use showModal(), so a toast fired while one is open renders under the ::backdrop and can't be clicked (elementFromPoint = DIALOG, checked with a native modal on both sides). Base UI dialogs aren't top-layer, so shadcn's z-index works there. Candidate: popover="manual" on the region, showPopover() on each spawn. Timers, stacking and swipe have no native equivalent. Requested: popover="manual" on the region; hidePopover()+showPopover() on each spawn so toasts sit above open modals.
↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover
✅ Spec div role=region aria-label="Notifications"
WAI-ARIA 1.2 region: "Authors MUST give each element with role region a brief label": both do; shadcn's non-modal dialogs have an accessible name. Base UI's close is aria-hidden only while the toast is neither expanded nor focused (ToastClose.js:49) and loses it when focus enters, so no focusable aria-hidden element was observed.
↳ https://www.w3.org/TR/wai-aria-1.2/#region
Audit on this branch (after)
shadcn audit ruby-ui/ruby_ui
base 1998c5e8 head 7141afb1 shadcn 8a353667
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment fixture shims (local, uncommitted map + fixtures): shadcn.tsx exposes the toast manager as window.__auditToast; RubyUI spawns with duration: Infinity + closeButton: true, shadcn with timeout: 0 (its ToastList always renders the close)
✅ Theme neutral tokens match shadcn
Toast (ref: bases/base/ui/toast.tsx + style-vega.css @ 8a353667)
❌ Look border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; border-top-left-radius: 16px; border-top-right-radius: 16px
vega: border-bottom-left-radius: 18px; border-bottom-right-radius: 18px; border-top-left-radius: 18px; border-top-right-radius: 18px
ToastItem — open/light, after-esc/light, open/dark, after-esc/dark. Both use rounded-2xl; the docs theme has no --radius-2xl, so Tailwind's default (1rem) applies instead of shadcn's calc(var(--radius) * 1.8). Theme follow-up (also affects Empty and Bubble), not a component change.
↳ style-vega.css:1245
⏸ Look properties: font-family, letter-spacing
ToastRegion, ToastItem, ToastTitle, ToastDescription, ToastClose — explained by environment diff: font. Only the font-family fallback list differs by environment (Inter Variable on both); width/height/font-size match (384×78, text-sm).
✅ Look viewport fixed bottom-4 right-4, max-w-sm; toast 384×78 at (880,706)
Measured in the browser, light and dark.
↳ bases/base/ui/toast.tsx:24
✅ Look card border bg-popover shadow-lg; content p-4 gap-3; title/description text-sm
Computed styles match except the radius line above.
↳ bases/base/ui/toast.tsx:68
✅ Look ToastAction Button outline sm (32px, 14px/20px 500); ToastClose Button ghost icon-sm (32px) in the row
toast.success + action: action at (265,33) 58×32 and close at (335,33) 32×32 on both. ToastCancel is RubyUI-only (audit/divergences/toast.yml).
↳ bases/base/ui/toast.tsx:100
✅ Look stack: collapsed peek 12px, scale .9/.8/.7, content hidden behind; expanded gap 12
4 toasts: collapsed y 706/694/682/670, expanded 706/616/526/436 on both.
↳ bases/base/ui/toast.tsx:40
✅ Look ToastIcon 16×16; error icon text-destructive
↳ bases/base/ui/toast.tsx:174
✅ HTML div[role=region][data-slot=toast-viewport] > div#ruby-ui-toaster > div[role=dialog][data-slot=toast] > div[data-slot=toast-content] > icon, div > h2 + p, action, close
The inner #ruby-ui-toaster div is kept as the Turbo Stream target id; it adds no role.
↳ sandbox DOM
✅ HTML close button rendered by default (close_button: true)
close_button: false / closeButton: false still hide it (RubyUI extra).
↳ bases/base/ui/toast.tsx:229
✅ HTML ToastCancel (ghost sm) — intentional
RubyUI extra kept for existing callers.
↳ audit/divergences/toast.yml
✅ Props max, duration (region default 5000, now applied to spawned toasts), variant, id, priority, on_dismiss (ruby-ui:toast:close), on_remove
Region duration verified in the browser. Removed (they never had an effect): offset:, theme:, rich_colors:, dir:, invert:, on_auto_close: raise ArgumentError.
↳ gem/lib/ruby_ui/toast/toast_item.rb:14
✅ Props one global manager; action:/ToastAction — intentional
Registered divergence.
↳ audit/divergences/toast.yml
✅ ARIA snapshot: only here: - button "Close toast" — intentional
ToastRegion — Follows from the ToastClose entry above.
↳ audit/divergences/toast.yml
✅ ARIA snapshot: only here: - button "Close toast" — intentional
ToastItem — Follows from the ToastClose entry above: the only difference is the exposed close button.
↳ audit/divergences/toast.yml
✅ ARIA snapshot: only here: - button "Close toast" — intentional
ToastClose — RubyUI keeps the X in the accessibility tree: it is a real, focusable control, and hiding it until focus enters the toast makes it undiscoverable when browsing the region. The toast is still announced through the single polite region.
↳ audit/divergences/toast.yml
✅ ARIA ToastTitle, ToastDescription: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
✅ ARIA toast role=dialog aria-modal=false, aria-labelledby title, aria-describedby description; priority high → role=alertdialog + visually hidden role=alert
No per-toast live region any more; the region is the single polite one.
↳ bases/base/ui/toast.tsx:35
✅ Behavior hover, keyboard focus and window blur pause; default 5000ms; F6 focuses the region and Tab the front toast; Escape closes the focused toast; focus moves on when a focused toast leaves
Focus: still there at 6.8s on both. Auto-close: present at 4.5s on both. F6 → toast-viewport, Tab → front toast, Escape → removed, on both.
↳ sandbox DOM
✅ Behavior swipe dismisses down/right only (left 120px kept), direction derived from position or swipe_directions:
↳ sandbox DOM
✅ Behavior past max: oldest stays mounted with data-limited (opacity 0, inert) and returns when a front toast closes
4 toasts: 4 nodes on both.
↳ bases/base/ui/toast.tsx:40
✅ Behavior action click closes the toast — intentional
Registered divergence.
↳ audit/divergences/toast.yml
✅ Animation enter: slide from translateY(150%), 500ms cubic-bezier(0.22,1,0.36,1)
Sampled 0-700ms: y 823→706 on both.
↳ bases/base/ui/toast.tsx:43
✅ Animation exit: slide to translateY(150%), removed on transitionend
Sampled 0-400ms: y 718→817 on both.
↳ bases/base/ui/toast.tsx:44
⚠️ Native region is popover="manual", re-shown on each toast while a modal <dialog> is open: the toast now paints above the modal
shadcn: not in the top layer
Visible above a showModal() dialog (:popover-open, painted over the ::backdrop), but the page behind a modal stays inert, so its buttons can't be clicked until the modal closes. Follow-up: render the toast inside the open dialog.
↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover
✅ Spec div role=region aria-label="Notifications"
WAI-ARIA 1.2 region: "Authors MUST give each element with role region a brief label": both do; shadcn's non-modal dialogs have an accessible name. Base UI's close is aria-hidden only while the toast is neither expanded nor focused (ToastClose.js:49) and loses it when focus enters, so no focusable aria-hidden element was observed.
↳ https://www.w3.org/TR/wai-aria-1.2/#region
Follow-ups
- The docs CSS has no
--radius-2xl, sorounded-2xlis Tailwind's 16px where shadcn computes 18px (calc(var(--radius) * 1.8)). That's the one Look line still open here. It also affects Empty and Bubble, so it belongs in a theme PR. - Toasts fired under a modal are visible but not clickable: the page behind a
showModal()dialog is inert, popovers included. Fixing that means rendering the toast inside the open dialog. - Base UI pauses only on focus-visible focus; this PR pauses on any focus inside the region.
flash: {alert: …}/{error: …}could default topriority: :high. It's left at:lowhere to stay with Base UI, where type and priority are separate.- The audit walkthrough first recorded that Base UI keeps the toast on Escape. That measurement was wrong: Base UI was still animating out, and its F6 had focused the viewport, not the toast. Escape closes the toast on both sides, and the report says so now.
Test steps
cd gem && bundle exec rake(737 runs),cd docs && bin/rails test,cd mcp && bundle exec rake,cd audit && bundle exec rake.cd docs && bin/dev, open /docs/toast and fire the examples: stacking on hover, the X, Undo, swipe right and down (left doesn't dismiss), F6 then Tab then Escape.- Open a Dialog, fire a toast from the console (
RubyUI.toast("Saved")): it shows above the backdrop. RubyUI::ToastRegion.new(offset: 10)raisesArgumentError.
🤖 Generated with Claude Code
Summary by cubic
Moves RubyUI's Toast from the sonner port to shadcn's Base UI Toast with the vega style, matching shadcn's look, semantics, and behavior.
Changes
- The viewport sits 16px from the corner at
max-w-sm; toasts arerounded-2xlwithshadow-lg, ap-4 gap-3content row, andtext-smtitle and description. ToastActionrenders an outlinesmButton, and the close is a ghosticon-smButton in the row, shown by default.- Each toast is a
role="dialog"named by its<h2>title and described by its<p>description; a toast without a title uses its description, else "Notification". The region is the single polite live region, andpriority: :highupgrades torole="alertdialog". - Collapsed toasts peek 12px and shrink 10% per step with hidden content; toasts past
maxstay mounted hidden and return when a front toast closes. - Timers pause on hover, keyboard focus, and window blur; the default is 5000ms, and the region's
duration:now reaches spawned toasts. - F6 focuses the region, Tab moves to the newest toast, and Escape closes the focused toast; focus moves on when a focused toast goes away.
- Swipe dismisses down or right, derived from
position:or set withswipe_directions:; unknown directions raiseArgumentError. - Toasts slide in and out from
translateY(150%)over 500ms and are removed ontransitionend. - The region is
popover="manual", so toasts show above RubyUI Dialog, AlertDialog, and Sheet. ToastItemgainspriority:andon_remove:;on_dismiss:/on_remove:bindruby-ui:toast:close/ruby-ui:toast:remove, and the JS API gains matching options plusRubyUI.toast.close.
Breaking
- Removed dead props
ToastRegionoffset:,theme:,rich_colors:,dir:andToastIteminvert:,on_auto_close:— passing them raisesArgumentError. data-slotnames are now shadcn's (toast,toast-title,toast-description,toast-icon,toast-action,toast-cancel,toast-close,toast-viewport); the list is a<div>but keeps theruby-ui-toasterid for Turbo Streams.- Error toasts no longer use
role="alert"; urgency comes frompriority: :high.