Getting Started
IntroductionHow to Use
Components
FrameMenuAlertAccordionDialogTabsToastNewButtonInputSwitchTextareaRadio GroupCheckboxChartComboboxSelectNewAlert DialogSoonAvatarSoonBreadcrumbSoonButton GroupSoonCalendarSoonDatepickerSoonDaterangepickerSoonSheetSoonNumber InputSoonPaginationSoonTableSoon

Toast

The toast component is used to give feedback to users after an action has taken place.
Installation
Expand

npx @left4code/cosmic-ui-cli@latest add toast
 
Expand

This writes the component and everything it imports, then installs the packages it needs. Run npx @left4code/cosmic-ui-cli@latest initfirst if you haven't already (it detects Vue automatically).

Install the following dependencies:

pnpm
npm
yarn
bun
yarn add @zag-js/toast @zag-js/vue

This component is built directly on the Zag.js state machine (not a wrapper), plus the sharedPortalprimitive from theMenupage. Each toast in the list gets its own machine instance parented to a shared group machine, the same pattern Zag's own adapters use internally — in Vue this means the per-toast content is passed through as a scoped slot rather than a render-prop function. Vue SFCs can't export multiple components from one file, so Toast is a folder instead of a single file — copy all of it into your project.

components/ui/toast/context.ts
Expand

import type { InjectionKey, ComputedRef } from "vue";
import type * as toast from "@zag-js/toast";

type ToastApi = ComputedRef<ReturnType<typeof toast.connect>>;

const ToastApiKey: InjectionKey<ToastApi> = Symbol("toast-api");

export { ToastApiKey };
export type { ToastApi };
 
Expand
components/ui/toast/ToastActor.vue
Expand

<script setup lang="ts">
import { computed, provide } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as toast from "@zag-js/toast";
import { ToastApiKey } from "./context";

// `defineProps()`'s runtime declaration form still trips Vue's automatic
// type-declaration generation if a prop is cast to a complex external type
// (`Object as () => toast.Props`) right there in the macro call. Keeping the
// props untyped here and casting them as normal TS expressions below avoids it.
const props = defineProps({
  value: { type: Object, required: true },
  parent: { type: Object, required: true },
  index: { type: Number, required: true },
});
const value = props.value as toast.Props;
const parent = props.parent as toast.GroupService;

const service = useMachine(
  toast.machine,
  computed(() => ({ ...value, parent, index: props.index }))
);
const api = computed(() => toast.connect(service, normalizeProps));

provide(ToastApiKey, api);
</script>

<template>
  <slot :toast="value" />
</template>
 
Expand
components/ui/toast/ToasterRoot.vue
Expand

<script setup lang="ts">
import { computed, useId } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as toast from "@zag-js/toast";
import Portal from "@/components/ui/portal.vue";
import ToastActor from "./ToastActor.vue";

const props = defineProps({
  toaster: { type: Object, required: true },
});
const toaster = props.toaster as toast.Store;
const id = useId();

const service = useMachine(
  toast.group.machine,
  computed(() => ({ id, store: toaster }))
);
const api = computed(() => toast.group.connect(service, normalizeProps));
</script>

<template>
  <Portal>
    <div v-bind="api.getGroupProps()">
      <ToastActor
        v-for="(item, index) in api.getToasts()"
        :key="item.id"
        :value="item"
        :parent="service"
        :index="index"
      >
        <template #default="{ toast: toastValue }">
          <slot :toast="toastValue" />
        </template>
      </ToastActor>
    </div>
  </Portal>
</template>
 
