Notification Badge
A badge that anchors itself to the top-right of whatever you put in the default
slot, and reacts to the count changing. It is a thin wrapper: the badge itself is
a Nuxt UI UBadge. The badge scales and fades in with AnimatePresence when the
count rises above zero, and on every change the previous number slides out
vertically while the new one slides in.
Two rules come straight from the source. count <= 0 hides the badge entirely
— there is no "zero" state to render — and count > max is clamped to a max+
label so an unread inbox never grows past a fixed width.
The whole wrapper is a UTooltip, so anything you pass as tooltip becomes the
trigger's description on hover and on keyboard focus.
Usage
Wrap any element — an icon button, an avatar, a nav link — and feed it count.
The badge is positioned absolutely, so it never reflows the element underneath.
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="flex items-center justify-between rounded-2xl border p-4">
<div class="flex items-center gap-3">
<MNotificationBadge
:count="count"
:tooltip="tooltip"
>
<UButton
icon="i-lucide-bell"
color="neutral"
variant="outline"
aria-label="Notifications"
/>
</MNotificationBadge>
<span class="text-sm font-medium">
Inbox
</span>
</div>
<span class="text-xs text-muted tabular-nums">
{{ badgeLabel }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
label="New message"
icon="i-lucide-plus"
size="sm"
@click="count++"
/>
<UButton
label="Overflow"
icon="i-lucide-chevrons-up"
size="sm"
variant="outline"
color="neutral"
@click="count = 150"
/>
<UButton
label="Mark all read"
icon="i-lucide-check-check"
size="sm"
variant="outline"
color="neutral"
@click="count = 0"
/>
</div>
</div>
</template>
<script setup lang="ts">
const count = ref(3)
const badgeLabel = computed(() => (count.value > 99 ? '99+' : String(count.value)))
const tooltip = computed(() => {
if (count.value === 0)
return 'No unread messages'
return `${count.value} unread ${count.value === 1 ? 'message' : 'messages'}`
})
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | - | The number the badge represents. Required. At 0 or below the badge is not
rendered at all, and the enter/exit animation does not play. |
tooltip | string | '' | Text for the UTooltip that wraps the whole thing, so hovering or focusing
the anchored element describes the count in words. Defaults to empty, which
leaves the trigger with nothing extra announced. |
max | number | 99 | Upper bound for the label. Anything above it renders as `${max}+`. It
only affects the text — the badge is still rendered at its full width. |
color | string | 'error' | Forwarded straight to UBadge's color, so any Nuxt UI colour alias works
(error, success, info, primary, or a raw hex value). |
badgeSize | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'sm' | Forwarded to UBadge's size. The wrapper's own padding and
min-w-[1.25rem] are fixed regardless, so larger sizes grow the badge but not
the offset from the corner. |
Slots
| Slot | Props | Description |
|---|---|---|
default | - | The element the badge anchors to. Rendered inside a relative inline-flex
wrapper, so give it whatever size you like — the badge positions itself
against that wrapper. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.notificationBadge in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative inline-flex |
badge | - | absolute -top-2 -right-2 z-10 |
badgeContent | - | rounded-full px-1.5 min-w-[1.25rem] h-5 flex items-center justify-center |
Accessibility
The badge is plain text inside the wrapper, with no role and no live region
of its own. Because the wrapper is also the tooltip's trigger, that text becomes
part of the trigger's accessible name — a button containing this component reads
as something like "3 Notifications", and a change from 3 to 4 is silent to a
screen reader. Announce the change yourself if it matters.
No reduced-motion branch exists here. The badge is animated by a fixed spring
(stiffness: 500, damping: 20), and that spring runs whatever the user's
preference is. It is short and small, but it is not opt-out.
Performance
- The badge subtree exists only while
count > 0;AnimatePresencekeeps it mounted just long enough to play the exit. - The animation is
transform-only (scaleon show/hide and a smallyslide per count change) plusopacity, and the badge isposition: absolute, so neither entering nor updating reflows the element underneath. - No timers, no listeners, no per-frame work. The only reactive computation is
the
displayTextclamp.
color is typed as a plain string in the component's own props, so a typo is
not caught by the type checker — it fails silently and the badge falls back to
UBadge's default colour. If the badge is the wrong colour, check the string
before anything else.