Lena Ortiz
2mCan you bring the latency numbers to the review?
A bell with a live badge and a panel of per-app stacks that fan open, swipe away and clear in a cascade.
Acme Console
Notifications
Messages 3
Lena Ortiz
2mCan you bring the latency numbers to the review?
Marcus Lee
18mRamen on Friday? 🍜
Priya Nair
1hShared “Q3 metrics.pdf” with you.
Deploys 2
Production deploy succeeded
6mweb@4f2a91c is live in 3 regions.
Preview ready
2hfeat/notification-center → preview-218
Calendar
Design crit in 15 minutes
25mRoom 4B · Checkout v4
Open the bell, tap a stack to fan it out, drag a card sideways to dismiss.
<script setup lang="ts">
import type { NotificationCenterItem } from '@/components/ui/notification-center'
import { NotificationCenter } from '@/components/ui/notification-center'
import { CalendarDays, MessageSquare, Plus, Rocket } from 'lucide-vue-next'
import { ref } from 'vue'
const icons = { Messages: MessageSquare, Calendar: CalendarDays, Deploys: Rocket } as const
const notifications = ref<NotificationCenterItem[]>([
{ id: 1, app: 'Messages', title: 'Lena Ortiz', body: 'Can you bring the latency numbers to the review?', time: '2m', avatar: 'https://i.pravatar.cc/96?img=45' },
{ id: 2, app: 'Deploys', title: 'Production deploy succeeded', body: 'web@4f2a91c is live in 3 regions.', time: '6m' },
{ id: 3, app: 'Messages', title: 'Marcus Lee', body: 'Ramen on Friday? 🍜', time: '18m', avatar: 'https://i.pravatar.cc/96?img=53' },
{ id: 4, app: 'Calendar', title: 'Design crit in 15 minutes', body: 'Room 4B · Checkout v4', time: '25m' },
{ id: 5, app: 'Messages', title: 'Priya Nair', body: 'Shared “Q3 metrics.pdf” with you.', time: '1h', avatar: 'https://i.pravatar.cc/96?img=32' },
{ id: 6, app: 'Deploys', title: 'Preview ready', body: 'feat/notification-center → preview-218', time: '2h' },
])
const samples = [
{ app: 'Messages', title: 'Kenji Tanaka', body: 'Pushed the fix, mind taking a look?', avatar: 'https://i.pravatar.cc/96?img=12' },
{ app: 'Calendar', title: 'Standup moved to 10:30', body: 'Updated by Lena Ortiz' },
{ app: 'Deploys', title: 'Build failed on main', body: 'Type error in checkout/Summary.vue' },
]
const open = ref(true)
let next = 100
function push() {
const s = samples[next % samples.length]!
notifications.value = [{ id: next++, time: 'now', ...s }, ...notifications.value]
}
</script>
<template>
<div class="w-full max-w-xl">
<div class="flex items-center justify-between rounded-2xl border bg-card px-4 py-3 shadow-sm">
<div class="flex items-center gap-2">
<span class="size-6 rounded-md bg-gradient-to-br from-emerald-400 to-sky-500" />
<p class="m-0 text-sm font-semibold">
Acme Console
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="push"
>
<Plus class="size-3.5" /> Simulate
</button>
<NotificationCenter v-model:notifications="notifications" v-model:open="open">
<template #icon="{ app }">
<component :is="icons[app as keyof typeof icons]" class="size-4" />
</template>
</NotificationCenter>
</div>
</div>
<p class="m-0 mt-3 text-center text-xs text-muted-foreground">
Open the bell, tap a stack to fan it out, drag a card sideways to dismiss.
</p>
</div>
</template>pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/notification-center.jsonInstall the dependencies:
npm install motion-vThen copy these files into components/ui/notification-center/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { PanInfo } from 'motion-v'
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useAnimate, useReducedMotion } from 'motion-v'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
export interface NotificationCenterItem {
id: string | number
/** Notifications are grouped (and stacked) by app. */
app: string
title: string
body?: string
/** Pre-formatted, e.g. "2m ago". */
time?: string
read?: boolean
/** Image shown instead of the app icon (e.g. a sender avatar). */
avatar?: string
}
const props = withDefaults(defineProps<{
/** Accessible name of the bell and panel. */
label?: string
/** Cards visible in a collapsed stack. */
stack?: number
/** Which edge of the bell the panel aligns to. */
align?: 'start' | 'end'
/** Mark everything as read when the panel closes. */
markReadOnClose?: boolean
class?: HTMLAttributes['class']
panelClass?: HTMLAttributes['class']
}>(), {
label: 'Notifications',
stack: 3,
align: 'end',
markReadOnClose: true,
})
const notifications = defineModel<NotificationCenterItem[]>('notifications', { default: () => [] })
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
dismiss: [item: NotificationCenterItem]
clear: []
}>()
defineSlots<{
/** App icon for a group / card. */
icon?: (props: { app: string, item?: NotificationCenterItem }) => any
empty?: () => any
}>()
const SPRING = { type: 'spring', stiffness: 420, damping: 34 } as const
const PEEK = 8
const panelId = `vx-notif-${useId()}`
const reduced = useReducedMotion()
const unread = computed(() => notifications.value.filter(n => !n.read).length)
const groups = computed(() => {
const map = new Map<string, NotificationCenterItem[]>()
for (const n of notifications.value) {
if (!map.has(n.app))
map.set(n.app, [])
map.get(n.app)!.push(n)
}
return [...map].map(([app, items]) => ({ app, items }))
})
const expanded = ref<string[]>([])
const isOpen = (app: string, count: number) => count === 1 || expanded.value.includes(app)
function toggleGroup(app: string) {
expanded.value = expanded.value.includes(app) ? expanded.value.filter(a => a !== app) : [...expanded.value, app]
}
/** Transform of a card inside a collapsed stack. */
function stackPose(i: number, openGroup: boolean) {
if (openGroup)
return { y: 0, scale: 1, opacity: 1 }
const depth = Math.min(i, props.stack - 1)
return { y: depth * PEEK, scale: 1 - depth * 0.05, opacity: i < props.stack ? 1 - depth * 0.15 : 0 }
}
// ---------------------------------------------------------------------------
// Dismissing
// ---------------------------------------------------------------------------
const exitDir = ref<Record<string | number, number>>({})
const clearing = ref(false)
function dismiss(item: NotificationCenterItem, dir = 1) {
exitDir.value[item.id] = dir
notifications.value = notifications.value.filter(n => n.id !== item.id)
emit('dismiss', item)
}
function onDragEnd(item: NotificationCenterItem, info: PanInfo) {
if (Math.abs(info.offset.x) > 110 || Math.abs(info.velocity.x) > 600)
dismiss(item, Math.sign(info.offset.x || info.velocity.x))
}
function clearGroup(app: string) {
notifications.value = notifications.value.filter(n => n.app !== app)
expanded.value = expanded.value.filter(a => a !== app)
}
async function clearAll() {
clearing.value = true
await nextTick() // let the staggered exit props render before the items leave
notifications.value = []
expanded.value = []
emit('clear')
setTimeout(() => (clearing.value = false), 900)
document.getElementById(panelId)?.focus()
}
function groupExit(gi: number) {
return clearing.value
? { x: 80, opacity: 0, transition: { delay: gi * 0.07, duration: 0.28, ease: [0.4, 0, 1, 1] } }
: { opacity: 0, scale: 0.96, transition: { duration: 0.18 } }
}
// ---------------------------------------------------------------------------
// Open / close
// ---------------------------------------------------------------------------
const root = ref<HTMLElement>()
const bell = ref<HTMLButtonElement>()
function onDocPointer(e: PointerEvent) {
if (!root.value?.contains(e.target as Node))
open.value = false
}
function onDocKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
open.value = false
bell.value?.focus()
}
}
/** Nudge the panel sideways so it never hangs off a narrow screen. */
const nudge = ref(0)
function fitToViewport(el: HTMLElement | null) {
if (!el)
return
// Measure from the anchored edge: the panel may still be mid scale-in.
const rect = el.getBoundingClientRect()
const right = (props.align === 'end' ? rect.right : rect.left + el.offsetWidth) - nudge.value
const left = right - el.offsetWidth
const max = document.documentElement.clientWidth - 8
nudge.value = left < 8 ? 8 - left : right > max ? max - right : 0
}
function listen(on: boolean) {
const method = on ? 'addEventListener' : 'removeEventListener'
document[method]('pointerdown', onDocPointer as EventListener, true)
document[method]('keydown', onDocKey as EventListener)
}
// Open from the start (e.g. v-model:open="true"): listen, but don't steal focus.
onMounted(() => {
if (!open.value)
return
listen(true)
requestAnimationFrame(() => fitToViewport(document.getElementById(panelId)))
})
watch(open, async (v) => {
if (typeof document === 'undefined')
return
listen(v)
if (v) {
await nextTick()
// The panel mounts inside AnimatePresence, so look it up once it's in the DOM.
requestAnimationFrame(() => {
const el = document.getElementById(panelId)
el?.focus()
fitToViewport(el)
})
}
else {
expanded.value = []
if (props.markReadOnClose && unread.value)
notifications.value = notifications.value.map(n => (n.read ? n : { ...n, read: true }))
}
})
onBeforeUnmount(() => typeof document !== 'undefined' && listen(false))
// A little ring when new notifications arrive.
const [bellScope, animateBell] = useAnimate()
watch(unread, (now, before) => {
if (now > before && bellScope.value && !reduced.value)
animateBell(bellScope.value, { rotate: [0, -16, 13, -9, 6, -2, 0] }, { duration: 0.7 })
})
function initials(app: string) {
return app.slice(0, 2)
}
</script>
<template>
<div ref="root" :class="cn('relative inline-flex', props.class)">
<button
ref="bell"
type="button"
:aria-label="unread ? `${props.label}, ${unread} unread` : props.label"
aria-haspopup="dialog"
:aria-expanded="open"
:aria-controls="open ? panelId : undefined"
class="relative inline-flex size-10 items-center justify-center rounded-full border bg-card text-foreground shadow-xs outline-none transition-colors hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="open = !open"
>
<span ref="bellScope" class="inline-flex origin-top">
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /></svg>
</span>
<AnimatePresence>
<motion.span
v-if="unread"
key="badge"
aria-hidden="true"
class="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center overflow-hidden rounded-full bg-destructive px-1 text-[11px] font-semibold tabular-nums text-white ring-2 ring-background"
:initial="{ scale: 0 }"
:animate="{ scale: 1 }"
:exit="{ scale: 0 }"
:transition="{ type: 'spring', stiffness: 600, damping: 22 }"
>
<AnimatePresence mode="popLayout" :initial="false">
<motion.span
:key="unread"
:initial="{ y: 10, opacity: 0 }"
:animate="{ y: 0, opacity: 1 }"
:exit="{ y: -10, opacity: 0 }"
:transition="SPRING"
>
{{ unread > 99 ? '99+' : unread }}
</motion.span>
</AnimatePresence>
</motion.span>
</AnimatePresence>
</button>
<AnimatePresence>
<motion.div
v-if="open"
:id="panelId"
key="panel"
role="dialog"
:aria-label="props.label"
tabindex="-1"
:class="cn(
'absolute top-full z-50 mt-2 w-[min(360px,calc(100vw-2rem))] overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-xl outline-none',
props.align === 'end' ? 'right-0 origin-top-right' : 'left-0 origin-top-left',
props.panelClass,
)"
:style="nudge ? { translate: `${nudge}px 0` } : undefined"
:initial="{ opacity: 0, scale: 0.94, y: -6 }"
:animate="{ opacity: 1, scale: 1, y: 0 }"
:exit="{ opacity: 0, scale: 0.96, y: -4, transition: { duration: 0.15 } }"
:transition="SPRING"
>
<div class="flex items-center justify-between border-b px-4 py-3">
<p class="m-0 text-sm font-semibold">
{{ props.label }}
</p>
<button
v-if="notifications.length"
type="button"
class="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="clearAll"
>
Clear all
</button>
</div>
<motion.div class="max-h-[420px] overflow-y-auto overflow-x-hidden p-3" layout-scroll>
<AnimatePresence>
<motion.section
v-for="(group, gi) in groups"
:key="group.app"
layout="position"
:aria-label="`${group.app}, ${group.items.length} notifications`"
class="mb-3 last:mb-0"
:exit="groupExit(gi)"
:transition="SPRING"
>
<div class="flex h-7 items-center gap-2 px-1">
<p class="m-0 flex-1 truncate text-xs font-semibold text-muted-foreground">
{{ group.app }}
<span v-if="group.items.length > 1" class="ml-1 font-normal tabular-nums opacity-80">{{ group.items.length }}</span>
</p>
<button
v-if="group.items.length > 1 && expanded.includes(group.app)"
type="button"
:aria-expanded="true"
class="rounded-md px-1.5 py-0.5 text-xs text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="toggleGroup(group.app)"
>
Show less
</button>
<button
type="button"
:aria-label="`Clear ${group.app} notifications`"
class="inline-flex size-6 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="clearGroup(group.app)"
>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div
class="relative flex flex-col gap-2"
:style="{ paddingBottom: isOpen(group.app, group.items.length) ? '0px' : `${Math.min(group.items.length - 1, props.stack - 1) * PEEK}px` }"
>
<AnimatePresence :initial="false">
<motion.article
v-for="(item, i) in group.items"
:key="item.id"
layout
:drag="isOpen(group.app, group.items.length) ? 'x' : false"
:drag-snap-to-origin="true"
:drag-elastic="0.7"
:class="cn(
'group/card flex touch-pan-y items-start gap-3 rounded-xl border bg-card p-3 text-card-foreground shadow-xs',
!isOpen(group.app, group.items.length) && i > 0 && 'pointer-events-none absolute inset-x-0 top-0',
)"
:style="{
zIndex: group.items.length - i,
bottom: !isOpen(group.app, group.items.length) && i > 0 ? `${Math.min(group.items.length - 1, props.stack - 1) * PEEK}px` : undefined,
}"
:aria-hidden="!isOpen(group.app, group.items.length) && i > 0 ? true : undefined"
:initial="{ opacity: 0, y: -12, scale: 0.96 }"
:animate="stackPose(i, isOpen(group.app, group.items.length))"
:exit="{ x: (exitDir[item.id] ?? 1) * 360, opacity: 0, transition: { duration: 0.25 } }"
:transition="SPRING"
@drag-end="(_e: PointerEvent, info: PanInfo) => onDragEnd(item, info)"
>
<motion.div
class="flex min-w-0 flex-1 items-start gap-3"
:animate="{ opacity: !isOpen(group.app, group.items.length) && i > 0 ? 0 : 1 }"
:transition="{ duration: 0.18 }"
>
<span class="relative mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-xs font-semibold uppercase text-muted-foreground">
<img v-if="item.avatar" :src="item.avatar" alt="" class="size-full object-cover" draggable="false">
<slot v-else name="icon" :app="group.app" :item="item">{{ initials(group.app) }}</slot>
</span>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2">
<p class="m-0 min-w-0 flex-1 truncate text-sm font-medium">
{{ item.title }}
</p>
<span v-if="item.time" class="shrink-0 text-xs text-muted-foreground">{{ item.time }}</span>
<span v-if="!item.read" class="size-2 shrink-0 self-center rounded-full bg-brand" aria-label="Unread" />
</div>
<p v-if="item.body" class="m-0 mt-0.5 line-clamp-2 text-sm text-muted-foreground">
{{ item.body }}
</p>
</div>
</motion.div>
<button
v-if="isOpen(group.app, group.items.length)"
type="button"
:aria-label="`Dismiss: ${item.title}`"
class="-mr-1 -mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 outline-none transition-[opacity,color] hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 group-hover/card:opacity-100 [@media(hover:none)]:opacity-100"
@click="dismiss(item)"
>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</motion.article>
</AnimatePresence>
<button
v-if="!isOpen(group.app, group.items.length)"
type="button"
:aria-label="`Show ${group.items.length} ${group.app} notifications`"
:aria-expanded="false"
class="absolute inset-0 z-[60] cursor-pointer rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="toggleGroup(group.app)"
/>
</div>
</motion.section>
</AnimatePresence>
<AnimatePresence>
<motion.div
v-if="!notifications.length"
key="empty"
class="flex flex-col items-center gap-2 px-4 py-10 text-center"
:initial="{ opacity: 0, y: 8 }"
:animate="{ opacity: 1, y: 0, transition: { delay: clearing ? 0.35 : 0 } }"
>
<slot name="empty">
<span class="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
</span>
<p class="m-0 text-sm font-medium">
You're all caught up
</p>
<p class="m-0 text-xs text-muted-foreground">
New notifications will show up here.
</p>
</slot>
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
</AnimatePresence>
</div>
</template>export { default as NotificationCenter } from './NotificationCenter.vue'
export type { NotificationCenterItem } from './NotificationCenter.vue'import { NotificationCenter, type NotificationCenterItem } from '@/components/ui/notification-center'
const notifications = ref<NotificationCenterItem[]>([
{ id: 1, app: 'Messages', title: 'Lena Ortiz', body: 'Running 5 min late', time: '2m' },
{ id: 2, app: 'Deploys', title: 'Production deploy succeeded', time: '6m' },
])<NotificationCenter v-model:notifications="notifications">
<template #icon="{ app }">
<component :is="appIcons[app]" class="size-4" />
</template>
</NotificationCenter>Put the newest notification first. Notifications are grouped by app; a group with more than one item collapses into a stack — click (or press Enter on) it to fan the cards out. Expanded cards can be dragged sideways or closed with their × button. Escape or a click outside closes the panel, and everything is marked as read when it closes.
| Prop | Type | Default | Description |
|---|---|---|---|
v-model:notifications | NotificationCenterItem[] | [] | The notifications, newest first. |
v-model:open | boolean | false | Whether the panel is open. |
label | string | 'Notifications' | Accessible name of the bell and panel title. |
stack | number | 3 | Cards visible in a collapsed stack. |
align | 'start' | 'end' | 'end' | Which edge of the bell the panel lines up with. |
markReadOnClose | boolean | true | Set read: true on every item when the panel closes. |
panelClass | string | — | Extra classes for the panel. |
NotificationCenterItem: { id: string \| number, app: string, title: string, body?: string, time?: string, read?: boolean, avatar?: string }.
| Event | Payload | Description |
|---|---|---|
dismiss | (item) | A single notification was swiped or closed. |
clear | — | "Clear all" was pressed. |
| Slot | Props | Description |
|---|---|---|
icon | { app, item? } | App icon shown when an item has no avatar. |
empty | — | Content of the empty state. |
Designed by Kuldeep Pisda · Released under the MIT License