Expand
components/ui/toast/ToastRoot.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import Frame, { parsePaths } from "@/components/ui/frame.vue";
import { ToastApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(ToastApiKey);
if (!injectedApi) throw new Error("Toast parts must be used within <ToastRoot>'s <Toaster>");
const api = injectedApi;

const framePaths = parsePaths(
  '[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","35","0"],["L","0% + 70.5","0"],["L","0% + 87.5","7"],["L","0% + 81.5","0% + 0"],["L","100% - 96.5","0% + 0"],["L","100% - 91.5","0% + 3"],["L","100% - 86.5","0% + 0"],["L","100% - 32.5","0% + 0"],["L","100% - 18.5","0% + 10"],["L","100% + 0","100% - 16"],["L","100% - 9","100% - 6"],["L","0% + 12","100% - 6"],["L","0","100% - 17.5"],["L","16","0% + 14.5"],["L","35","0"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-2-stroke)","fill":"var(--color-frame-2-fill)"},"path":[["M","20","100% - 6"],["L","100% - 19.5","100% - 6"],["L","100% - 25.5","100% + 0"],["L","26","100% + 0"],["L","20","100% - 6"]]}]'
);
</script>

<template>
  <div
    v-bind="api.getRootProps()"
    class="[translate:var(--x)_var(--y)] [scale:var(--scale)] [z-index:var(--z-index)] [height:var(--height)] [opacity:var(--opacity)] [will-change:translate,scale] [transition:translate_400ms,_scale_400ms,_opacity_400ms] [transition-timing-function:cubic-bezier(0.21,_1.02,_0.73,_1)] data-[state=closed]:[transition:translate_400ms,_scale_400ms,_opacity_200ms] data-[state=closed]:[transition-timing-function:cubic-bezier(0.06,_0.71,_0.55,_1)]"
  >
    <div v-bind="api.getGhostBeforeProps()" />
    <div
      :class="
        twMerge([
          'relative me-1 px-10 py-6 font-orbitron text-sm',
          '[--color-frame-1-stroke:var(--color-primary)]',
          '[--color-frame-1-fill:var(--color-primary)]/20',
          '[--color-frame-2-stroke:var(--color-primary)]',
          '[--color-frame-2-fill:var(--color-primary)]/20',
          '[--color-frame-3-stroke:var(--color-accent)]',
          '[--color-frame-3-fill:var(--color-accent)]/35',
          className,
        ])
      "
    >
      <Frame enable-backdrop-blur enable-view-box :paths="framePaths" />
      <slot />
    </div>
    <div v-bind="api.getGhostAfterProps()" />
  </div>
</template>
 
Expand
components/ui/toast/ToastTitle.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { ToastApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(ToastApiKey);
if (!injectedApi) throw new Error("Toast parts must be used within <ToastRoot>'s <Toaster>");
const api = injectedApi;
</script>

<template>
  <div
    v-bind="api.getTitleProps()"
    :class="
      twMerge([
        'flex items-center text-shadow-lg text-shadow-primary font-bold w-full relative text-nowrap',
        className,
      ])
    "
  >
    <slot />
  </div>
</template>
 
Expand
components/ui/toast/ToastDescription.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { ToastApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(ToastApiKey);
if (!injectedApi) throw new Error("Toast parts must be used within <ToastRoot>'s <Toaster>");
const api = injectedApi;
</script>

<template>
  <div
    v-bind="api.getDescriptionProps()"
    :class="twMerge(['relative pt-2 opacity-80 text-nowrap', className])"
  >
    <slot />
  </div>
</template>
 
Expand
components/ui/toast/ToastCloseTrigger.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import Button from "@/components/ui/button.vue";
import { X } from "@lucide/vue";
import { ToastApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(ToastApiKey);
if (!injectedApi) throw new Error("Toast parts must be used within <ToastRoot>'s <Toaster>");
const api = injectedApi;
</script>

<template>
  <Button
    v-bind="api.getCloseTriggerProps()"
    shape="flat"
    variant="accent"
    enable-view-box
    :class="
      twMerge([
        'absolute right-2 -top-1.5 px-4 py-1.5 transform scale-x-[-1]',
        '[--color-frame-1-fill:var(--color-accent)]/70',
        className,
      ])
    "
  >
    <X class="size-4" />
  </Button>
</template>
 
Expand
components/ui/toast/index.ts
Expand

import * as toast from "@zag-js/toast";

const createToaster = toast.createStore;

export { default as ToasterRoot } from "./ToasterRoot.vue";
export { default as Toaster } from "./ToasterRoot.vue";
export { default as ToastRoot } from "./ToastRoot.vue";
export { default as ToastTitle } from "./ToastTitle.vue";
export { default as ToastDescription } from "./ToastDescription.vue";
export { default as ToastCloseTrigger } from "./ToastCloseTrigger.vue";
export { createToaster };
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import {
  createToaster,
  Toaster,
  ToastRoot,
  ToastTitle,
  ToastDescription,
  ToastCloseTrigger,
} from "@/components/ui/toast";
 
Expand
Expand

const toaster = createToaster({ placement: "bottom-end" });

toaster.create({ title: "Saved", description: "Your changes were saved." });

<Toaster :toaster="toaster">
  <template #default="{ toast }">
    <ToastRoot :key="toast.id">
      <ToastTitle>{{ toast.title }}</ToastTitle>
      <ToastDescription>{{ toast.description }}</ToastDescription>
      <ToastCloseTrigger />
    </ToastRoot>
  </template>
</Toaster>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.