Skip to content

Toast

A transient notification with a status icon that auto-dismisses after a timeout or when closed.

A transient notification with a status icon that auto-dismisses after a timeout or when closed. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-toast
"use client"

import * as React from "react"

import {
  Toast,
  ToastClose,
  ToastContent,
  ToastDescription,
  ToastIcon,
  ToastTitle,
} from "@/registry/bases/newt/ui/toast"

export default function ToastDemo() {
  const [key, setKey] = React.useState(0)

  return (
    <div className="flex flex-col items-start gap-4">
      <Toast key={key} variant="success" duration={5000}>
        <ToastIcon />
        <ToastContent>
          <ToastTitle>Success title</ToastTitle>
          <ToastDescription>Description text.</ToastDescription>
        </ToastContent>
        <ToastClose />
      </Toast>
      <button
        type="button"
        className="rounded-sm bg-newt-brand px-3 py-1.5 text-sm font-medium text-white transition-colors duration-fast ease-newt hover:bg-newt-brand-hover"
        onClick={() => setKey((k) => k + 1)}
      >
        Show toast again
      </button>
    </div>
  )
}
<script setup lang="ts">
import { ref } from "vue"
import {
  Toast,
  ToastClose,
  ToastContent,
  ToastDescription,
  ToastIcon,
  ToastTitle,
} from "~~/registry/bases/newt/ui/toast"

const key = ref(0)
</script>

<template>
  <div class="flex flex-col items-start gap-4">
    <Toast :key="key" variant="success" :duration="5000">
      <ToastIcon />
      <ToastContent>
        <ToastTitle>Success title</ToastTitle>
        <ToastDescription>Description text.</ToastDescription>
      </ToastContent>
      <ToastClose />
    </Toast>
    <button
      type="button"
      class="rounded-sm bg-newt-brand px-3 py-1.5 text-sm font-medium text-white transition-colors duration-fast ease-newt hover:bg-newt-brand-hover"
      @click="key++"
    >
      Show toast again
    </button>
  </div>
</template>

Installation

pnpm dlx newtui@latest add toast
npx newtui@latest add toast
yarn dlx newtui@latest add toast
bunx newtui@latest add toast

Install the dependencies

pnpm dlx --yes class-variance-authority
npx --yes class-variance-authority
yarn dlx --yes class-variance-authority
bunx --yes class-variance-authority

Copy the source into your project

"use client"

import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"

import { cn } from "@/lib/utils"

const toastVariants = cva(
  "flex max-w-[360px] items-start gap-3 rounded-md border border-newt-border bg-newt-bg-elevated px-3.5 py-3 text-newt-text-primary shadow-elevation-high",
  {
    variants: {
      variant: {
        success: "",
        error: "",
      },
    },
    defaultVariants: {
      variant: "success",
    },
  }
)

const toastIconVariants = cva(
  "flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs text-white",
  {
    variants: {
      variant: {
        success: "bg-newt-online",
        error: "bg-newt-dnd",
      },
    },
    defaultVariants: {
      variant: "success",
    },
  }
)

export interface ToastProps
  extends
    React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof toastVariants> {
  /** Auto-dismiss after this many ms. Defaults to 5000. Pass 0 to disable. */
  duration?: number
  /** Controlled open state. */
  open?: boolean
  /** Called when the toast closes, either by timeout or the close button. */
  onOpenChange?: (open: boolean) => void
}

const Toast = React.forwardRef<HTMLDivElement, ToastProps>(
  (
    {
      className,
      variant,
      duration = 5000,
      open,
      onOpenChange,
      children,
      ...props
    },
    ref
  ) => {
    const [internalOpen, setInternalOpen] = React.useState(true)
    const isControlled = open !== undefined
    const isOpen = isControlled ? open : internalOpen

    const close = React.useCallback(() => {
      if (!isControlled) setInternalOpen(false)
      onOpenChange?.(false)
    }, [isControlled, onOpenChange])

    React.useEffect(() => {
      if (!isOpen || duration <= 0) return
      const id = window.setTimeout(close, duration)
      return () => window.clearTimeout(id)
    }, [isOpen, duration, close])

    if (!isOpen) return null

    return (
      <ToastContext.Provider value={{ variant: variant ?? "success", close }}>
        <div
          ref={ref}
          role="status"
          aria-live="polite"
          data-variant={variant ?? "success"}
          className={cn(toastVariants({ variant }), className)}
          {...props}
        >
          {children}
        </div>
      </ToastContext.Provider>
    )
  }
)
Toast.displayName = "Toast"

type ToastContextValue = {
  variant: NonNullable<VariantProps<typeof toastVariants>["variant"]>
  close: () => void
}
const ToastContext = React.createContext<ToastContextValue>({
  variant: "success",
  close: () => {},
})

const ToastIcon = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
  const { variant } = React.useContext(ToastContext)
  return (
    <div
      ref={ref}
      aria-hidden="true"
      className={cn(toastIconVariants({ variant }), className)}
      {...props}
    >
      {children ?? (variant === "success" ? "✓" : "!")}
    </div>
  )
})
ToastIcon.displayName = "ToastIcon"

const ToastContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn("min-w-0 flex-1", className)} {...props} />
))
ToastContent.displayName = "ToastContent"

const ToastTitle = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("text-sm font-semibold", className)}
    {...props}
  />
))
ToastTitle.displayName = "ToastTitle"

const ToastDescription = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      "mt-0.5 text-[13px] text-[var(--newt-text-secondary)]",
      className
    )}
    {...props}
  />
))
ToastDescription.displayName = "ToastDescription"

const ToastClose = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, onClick, children, ...props }, ref) => {
  const { close } = React.useContext(ToastContext)
  return (
    <button
      ref={ref}
      type="button"
      aria-label="Dismiss"
      className={cn(
        "shrink-0 cursor-pointer border-0 bg-transparent p-0 text-base leading-none text-newt-text-muted transition-colors duration-fast ease-newt hover:text-newt-text-primary",
        className
      )}
      onClick={(e) => {
        onClick?.(e)
        if (!e.defaultPrevented) close()
      }}
      {...props}
    >
      {children ?? "×"}
    </button>
  )
})
ToastClose.displayName = "ToastClose"

export {
  Toast,
  ToastIcon,
  ToastContent,
  ToastTitle,
  ToastDescription,
  ToastClose,
  toastVariants,
  toastIconVariants,
}
<script setup lang="ts">
import {
  computed,
  onBeforeUnmount,
  provide,
  ref,
  watch,
  type HTMLAttributes,
} from "vue"

import { cn } from "@/lib/utils"

import { TOAST_INJECTION_KEY, toastVariants, type ToastVariants } from "."

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    variant?: ToastVariants["variant"]
    /** Auto-dismiss after this many ms. 0 disables. */
    duration?: number
    open?: boolean
  }>(),
  { variant: "success", duration: 5000, open: undefined }
)

const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()

const internalOpen = ref(true)
const isControlled = computed(() => props.open !== undefined)
const isOpen = computed(() =>
  isControlled.value ? props.open : internalOpen.value
)

function close() {
  if (!isControlled.value) internalOpen.value = false
  emit("update:open", false)
}

let timer: ReturnType<typeof setTimeout> | undefined
function arm() {
  if (timer) clearTimeout(timer)
  timer = undefined
  if (isOpen.value && props.duration > 0)
    timer = setTimeout(close, props.duration)
}
watch([isOpen, () => props.duration], arm, { immediate: true })
onBeforeUnmount(() => timer && clearTimeout(timer))

provide(TOAST_INJECTION_KEY, {
  variant: computed(() => props.variant ?? "success"),
  close,
})
</script>

<template>
  <div
    v-if="isOpen"
    role="status"
    aria-live="polite"
    :data-variant="props.variant"
    :class="cn(toastVariants({ variant: props.variant }), props.class)"
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import { computed, inject, type HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { TOAST_INJECTION_KEY, toastIconVariants } from "."

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const ctx = inject(TOAST_INJECTION_KEY, null)
const variant = computed(() => ctx?.variant.value ?? "success")
</script>

<template>
  <div
    aria-hidden="true"
    :class="cn(toastIconVariants({ variant }), props.class)"
  >
    <slot>{{ variant === "success" ? "✓" : "!" }}</slot>
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div :class="cn('min-w-0 flex-1', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div :class="cn('text-sm font-semibold', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div
    :class="
      cn('mt-0.5 text-[13px] text-[var(--newt-text-secondary)]', props.class)
    "
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import { inject, type HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { TOAST_INJECTION_KEY } from "."

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const ctx = inject(TOAST_INJECTION_KEY, null)
</script>

<template>
  <button
    type="button"
    aria-label="Dismiss"
    :class="
      cn(
        'shrink-0 cursor-pointer border-0 bg-transparent p-0 text-base leading-none text-newt-text-muted transition-colors duration-fast ease-newt hover:text-newt-text-primary',
        props.class
      )
    "
    @click="ctx?.close()"
  >
    <slot>&times;</slot>
  </button>
</template>
import { cva, type VariantProps } from "class-variance-authority"
import type { ComputedRef, InjectionKey } from "vue"

export { default as Toast } from "./Toast.vue"
export { default as ToastIcon } from "./ToastIcon.vue"
export { default as ToastContent } from "./ToastContent.vue"
export { default as ToastTitle } from "./ToastTitle.vue"
export { default as ToastDescription } from "./ToastDescription.vue"
export { default as ToastClose } from "./ToastClose.vue"

export const toastVariants = cva(
  "flex max-w-[360px] items-start gap-3 rounded-md border border-newt-border bg-newt-bg-elevated px-3.5 py-3 text-newt-text-primary shadow-elevation-high",
  {
    variants: {
      variant: {
        success: "",
        error: "",
      },
    },
    defaultVariants: {
      variant: "success",
    },
  }
)

export const toastIconVariants = cva(
  "flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs text-white",
  {
    variants: {
      variant: {
        success: "bg-newt-online",
        error: "bg-newt-dnd",
      },
    },
    defaultVariants: {
      variant: "success",
    },
  }
)

export type ToastVariants = VariantProps<typeof toastVariants>

export interface ToastContext {
  variant: ComputedRef<NonNullable<ToastVariants["variant"]>>
  close: () => void
}

export const TOAST_INJECTION_KEY: InjectionKey<ToastContext> =
  Symbol("newt-toast")

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { Toast } from "@/components/ui/toast"

export function Example() {
  return <Toast />
}
<script setup lang="ts">
import { Toast, ToastIcon, ToastContent, ToastTitle, ToastDescription, ToastClose } from "@/components/ui/toast"
</script>

<template>
  <Toast />
</template>

API reference

Toast

PropType
duration?number | undefined

Auto-dismiss after this many ms. Defaults to 5000. Pass 0 to disable.

Typenumber | undefined
open?boolean | undefined

Controlled open state.

Typeboolean | undefined
onOpenChange?(open: boolean) => void | undefined

Called when the toast closes, either by timeout or the close button.

Type(open: boolean) => void | undefined

The registry exports Toast, ToastIcon, ToastContent, ToastTitle, ToastDescription, ToastClose. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Use role="status" for routine updates and role="alert" only for urgent failures. Do not move focus into a toast, and give its close button an accessible name.

Tokens

Toast reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-toast.