Badge

Display a badge on the app icon

The Badge component provides an interface to the Badging API, enabling your Progressive Web App to display badges on the application icon. These badges are perfect for notifying users of new content, unread messages, pending tasks, or any other count-based information that requires attention.

This component is particularly useful for:

  • Displaying unread message or email counts

  • Showing notification counters

  • Indicating pending tasks or updates

  • Alerting users to new content availability

  • Displaying shopping cart item counts

Browser Support

The Badging API is supported in Chromium-based browsers (Chrome, Edge, Opera) on desktop and Android. The badge appears on the PWA's icon when it's installed on the device.

Badges only appear when the PWA is installed. Users must install your app to their home screen or desktop to see badges.

Usage

Basic Badge Counter

<body {{ stimulus_controller('@pwa/badge') }}>
    <div class="notifications">
        <h2>Notifications</h2>
        <p>You have <span id="notification-count">5</span> new notifications</p>

        <button {{ stimulus_action('@pwa/badge', 'update', 'click', {counter: 5}) }}>
            Set Badge to 5
        </button>

        <button {{ stimulus_action('@pwa/badge', 'clear', 'click') }}>
            Clear Badge
        </button>
    </div>
</body>

<script>
    document.addEventListener('pwa--badge:updated', (event) => {
        console.log('Badge updated:', event.detail.counter);
    });
</script>

Dynamic Badge Update on Page Load

<body
    {{ stimulus_controller('@pwa/badge') }}
    {{ stimulus_action('@pwa/badge', 'update', 'load@window', {counter: 42}) }}
>
    <h1>Welcome Back!</h1>
    <p>You have 42 unread messages</p>
</body>

Real-time Badge Updates

<div {{ stimulus_controller('@pwa/badge') }}>
    <div class="inbox">
        <h2>Inbox</h2>
        <div id="message-list">
            <!-- Messages here -->
        </div>

        <p>Unread messages: <span id="unread-count">0</span></p>
    </div>
</div>

<script>
    let unreadCount = 0;

    // Update badge when new message arrives
    function onNewMessage(message) {
        if (!message.read) {
            unreadCount++;
            updateBadge();
        }
    }

    function onMessageRead(messageId) {
        unreadCount = Math.max(0, unreadCount - 1);
        updateBadge();
    }

    function updateBadge() {
        document.getElementById('unread-count').textContent = unreadCount;

        // Trigger badge update
        const controller = document.querySelector('[data-controller="@pwa/badge"]');
        controller.dispatchEvent(new CustomEvent('update', {
            detail: {
                params: { counter: unreadCount }
            }
        }));
    }

    // Listen for badge updates
    document.addEventListener('pwa--badge:updated', (event) => {
        console.log('Badge now shows:', event.detail.counter);
    });
</script>

Badge with WebSocket Updates

<div {{ stimulus_controller('@pwa/badge') }}>
    <div class="notifications-panel">
        <h2>Live Notifications</h2>
        <div id="notification-list"></div>
    </div>
</div>

<script>
    // Connect to WebSocket for real-time updates
    const ws = new WebSocket('wss://example.com/notifications');
    let notificationCount = 0;

    ws.onmessage = function(event) {
        const data = JSON.parse(event.data);

        if (data.type === 'new_notification') {
            notificationCount++;
            addNotificationToList(data.notification);
            updateAppBadge(notificationCount);
        }
    };

    function updateAppBadge(count) {
        const controller = document.querySelector('[data-controller="@pwa/badge"]');
        if (controller) {
            controller.dispatchEvent(new CustomEvent('update', {
                detail: { params: { counter: count } }
            }));
        }
    }

    function addNotificationToList(notification) {
        const list = document.getElementById('notification-list');
        const item = document.createElement('div');
        item.className = 'notification-item';
        item.textContent = notification.message;
        item.onclick = () => markAsRead(notification.id);
        list.prepend(item);
    }

    function markAsRead(id) {
        notificationCount = Math.max(0, notificationCount - 1);
        updateAppBadge(notificationCount);
        // Send read status to server
        fetch(`/notifications/${id}/read`, { method: 'POST' });
    }
