Overview of the Phoenix and LiveView Headless Toast Component
The Mishka Chelekom Headless Toast
ships zero opinionated styling
- only the semantic markup and a polite aria-live
viewport that stacks toasts, newest in front with older ones peeking behind. Hovering
or focusing the viewport expands
the stack into a full list and pauses auto-dismiss. Create toasts either by rendering
:toast
slots up front (static or LiveView-streamed), or by pairing a
:trigger
button with a
:template
- the engine clones a new toast on every click. Every part - trigger, viewport, toast, content, and
close
- is addressable through a
*_class
attribute, so you style it entirely with your own Tailwind
classes while the stacking behavior keeps working underneath.
Base UI Toast example (Phoenix headless)
<.toast id="toast-hero" trigger_class="flex h-8 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:bg-neutral-800 dark:active:bg-neutral-700 disabled:border-neutral-500 disabled:text-neutral-500 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white" viewport_class="fixed top-auto right-[1rem] bottom-[1rem] z-[1] mx-auto w-[calc(100vw-2rem)] sm:right-[2rem] sm:bottom-[2rem] sm:w-[22.5rem]" toast_class="[--gap:0.75rem] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] absolute right-0 bottom-0 left-auto z-[calc(1000-var(--toast-index))] mr-0 w-full origin-bottom [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] border border-neutral-950 bg-white text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 select-none dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-[''] data-[ending-style]:opacity-0 data-[expanded]:[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--offset-y)))] data-[limited]:opacity-0 data-[starting-style]:[transform:translateY(150%)] h-[var(--height)] data-[expanded]:h-[var(--toast-height)] [transition:transform_0.5s_cubic-bezier(0.22,1,0.36,1),opacity_0.5s,height_0.15s]" content_class="flex h-full items-center gap-4 p-3 overflow-hidden transition-opacity duration-[250ms] ease-[cubic-bezier(0.22,1,0.36,1)] data-[behind]:opacity-0 data-[expanded]:opacity-100" close_class="flex h-8 shrink-0 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 hover:not-data-[disabled]:bg-neutral-100 active:not-data-[disabled]:bg-neutral-200 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-[disabled]:bg-neutral-800 dark:active:not-data-[disabled]:bg-neutral-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white" > <:trigger>Create toast</:trigger> <:close>Dismiss</:close> <:template> <div class="flex min-w-0 flex-1 flex-col gap-1"> <div class="text-sm font-bold">Toast <span data-toast-count></span> created</div> <div class="text-sm">This is a toast notification.</div> </div> </:template> </.toast>
Position
<.toast id="toast-position" trigger_class="flex h-8 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:bg-neutral-800 dark:active:bg-neutral-700 disabled:border-neutral-500 disabled:text-neutral-500 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white" viewport_class="fixed top-[1rem] right-0 bottom-auto left-0 z-[1] mx-auto w-[calc(100vw-2rem)] max-w-[22.5rem]" toast_class="[--gap:0.75rem] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)+(var(--toast-index)*var(--gap))+var(--toast-swipe-movement-y))] absolute top-0 right-0 left-0 z-[calc(1000-var(--toast-index))] mx-auto w-full origin-top [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--peek))+(var(--shrink)*var(--height))))_scale(var(--scale))] border border-neutral-950 bg-white text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 select-none dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none after:absolute after:bottom-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-[''] data-[ending-style]:opacity-0 data-[expanded]:[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--offset-y)))] data-[limited]:opacity-0 data-[starting-style]:[transform:translateY(-150%)] h-[var(--height)] data-[expanded]:h-[var(--toast-height)] [transition:transform_0.5s_cubic-bezier(0.22,1,0.36,1),opacity_0.5s,height_0.15s]" content_class="flex h-full items-center gap-4 p-3 overflow-hidden transition-opacity duration-[250ms] ease-[cubic-bezier(0.22,1,0.36,1)] data-[behind]:opacity-0 data-[expanded]:opacity-100" close_class="flex h-8 shrink-0 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 hover:not-data-[disabled]:bg-neutral-100 active:not-data-[disabled]:bg-neutral-200 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-[disabled]:bg-neutral-800 dark:active:not-data-[disabled]:bg-neutral-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white" > <:trigger>Create toast</:trigger> <:close>Dismiss</:close> <:template> <div class="flex min-w-0 flex-1 flex-col gap-1"> <div class="text-sm font-bold">Toast <span data-toast-count></span> created</div> <div class="text-sm">This is a toast notification.</div> </div> </:template> </.toast>
Anchored (stacked manager)
<.toast id="toast-anchored" trigger_class="flex h-8 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:bg-neutral-800 dark:active:bg-neutral-700 disabled:border-neutral-500 disabled:text-neutral-500 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white" viewport_class="fixed top-auto right-[1rem] bottom-[1rem] z-[1] mx-auto w-[calc(100vw-2rem)] sm:right-[2rem] sm:bottom-[2rem] sm:w-[22.5rem]" toast_class="[--gap:0.75rem] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] absolute right-0 bottom-0 left-auto z-[calc(1000-var(--toast-index))] mr-0 w-full origin-bottom [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] border border-neutral-950 bg-white text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 select-none dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-[''] data-[ending-style]:opacity-0 data-[expanded]:[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--offset-y)))] data-[limited]:opacity-0 data-[starting-style]:[transform:translateY(150%)] h-[var(--height)] data-[expanded]:h-[var(--toast-height)] [transition:transform_0.5s_cubic-bezier(0.22,1,0.36,1),opacity_0.5s,height_0.15s]" content_class="flex h-full items-center gap-4 p-3 overflow-hidden transition-opacity duration-[250ms] ease-[cubic-bezier(0.22,1,0.36,1)] data-[behind]:opacity-0 data-[expanded]:opacity-100" close_class="flex h-8 shrink-0 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 hover:not-data-[disabled]:bg-neutral-100 active:not-data-[disabled]:bg-neutral-200 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-[disabled]:bg-neutral-800 dark:active:not-data-[disabled]:bg-neutral-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white" > <:trigger>Stacked toast</:trigger> <:close>Dismiss</:close> <:template> <div class="flex min-w-0 flex-1 flex-col gap-1"> <div class="text-sm">Copied</div> </div> </:template> </.toast>
Single toast - only the latest is shown (limit)
With a
limit
of 1, the toaster keeps only the newest toast visible - older ones are marked
data-limited
and faded to
opacity-0
by the
toast_class
. So clicking
Create toast
again replaces
the current toast instead of stacking a new copy. Raise
the limit to allow a small stack.
<.toast id="toast-single" limit={1} trigger_class="flex h-8 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:bg-neutral-800 dark:active:bg-neutral-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white" viewport_class="fixed top-auto right-[1rem] bottom-[1rem] z-[1] mx-auto w-[calc(100vw-2rem)] sm:right-[2rem] sm:bottom-[2rem] sm:w-[22.5rem]" toast_class="[--gap:0.75rem] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] absolute right-0 bottom-0 left-auto z-[calc(1000-var(--toast-index))] mr-0 w-full origin-bottom border border-neutral-950 bg-white text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 select-none dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none data-[ending-style]:opacity-0 data-[limited]:opacity-0 data-[starting-style]:[transform:translateY(150%)] h-[var(--height)] data-[expanded]:h-[var(--toast-height)] [transition:transform_0.5s_cubic-bezier(0.22,1,0.36,1),opacity_0.5s,height_0.15s]" content_class="flex h-full items-center gap-4 p-3 overflow-hidden" close_class="flex h-8 shrink-0 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 hover:bg-neutral-100 active:bg-neutral-200 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:bg-neutral-800 dark:active:bg-neutral-700" > <:trigger>Create toast</:trigger> <:close>Dismiss</:close> <:template> <div class="flex min-w-0 flex-1 flex-col gap-1"> <div class="text-sm font-bold">Toast <span data-toast-count></span> (latest only)</div> <div class="text-sm">Click again — it replaces this one instead of stacking.</div> </div> </:template> </.toast>
Parts & data attributes
Each part carries a stable
data-part
and a structural
chelekom-toast__*
class you can target, plus paired-presence state for the stacking/expand/dismiss
transitions.
| Part | data-part | State / ARIA | Purpose |
|---|---|---|---|
| Root | root | data-limit | Carries the ToastRegion hook that owns the stacking engine. |
| Trigger | trigger | - | Creates a new toast from the template on click. |
| Viewport | viewport | aria-live="polite", aria-atomic="false", data-expanded | The live region and stacking container; expands on hover/focus and pauses auto-dismiss. |
| Template | template | - | Cloned per new toast; fill a data-toast-count element for a running number. |
| Toast | toast | data-duration, data-open, data-closed, data-expanded, data-limited, data-starting-style, data-ending-style | One notification (an li); carries the --toast-index/--toast-offset-y/--toast-height stacking vars. |
| Content | content | data-behind | Wraps the toast body and close button; fades when behind the front toast. |
| Close | close | aria-label | Dismiss button for the toast. |
Accessibility
Follows the
WAI-ARIA aria-live region guidance
: the viewport is a
polite
live region, so new toasts are announced without interrupting the user. Hovering or
focusing the viewport expands the stack into a full list and pauses auto-dismiss
timers, and each toast's close button is reachable by keyboard.