Stacked toast notifications — measured-height choreography matching Base UI: uniform collapsed stack with peek layers, hover-to-expand spread, limited visible entries, enter/exit motion, auto-dismiss and hover-pause — built on the @foldkit/ui Toast submodel (embed via h.submodel). ⚠ No swipe-to-dismiss.

Preview
Style
Basic
    Variants
    src/demo/views/toast.ts212 lines
    import { Match as M, Option } from 'effect'
    import { Schema as S } from 'effect'
    import { Command, Update } from 'foldkit'
    import { evo } from 'foldkit/struct'
    import { defineMessageUnion } from 'foldkit/message'
    import type { Html, HtmlBuilder } from 'foldkit/html'
    
    import * as ToastModule from '../../generated/registry/ui/toast'
    
    import { defineSlice, type UpdateReturn } from '../slice'
    import type { Model, Message as AppMessage } from '../assemble'
    import { Toast } from '../toast'
    
    export const Message = defineMessageUnion({
      GotToastMessage: { message: Toast.Message },
      ClickedShowInfoToast: {},
      ClickedShowSuccessToast: {},
      ClickedShowWarningToast: {},
      ClickedShowErrorToast: {},
      ClickedDismissAllToasts: {},
    })
    
    export const toastView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
      h.div(
        [h.Class('flex w-full flex-col gap-8')],
        [
          h.div(
            [h.Class('flex w-full flex-col gap-2')],
            [
              h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
              h.div(
                [h.Class('flex flex-col items-start gap-6')],
                [
                  h.div(
                    [h.Class('flex flex-wrap gap-2')],
                    [hButton(h, 'Show toast', Message.ClickedShowInfoToast())],
                  ),
                  h.div(
                    [h.Class('flex flex-wrap gap-2')],
                    [
                      h.button(
                        [
                          h.Class(
                            'rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
                          ),
                          h.OnClick(Message.ClickedDismissAllToasts()),
                        ],
                        ['Dismiss all'],
                      ),
                    ],
                  ),
                  h.submodel({
                    slotId: model.toast.id,
                    model: model.toast,
                    view: Toast.view,
                    viewInputs: Toast.styledViewInputs(
                      model.toast,
                      {
                        position: 'BottomRight',
                        toContent: (entry, h) => [
                          h.p([h.Class(ToastModule.toastTitleClass)], [entry.payload.title]),
                          ...Option.match(entry.payload.description, {
                            onNone: () => [],
                            onSome: (description) => [
                              h.p([h.Class(ToastModule.toastDescriptionClass)], [description]),
                            ],
                          }),
                        ],
                      },
                      h,
                    ),
                    toParentMessage: (message) => Message.GotToastMessage({ message }),
                  }),
                ],
              ),
            ],
          ),
          h.div(
            [h.Class('flex w-full flex-col gap-2')],
            [
              h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Variants']),
              h.div(
                [h.Class('flex flex-wrap gap-2')],
                [
                  hVariantButton(h, 'Info', Message.ClickedShowInfoToast()),
                  hVariantButton(h, 'Success', Message.ClickedShowSuccessToast()),
                  hVariantButton(h, 'Warning', Message.ClickedShowWarningToast()),
                  hVariantButton(h, 'Error', Message.ClickedShowErrorToast()),
                ],
              ),
            ],
          ),
        ],
      )
    
    const hButton = (h: HtmlBuilder<AppMessage>, label: string, message: AppMessage): Html =>
      h.button(
        [
          h.Class('rounded-md border border-input bg-background px-4 py-2 text-sm font-medium'),
          h.OnClick(message),
        ],
        [label],
      )
    
    /** Live variant button: renders the registry's own per-variant icon
     *  (`ToastModule.toastIcon`, the same renderer the toast stack uses) and
     *  fires a real toast of that variant on click — mirroring upstream's
     *  `toast-types` demo. Previously this section was inert tinted spans. */
    const hVariantButton = (
      h: HtmlBuilder<AppMessage>,
      variant: 'Info' | 'Success' | 'Warning' | 'Error',
      message: AppMessage,
    ): Html =>
      h.button(
        [
          h.Class(
            'inline-flex items-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
          ),
          h.OnClick(message),
        ],
        [ToastModule.toastIcon(h, variant), variant],
      )
    
    const foldNoOp =
      (): ((out: typeof Toast.OutMessage.Type) => Update.Step<State, unknown>) => () => (model) => ({
        model,
      })
    
    const foldToastOutMessage = M.type<typeof Toast.OutMessage.Type>().pipe(
      M.withReturnType<Update.Step<State, unknown>>(),
      M.tagsExhaustive({
        DismissedToast: foldNoOp(),
      }),
    )
    
    const foldToast = Update.foldChild({
      update: Toast.update,
      read: (model: State) => Option.some(model.toast),
      write: (model, next) => evo(model, { toast: () => next }),
      toParentMessage: (message) => Message.GotToastMessage({ message }),
      foldOutMessage: foldToastOutMessage,
    })
    
    const showToast = (
      model: State,
      variant: 'Info' | 'Success' | 'Warning' | 'Error',
      title: string,
      description: Option.Option<string>,
    ): UpdateReturn => {
      const { model: next, commands = [] } = Toast.show(model.toast, {
        variant,
        payload: { title, description },
      })
      return {
        model: evo(model, { toast: () => next }),
        commands: Command.mapMessages(commands, (message) => Message.GotToastMessage({ message })),
      }
    }
    
    const fields = { toast: Toast.Model }
    
    const stateSchema = S.Struct(fields)
    type State = typeof stateSchema.Type
    
    export const slice = defineSlice({
      fields,
      init: { toast: Toast.init({ id: 'toast-demo' }) },
      messages: [
        Message.GotToastMessage,
        Message.ClickedShowInfoToast,
        Message.ClickedShowSuccessToast,
        Message.ClickedShowWarningToast,
        Message.ClickedShowErrorToast,
        Message.ClickedDismissAllToasts,
      ],
      handlers: (model: State) => ({
        GotToastMessage: (payload: typeof Message.GotToastMessage.Type): UpdateReturn =>
          foldToast(model, payload.message),
        ClickedShowInfoToast: (): UpdateReturn =>
          showToast(model, 'Info', 'Changes saved', Option.some('Your preferences have been updated.')),
        ClickedShowSuccessToast: (): UpdateReturn =>
          showToast(
            model,
            'Success',
            'Uploaded successfully',
            Option.some('kit-manual.pdf is now available.'),
          ),
        ClickedShowWarningToast: (): UpdateReturn =>
          showToast(
            model,
            'Warning',
            'Network slow',
            Option.some('Some assets are loading over a weak connection.'),
          ),
        ClickedShowErrorToast: (): UpdateReturn =>
          showToast(
            model,
            'Error',
            'Failed to save',
            Option.some('Check your connection and try again.'),
          ),
        ClickedDismissAllToasts: (): UpdateReturn => {
          const { model: next, commands = [] } = Toast.dismissAll(model.toast)
          return {
            model: evo(model, { toast: () => next }),
            commands: Command.mapMessages(commands, (message) => Message.GotToastMessage({ message })),
          }
        },
      }),
      samples: [Message.ClickedShowInfoToast()],
    })
    

    This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

    Installation

    Add this component to your project:

    pnpm dlx shadcn@latest add @foldcn/toast

    Source

    The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

    registry/default/ui/toast.ts603 lines
    /** Toast notifications — transient message stack with auto-dismiss, backed by
     *  the @foldkit/ui Toast submodel.
     *
     *  Usage: `import * as ToastModule from '@/components/ui/toast'`
     *
     *  1. Bind your payload schema once (module scope):
     *       export const Toast = ToastModule.make(S.Struct({ title: S.String }))
     *  2. Embed `Toast.Model` in your app Model (`toast: Toast.Model`), init it
     *     (`Toast.init({ id: 'toast' })`), route `Toast.Message` through your
     *     update, and show/dismiss via `Toast.show` / `Toast.dismiss` /
     *     `Toast.dismissAll`.
     *  3. Render the stack with `h.submodel({ slotId: model.toast.id,
     *     model: model.toast, view: Toast.view, viewInputs:
     *     Toast.styledViewInputs(model.toast, { position: 'BottomRight',
     *     toContent: (entry, h) => [...] }, h) })`.
     *
     *  Presentation mirrors upstream shadcn `toast.tsx` (Base UI toast): entries
     *  dock bottom-right in a collapsed stack — every card takes the frontmost
     *  toast's height, older layers scale down and peek `0.75rem` above the one
     *  in front — and expand into a spread list (exact per-entry offsets computed
     *  from measured heights) while any entry is hovered. Entering toasts slide
     *  up from below the viewport; dismissed ones slide back down while the
     *  survivors glide into their new slots; at most 3 entries are visible
     *  (older overflow renders with `data-limited`, matching Base UI's default
     *  limit). All motion rides compositor-only CSS transitions on
     *  `transform` and `opacity` (`height` is applied instantly without a
     *  transition to avoid layout thrashing — see `toastEntryClass`).
     *
     *  Like Base UI, entry heights are measured in the DOM: after each `show`,
     *  a background command reads the new card's natural height after paint and
     *  stores it in the model. Heights are measured once per entry — they don't
     *  re-measure on window resize. Before a measurement lands, cards render at
     *  natural height with an approximate %-based stack, then settle exactly.
     *
     *  ⚠ BEHAVIOR GAP vs upstream: no swipe-to-dismiss — foldkit has no
     *  pointer-move gesture primitive yet. Auto-dismiss, hover-pause,
     *  hover-to-expand, the stacked/expanded choreography, and manual close all
     *  work as expected.
     */
    
    import { Effect, Option, Schema as S, pipe } from 'effect'
    import * as Command from 'foldkit/command'
    import { Toast as FoldkitToast } from '@foldkit/ui'
    import type { Attribute, ChildAttribute, Html, HtmlBuilder } from 'foldkit/html'
    import { defineMessageUnion } from 'foldkit/message'
    import * as Render from 'foldkit/render'
    import { evo } from 'foldkit/struct'
    import { defineView } from 'foldkit/submodel'
    import * as Update from 'foldkit/update'
    
    type Child = Html | string
    
    import { icon } from '@/lib/icons'
    import { CircleCheck, Info, LoaderCircle, OctagonX, TriangleAlert, X } from 'lucide'
    import { cn } from '@/lib/utils'
    
    export const Variant = FoldkitToast.Variant
    export type Variant = typeof Variant.Type
    export const Position = FoldkitToast.Position
    export type Position = typeof Position.Type
    export type EntryHandlers = FoldkitToast.EntryHandlers
    export type InitConfig = FoldkitToast.InitConfig
    export type ShowInput<A> = FoldkitToast.ShowInput<A>
    
    const DEFAULT_POSITION: Position = 'BottomRight'
    
    /** Vertical gap between layers — collapsed peek offset and expanded
     *  spacing. Mirrors Base UI's `--gap: 0.75rem`. */
    const GAP_PX = 12
    /** Scale step per layer behind the frontmost one. Mirrors Base UI's
     *  `--scale: max(0, 1 - index * 0.1)`. */
    const SCALE_STEP = 0.1
    /** Maximum simultaneously visible entries (Base UI ToastProvider default). */
    const LIMIT = 3
    
    const isLeavingState = (
      state: 'Idle' | 'EnterStart' | 'EnterAnimating' | 'LeaveStart' | 'LeaveAnimating',
    ): boolean => state === 'LeaveStart' || state === 'LeaveAnimating'
    
    /** Accent for the per-variant icon. Only `Error` needs an explicit tint
     *  (`text-destructive`); the other variants inherit `currentColor` on the
     *  neutral popover surface, matching the reference `toast.tsx` where only
     *  the error icon is colored. */
    export const toastVariantClass = (variant: Variant): string =>
      variant === 'Error' ? 'text-destructive' : ''
    
    /** Entry card. Geometry (transform/height) is applied inline per render;
     *  the `after:` bridge extends the hover zone `--gap` + 1px below the card
     *  so the pointer can cross between stacked layers without a dead zone,
     *  exactly like the reference root class. Entries beyond `LIMIT` render with
     *  `data-limited` and are fully hidden until a slot frees up. */
    export const toastEntryClass =
      'pointer-events-auto absolute right-0 bottom-0 w-80 origin-bottom rounded-lg border bg-popover text-popover-foreground shadow-lg [transform:translateZ(0)] [contain:layout] [backface-visibility:hidden] outline-none select-none after:absolute after:top-full after:left-0 after:h-[calc(0.75rem+1px)] after:w-full after:content-[""] [transition:transform_350ms_cubic-bezier(0.22,1,0.36,1),opacity_350ms] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[limited]:pointer-events-none data-[limited]:opacity-0'
    
    /** Content row — the reference `ToastContent`: full card height, clipped,
     *  fading out while hidden behind the frontmost layer (`data-behind`) and
     *  back in when the stack expands (`data-expanded`). */
    export const toastContentClass =
      'flex h-full w-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-[behind]:opacity-0 data-[expanded]:opacity-100'
    
    export const toastTitleClass = 'text-sm font-medium'
    
    export const toastDescriptionClass = 'text-sm text-muted-foreground'
    
    export const toastDismissButtonClass =
      "relative shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 after:absolute after:-inset-2 after:content-['']"
    
    export const toastActionClass = 'shrink-0'
    
    /** Action button for a toast (e.g. Undo). Mirrors upstream `ToastAction`
     *  which renders a `Button` with `variant="outline" size="sm"`.
     *  Render it inside `toContent` and wire `handlers.dismiss` or your own
     *  action via `attributes`. */
    export const toastAction = <M>(
      attributes: ReadonlyArray<Attribute<M> | ChildAttribute>,
      config: Readonly<{ className?: string }>,
      children: ReadonlyArray<Child>,
      h: HtmlBuilder<M>,
    ): Html =>
      h.button(
        [
          ...attributes,
          h.DataAttribute('slot', 'toast-action'),
          h.Class(
            cn(
              'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 border bg-clip-padding font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=\'size-\'])]:size-3.5',
              toastActionClass,
              config.className,
            ),
          ),
          h.Type('button'),
        ],
        children,
      )
    
    const variantIconNode = (variant: Variant) => {
      switch (variant) {
        case 'Success':
          return CircleCheck
        case 'Info':
          return Info
        case 'Warning':
          return TriangleAlert
        case 'Error':
          return OctagonX
        default:
          return Info
      }
    }
    
    /** Render the per-variant icon, mirroring the reference `ToastIcon`.
     *  `loading` is not a `Variant` in the Foldkit schema, so it is not handled
     *  here — render a spinner from your payload via `toContent` if you need a
     *  loading state. */
    export const toastIcon = <M>(h: HtmlBuilder<M>, variant: Variant): Html =>
      h.span(
        [
          h.DataAttribute('slot', 'toast-icon'),
          h.Class("shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"),
        ],
        [icon(h, variantIconNode(variant), cn('size-4 shrink-0', toastVariantClass(variant)))],
      )
    
    /** Spinner icon for ad-hoc loading toasts. Not driven by `Variant` — use
     *  from `toContent` when your payload represents a loading state. */
    export const toastLoadingIcon = <M>(h: HtmlBuilder<M>): Html =>
      h.span(
        [
          h.DataAttribute('slot', 'toast-icon'),
          h.Class("shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"),
        ],
        [icon(h, LoaderCircle, 'size-4 shrink-0 animate-spin')],
      )
    
    /** Reads the natural height of every entry card in the stack. Cards whose
     *  height is already known render with the frontmost's height (the collapsed
     *  uniform-height rule), so only never-measured cards — which render at
     *  natural height — contribute meaningful values. Runs after paint so the
     *  just-mounted card is in the DOM. */
    const measureHeights = (containerId: string) => {
      if (typeof document === 'undefined') {
        return {} satisfies Readonly<Record<string, number>>
      }
      const container = document.getElementById(containerId)
      if (container === null) {
        return {} satisfies Readonly<Record<string, number>>
      }
      const heights: Record<string, number> = {}
      // Batched read: single query + single layout pass via
      // getBoundingClientRect (sub-pixel accurate, coalesced after paint).
      // offsetHeight per-card would each force a reflow. Cards whose heights
      // are already known render with the frontmost's forced height, so the
      // natural value is irrelevant — mergeHeights discards those reads.
      const items = container.querySelectorAll(':scope > li[id]')
      for (const item of Array.from(items)) {
        if (!(item instanceof HTMLElement) || item.id === '') {
          continue
        }
        const card = item.querySelector(':scope > [data-slot="toast"]')
        if (!(card instanceof HTMLElement)) {
          continue
        }
        const h = card.getBoundingClientRect().height
        if (h > 0) {
          heights[item.id] = Math.round(h)
        }
      }
      return heights satisfies Readonly<Record<string, number>>
    }
    
    /** Per-entry placement for the current frame, mirroring Base UI's CSS
     *  variables (`--toast-index`, `--toast-offset-y`, `--height`, `--scale`,
     *  `--shrink`) computed from measured heights:
     *
     *  - Collapsed: every card takes the frontmost's height; layer `k` lifts by
     *    `k * gap + shrink * frontmostHeight` and scales to
     *    `max(0, 1 - k * 0.1)` around its bottom edge, so each layer's top edge
     *    peeks exactly `gap` above the layer in front regardless of mixed
     *    content heights.
     *  - Expanded (any entry hovered): layers separate to their own measured
     *    height, offset by the cumulative height of the layers in front plus
     *    one gap each.
     *  - Enter start / leaving: `translate3d(0, 150%, 0)` — below the viewport
     *    (translate3d promotes to the compositor, unlike translateY).
     */
    type Placement = Readonly<{
      transform: string
      height: string
      isBehind: boolean
      isLimited: boolean
      isExpanded: boolean
    }>
    
    /** Structural subset of a bound toast entry the placement math needs. */
    type PlacementEntry = Readonly<{
      id: string
      isHovered: boolean
      animation: Readonly<{
        transitionState: 'Idle' | 'EnterStart' | 'EnterAnimating' | 'LeaveStart' | 'LeaveAnimating'
      }>
    }>
    
    const makePlacementFor = (
      entries: ReadonlyArray<PlacementEntry>,
      heights: Readonly<Record<string, number>>,
    ) => {
      const isExpanded = entries.some(({ isHovered }) => isHovered)
      const nonLeaving = entries.filter(
        (candidate) => !isLeavingState(candidate.animation.transitionState),
      )
      // Only the newest `LIMIT` non-leaving entries participate in the stack
      // layout; older overflow renders hidden with `data-limited` (Base UI's
      // default limit behavior) until a slot frees up.
      const alive = nonLeaving.slice(-LIMIT)
      const limitedIds = new Set(nonLeaving.slice(0, -LIMIT).map(({ id }) => id))
      const frontmost = alive.length > 0 ? alive[alive.length - 1] : undefined
      const frontHeight = frontmost === undefined ? undefined : heights[frontmost.id]
    
      // Expanded offset per entry: negative cumulative height of the layers in
      // front plus one gap each (frontmost stays at 0).
      const expandedOffset = new Map<string, number>()
      let cumulative = 0
      for (let index = alive.length - 1; index >= 0; index -= 1) {
        const candidate = alive[index]
        if (candidate === undefined) {
          continue
        }
        expandedOffset.set(candidate.id, -cumulative)
        cumulative += (heights[candidate.id] ?? 0) + GAP_PX
      }
    
      return (entry: PlacementEntry): Placement => {
        const ownHeight = heights[entry.id]
        const isLimited = limitedIds.has(entry.id)
        const aliveIndex = alive.indexOf(entry)
        const indexFromFront =
          aliveIndex === -1
            ? nonLeaving.length - 1 - nonLeaving.indexOf(entry)
            : alive.length - 1 - aliveIndex
        const state = entry.animation.transitionState
    
        if (isLeavingState(state)) {
          return {
            transform: 'translate3d(0, 150%, 0)',
            height:
              ownHeight === undefined
                ? 'auto'
                : `${isExpanded ? ownHeight : (frontHeight ?? ownHeight)}px`,
            isBehind: false,
            isLimited: false,
            isExpanded,
          }
        }
    
        if (state === 'EnterStart') {
          // Starting style: mount below the viewport at the collapsed height;
          // the next frame drops this and the transition glides the card in.
          return {
            transform: 'translate3d(0, 150%, 0)',
            height: ownHeight === undefined ? 'auto' : `${frontHeight ?? ownHeight}px`,
            isBehind: false,
            isLimited,
            isExpanded: false,
          }
        }
    
        if (isLimited) {
          // Overflow beyond LIMIT: hold a collapsed pose behind the visible
          // stack, fully hidden via the card's data-limited CSS, until a slot
          // frees up. Never joins the expanded spread.
          const scale = Math.max(0, 1 - indexFromFront * SCALE_STEP)
          return {
            transform: `translate3d(0, ${-(indexFromFront * GAP_PX + (1 - scale) * (frontHeight ?? ownHeight ?? 0))}px, 0) scale(${scale.toFixed(2)})`,
            height: ownHeight === undefined ? 'auto' : `${frontHeight ?? ownHeight}px`,
            isBehind: true,
            isLimited: true,
            isExpanded: false,
          }
        }
    
        if (isExpanded) {
          return {
            transform: `translate3d(0, ${expandedOffset.get(entry.id) ?? 0}px, 0)`,
            height: ownHeight === undefined ? 'auto' : `${ownHeight}px`,
            isBehind: false,
            isLimited,
            isExpanded: true,
          }
        }
    
        const scale = Math.max(0, 1 - indexFromFront * SCALE_STEP)
        if (frontHeight !== undefined && ownHeight !== undefined) {
          // Measured: exact lift — peek offset plus this layer's scale shrink
          // against the frontmost's height (the uniform collapsed card height).
          // translate3d keeps this on the compositor thread.
          return {
            transform: `translate3d(0, ${-(indexFromFront * GAP_PX + (1 - scale) * frontHeight)}px, 0) scale(${scale.toFixed(2)})`,
            height: `${frontHeight}px`,
            isBehind: indexFromFront > 0,
            isLimited,
            isExpanded: false,
          }
        }
        // Pre-measurement fallback: lift by percentages of the card's own height
        // until the measure command reports back.
        return {
          transform: `translate3d(0, calc(${indexFromFront * -GAP_PX}px - ${indexFromFront * SCALE_STEP * 100}%), 0) scale(${scale.toFixed(2)})`,
          height: 'auto',
          isBehind: indexFromFront > 0,
          isLimited,
          isExpanded: false,
        }
      }
    }
    
    /** Bind a toast stack to your payload schema. Wraps `@foldkit/ui`'s
     *  `Toast.make` with measured entry heights (a background command measures
     *  each new card after paint) and a `styledViewInputs` renderer that
     *  produces the stacked, animated presentation for `h.submodel`.
     *
     *  ```ts
     *  export const Toast = ToastModule.make(S.Struct({
     *    title: S.String,
     *    description: S.Option(S.String),
     *  }))
     *  ```
     */
    export const make = <A, I>(payloadSchema: S.Codec<A, I>) => {
      const Bound = FoldkitToast.make(payloadSchema)
      type BoundMessage = typeof Bound.Message.Type
      type BoundOutMessage = typeof Bound.OutMessage.Type
      type Entry = typeof Bound.Entry.Type
    
      const Model = S.Struct({
        id: S.String,
        toast: Bound.Model,
        heights: S.Record(S.String, S.Number),
      })
      type Model = typeof Model.Type
    
      const Message = defineMessageUnion({
        GotHeights: { heights: S.Record(S.String, S.Number) },
        GotToastMessage: { message: Bound.Message },
      })
      type Message = typeof Message.Type
    
      const OutMessage = defineMessageUnion({
        DismissedToast: { payload: payloadSchema },
      })
      type OutMessage = typeof OutMessage.Type
    
      type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>
    
      /** Measures the stack after paint and reports natural card heights. */
      const MeasureHeights = Command.define('MeasureToastHeights', {
        args: { containerId: S.String },
        messages: [Message.GotHeights],
        execute: ({ containerId }) =>
          pipe(
            Render.afterPaint,
            Effect.map(() => Message.GotHeights({ heights: measureHeights(containerId) })),
          ),
      })
    
      const toGotToastMessage = (message: BoundMessage): Message => Message.GotToastMessage({ message })
    
      const foldToast = Update.foldChild({
        update: Bound.update,
        read: (model: Model) => Option.some(model.toast),
        write: (model, nextToast) => evo(model, { toast: () => nextToast }),
        toParentMessage: toGotToastMessage,
        toParentOutMessage: (outMessage: BoundOutMessage): OutMessage =>
          OutMessage.DismissedToast({ payload: outMessage.payload }),
      })
    
      /** Merges newly measured heights. Heights of known entries are kept —
       *  measured cards render height-forced, so re-reading them would capture
       *  the forced value, not the natural one. Stale ids are pruned. */
      const mergeHeights = (model: Model, measured: Readonly<Record<string, number>>): Model => {
        const knownIds = new Set(model.toast.entries.map(({ id }) => id))
        const heights: Record<string, number> = {}
        for (const [id, height] of Object.entries(model.heights)) {
          if (knownIds.has(id)) {
            heights[id] = height
          }
        }
        for (const [id, height] of Object.entries(measured)) {
          if (!Object.hasOwn(heights, id)) {
            heights[id] = height
          }
        }
        return evo(model, { heights: () => heights })
      }
    
      /** Processes a toast message. Delegates to the bound toast update and
       *  schedules a height measurement whenever an entry is added. */
      const update = (model: Model, message: Message): UpdateReturn =>
        Message.match<UpdateReturn>(message, {
          GotHeights: ({ heights }) => ({ model: mergeHeights(model, heights) }),
          GotToastMessage: ({ message: toastMessage }) => {
            const { model: nextModel, commands = [], outMessage: out } = foldToast(model, toastMessage)
            const measure =
              toastMessage._tag === 'Added' ? [MeasureHeights({ containerId: nextModel.toast.id })] : []
            return Update.withOutMessage({ model: nextModel, commands: [...commands, ...measure] }, out)
          },
        })
    
      /** Adds a toast entry and schedules its height measurement. */
      const show = (model: Model, input: FoldkitToast.ShowInput<A>): UpdateReturn => {
        const { model: nextToast, commands = [], outMessage: out } = Bound.show(model.toast, input)
        return Update.withOutMessage(
          {
            model: evo(model, { toast: () => nextToast }),
            commands: [
              ...Command.mapMessages(commands, toGotToastMessage),
              MeasureHeights({ containerId: nextToast.id }),
            ],
          },
          out,
        )
      }
    
      /** Begins dismissing a specific entry. */
      const dismiss = (model: Model, entryId: string): UpdateReturn => {
        const { model: nextToast, commands = [], outMessage: out } = Bound.dismiss(model.toast, entryId)
        return Update.withOutMessage(
          {
            model: evo(model, { toast: () => nextToast }),
            commands: Command.mapMessages(commands, toGotToastMessage),
          },
          out,
        )
      }
    
      /** Begins dismissing every currently-visible entry. */
      const dismissAll = (model: Model): UpdateReturn => {
        const { model: nextToast, commands = [], outMessage: out } = Bound.dismissAll(model.toast)
        return Update.withOutMessage(
          {
            model: evo(model, { toast: () => nextToast }),
            commands: Command.mapMessages(commands, toGotToastMessage),
          },
          out,
        )
      }
    
      /** Creates an initial toast container model from a config. Starts empty
       *  with no measurements; the first `show` measures after paint. */
      const init = (config: FoldkitToast.InitConfig): Model => ({
        id: config.id,
        toast: Bound.init(config),
        heights: {},
      })
    
      type ViewInputs = Readonly<{
        position: Position
        ariaLabel?: string
        containerClassName?: string
        entryClassName?: string
        entryToView: (entry: Entry, handlers: EntryHandlers) => Html
      }>
    
      /** Renders the stack: a headless wrapper around the bound toast view that
       *  feeds each entry's measured placement into `entryToView`. */
      const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) =>
        h.submodel({
          slotId: 'stack',
          model: model.toast,
          view: Bound.view,
          viewInputs: viewInputs,
          toParentMessage: toGotToastMessage,
        }),
      )
    
      /** Build the `viewInputs` for `h.submodel`. `toContent` renders the
       *  payload column between the variant icon and the close button; render
       *  action buttons there when you need them. */
      const styledViewInputs = <M2>(
        model: Model,
        config: Readonly<{
          position?: Position
          ariaLabel?: string
          containerClassName?: string
          /** Extra classes merged onto each entry card (e.g. a width override
           *  over the `w-80` default). */
          className?: string
          toContent: (entry: Entry, h: HtmlBuilder<M2>) => ReadonlyArray<Child>
        }>,
        h: HtmlBuilder<M2>,
      ): ViewInputs => {
        const position = config.position ?? DEFAULT_POSITION
        const placementFor = makePlacementFor(model.toast.entries, model.heights)
    
        const entryToView = (entry: Entry, handlers: EntryHandlers): Html => {
          const placement = placementFor(entry)
    
          return h.div(
            [
              h.DataAttribute('slot', 'toast'),
              ...(placement.isExpanded ? [h.DataAttribute('expanded', '')] : []),
              ...(placement.isLimited ? [h.DataAttribute('limited', '')] : []),
              // Docked inside the fixed <ol>'s 16px padding so the stack sits
              // inset from the viewport corner like upstream's viewport.
              h.Style({
                right: '1rem',
                bottom: '1rem',
                height: placement.height,
                transform: placement.transform,
              }),
              h.Class(cn(toastEntryClass, config.className)),
            ],
            [
              h.div(
                [
                  h.DataAttribute('slot', 'toast-content'),
                  ...(placement.isBehind ? [h.DataAttribute('behind', '')] : []),
                  ...(placement.isExpanded ? [h.DataAttribute('expanded', '')] : []),
                  ...(placement.isLimited ? [h.DataAttribute('limited', '')] : []),
                  h.Class(toastContentClass),
                ],
                [
                  toastIcon(h, entry.variant),
                  h.div([h.Class('flex min-w-0 flex-1 flex-col gap-1')], config.toContent(entry, h)),
                  h.button(
                    [
                      ...handlers.dismiss,
                      h.Class(toastDismissButtonClass),
                      h.AriaLabel('Close toast'),
                      h.DataAttribute('slot', 'toast-close'),
                    ],
                    [icon(h, X)],
                  ),
                ],
              ),
            ],
          )
        }
    
        return {
          position,
          ...(config.ariaLabel !== undefined && { ariaLabel: config.ariaLabel }),
          ...(config.containerClassName !== undefined && {
            containerClassName: config.containerClassName,
          }),
          entryToView,
        }
      }
    
      return {
        ...Bound,
        Model,
        Message,
        OutMessage,
        init,
        update,
        show,
        dismiss,
        dismissAll,
        view,
        styledViewInputs,
      } as const
    }