</script>

E-commerce Shopping Cart Badge

<div {{ stimulus_controller('@pwa/badge') }}>
    <header>
        <nav>
            <a href="/cart">
                🛒 Cart
                <span id="cart-count" class="badge-inline">0</span>
            </a>
        </nav>
    </header>
</div>

<script>
    class ShoppingCart {
        constructor() {
            this.items = JSON.parse(localStorage.getItem('cart') || '[]');
            this.updateBadge();
        }

        addItem(product) {
            this.items.push(product);
            this.save();
            this.updateBadge();
        }

        removeItem(productId) {
            this.items = this.items.filter(item => item.id !== productId);
            this.save();
            this.updateBadge();
        }

        save() {
            localStorage.setItem('cart', JSON.stringify(this.items));
        }

        updateBadge() {
            const count = this.items.length;

            // Update inline badge
            document.getElementById('cart-count').textContent = count;

            // Update app icon badge
            const controller = document.querySelector('[data-controller="@pwa/badge"]');
            if (controller) {
                if (count > 0) {
                    controller.dispatchEvent(new CustomEvent('update', {
                        detail: { params: { counter: count } }
                    }));
                } else {
                    controller.dispatchEvent(new CustomEvent('clear'));
                }
            }
        }
    }

    const cart = new ShoppingCart();

    // Example: Add item to cart
    document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
        btn.addEventListener('click', (e) => {
            const product = {
                id: e.target.dataset.productId,
                name: e.target.dataset.productName
            };
            cart.addItem(product);
        });
    });
</script>

Parameters

None

Actions

update

Sets the badge counter to a specified value. The action requires a counter parameter.

Parameters:

  • counter (number): The value to display on the badge. Must be a positive integer.

{{ stimulus_action('@pwa/badge', 'update', 'click', {counter: 10}) }}

clear

Removes the badge from the application icon.

{{ stimulus_action('@pwa/badge', 'clear', 'click') }}

This is useful when:

  • User has read all notifications

  • All tasks are completed

  • Shopping cart is empty

  • Any count-based metric reaches zero

Targets

None

Events

pwa--badge:updated

Dispatched whenever the badge value changes (either updated with a new count or cleared).

Payload: {counter}

  • counter (number|null): The new badge value, or null if the badge was cleared

Example:

document.addEventListener('pwa--badge:updated', (event) => {
    const { counter } = event.detail;

    if (counter === null) {
        console.log('Badge cleared');
    } else {
        console.log('Badge updated to:', counter);
    }

    // Update UI to match badge state
    updateUIBadge(counter);
});

Best Practices

  1. Keep counts accurate: Ensure badge numbers reflect actual unread/pending items

  2. Clear when appropriate: Remove badges when counts reach zero

  3. Use reasonable numbers: Very large numbers may be truncated by the browser

  4. Sync with server: For multi-device users, sync badge counts across devices

  5. Update immediately: Update badges as soon as content changes, not on page reload

  6. Provide context: Combine app icon badges with in-app indicators

  7. Don't overuse: Reserve badges for truly important notifications

Badge vs Push Notifications

While badges and push notifications both alert users, they serve different purposes:

Feature
Badge
Push Notification

Visibility

Always visible on app icon

Temporary notification

Content

Number only

Rich text and images

User interaction

None required

Can be dismissed

Best for

Counts and totals

Time-sensitive alerts

Timing

Persistent until cleared

Appears once

Use both together for the best user experience: push notifications for new content, badges for ongoing counts.

Integration with Service Worker

Badges can be updated from the service worker, enabling background updates:

