Skip to content

feat(toast): add the Toast primitive - #99

Merged
Agustin-Delgado merged 13 commits into
mainfrom
feat/toast
Sep 23, 2026
Merged

Agustin-Delgado merged 13 commits into
mainfrom
feat/toast

Conversation

@Agustin-Delgado

@Agustin-Delgado Agustin-Delgado commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds the Toast primitive: Provider, Viewport, Positioner, Root, Content, Title, Description, Action and Close.

  • Toast.Provider holds the list of toasts and the timers. useToastManager() in a component under it, or bind:manager on it, gives add, update, close and promise. add with the id of a toast on the screen updates it, and promise turns a loading toast into success or error.

  • Toast.Viewport is a role="region" landmark named with the count of toasts. F6 moves the focus into it from anywhere on the page, and back. A Tab past the last button goes back to where the focus was. It renders at the end of the body by default.

  • Two live regions beside the viewport announce each toast: role="status" for the normal priority and role="alert" for the high priority. They are on the page before the first toast, they read the title and the description, not the buttons, and two toasts in the same tick are two messages.

  • Toast.Root is a role="dialog" that is not modal, or an alertdialog for the high priority, with aria-labelledby and aria-describedby from the title and the description. A toast without a title takes its name from the description. Escape closes it, and the focus moves to the next toast or back to where it was. It leaves the DOM after its exit animation.

  • The timers stop while the pointer rests on the viewport, while the keyboard focus is in it, and while the tab is hidden. They start again with the time each toast had left. An update gives the toast its full time again.

  • The newest toasts stay on the screen up to limit. The older ones wait behind, hidden and inert, with their timers stopped.

  • A swipe follows the finger through --toast-swipe-movement-x and --toast-swipe-movement-y, dismisses past 40 px or on a flick, and comes back when short. It never starts on Toast.Close or Toast.Action. The root closes the swipe axes to the native pan with touch-action.

  • add takes anchor, placement and offset, and Toast.Positioner puts the toast against that element as a fixed panel that follows it and flips. A toast with an anchor is out of the stack. For a toast without one, the positioner is out of the layout, thus one snippet serves both kinds.

  • Every toast gets --toast-index, --toast-offset-y and --toast-height, and the viewport --toast-frontmost-height, with data-front and data-expanded, for a stack.

  • ariaHideOutside learned data-hk-hide-outside-exempt, thus the viewport stays reachable behind a modal Dialog, and the focus trap leaves Tab to it while the focus is in it. getFocusableElements is exported from the focus trap.

  • Localized strings for the name of the region in six languages.

  • The presence primitive starts the enter state at creation, thus a node that is in the DOM from the first render paints its first frame in it. Before, a measure between the mount and the effect fixed the final styles first, and the enter played backwards.

  • The docs say to write the enter as an animation on data-entering and the exit as a transition on data-exiting, and why.

  • Includes the serial test run of test: run the browser test files one at a time #100, thus the checks are green here before that one merges.

  • A toast on its way out is out of stackedToasts, thus the ones behind it move up while it fades. It keeps the index and the offset it had, and the demo runs the exit from that place. The demo also covers the gap between two spread toasts with a pseudo-element: a pointer in that gap left the viewport, which closed the stack under it, and the toasts behind were out of reach. The dev render-scan button of the docs moves out of the corner, where it sat over the close button.

  • A touch has no hover: a tap on a toast, one that lifts where it landed and not on a button, holds the stack open, and the timers with it, until a touch outside the viewport. A swipe is not a tap. A pull against the swipe direction is a rubber band of 8 px at most: a long pull moved the front toast off the one behind, which showed empty.

Verification

  • 35 browser tests with fake timers: the region and the announcements, the timers on hover, focus, hidden tab, a touch that passes and a tap that holds, timeout 0 and loading, update, promise, close all, the shared manager, the portal, the modal, F6, Escape and the focus after a close, Tab out, the buttons, the limit, the positioner, the stack on a close, the swipe in both ends, and the pull the wrong way. 1 SSR test.
  • Full browser suite: 1841 tests green. Lint, STE check and both typechecks green.
  • The docs page was checked live: two toasts stack and spread on F6, the timers hold while the focus is in the region and while the pointer rests on it, Escape moves the focus to the next toast, Tab leaves the region back to the button, and a touch swipe of 120 px follows the finger and dismisses to the right. The anchored toast sits 8 px above its button, centered, and a second press updates it in place.

@vercel

vercel Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
human-kit-ui-docs Ready Ready Preview Sep 23, 2026 11:30pm UTC

