Toast

Generates toast notifications.

Based on Base UI’s toast component.

View source
import { For, createSignal } from "solid-js"
import { Toast } from "@danielfrg/solid-ui/toast"
import styles from "./index.module.css"

export function DemoToastHero() {
  return (
    <Toast.Provider>
      <ToastButton />
      <Toast.Portal>
        <Toast.Viewport class={styles.Viewport}>
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function ToastButton() {
  const toastManager = Toast.useToastManager()
  const [count, setCount] = createSignal(0)

  function createToast() {
    setCount((prev) => prev + 1)
    toastManager.add({
      title: `Toast ${count()} created`,
      description: "This is a toast notification.",
    })
  }

  return (
    <button type="button" class={styles.Button} onClick={createToast}>
      Create toast
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return (
    <For each={toasts()}>
      {(toast) => (
        <Toast.Root toast={toast} class={styles.Toast}>
          <Toast.Content class={styles.Content}>
            <Toast.Title class={styles.Title} />
            <Toast.Description class={styles.Description} />
            <Toast.Close class={styles.Close} aria-label="Close">
              <XIcon class={styles.Icon} />
            </Toast.Close>
          </Toast.Content>
        </Toast.Root>
      )}
    </For>
  )
}

function XIcon(props: { class?: string }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      class={props.class}
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
  )
}
/* ---- Shared trigger button ---- */
.Button {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 2.5rem;
  padding: 0 0.875rem;
  margin: 0;
  outline: 0;
  border: 1px solid var(--color-gray-200);
  border-radius: 0.375rem;
  background-color: var(--color-gray-50);
  font-family: inherit;
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.5rem;
  color: var(--color-gray-900);
  user-select: none;
  cursor: pointer;
}

@media (hover: hover) {
  .Button:hover {
    background-color: var(--color-gray-100);
  }
}

.Button:active {
  background-color: var(--color-gray-100);
}

.Button:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.ButtonGroup {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* ---- Bottom-right viewport (default) ---- */
.Viewport {
  position: fixed;
  z-index: 9999;
  width: 250px;
  bottom: 1rem;
  right: 1rem;
  left: auto;
  top: auto;
}

@media (min-width: 500px) {
  .Viewport {
    bottom: 2rem;
    right: 2rem;
    width: 300px;
  }
}

/* ---- Top-center viewport (position demo) ---- */
.ViewportTop {
  position: fixed;
  z-index: 9999;
  width: 100%;
  max-width: 300px;
  top: 1rem;
  right: 0;
  left: 0;
  margin: 0 auto;
  bottom: auto;
}

/* ---- Bottom-right toast (default — stacks upward) ---- */
.Toast {
  --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 + (var(--toast-index) * var(--gap) * -1) + var(--toast-swipe-movement-y, 0px)
  );

  position: absolute;
  right: 0;
  bottom: 0;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: bottom center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed stack — scale + peek upward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) - (var(--toast-index) * var(--peek)) - (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

/* expanded stack */
.Toast[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

/* enter */
.Toast[data-starting-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit base */
.Toast[data-ending-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit — swipe direction overrides */
.Toast[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.Toast[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.Toast[data-limited] {
  opacity: 0;
}

/* gap-catcher so pointer can travel between toasts without leaving the hover zone */
.Toast::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Top-stacking toast (stacks downward) ---- */
.ToastTop {
  --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, 0px));

  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: top center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed — scale + peek downward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) + (var(--toast-index) * var(--peek)) + (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

.ToastTop[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

.ToastTop[data-starting-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.ToastTop[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.ToastTop[data-limited] {
  opacity: 0;
}

.ToastTop::after {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Content ---- */
.Content {
  overflow: hidden;
  transition: opacity 0.25s;
}

.Content[data-behind] {
  opacity: 0;
}

.Content[data-expanded] {
  opacity: 1;
}

.Title {
  font-weight: 500;
  font-size: 0.975rem;
  line-height: 1.25rem;
  margin: 0;
}

.Description {
  font-size: 0.925rem;
  line-height: 1.25rem;
  color: var(--color-gray-600);
  margin: 0.125rem 0 0;
}

/* ---- Close button ---- */
.Close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  padding: 0;
  border: none;
  background: transparent;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--color-gray-500);
}

.Close:hover {
  background-color: var(--color-gray-100);
  color: var(--color-gray-700);
}

.Close:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.Icon {
  width: 1rem;
  height: 1rem;
}

/* ---- Action / Undo button ---- */
.UndoButton {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2rem;
  padding: 0 0.75rem;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.25rem;
  border-radius: 0.25rem;
  margin-top: 0.625rem;
  background-color: var(--color-gray-900);
  color: var(--color-gray-50);
  border: none;
  cursor: pointer;
}

.UndoButton:hover {
  background-color: var(--color-gray-700);
}

.UndoButton:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

/* ---- Promise type indicators ---- */
.Toast[data-type="loading"] .Title::before {
  content: "⏳ ";
}

.Toast[data-type="success"] {
  border-left: 3px solid #16a34a;
}

.Toast[data-type="error"] {
  border-left: 3px solid #dc2626;
}
:root {
  --color-blue: oklch(45% 50% 264deg);
  --color-red: oklch(50% 55% 31deg);
  --color-gray-50: oklch(98% 0.25% 264deg);
  --color-gray-100: oklch(12% 9.5% 264deg / 5%);
  --color-gray-200: oklch(12% 9% 264deg / 7%);
  --color-gray-300: oklch(12% 8.5% 264deg / 17%);
  --color-gray-400: oklch(12% 8% 264deg / 38%);
  --color-gray-500: oklch(12% 7.5% 264deg / 50%);
  --color-gray-600: oklch(12% 7% 264deg / 67%);
  --color-gray-700: oklch(12% 6% 264deg / 77%);
  --color-gray-800: oklch(12% 5% 264deg / 85%);
  --color-gray-900: oklch(12% 5% 264deg / 90%);
  --color-gray-950: oklch(12% 5% 264deg / 95%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-blue: oklch(69% 50% 264deg);
    --color-red: oklch(80% 55% 31deg);
    --color-gray-50: oklch(17% 0.25% 264deg);
    --color-gray-100: oklch(28% 0.75% 264deg / 65%);
    --color-gray-200: oklch(29% 0.75% 264deg / 80%);
    --color-gray-300: oklch(35% 0.75% 264deg / 80%);
    --color-gray-400: oklch(47% 0.875% 264deg / 80%);
    --color-gray-500: oklch(64% 1% 264deg / 80%);
    --color-gray-600: oklch(82% 1% 264deg / 80%);
    --color-gray-700: oklch(92% 1.125% 264deg / 80%);
    --color-gray-800: oklch(93% 0.875% 264deg / 85%);
    --color-gray-900: oklch(95% 0.5% 264deg / 90%);
    --color-gray-950: oklch(94% 0.375% 264deg / 95%);
  }
}

Examples

Position

Use a top-aligned Viewport and swipeDirection="up" on each Root for a top-center stack.

View source
import { For, createSignal } from "solid-js"
import { Toast } from "@danielfrg/solid-ui/toast"
import styles from "./index.module.css"

export function DemoToastPosition() {
  return (
    <Toast.Provider>
      <ToastButton />
      <Toast.Portal>
        <Toast.Viewport class={styles.ViewportTop}>
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function ToastButton() {
  const toastManager = Toast.useToastManager()
  const [count, setCount] = createSignal(0)

  function createToast() {
    setCount((prev) => prev + 1)
    toastManager.add({
      title: `Toast ${count()} created`,
      description: "This is a toast notification.",
    })
  }

  return (
    <button type="button" class={styles.Button} onClick={createToast}>
      Create toast
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return (
    <For each={toasts()}>
      {(toast) => (
        <Toast.Root toast={toast} swipeDirection="up" class={styles.ToastTop}>
          <Toast.Content class={styles.Content}>
            <Toast.Title class={styles.Title} />
            <Toast.Description class={styles.Description} />
            <Toast.Close class={styles.Close} aria-label="Close">
              <XIcon class={styles.Icon} />
            </Toast.Close>
          </Toast.Content>
        </Toast.Root>
      )}
    </For>
  )
}

function XIcon(props: { class?: string }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      class={props.class}
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
  )
}
/* ---- Shared trigger button ---- */
.Button {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 2.5rem;
  padding: 0 0.875rem;
  margin: 0;
  outline: 0;
  border: 1px solid var(--color-gray-200);
  border-radius: 0.375rem;
  background-color: var(--color-gray-50);
  font-family: inherit;
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.5rem;
  color: var(--color-gray-900);
  user-select: none;
  cursor: pointer;
}

@media (hover: hover) {
  .Button:hover {
    background-color: var(--color-gray-100);
  }
}

.Button:active {
  background-color: var(--color-gray-100);
}

.Button:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.ButtonGroup {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* ---- Bottom-right viewport (default) ---- */
.Viewport {
  position: fixed;
  z-index: 9999;
  width: 250px;
  bottom: 1rem;
  right: 1rem;
  left: auto;
  top: auto;
}

@media (min-width: 500px) {
  .Viewport {
    bottom: 2rem;
    right: 2rem;
    width: 300px;
  }
}

/* ---- Top-center viewport (position demo) ---- */
.ViewportTop {
  position: fixed;
  z-index: 9999;
  width: 100%;
  max-width: 300px;
  top: 1rem;
  right: 0;
  left: 0;
  margin: 0 auto;
  bottom: auto;
}

/* ---- Bottom-right toast (default — stacks upward) ---- */
.Toast {
  --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 + (var(--toast-index) * var(--gap) * -1) + var(--toast-swipe-movement-y, 0px)
  );

  position: absolute;
  right: 0;
  bottom: 0;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: bottom center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed stack — scale + peek upward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) - (var(--toast-index) * var(--peek)) - (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

/* expanded stack */
.Toast[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

/* enter */
.Toast[data-starting-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit base */
.Toast[data-ending-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit — swipe direction overrides */
.Toast[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.Toast[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.Toast[data-limited] {
  opacity: 0;
}

/* gap-catcher so pointer can travel between toasts without leaving the hover zone */
.Toast::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Top-stacking toast (stacks downward) ---- */
.ToastTop {
  --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, 0px));

  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: top center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed — scale + peek downward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) + (var(--toast-index) * var(--peek)) + (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

.ToastTop[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

.ToastTop[data-starting-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.ToastTop[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.ToastTop[data-limited] {
  opacity: 0;
}

.ToastTop::after {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Content ---- */
.Content {
  overflow: hidden;
  transition: opacity 0.25s;
}

.Content[data-behind] {
  opacity: 0;
}

.Content[data-expanded] {
  opacity: 1;
}

.Title {
  font-weight: 500;
  font-size: 0.975rem;
  line-height: 1.25rem;
  margin: 0;
}

.Description {
  font-size: 0.925rem;
  line-height: 1.25rem;
  color: var(--color-gray-600);
  margin: 0.125rem 0 0;
}

/* ---- Close button ---- */
.Close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  padding: 0;
  border: none;
  background: transparent;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--color-gray-500);
}

.Close:hover {
  background-color: var(--color-gray-100);
  color: var(--color-gray-700);
}

.Close:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.Icon {
  width: 1rem;
  height: 1rem;
}

/* ---- Action / Undo button ---- */
.UndoButton {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2rem;
  padding: 0 0.75rem;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.25rem;
  border-radius: 0.25rem;
  margin-top: 0.625rem;
  background-color: var(--color-gray-900);
  color: var(--color-gray-50);
  border: none;
  cursor: pointer;
}

.UndoButton:hover {
  background-color: var(--color-gray-700);
}

.UndoButton:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

/* ---- Promise type indicators ---- */
.Toast[data-type="loading"] .Title::before {
  content: "⏳ ";
}

.Toast[data-type="success"] {
  border-left: 3px solid #16a34a;
}

.Toast[data-type="error"] {
  border-left: 3px solid #dc2626;
}
:root {
  --color-blue: oklch(45% 50% 264deg);
  --color-red: oklch(50% 55% 31deg);
  --color-gray-50: oklch(98% 0.25% 264deg);
  --color-gray-100: oklch(12% 9.5% 264deg / 5%);
  --color-gray-200: oklch(12% 9% 264deg / 7%);
  --color-gray-300: oklch(12% 8.5% 264deg / 17%);
  --color-gray-400: oklch(12% 8% 264deg / 38%);
  --color-gray-500: oklch(12% 7.5% 264deg / 50%);
  --color-gray-600: oklch(12% 7% 264deg / 67%);
  --color-gray-700: oklch(12% 6% 264deg / 77%);
  --color-gray-800: oklch(12% 5% 264deg / 85%);
  --color-gray-900: oklch(12% 5% 264deg / 90%);
  --color-gray-950: oklch(12% 5% 264deg / 95%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-blue: oklch(69% 50% 264deg);
    --color-red: oklch(80% 55% 31deg);
    --color-gray-50: oklch(17% 0.25% 264deg);
    --color-gray-100: oklch(28% 0.75% 264deg / 65%);
    --color-gray-200: oklch(29% 0.75% 264deg / 80%);
    --color-gray-300: oklch(35% 0.75% 264deg / 80%);
    --color-gray-400: oklch(47% 0.875% 264deg / 80%);
    --color-gray-500: oklch(64% 1% 264deg / 80%);
    --color-gray-600: oklch(82% 1% 264deg / 80%);
    --color-gray-700: oklch(92% 1.125% 264deg / 80%);
    --color-gray-800: oklch(93% 0.875% 264deg / 85%);
    --color-gray-900: oklch(95% 0.5% 264deg / 90%);
    --color-gray-950: oklch(94% 0.375% 264deg / 95%);
  }
}

Promise

Pass a Promise to toastManager.promise() with loading, success, and error options. The toast automatically transitions between states as the promise settles.

View source
import { For } from "solid-js"
import { Toast } from "@danielfrg/solid-ui/toast"
import styles from "./index.module.css"

export function DemoToastPromise() {
  return (
    <Toast.Provider>
      <PromiseDemo />
      <Toast.Portal>
        <Toast.Viewport class={styles.Viewport}>
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function PromiseDemo() {
  const toastManager = Toast.useToastManager()

  function runPromise() {
    toastManager.promise(
      new Promise<string>((resolve, reject) => {
        const shouldSucceed = Math.random() > 0.3
        setTimeout(() => {
          if (shouldSucceed) {
            resolve("operation completed")
          } else {
            reject(new Error("operation failed"))
          }
        }, 2000)
      }),
      {
        loading: "Loading data...",
        success: (data: string) => `Success: ${data}`,
        error: (err: unknown) => `Error: ${(err as Error).message}`,
      },
    )
  }

  return (
    <button type="button" onClick={runPromise} class={styles.Button}>
      Run promise
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return (
    <For each={toasts()}>
      {(toast) => (
        <Toast.Root toast={toast} class={styles.Toast}>
          <Toast.Content class={styles.Content}>
            <Toast.Title class={styles.Title} />
            <Toast.Description class={styles.Description} />
            <Toast.Close class={styles.Close} aria-label="Close">
              <XIcon class={styles.Icon} />
            </Toast.Close>
          </Toast.Content>
        </Toast.Root>
      )}
    </For>
  )
}

function XIcon(props: { class?: string }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      class={props.class}
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
  )
}
/* ---- Shared trigger button ---- */
.Button {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 2.5rem;
  padding: 0 0.875rem;
  margin: 0;
  outline: 0;
  border: 1px solid var(--color-gray-200);
  border-radius: 0.375rem;
  background-color: var(--color-gray-50);
  font-family: inherit;
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.5rem;
  color: var(--color-gray-900);
  user-select: none;
  cursor: pointer;
}

@media (hover: hover) {
  .Button:hover {
    background-color: var(--color-gray-100);
  }
}

.Button:active {
  background-color: var(--color-gray-100);
}

.Button:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.ButtonGroup {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* ---- Bottom-right viewport (default) ---- */
.Viewport {
  position: fixed;
  z-index: 9999;
  width: 250px;
  bottom: 1rem;
  right: 1rem;
  left: auto;
  top: auto;
}

@media (min-width: 500px) {
  .Viewport {
    bottom: 2rem;
    right: 2rem;
    width: 300px;
  }
}

/* ---- Top-center viewport (position demo) ---- */
.ViewportTop {
  position: fixed;
  z-index: 9999;
  width: 100%;
  max-width: 300px;
  top: 1rem;
  right: 0;
  left: 0;
  margin: 0 auto;
  bottom: auto;
}

/* ---- Bottom-right toast (default — stacks upward) ---- */
.Toast {
  --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 + (var(--toast-index) * var(--gap) * -1) + var(--toast-swipe-movement-y, 0px)
  );

  position: absolute;
  right: 0;
  bottom: 0;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: bottom center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed stack — scale + peek upward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) - (var(--toast-index) * var(--peek)) - (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

/* expanded stack */
.Toast[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

/* enter */
.Toast[data-starting-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit base */
.Toast[data-ending-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit — swipe direction overrides */
.Toast[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.Toast[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.Toast[data-limited] {
  opacity: 0;
}

/* gap-catcher so pointer can travel between toasts without leaving the hover zone */
.Toast::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Top-stacking toast (stacks downward) ---- */
.ToastTop {
  --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, 0px));

  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: top center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed — scale + peek downward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) + (var(--toast-index) * var(--peek)) + (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

.ToastTop[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

.ToastTop[data-starting-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.ToastTop[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.ToastTop[data-limited] {
  opacity: 0;
}

.ToastTop::after {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Content ---- */
.Content {
  overflow: hidden;
  transition: opacity 0.25s;
}

.Content[data-behind] {
  opacity: 0;
}

.Content[data-expanded] {
  opacity: 1;
}

.Title {
  font-weight: 500;
  font-size: 0.975rem;
  line-height: 1.25rem;
  margin: 0;
}

.Description {
  font-size: 0.925rem;
  line-height: 1.25rem;
  color: var(--color-gray-600);
  margin: 0.125rem 0 0;
}

/* ---- Close button ---- */
.Close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  padding: 0;
  border: none;
  background: transparent;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--color-gray-500);
}

.Close:hover {
  background-color: var(--color-gray-100);
  color: var(--color-gray-700);
}

.Close:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.Icon {
  width: 1rem;
  height: 1rem;
}

/* ---- Action / Undo button ---- */
.UndoButton {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2rem;
  padding: 0 0.75rem;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.25rem;
  border-radius: 0.25rem;
  margin-top: 0.625rem;
  background-color: var(--color-gray-900);
  color: var(--color-gray-50);
  border: none;
  cursor: pointer;
}

.UndoButton:hover {
  background-color: var(--color-gray-700);
}

.UndoButton:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

/* ---- Promise type indicators ---- */
.Toast[data-type="loading"] .Title::before {
  content: "⏳ ";
}

.Toast[data-type="success"] {
  border-left: 3px solid #16a34a;
}

.Toast[data-type="error"] {
  border-left: 3px solid #dc2626;
}
:root {
  --color-blue: oklch(45% 50% 264deg);
  --color-red: oklch(50% 55% 31deg);
  --color-gray-50: oklch(98% 0.25% 264deg);
  --color-gray-100: oklch(12% 9.5% 264deg / 5%);
  --color-gray-200: oklch(12% 9% 264deg / 7%);
  --color-gray-300: oklch(12% 8.5% 264deg / 17%);
  --color-gray-400: oklch(12% 8% 264deg / 38%);
  --color-gray-500: oklch(12% 7.5% 264deg / 50%);
  --color-gray-600: oklch(12% 7% 264deg / 67%);
  --color-gray-700: oklch(12% 6% 264deg / 77%);
  --color-gray-800: oklch(12% 5% 264deg / 85%);
  --color-gray-900: oklch(12% 5% 264deg / 90%);
  --color-gray-950: oklch(12% 5% 264deg / 95%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-blue: oklch(69% 50% 264deg);
    --color-red: oklch(80% 55% 31deg);
    --color-gray-50: oklch(17% 0.25% 264deg);
    --color-gray-100: oklch(28% 0.75% 264deg / 65%);
    --color-gray-200: oklch(29% 0.75% 264deg / 80%);
    --color-gray-300: oklch(35% 0.75% 264deg / 80%);
    --color-gray-400: oklch(47% 0.875% 264deg / 80%);
    --color-gray-500: oklch(64% 1% 264deg / 80%);
    --color-gray-600: oklch(82% 1% 264deg / 80%);
    --color-gray-700: oklch(92% 1.125% 264deg / 80%);
    --color-gray-800: oklch(93% 0.875% 264deg / 85%);
    --color-gray-900: oklch(95% 0.5% 264deg / 90%);
    --color-gray-950: oklch(94% 0.375% 264deg / 95%);
  }
}

Undo

Use actionProps when adding a toast to supply an action button. The action can close the current toast and trigger follow-up behaviour.

View source
import { For } from "solid-js"
import { Toast } from "@danielfrg/solid-ui/toast"
import styles from "./index.module.css"

export function DemoToastUndo() {
  return (
    <Toast.Provider>
      <Form />
      <Toast.Portal>
        <Toast.Viewport class={styles.Viewport}>
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function Form() {
  const toastManager = Toast.useToastManager()

  function action() {
    const id = toastManager.add({
      title: "Action performed",
      description: "You can undo this action.",
      type: "success",
      actionProps: {
        children: "Undo",
        onClick() {
          toastManager.close(id)
          toastManager.add({
            title: "Action undone",
          })
        },
      },
    })
  }

  return (
    <button type="button" onClick={action} class={styles.Button}>
      Perform action
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return (
    <For each={toasts()}>
      {(toast) => (
        <Toast.Root toast={toast} class={styles.Toast}>
          <Toast.Content class={styles.Content}>
            <Toast.Title class={styles.Title} />
            <Toast.Description class={styles.Description} />
            <Toast.Action class={styles.UndoButton} />
            <Toast.Close class={styles.Close} aria-label="Close">
              <XIcon class={styles.Icon} />
            </Toast.Close>
          </Toast.Content>
        </Toast.Root>
      )}
    </For>
  )
}

function XIcon(props: { class?: string }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      class={props.class}
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
  )
}
/* ---- Shared trigger button ---- */
.Button {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 2.5rem;
  padding: 0 0.875rem;
  margin: 0;
  outline: 0;
  border: 1px solid var(--color-gray-200);
  border-radius: 0.375rem;
  background-color: var(--color-gray-50);
  font-family: inherit;
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.5rem;
  color: var(--color-gray-900);
  user-select: none;
  cursor: pointer;
}

@media (hover: hover) {
  .Button:hover {
    background-color: var(--color-gray-100);
  }
}

.Button:active {
  background-color: var(--color-gray-100);
}

.Button:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.ButtonGroup {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* ---- Bottom-right viewport (default) ---- */
.Viewport {
  position: fixed;
  z-index: 9999;
  width: 250px;
  bottom: 1rem;
  right: 1rem;
  left: auto;
  top: auto;
}

@media (min-width: 500px) {
  .Viewport {
    bottom: 2rem;
    right: 2rem;
    width: 300px;
  }
}

/* ---- Top-center viewport (position demo) ---- */
.ViewportTop {
  position: fixed;
  z-index: 9999;
  width: 100%;
  max-width: 300px;
  top: 1rem;
  right: 0;
  left: 0;
  margin: 0 auto;
  bottom: auto;
}

/* ---- Bottom-right toast (default — stacks upward) ---- */
.Toast {
  --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 + (var(--toast-index) * var(--gap) * -1) + var(--toast-swipe-movement-y, 0px)
  );

  position: absolute;
  right: 0;
  bottom: 0;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: bottom center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed stack — scale + peek upward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) - (var(--toast-index) * var(--peek)) - (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

/* expanded stack */
.Toast[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

/* enter */
.Toast[data-starting-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit base */
.Toast[data-ending-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit — swipe direction overrides */
.Toast[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.Toast[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.Toast[data-limited] {
  opacity: 0;
}

/* gap-catcher so pointer can travel between toasts without leaving the hover zone */
.Toast::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Top-stacking toast (stacks downward) ---- */
.ToastTop {
  --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, 0px));

  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: top center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed — scale + peek downward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) + (var(--toast-index) * var(--peek)) + (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

.ToastTop[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

.ToastTop[data-starting-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.ToastTop[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.ToastTop[data-limited] {
  opacity: 0;
}

.ToastTop::after {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Content ---- */
.Content {
  overflow: hidden;
  transition: opacity 0.25s;
}

.Content[data-behind] {
  opacity: 0;
}

.Content[data-expanded] {
  opacity: 1;
}

.Title {
  font-weight: 500;
  font-size: 0.975rem;
  line-height: 1.25rem;
  margin: 0;
}

.Description {
  font-size: 0.925rem;
  line-height: 1.25rem;
  color: var(--color-gray-600);
  margin: 0.125rem 0 0;
}

/* ---- Close button ---- */
.Close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  padding: 0;
  border: none;
  background: transparent;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--color-gray-500);
}

.Close:hover {
  background-color: var(--color-gray-100);
  color: var(--color-gray-700);
}

.Close:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.Icon {
  width: 1rem;
  height: 1rem;
}

/* ---- Action / Undo button ---- */
.UndoButton {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2rem;
  padding: 0 0.75rem;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.25rem;
  border-radius: 0.25rem;
  margin-top: 0.625rem;
  background-color: var(--color-gray-900);
  color: var(--color-gray-50);
  border: none;
  cursor: pointer;
}

.UndoButton:hover {
  background-color: var(--color-gray-700);
}

.UndoButton:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

/* ---- Promise type indicators ---- */
.Toast[data-type="loading"] .Title::before {
  content: "⏳ ";
}

.Toast[data-type="success"] {
  border-left: 3px solid #16a34a;
}

.Toast[data-type="error"] {
  border-left: 3px solid #dc2626;
}
:root {
  --color-blue: oklch(45% 50% 264deg);
  --color-red: oklch(50% 55% 31deg);
  --color-gray-50: oklch(98% 0.25% 264deg);
  --color-gray-100: oklch(12% 9.5% 264deg / 5%);
  --color-gray-200: oklch(12% 9% 264deg / 7%);
  --color-gray-300: oklch(12% 8.5% 264deg / 17%);
  --color-gray-400: oklch(12% 8% 264deg / 38%);
  --color-gray-500: oklch(12% 7.5% 264deg / 50%);
  --color-gray-600: oklch(12% 7% 264deg / 67%);
  --color-gray-700: oklch(12% 6% 264deg / 77%);
  --color-gray-800: oklch(12% 5% 264deg / 85%);
  --color-gray-900: oklch(12% 5% 264deg / 90%);
  --color-gray-950: oklch(12% 5% 264deg / 95%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-blue: oklch(69% 50% 264deg);
    --color-red: oklch(80% 55% 31deg);
    --color-gray-50: oklch(17% 0.25% 264deg);
    --color-gray-100: oklch(28% 0.75% 264deg / 65%);
    --color-gray-200: oklch(29% 0.75% 264deg / 80%);
    --color-gray-300: oklch(35% 0.75% 264deg / 80%);
    --color-gray-400: oklch(47% 0.875% 264deg / 80%);
    --color-gray-500: oklch(64% 1% 264deg / 80%);
    --color-gray-600: oklch(82% 1% 264deg / 80%);
    --color-gray-700: oklch(92% 1.125% 264deg / 80%);
    --color-gray-800: oklch(93% 0.875% 264deg / 85%);
    --color-gray-900: oklch(95% 0.5% 264deg / 90%);
    --color-gray-950: oklch(94% 0.375% 264deg / 95%);
  }
}

Custom data

Attach arbitrary typed data to a toast via the data option. Use a type guard to narrow the toast type inside the list renderer.

View source
import { For, Show } from "solid-js"
import { Toast } from "@danielfrg/solid-ui/toast"
import type { ToastObject } from "@danielfrg/solid-ui/toast"
import styles from "./index.module.css"

interface CustomToastData extends Record<string, unknown> {
  userId: string
}

function isCustomToast(toast: ToastObject): toast is ToastObject<CustomToastData> {
  return typeof (toast.data as CustomToastData | undefined)?.userId === "string"
}

export function DemoToastCustomData() {
  return (
    <Toast.Provider>
      <CustomToastButton />
      <Toast.Portal>
        <Toast.Viewport class={styles.Viewport}>
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function CustomToastButton() {
  const toastManager = Toast.useToastManager()

  function action() {
    const data: CustomToastData = { userId: "123" }
    toastManager.add({
      title: "Toast with custom data",
      data,
    })
  }

  return (
    <button type="button" onClick={action} class={styles.Button}>
      Create custom toast
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return (
    <For each={toasts()}>
      {(toast) => (
        <Toast.Root toast={toast} class={styles.Toast}>
          <Toast.Content class={styles.Content}>
            <Toast.Title class={styles.Title}>{toast.title}</Toast.Title>
            <Show
              when={isCustomToast(toast) && (toast as ToastObject<CustomToastData>).data}
              fallback={<Toast.Description class={styles.Description} />}
            >
              {(data) => (
                <Toast.Description class={styles.Description}>`data.userId` is {data().userId}</Toast.Description>
              )}
            </Show>
            <Toast.Close class={styles.Close} aria-label="Close">
              <XIcon class={styles.Icon} />
            </Toast.Close>
          </Toast.Content>
        </Toast.Root>
      )}
    </For>
  )
}

function XIcon(props: { class?: string }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      class={props.class}
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
  )
}
/* ---- Shared trigger button ---- */
.Button {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 2.5rem;
  padding: 0 0.875rem;
  margin: 0;
  outline: 0;
  border: 1px solid var(--color-gray-200);
  border-radius: 0.375rem;
  background-color: var(--color-gray-50);
  font-family: inherit;
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.5rem;
  color: var(--color-gray-900);
  user-select: none;
  cursor: pointer;
}

@media (hover: hover) {
  .Button:hover {
    background-color: var(--color-gray-100);
  }
}

.Button:active {
  background-color: var(--color-gray-100);
}

.Button:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.ButtonGroup {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* ---- Bottom-right viewport (default) ---- */
.Viewport {
  position: fixed;
  z-index: 9999;
  width: 250px;
  bottom: 1rem;
  right: 1rem;
  left: auto;
  top: auto;
}

@media (min-width: 500px) {
  .Viewport {
    bottom: 2rem;
    right: 2rem;
    width: 300px;
  }
}

/* ---- Top-center viewport (position demo) ---- */
.ViewportTop {
  position: fixed;
  z-index: 9999;
  width: 100%;
  max-width: 300px;
  top: 1rem;
  right: 0;
  left: 0;
  margin: 0 auto;
  bottom: auto;
}

/* ---- Bottom-right toast (default — stacks upward) ---- */
.Toast {
  --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 + (var(--toast-index) * var(--gap) * -1) + var(--toast-swipe-movement-y, 0px)
  );

  position: absolute;
  right: 0;
  bottom: 0;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: bottom center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed stack — scale + peek upward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) - (var(--toast-index) * var(--peek)) - (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

/* expanded stack */
.Toast[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

/* enter */
.Toast[data-starting-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit base */
.Toast[data-ending-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit — swipe direction overrides */
.Toast[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.Toast[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.Toast[data-limited] {
  opacity: 0;
}

/* gap-catcher so pointer can travel between toasts without leaving the hover zone */
.Toast::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Top-stacking toast (stacks downward) ---- */
.ToastTop {
  --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, 0px));

  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: top center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed — scale + peek downward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) + (var(--toast-index) * var(--peek)) + (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

.ToastTop[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

.ToastTop[data-starting-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.ToastTop[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.ToastTop[data-limited] {
  opacity: 0;
}

.ToastTop::after {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Content ---- */
.Content {
  overflow: hidden;
  transition: opacity 0.25s;
}

.Content[data-behind] {
  opacity: 0;
}

.Content[data-expanded] {
  opacity: 1;
}

.Title {
  font-weight: 500;
  font-size: 0.975rem;
  line-height: 1.25rem;
  margin: 0;
}

.Description {
  font-size: 0.925rem;
  line-height: 1.25rem;
  color: var(--color-gray-600);
  margin: 0.125rem 0 0;
}

/* ---- Close button ---- */
.Close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  padding: 0;
  border: none;
  background: transparent;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--color-gray-500);
}

.Close:hover {
  background-color: var(--color-gray-100);
  color: var(--color-gray-700);
}

.Close:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.Icon {
  width: 1rem;
  height: 1rem;
}

/* ---- Action / Undo button ---- */
.UndoButton {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2rem;
  padding: 0 0.75rem;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.25rem;
  border-radius: 0.25rem;
  margin-top: 0.625rem;
  background-color: var(--color-gray-900);
  color: var(--color-gray-50);
  border: none;
  cursor: pointer;
}

.UndoButton:hover {
  background-color: var(--color-gray-700);
}

.UndoButton:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

/* ---- Promise type indicators ---- */
.Toast[data-type="loading"] .Title::before {
  content: "⏳ ";
}

.Toast[data-type="success"] {
  border-left: 3px solid #16a34a;
}

.Toast[data-type="error"] {
  border-left: 3px solid #dc2626;
}
:root {
  --color-blue: oklch(45% 50% 264deg);
  --color-red: oklch(50% 55% 31deg);
  --color-gray-50: oklch(98% 0.25% 264deg);
  --color-gray-100: oklch(12% 9.5% 264deg / 5%);
  --color-gray-200: oklch(12% 9% 264deg / 7%);
  --color-gray-300: oklch(12% 8.5% 264deg / 17%);
  --color-gray-400: oklch(12% 8% 264deg / 38%);
  --color-gray-500: oklch(12% 7.5% 264deg / 50%);
  --color-gray-600: oklch(12% 7% 264deg / 67%);
  --color-gray-700: oklch(12% 6% 264deg / 77%);
  --color-gray-800: oklch(12% 5% 264deg / 85%);
  --color-gray-900: oklch(12% 5% 264deg / 90%);
  --color-gray-950: oklch(12% 5% 264deg / 95%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-blue: oklch(69% 50% 264deg);
    --color-red: oklch(80% 55% 31deg);
    --color-gray-50: oklch(17% 0.25% 264deg);
    --color-gray-100: oklch(28% 0.75% 264deg / 65%);
    --color-gray-200: oklch(29% 0.75% 264deg / 80%);
    --color-gray-300: oklch(35% 0.75% 264deg / 80%);
    --color-gray-400: oklch(47% 0.875% 264deg / 80%);
    --color-gray-500: oklch(64% 1% 264deg / 80%);
    --color-gray-600: oklch(82% 1% 264deg / 80%);
    --color-gray-700: oklch(92% 1.125% 264deg / 80%);
    --color-gray-800: oklch(93% 0.875% 264deg / 85%);
    --color-gray-900: oklch(95% 0.5% 264deg / 90%);
    --color-gray-950: oklch(94% 0.375% 264deg / 95%);
  }
}

Varying heights

The stack layout uses per-toast height measurements, so toasts of different sizes animate and collapse correctly.

View source
import { For, createSignal } from "solid-js"
import { Toast } from "@danielfrg/solid-ui/toast"
import styles from "./index.module.css"

const TEXTS = [
  "Short message.",
  "A bit longer message that spans two lines.",
  "This is a longer description that intentionally takes more vertical space to demonstrate stacking with varying heights.",
  "An even longer description that should span multiple lines so we can verify the clamped collapsed height and smooth expansion animation when hovering or focusing the viewport.",
]

export function DemoToastVaryingHeights() {
  return (
    <Toast.Provider>
      <ToastButton />
      <Toast.Portal>
        <Toast.Viewport class={styles.Viewport}>
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function ToastButton() {
  const toastManager = Toast.useToastManager()
  const [count, setCount] = createSignal(0)

  function createToast() {
    setCount((prev) => prev + 1)
    const description = TEXTS[Math.floor(Math.random() * TEXTS.length)]
    toastManager.add({
      title: `Toast ${count()} created`,
      description,
    })
  }

  return (
    <button type="button" class={styles.Button} onClick={createToast}>
      Create varying height toast
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return (
    <For each={toasts()}>
      {(toast) => (
        <Toast.Root toast={toast} class={styles.Toast}>
          <Toast.Content class={styles.Content}>
            <Toast.Title class={styles.Title} />
            <Toast.Description class={styles.Description} />
            <Toast.Close class={styles.Close} aria-label="Close">
              <XIcon class={styles.Icon} />
            </Toast.Close>
          </Toast.Content>
        </Toast.Root>
      )}
    </For>
  )
}

function XIcon(props: { class?: string }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      class={props.class}
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
  )
}
/* ---- Shared trigger button ---- */
.Button {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 2.5rem;
  padding: 0 0.875rem;
  margin: 0;
  outline: 0;
  border: 1px solid var(--color-gray-200);
  border-radius: 0.375rem;
  background-color: var(--color-gray-50);
  font-family: inherit;
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.5rem;
  color: var(--color-gray-900);
  user-select: none;
  cursor: pointer;
}

@media (hover: hover) {
  .Button:hover {
    background-color: var(--color-gray-100);
  }
}

.Button:active {
  background-color: var(--color-gray-100);
}

.Button:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.ButtonGroup {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* ---- Bottom-right viewport (default) ---- */
.Viewport {
  position: fixed;
  z-index: 9999;
  width: 250px;
  bottom: 1rem;
  right: 1rem;
  left: auto;
  top: auto;
}

@media (min-width: 500px) {
  .Viewport {
    bottom: 2rem;
    right: 2rem;
    width: 300px;
  }
}

/* ---- Top-center viewport (position demo) ---- */
.ViewportTop {
  position: fixed;
  z-index: 9999;
  width: 100%;
  max-width: 300px;
  top: 1rem;
  right: 0;
  left: 0;
  margin: 0 auto;
  bottom: auto;
}

/* ---- Bottom-right toast (default — stacks upward) ---- */
.Toast {
  --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 + (var(--toast-index) * var(--gap) * -1) + var(--toast-swipe-movement-y, 0px)
  );

  position: absolute;
  right: 0;
  bottom: 0;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: bottom center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed stack — scale + peek upward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) - (var(--toast-index) * var(--peek)) - (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

/* expanded stack */
.Toast[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

/* enter */
.Toast[data-starting-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit base */
.Toast[data-ending-style] {
  transform: translateY(150%);
  opacity: 0;
}

/* exit — swipe direction overrides */
.Toast[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.Toast[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.Toast[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.Toast[data-limited] {
  opacity: 0;
}

/* gap-catcher so pointer can travel between toasts without leaving the hover zone */
.Toast::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Top-stacking toast (stacks downward) ---- */
.ToastTop {
  --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, 0px));

  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  box-sizing: border-box;
  width: 100%;
  padding: 1rem;
  background: var(--color-gray-50);
  color: var(--color-gray-900);
  border: 1px solid var(--color-gray-200);
  border-radius: 0.5rem;
  box-shadow: 0 2px 10px rgb(0 0 0 / 0.1);
  background-clip: padding-box;
  transform-origin: top center;
  -webkit-user-select: none;
  user-select: none;
  cursor: default;
  z-index: calc(1000 - var(--toast-index));
  height: var(--height);
  transition:
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.5s,
    height 0.15s;

  /* collapsed — scale + peek downward */
  transform: translateX(var(--toast-swipe-movement-x, 0px))
    translateY(
      calc(var(--toast-swipe-movement-y, 0px) + (var(--toast-index) * var(--peek)) + (var(--shrink) * var(--height)))
    )
    scale(var(--scale));
}

.ToastTop[data-expanded] {
  transform: translateX(var(--toast-swipe-movement-x, 0px)) translateY(var(--offset-y));
  height: var(--toast-height);
}

.ToastTop[data-starting-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style] {
  transform: translateY(-150%);
  opacity: 0;
}

.ToastTop[data-ending-style][data-swipe-direction="up"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
}
.ToastTop[data-ending-style][data-swipe-direction="left"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%)) translateY(var(--offset-y));
}
.ToastTop[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
}

.ToastTop[data-limited] {
  opacity: 0;
}

.ToastTop::after {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}

/* ---- Content ---- */
.Content {
  overflow: hidden;
  transition: opacity 0.25s;
}

.Content[data-behind] {
  opacity: 0;
}

.Content[data-expanded] {
  opacity: 1;
}

.Title {
  font-weight: 500;
  font-size: 0.975rem;
  line-height: 1.25rem;
  margin: 0;
}

.Description {
  font-size: 0.925rem;
  line-height: 1.25rem;
  color: var(--color-gray-600);
  margin: 0.125rem 0 0;
}

/* ---- Close button ---- */
.Close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  padding: 0;
  border: none;
  background: transparent;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 0.25rem;
  cursor: pointer;
  color: var(--color-gray-500);
}

.Close:hover {
  background-color: var(--color-gray-100);
  color: var(--color-gray-700);
}

.Close:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

.Icon {
  width: 1rem;
  height: 1rem;
}

/* ---- Action / Undo button ---- */
.UndoButton {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2rem;
  padding: 0 0.75rem;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.25rem;
  border-radius: 0.25rem;
  margin-top: 0.625rem;
  background-color: var(--color-gray-900);
  color: var(--color-gray-50);
  border: none;
  cursor: pointer;
}

.UndoButton:hover {
  background-color: var(--color-gray-700);
}

.UndoButton:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: -1px;
}

/* ---- Promise type indicators ---- */
.Toast[data-type="loading"] .Title::before {
  content: "⏳ ";
}

.Toast[data-type="success"] {
  border-left: 3px solid #16a34a;
}

.Toast[data-type="error"] {
  border-left: 3px solid #dc2626;
}
:root {
  --color-blue: oklch(45% 50% 264deg);
  --color-red: oklch(50% 55% 31deg);
  --color-gray-50: oklch(98% 0.25% 264deg);
  --color-gray-100: oklch(12% 9.5% 264deg / 5%);
  --color-gray-200: oklch(12% 9% 264deg / 7%);
  --color-gray-300: oklch(12% 8.5% 264deg / 17%);
  --color-gray-400: oklch(12% 8% 264deg / 38%);
  --color-gray-500: oklch(12% 7.5% 264deg / 50%);
  --color-gray-600: oklch(12% 7% 264deg / 67%);
  --color-gray-700: oklch(12% 6% 264deg / 77%);
  --color-gray-800: oklch(12% 5% 264deg / 85%);
  --color-gray-900: oklch(12% 5% 264deg / 90%);
  --color-gray-950: oklch(12% 5% 264deg / 95%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-blue: oklch(69% 50% 264deg);
    --color-red: oklch(80% 55% 31deg);
    --color-gray-50: oklch(17% 0.25% 264deg);
    --color-gray-100: oklch(28% 0.75% 264deg / 65%);
    --color-gray-200: oklch(29% 0.75% 264deg / 80%);
    --color-gray-300: oklch(35% 0.75% 264deg / 80%);
    --color-gray-400: oklch(47% 0.875% 264deg / 80%);
    --color-gray-500: oklch(64% 1% 264deg / 80%);
    --color-gray-600: oklch(82% 1% 264deg / 80%);
    --color-gray-700: oklch(92% 1.125% 264deg / 80%);
    --color-gray-800: oklch(93% 0.875% 264deg / 85%);
    --color-gray-900: oklch(95% 0.5% 264deg / 90%);
    --color-gray-950: oklch(94% 0.375% 264deg / 95%);
  }
}