// In your service worker
self.addEventListener('push', (event) => {
    const data = event.data.json();

    // Update badge
    if (data.unreadCount) {
        navigator.setAppBadge(data.unreadCount);
    }

    // Show notification
    self.registration.showNotification(data.title, {
        body: data.body,
        badge: '/icons/badge.png'
    });
});

Complete Example: Messaging App

<div {{ stimulus_controller('@pwa/badge') }}>
    <div class="messaging-app">
        <header>
            <h1>Messages</h1>
            <span id="unread-indicator"></span>
        </header>

        <div id="conversation-list">
            <!-- Conversations loaded here -->
        </div>

        <button id="mark-all-read">Mark All as Read</button>
    </div>
</div>

<script>
    class MessagingApp {
        constructor() {
            this.unreadConversations = new Set();
            this.controller = document.querySelector('[data-controller="@pwa/badge"]');
            this.loadConversations();
            this.setupEventListeners();
        }

        loadConversations() {
            // Fetch conversations from API
            fetch('/api/conversations')
                .then(res => res.json())
                .then(conversations => {
                    conversations.forEach(conv => {
                        if (conv.unread) {
                            this.unreadConversations.add(conv.id);
                        }
                    });
                    this.updateBadge();
                });
        }

        markConversationRead(conversationId) {
            this.unreadConversations.delete(conversationId);
            this.updateBadge();

            // Notify server
            fetch(`/api/conversations/${conversationId}/read`, {
                method: 'POST'
            });
        }

        markAllRead() {
            this.unreadConversations.clear();
            this.updateBadge();

            // Notify server
            fetch('/api/conversations/mark-all-read', {
                method: 'POST'
            });
        }

        updateBadge() {
            const count = this.unreadConversations.size;

            // Update in-app indicator
            const indicator = document.getElementById('unread-indicator');
            if (count > 0) {
                indicator.textContent = `${count} unread`;
                indicator.className = 'unread-badge';
            } else {
                indicator.textContent = '';
                indicator.className = '';
            }

            // Update app icon badge
            if (count > 0) {
                this.controller.dispatchEvent(new CustomEvent('update', {
                    detail: { params: { counter: count } }
                }));
            } else {
                this.controller.dispatchEvent(new CustomEvent('clear'));
            }
        }

        setupEventListeners() {
            document.getElementById('mark-all-read').addEventListener('click', () => {
                this.markAllRead();
            });

            // Listen for new messages via WebSocket
            const ws = new WebSocket('wss://example.com/messages');
            ws.onmessage = (event) => {
                const message = JSON.parse(event.data);
                if (message.type === 'new_message') {
                    this.unreadConversations.add(message.conversationId);
                    this.updateBadge();
                }
            };
        }
    }

    const app = new MessagingApp();

    // Listen for badge updates
    document.addEventListener('pwa--badge:updated', (event) => {
        console.log('App badge updated:', event.detail.counter);

        // Optional: Store badge count for analytics
        if (event.detail.counter !== null) {
            localStorage.setItem('lastBadgeCount', event.detail.counter);
        }
    });
</script>

Troubleshooting

Badge not appearing

Possible causes:

  1. App not installed: Badges only work for installed PWAs

  2. Browser support: Check if the browser supports the Badging API

  3. Permissions: Some browsers require notification permissions for badges

Badge not updating

Common issues:

  1. Controller not found: Ensure the controller is properly initialized

  2. Invalid counter value: Must be a positive integer or 0

  3. Event not dispatched: Check that events are properly triggered

Badge count incorrect

Solutions:

  1. Sync with server: Fetch accurate count from server on app launch

  2. Persistent storage: Store count in localStorage for consistency

  3. Validate updates: Ensure all increment/decrement operations are correct

Platform-Specific Behavior

  • Chrome/Edge (Desktop): Badge appears as a circle with number

  • Chrome/Edge (Android): Badge shown as notification dot or number

  • Safari: Limited or no support (as of 2024)

  • Firefox: No support (as of 2024)

Always provide in-app indicators as a fallback for all users.

Last updated

Was this helpful?