`Toast.Provider` holds the list and the timers, and gives the manager to
the page through `useToastManager()` or `bind:manager`: `add`, `update`,
`close` and `promise`. `Toast.Viewport` is a `role="region"` landmark
named with the count of toasts, with `F6` in and out, and two live regions
beside it announce each toast without its buttons. `Toast.Root` is a
`dialog` that is not modal, or an `alertdialog` for a high priority, with
`Escape`, a swipe that follows the finger, and an exit animation from its
CSS. The timers stop on hover, on keyboard focus and in a background
window. The newest toasts stay up to a limit, and the older ones wait
inert behind them.

The `ariaHideOutside` primitive learned `data-hk-hide-outside-exempt`,
thus the viewport stays reachable behind a modal dialog.
`add` takes `anchor`, `placement` and `offset`, and `Toast.Positioner`
puts the toast against that element as a fixed panel that follows it and
flips when the space is not sufficient. A toast with an anchor is out of
the stack: `--toast-index` reads 0, and `data-anchored` is on the root.
For a toast without one, the positioner is out of the layout, thus one
snippet serves both kinds. The docs say where `data-hk-swipe-ignore` goes.
…Tab behind a modal

Two toasts in the same tick were one message: the announcers now hold one
node per message, for a while, thus the screen reader reads both, and the
same text twice. A toast without a title takes its name from the
description, because a dialog without a name is a fault. A close from the
page moves the focus on when it was in the toast. The count in the name of
the region leaves out a toast on its way out. A hidden tab stops the
timers, and the focus trap of a modal leaves `Tab` to a surface that stays
reachable behind it, thus `Tab` moves in the toast and goes back to the
dialog after its last button.
…the focus

A window without the focus can still be in view, and a frame beside
others loses the focus all the time: the pause followed the focus and
never came back. The visibility of the tab is the signal that means the
user cannot see the toast.
The presence effect ran after the mount, and the measure of the toast in
between fixed the final styles first: the enter played backwards, then
forwards. The enter state now starts at creation, thus the first frame is
in it. The demos write the enter as an animation on `data-entering`, which
stays on for the whole motion, and the exit as a transition on
`data-exiting`; the docs say why.
…r in the spread stack

A toast on its way out is out of `stackedToasts` now, thus the ones behind it move up while it fades. It keeps the index and the offset it had for its exit, and the demo runs the exit from that place: a bare `translateY(1rem)` sank a toast from behind under the front one.

The demo also covers the gap between two spread toasts with a pseudo-element. A pointer in that gap left the viewport, which closed the stack under it and opened it again: the toasts behind were out of reach.

The dev render-scan button of the docs moves to the left of the corner: it sat over the close button of the front toast.
…ops short

A touch has no hover. A tap on a toast holds the stack open, and the timers with it, until a touch outside the viewport. A mouse press does nothing new: the hover already does it.

A pull against the swipe direction gave a quarter of the way: a long pull moved the front toast off the one behind, which showed empty. It is a rubber band of 8 px at most now.
The mouse rests where the file before left it. On the region, it is a hover that stops the timers and spreads the stack out, and the tests of the timers and of the tap read that hover as their own state.
The stack spread out on the finger down, thus every swipe started with a spread nobody asked for. The tap now resolves on the lift, within 10 px of where the touch landed, and not on a button.
…he front one

The demo hid the text of the toasts behind the front one while the stack is folded. A swipe moved the front one aside and showed an empty card, which read as a fault.
… reduced motion in the demos

The announcer walked the list of toasts, which is the newest first: two toasts of one tick came out in reverse. The demos set a short motion under prefers-reduced-motion on the toast, and the enter rule, more specific, kept its 0.4 s.
Three faults of the stack, found with a frame by frame trace of the demo:

- A toast past the limit read `--toast-index: 0`, thus it had the place in
  front. When a place freed, it came forward from over the toast the user
  reads. It now waits in the place behind the stack.
- A toast on its way out kept `data-front`, thus two toasts had it at one
  time. The front is now the one toast the user sees in front. The demo
  keeps the toast that goes out of the rule that hides the ones behind.
- The enter of the demo moved `transform`, which also holds the place in
  the stack. A toast pushed back while it came in jumped to its new place.
  The enter now moves `translate`, and the place keeps its transition.

Also: the close button of the action demo no longer has the room of a
button that floats.
@Agustin-Delgado
Agustin-Delgado merged commit 2fd296f into main Sep 23, 2026
8 checks passed
@Agustin-Delgado
Agustin-Delgado deleted the feat/toast branch September 23, 2026 23:32

This branch was successfully deployed

1 active deployment
Preview — 39fb9459 Deployed Sep 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant