Notification
The panel behind a toast when the message needs more than a line: an avatar, a couple of buttons, a choice. Static on its own, or in the toast stack through notify().
Successfully saved!
Anyone with a link can now view this file.
import { Notification } from '@/components/notification'
export default function Example({ onClose }) {
return (
<Notification
type="success"
title="Successfully saved!"
description="Anyone with a link can now view this file."
onClose={onClose}
/>
)
}<script setup lang="ts">
import Notification from '@/components/notification.vue'
</script>
<template>
<Notification
type="success"
title="Successfully saved!"
description="Anyone with a link can now view this file."
@close="onClose"
/>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/24/outline'
import { XMarkIcon } from '@heroicons/react/20/solid'
import clsx from 'clsx'
import type React from 'react'
import { Link } from './link'
const typeIcons = {
success: <CheckCircleIcon aria-hidden="true" className="size-6 text-green-500" />,
info: <InformationCircleIcon aria-hidden="true" className="size-6 text-blue-500" />,
warning: <ExclamationTriangleIcon aria-hidden="true" className="size-6 text-amber-500" />,
error: <XCircleIcon aria-hidden="true" className="size-6 text-red-500" />,
}
const actionStyles = {
accent:
'cursor-pointer text-sm/6 font-medium text-accent-600 hover:text-accent-700 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 dark:text-accent-400 dark:hover:text-accent-300',
plain:
'cursor-pointer text-sm/6 font-medium text-zinc-700 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 dark:text-zinc-300 dark:hover:text-white',
}
export type SplitAction = {
label: React.ReactNode
onClick?: () => void
href?: string
plain?: boolean
}
export type NotificationProps = {
type?: keyof typeof typeIcons
icon?: React.ReactNode
media?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
actions?: React.ReactNode
splitActions?: SplitAction[]
onClose?: () => void
}
export function Notification({ className, ...props }: NotificationProps & { className?: string }) {
return (
<div
className={clsx(
className,
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-zinc-950/10 dark:bg-zinc-800 dark:ring-white/10 dark:ring-inset'
)}
>
<NotificationContent {...props} />
</div>
)
}
export function NotificationContent({
type,
icon,
media,
title,
description,
action,
actions,
splitActions,
onClose,
}: NotificationProps) {
let leading = media ?? icon ?? (type && typeIcons[type])
return (
<div className={clsx(splitActions && 'flex')}>
<div className={clsx('p-4', splitActions && 'w-0 flex-1')}>
<div className="flex items-start">
{leading && <div className="shrink-0">{leading}</div>}
<div className={clsx('w-0 flex-1', leading && 'ml-3')}>
<p className="text-sm/6 text-zinc-950 dark:text-white font-medium">{title}</p>
{description && <p className="mt-1 text-sm/6 text-zinc-500 dark:text-zinc-400">{description}</p>}
{actions && <div className="mt-3 flex items-center gap-4">{actions}</div>}
</div>
{action && <div className="ml-3 flex shrink-0">{action}</div>}
{onClose && (
<div className="ml-4 flex shrink-0">
<button
type="button"
onClick={onClose}
className="inline-flex size-6 cursor-pointer items-center justify-center rounded-md text-zinc-400 hover:text-zinc-500 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:hover:text-white"
>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</button>
</div>
)}
</div>
</div>
{splitActions && (
<div className="flex flex-col divide-y divide-zinc-950/10 border-l border-zinc-950/10 dark:divide-white/10 dark:border-white/10">
{splitActions.map(({ label, plain, href, onClick }, index) => {
let className = clsx(
'flex flex-1 items-center justify-center px-4 py-3 whitespace-nowrap focus-visible:-outline-offset-2',
plain ? actionStyles.plain : actionStyles.accent
)
return href ? (
<Link key={index} href={href} className={className} onClick={onClick}>
{label}
</Link>
) : (
<button key={index} type="button" className={className} onClick={onClick}>
{label}
</button>
)
})}
</div>
)}
</div>
)
}
export function NotificationAction({
plain = false,
className,
href,
...props
}: { plain?: boolean; href?: string } & Omit<React.ComponentPropsWithoutRef<'button'>, 'type'>) {
let classes = clsx(className, 'rounded-sm focus-visible:outline-offset-2', plain ? actionStyles.plain : actionStyles.accent)
return href ? (
<Link {...(props as Omit<React.ComponentPropsWithoutRef<typeof Link>, 'href'>)} href={href} className={classes} />
) : (
<button type="button" className={classes} {...props} />
)
}<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import NotificationContent, { type NotificationType, type SplitAction } from './notification-content.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
type?: NotificationType
title: string
description?: string
splitActions?: SplitAction[]
}>()
const attrs = useAttrs()
const contentAttrs = computed(() => {
const { class: _class, style: _style, ...rest } = attrs
return rest
})
</script>
<template>
<div
:class="[
attrs.class,
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-zinc-950/10 dark:bg-zinc-800 dark:ring-white/10 dark:ring-inset',
]"
:style="attrs.style as string | undefined"
>
<NotificationContent v-bind="{ ...props, ...contentAttrs }">
<template v-for="(_, name) in $slots" #[name]="scope">
<slot :name="name" v-bind="scope ?? {}" />
</template>
</NotificationContent>
</div>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Notificationrenders a <div> element | ||
title | - | The first line. |
description | - | The second line, in zinc. |
type | - | Picks the icon: `success`, `info`, `warning`, `error`. |
icon | - | Any icon instead of the type, sized `size-6`. |
media | - | An avatar in place of the icon. |
action | - | One `NotificationAction` on the right of the title, for a single verb. |
actions | - | Buttons under the description: `NotificationAction`s or `Button`s. |
splitActions | - | `{ label, onClick, href, plain }[]`, buttons in a column on the right, edge to edge. |
onClose | - | Renders the cross and handles it. |
NotificationActionrenders a <button> element | ||
plain | false | Zinc instead of the accent color, for the way out. |
href | - | Renders a link instead of a button. |
| notify(props, options) | ||
props | - | The Notification props. The cross and `toast.dismiss(id)` are wired for you. |
options | - | Sonner options: `duration`, `id`, `dismissible`, `onDismiss`. |
returns | - | The toast id, to dismiss it from an action. |
| Prop | Default | Description |
|---|---|---|
Notificationrenders a <div> element | ||
title | - | The first line. |
description | - | The second line, in zinc. |
type | - | Picks the icon: `success`, `info`, `warning`, `error`. |
#icon | - | Any icon instead of the type, sized `size-6`. |
#media | - | An avatar in place of the icon. |
#action | - | One `NotificationAction` on the right of the title, for a single verb. |
#actions | - | Buttons under the description: `NotificationAction`s or `Button`s. |
splitActions | - | `{ label, onClick, href, plain }[]`, buttons in a column on the right, edge to edge. |
@close | - | Listen to it and the cross appears. |
NotificationActionrenders a <button> element | ||
plain | false | Zinc instead of the accent color, for the way out. |
href | - | Renders a link instead of a button. |
| notify(props, options) | ||
props | - | The Notification props, with the slots as render functions: `media: () => h(Avatar, …)`. The cross and `toast.dismiss(id)` are wired for you. |
options | - | Sonner options: `duration`, `id`, `dismissible`, `onDismiss`. |
returns | - | The toast id, to dismiss it from a button. |
Examples
Basic example
A type for the icon, a title, a description, and the cross when the user can close it. The panel is the same as a toast, so the two sit together in a stack:
Successfully saved!
Anyone with a link can now view this file.
import { Notification } from '@/components/notification'
export default function Example({ onClose }) {
return (
<Notification
type="success"
title="Successfully saved!"
description="Anyone with a link can now view this file."
onClose={onClose}
/>
)
}<script setup lang="ts">
import Notification from '@/components/notification.vue'
</script>
<template>
<Notification
type="success"
title="Successfully saved!"
description="Anyone with a link can now view this file."
@close="onClose"
/>
</template>Condensed
One line and one verb. action puts a NotificationAction on the right of the title:
Discussion archived
<Notification
title="Discussion archived"
action={<NotificationAction onClick={restore}>Undo</NotificationAction>}
onClose={onClose}
/><Notification title="Discussion archived" @close="onClose">
<template #action>
<NotificationAction @click="restore">Undo</NotificationAction>
</template>
</Notification>With actions below
When the message needs a sentence before the choice, actions puts the buttons under the description. plain marks the way out:
Discussion moved
It now lives in the Archived folder with the other closed threads.
<Notification
icon={<InboxIcon aria-hidden="true" className="size-6 text-zinc-400" />}
title="Discussion moved"
description="It now lives in the Archived folder with the other closed threads."
actions={
<>
<NotificationAction onClick={undo}>Undo</NotificationAction>
<NotificationAction plain onClick={onClose}>Dismiss</NotificationAction>
</>
}
onClose={onClose}
/><Notification
title="Discussion moved"
description="It now lives in the Archived folder with the other closed threads."
@close="onClose"
>
<template #icon>
<InboxIcon aria-hidden="true" class="size-6 text-zinc-400" />
</template>
<template #actions>
<NotificationAction @click="undo">Undo</NotificationAction>
<NotificationAction plain @click="onClose">Dismiss</NotificationAction>
</template>
</Notification>With an avatar
media replaces the icon with an avatar, for a message from someone. Here the reply is a split button, edge to edge on the right:
Emilia Gates
Sure! 8:30pm works great!
<Notification
media={<Avatar src={message.author.avatar} className="size-10" />}
title={message.author.name}
description={message.text}
splitActions={[{ label: 'Reply', href: message.href }]}
/><Notification
:title="message.author.name"
:description="message.text"
:split-actions="[{ label: 'Reply', href: message.href }]"
>
<template #media>
<Avatar :src="message.author.avatar" class="size-10" />
</template>
</Notification>With split buttons
splitActions stacks the choices in a column on the right, separated by a rule. For a yes or no the user has to make:
Receive notifications
Notifications may include alerts, sounds, and badges.
<Notification
title="Receive notifications"
description="Notifications may include alerts, sounds, and badges."
splitActions={[
{ label: 'Allow', onClick: allow },
{ label: "Don't allow", plain: true, onClick: onClose },
]}
/><Notification
title="Receive notifications"
description="Notifications may include alerts, sounds, and badges."
:split-actions="[
{ label: 'Allow', onClick: allow },
{ label: 'Don\'t allow', plain: true, onClick: onClose },
]"
/>With buttons below
Real Buttons in actions when the choice deserves them:
Emilia Gates
Sent you an invite to connect.
<Notification
media={<Avatar src={invite.from.avatar} className="size-10" />}
title={invite.from.name}
description="Sent you an invite to connect."
actions={
<>
<Button onClick={accept}>Accept</Button>
<Button outline onClick={decline}>Decline</Button>
</>
}
onClose={onClose}
/><Notification :title="invite.from.name" description="Sent you an invite to connect." @close="onClose">
<template #media>
<Avatar :src="invite.from.avatar" class="size-10" />
</template>
<template #actions>
<Button @click="accept">Accept</Button>
<Button outline @click="decline">Decline</Button>
</template>
</Notification>As a toast
notify() takes the same props and puts the panel in the toast stack, with the timer, the swipe and the cross wired. Keep the id to close it from a button:
import { Button } from '@/components/button'
import { notify, toast } from '@/components/toast'
function invite(from) {
let id = notify({
media: <Avatar src={from.avatar} className="size-10" />,
title: from.name,
description: 'Sent you an invite to connect.',
actions: (
<>
<Button onClick={() => accept(id)}>Accept</Button>
<Button outline onClick={() => toast.dismiss(id)}>Decline</Button>
</>
),
})
}import Button from '@/components/button.vue'
import { notify, toast } from '@/components/toast'
import { h } from 'vue'
function invite(from) {
const id = notify({
media: () => h(Avatar, { src: from.avatar, class: 'size-10' }),
title: from.name,
description: 'Sent you an invite to connect.',
actions: () => [
h(Button, { onClick: () => accept(id) }, () => 'Accept'),
h(Button, { outline: true, onClick: () => toast.dismiss(id) }, () => 'Decline'),
],
})
}