Getting Started
IntroductionHow to Use
Components
FrameMenuAlertAccordionDialogTabsToastNewButtonInputSwitchTextareaRadio GroupCheckboxChartComboboxSelectNewAlert DialogSoonAvatarSoonBreadcrumbSoonButton GroupSoonCalendarSoonDatepickerSoonDaterangepickerSoonSheetSoonNumber InputSoonPaginationSoonTableSoon

Alert

Displays a callout for user attention.
Success! Your changes have been saved
This is an alert with icon, title and description.
Installation
Expand

npx @left4code/cosmic-ui-cli@latest add alert
 
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/presence @zag-js/vue

This uses the sharedusePresencecomposable (see theMenupage's Installation section forcomponents/ui/presence.ts) to keep the alert mounted through its exit animation instead of vanishing the instant it's dismissed. Vue SFCs can't export multiple components from one file, so Alert is a folder instead of a single file — copy all of it into your project.

components/ui/presence.ts
Expand

import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as presence from "@zag-js/presence";

/**
 * Keeps a node mounted through its exit animation.
 *
 * `open` flips to false immediately, but `present` stays true until the
 * node's CSS `animationend` fires (detected via the returned ref callback),
 * so exit animations (`data-[state=closed]:animate-out` etc.) actually get
 * to play instead of the node disappearing on the same frame.
 */
function usePresence(open: MaybeRefOrGetter<boolean>) {
  const service = useMachine(
    presence.machine,
    computed(() => ({ present: toValue(open) }))
  );
  const api = computed(() => presence.connect(service, normalizeProps));

  return {
    present: computed(() => api.value.present),
    setNode: (node: HTMLElement | null) => api.value.setNode(node),
  };
}

export { usePresence };
 
Expand
components/ui/alert/context.ts
Expand

import type { InjectionKey, Ref } from "vue";

const AlertPresentKey: InjectionKey<Ref<boolean>> = Symbol("alert-present");

export { AlertPresentKey };
 
Expand
components/ui/alert/AlertRoot.vue
Expand

<script setup lang="ts">
import { ref, provide, useAttrs, computed } from "vue";
import { twMerge } from "tailwind-merge";
import Frame, { parsePaths } from "@/components/ui/frame.vue";
import { usePresence } from "@/components/ui/presence";
import { AlertPresentKey } from "./context";

defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as { class?: string };
const attrsWithoutClass = computed(() => {
  const { class: _class, ...rest } = rawAttrs;
  return rest;
});

const present = ref(true);
provide(AlertPresentKey, present);

const presence = usePresence(() => present.value);

const framePaths = parsePaths(
  '[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","0% + 34","7"],["L","0% + 79.5","7"],["L","0% + 96.5","13"],["L","100% - 21.5","13"],["L","100% + 0","34"],["L","100% - 13","100% - 15"],["L","100% - 26","100% - 6"],["L","0% + 11.5","100% - 6"],["L","0","100% - 18"],["L","13","0% + 28"],["L","34","7"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-2-stroke)","fill":"var(--color-frame-2-fill)"},"path":[["M","18","100% - 6"],["L","100% - 33.5","100% - 6"],["L","100% - 39.5","100% - 0"],["L","24","100% + 0"],["L","18","100% - 6"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-3-stroke)","fill":"var(--color-frame-3-fill)"},"path":[["M","17","7"],["L","0% + 26.5","7"],["L","0% + 12.5","0% + 20"],["L","13","0% + 11"],["L","17","7"]]}]'
);
</script>

<template>
  <div
    v-bind="attrsWithoutClass"
    :ref="presence.setNode"
    :hidden="!presence.present.value"
    :data-state="present ? 'open' : 'closed'"
    :class="
      twMerge([
        'relative px-10 pt-8 pb-6.5 w-full [&>svg]:drop-shadow-[0_0px_20px_var(--color-primary)]',
        'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-200',
        '[--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:transparent]',
        '[--color-frame-3-stroke:var(--color-accent)]',
        '[--color-frame-3-fill:var(--color-accent)]/50',
        rawAttrs.class,
      ])
    "
  >
    <Frame :paths="framePaths" />
    <slot />
  </div>
</template>
 
Expand
components/ui/alert/AlertTitle.vue
Expand

<script setup lang="ts">
import { useAttrs, computed } from "vue";
import { twMerge } from "tailwind-merge";

defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as { class?: string };
const attrsWithoutClass = computed(() => {
  const { class: _class, ...rest } = rawAttrs;
  return rest;
});
</script>

<template>
  <div
    v-bind="attrsWithoutClass"
    :class="
      twMerge([
        'flex items-center text-shadow-lg text-shadow-primary font-bold w-full relative',
        rawAttrs.class,
      ])
    "
  >
    <slot />
  </div>
</template>
 
Expand
components/ui/alert/AlertDescription.vue
Expand

<script setup lang="ts">
import { useAttrs, computed } from "vue";
import { twMerge } from "tailwind-merge";

defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as { class?: string };
const attrsWithoutClass = computed(() => {
  const { class: _class, ...rest } = rawAttrs;
  return rest;
});
</script>

<template>
  <div v-bind="attrsWithoutClass" :class="twMerge(['relative pt-2 opacity-80', rawAttrs.class])">
    <slot />
  </div>
</template>
 
Expand
components/ui/alert/AlertCloseTrigger.vue
Expand

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

defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as { class?: string };
const attrsWithoutClass = computed(() => {
  const { class: _class, ...rest } = rawAttrs;
  return rest;
});

const present = inject(AlertPresentKey);
if (!present) throw new Error("AlertCloseTrigger must be used within <AlertRoot>");
</script>

<template>
  <Button
    v-bind="attrsWithoutClass"
    shape="flat"
    variant="accent"
    :class="
      twMerge([
        'absolute -right-1 top-2 px-5 py-1.5 transform scale-x-[-1]',
        '[--color-frame-1-fill:var(--color-accent)]/70',
        rawAttrs.class,
      ])
    "
    @click="present = false"
  >
    <X class="size-4" />
  </Button>
</template>
 
Expand
components/ui/alert/index.ts
Expand

export { default as AlertRoot } from "./AlertRoot.vue";
export { default as AlertTitle } from "./AlertTitle.vue";
export { default as AlertDescription } from "./AlertDescription.vue";
export { default as AlertCloseTrigger } from "./AlertCloseTrigger.vue";
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import { AlertRoot, AlertTitle, AlertDescription, AlertCloseTrigger } from "@/components/ui/alert";
 
Expand
Expand

<AlertRoot>
  <AlertTitle>
    <SquareCheck class="flex-none size-4.5 me-2.5" /> Success! Your
    changes have been saved
  </AlertTitle>
  <AlertDescription>
    This is an alert with icon, title and description.
  </AlertDescription>
  <AlertCloseTrigger />
</AlertRoot>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.