Designing a Push Soft-Ask Bell Icon Widget
Calling Notification.requestPermission() on page load is the single most reliable way to destroy push opt-in rates — browsers penalize it, users dismiss it, and a denied response locks out the origin permanently until the user manually resets permissions.
The bell icon soft-ask pattern solves this by splitting the subscription flow into two explicit steps: a persistent, low-friction affordance (the bell) that users can engage with on their own terms, followed by a soft confirmation dialog, and only then the native browser prompt. Because the native call is deferred until the user clicks “Yes” in your custom UI, it fires within a trusted user gesture context, satisfies browser gesture requirements, and arrives at a moment of demonstrated intent.
Two-Step Pattern: How It Works
Step one renders a small bell icon button in a fixed corner of the viewport. The bell is always visible but never intrusive — it carries no forced overlay, no dimmed background, and no auto-dismissal timer. When the user clicks it, step two begins: a compact tooltip or inline dialog appears with a plain question (“Get notified about updates? Yes / No”) and two explicit actions. Only a “Yes” click proceeds to Notification.requestPermission(). A “No” click dismisses the widget and writes a declined timestamp to localStorage, enabling a 30-day re-prompt cooldown.
This architecture keeps the native browser dialog strictly off the critical path. It also means the entire flow is compatible with Permission Prompt Timing Strategies — you control when the bell appears based on scroll depth or session signals, while still guaranteeing the native prompt only fires on an explicit affirmative gesture.
Accessible Bell Button Markup
The bell button must be keyboard-reachable, screen-reader-describable, and unambiguous in purpose. Using a native <button> element is mandatory — it gets focus management, Enter/Space activation, and implicit role="button" for free. The soft-ask dialog uses role="dialog" with aria-modal="true" and aria-labelledby pointing at the visible heading, ensuring assistive technology announces the dialog title when focus moves into it.
<!-- Bell trigger button — rendered in a fixed bottom-right container -->
<div id="push-bell-container" class="push-bell-container" aria-live="polite">
<button
id="push-bell-btn"
class="push-bell-btn"
aria-label="Get push notifications"
type="button"
>
<!-- Inline SVG bell icon — purely decorative, hidden from AT -->
<svg aria-hidden="true" focusable="false" width="22" height="22" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C12 2 7 5.5 7 11v5H5v2h14v-2h-2v-5c0-5.5-5-9-5-9Z"
fill="currentColor"/>
<path d="M10 20a2 2 0 0 0 4 0" fill="currentColor"/>
</svg>
</button>
<!-- Soft-ask dialog — hidden until bell is clicked -->
<div
id="push-soft-ask"
class="push-soft-ask"
role="dialog"
aria-modal="true"
aria-labelledby="push-soft-ask-title"
hidden
>
<p id="push-soft-ask-title" class="push-soft-ask__title">
Get notified about updates?
</p>
<p class="push-soft-ask__body">
We'll only send relevant alerts. No spam.
</p>
<div class="push-soft-ask__actions">
<button id="push-soft-yes" type="button" class="push-soft-ask__yes">
Yes, notify me
</button>
<button id="push-soft-no" type="button" class="push-soft-ask__no">
No thanks
</button>
</div>
</div>
</div>
The aria-live="polite" on the container lets screen readers announce dynamic changes (such as the dialog appearing) without interrupting ongoing narration. The hidden attribute on the dialog keeps it fully out of the accessibility tree until explicitly shown — display:none via CSS class toggling alone is insufficient if the element is not also removed from the tree.
PushBellWidget JavaScript Class
/**
* PushBellWidget
*
* Manages the two-step push soft-ask bell icon flow.
* Calls Notification.requestPermission() ONLY after an explicit "Yes" click,
* satisfying the user-gesture requirement in Chromium, Firefox, and Safari.
*
* @example
* const widget = new PushBellWidget({ containerId: 'push-bell-container' });
* widget.init();
*/
class PushBellWidget {
constructor(options = {}) {
this.containerId = options.containerId || 'push-bell-container';
this.declinedKey = 'push-soft-declined';
this.cooldownMs = 30 * 24 * 60 * 60 * 1000; // 30 days
this._container = null;
this._bellBtn = null;
this._dialog = null;
this._previousFocus = null;
}
/**
* Entry point. Aborts silently if push is unsupported, already resolved,
* or still within the 30-day cooldown window.
*/
init() {
if (!this._isSupported()) return;
if (Notification.permission === 'granted' || Notification.permission === 'denied') return;
if (this._isInCooldown()) return;
this._container = document.getElementById(this.containerId);
if (!this._container) {
console.warn('PushBellWidget: container element not found:', this.containerId);
return;
}
this.render();
}
/**
* Injects the bell button into the container and wires up event handlers.
*/
render() {
// Bell button
const btn = document.createElement('button');
btn.id = 'push-bell-btn';
btn.className = 'push-bell-btn';
btn.type = 'button';
btn.setAttribute('aria-label', 'Get push notifications');
btn.innerHTML = `
<svg aria-hidden="true" focusable="false" width="22" height="22"
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C12 2 7 5.5 7 11v5H5v2h14v-2h-2v-5c0-5.5-5-9-5-9Z"
fill="currentColor"/>
<path d="M10 20a2 2 0 0 0 4 0" fill="currentColor"/>
</svg>`;
btn.addEventListener('click', () => this.showSoftAsk(), { once: true });
// Soft-ask dialog
const dialog = document.createElement('div');
dialog.id = 'push-soft-ask';
dialog.className = 'push-soft-ask';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal','true');
dialog.setAttribute('aria-labelledby', 'push-soft-ask-title');
dialog.hidden = true;
dialog.innerHTML = `
<p id="push-soft-ask-title" class="push-soft-ask__title">
Get notified about updates?
</p>
<p class="push-soft-ask__body">We'll only send relevant alerts. No spam.</p>
<div class="push-soft-ask__actions">
<button id="push-soft-yes" type="button" class="push-soft-ask__yes">
Yes, notify me
</button>
<button id="push-soft-no" type="button" class="push-soft-ask__no">
No thanks
</button>
</div>`;
this._container.appendChild(btn);
this._container.appendChild(dialog);
this._bellBtn = btn;
this._dialog = dialog;
dialog.querySelector('#push-soft-yes').addEventListener('click', () => this.handleSoftYes());
dialog.querySelector('#push-soft-no').addEventListener('click', () => this.handleSoftNo());
// Dismiss on Escape
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.handleSoftNo();
});
}
/**
* Reveals the soft-ask dialog and moves keyboard focus into it.
*/
showSoftAsk() {
if (!this._dialog) return;
this._previousFocus = document.activeElement;
this._bellBtn.hidden = true;
this._dialog.hidden = false;
// Move focus to the first interactive element in the dialog
const firstBtn = this._dialog.querySelector('button');
if (firstBtn) firstBtn.focus();
}
/**
* Handles the "Yes" click.
* This method is called synchronously from a click event, so
* Notification.requestPermission() executes within a valid user-gesture context.
*/
async handleSoftYes() {
if (!('Notification' in window)) return;
try {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
this._hide();
this._dispatchEvent('push-permission-granted');
// Proceed to subscribe via PushManager.subscribe() here or via callback
} else if (permission === 'denied') {
this._hide();
this._dispatchEvent('push-permission-denied');
} else {
// 'default' — user closed the native dialog without deciding
this._hide();
this._dispatchEvent('push-permission-dismissed');
}
} catch (err) {
// NotAllowedError is thrown in some browsers when gesture context is lost
console.error('PushBellWidget: requestPermission() failed:', err);
this._hide();
}
}
/**
* Handles the "No thanks" click.
* Hides the widget and records a 30-day cooldown timestamp in localStorage.
*/
handleSoftNo() {
this._hide();
try {
localStorage.setItem(
this.declinedKey,
JSON.stringify({ ts: Date.now(), reason: 'soft-declined' })
);
} catch (e) {
// localStorage unavailable (private browsing quota, etc.) — fail silently
}
this._dispatchEvent('push-soft-declined');
}
// ─── Private helpers ────────────────────────────────────────────────────────
_hide() {
if (this._dialog) { this._dialog.hidden = true; }
if (this._bellBtn) { this._bellBtn.hidden = true; }
if (this._previousFocus) { this._previousFocus.focus(); }
}
_isSupported() {
return 'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window;
}
_isInCooldown() {
try {
const raw = localStorage.getItem(this.declinedKey);
if (!raw) return false;
const { ts } = JSON.parse(raw);
return Date.now() - ts < this.cooldownMs;
} catch {
return false;
}
}
_dispatchEvent(name) {
window.dispatchEvent(new CustomEvent(name, { bubbles: true }));
}
}
// Usage
document.addEventListener('DOMContentLoaded', () => {
const widget = new PushBellWidget({ containerId: 'push-bell-container' });
widget.init();
});
Two-Step Implementation Procedure
-
Capability check before rendering. Query
Notification.permission,'serviceWorker' in navigator, and'PushManager' in windowsynchronously at init time. If any check fails or permission is already'granted'or'denied', skip rendering entirely. This prevents wasted DOM nodes and aligns with Silent Permission Checks & Pre-qualification — specifically the technique of detecting denied push permission without prompting. -
Inject the bell button into a fixed container. Use a
position: fixedwrapper at a corner of the viewport (bottom-right is conventional). The button must be a native<button>element withtype="button"and a descriptivearia-label. Never substitute a<div>or<span>— non-button elements require explicitrole="button",tabindex="0", and manual keyboard event handling, all of which are error-prone. -
On bell click, show the soft-ask dialog. Set
hiddentofalseon the dialog element and movedocument.activeElementfocus to the first interactive element inside it. Store the element that held focus before the dialog opened so you can restore it on dismissal. Attach anEscapekey listener within the dialog scope. -
Wire “Yes” to
Notification.requestPermission()within the click handler. The call must remain within the synchronous call stack that originated from the click event. If youawaitother promises before callingrequestPermission(), some browser implementations (notably older Safari) may consider the user gesture expired and throwNotAllowedError. Keep the call direct:const permission = await Notification.requestPermission(). -
Wire “No” to a dismissal + localStorage write. Write
{ ts: Date.now(), reason: 'soft-declined' }under a stable key (e.g.,push-soft-declined). On next page load, read this key at init time and suppress the bell if the timestamp is fewer than 30 days old. The UI Fallbacks & Soft Prompts parent section covers state persistence patterns in more depth. -
Restore focus and dispatch custom events. On any terminal outcome (granted, denied, dismissed, soft-declined), call
previousFocus.focus()and fire aCustomEventonwindow. Downstream code — analytics, preference center routing, fallback email capture — should listen for these events rather than being coupled directly into the widget.
Approach Comparison
| Approach | Triggers native prompt | User can dismiss | Conversion rate impact | Notes |
|---|---|---|---|---|
| Page-load auto-prompt | Immediately on load | Only via browser UI (Allow / Block) | Negative — browsers suppress high-dismiss origins | Violates Chrome permission UI guidelines; may trigger quieter UI or auto-deny |
| Contextual trigger | After a qualifying action (e.g., completing checkout) | Only via browser UI | Moderate positive — intent is demonstrated but UX is abrupt | Better than auto-prompt but still no graceful “not now” path |
| Bell icon soft-ask | Only after bell click + “Yes” in soft dialog | Yes — “No thanks” dismisses without touching native prompt | Strongest positive — users arrive at native prompt fully opted in | Requires two interactions but dramatically reduces denied-origin rate |
Gotchas and Edge Cases
-
requestPermission()called outside a user gesture context. Chromium requires the call to originate from a user-initiated event. WrappingrequestPermission()insidesetTimeout,fetch().then(), or any async boundary that is not itself within a click handler execution context causesNotAllowedErrorin Chromium and silent rejection in Firefox. ThehandleSoftYes()method above must be invoked directly from a click event listener — do not defer it. -
iOS Safari requires an explicit user-initiated gesture on every call. Safari on iOS 16.4+ supports web push in installed PWAs (home screen apps) only. Even in that context,
requestPermission()must be called synchronously within the same call frame as the originating touch event. There is noNotification.permissionproperty on mobile Safari prior to the PWA installation gate, so always guard with'Notification' in windowbefore accessing any property on theNotificationglobal. -
The user clicks “No” in the soft prompt. Write the declined timestamp to
localStorageimmediately. IflocalStorageis unavailable (storage quota exceeded, private browsing in certain browsers), fail silently — do not surface errors to the user. On subsequent page loads, read the timestamp ininit()and skip rendering the bell entirely until the 30-day window has elapsed. Consider a 30-day default with a configurable override via the constructoroptionsobject. -
The user clicks “Yes” in the soft prompt but then dismisses the native browser dialog.
Notification.requestPermission()resolves to'default'(not'denied') when the user closes the native prompt without choosing.'default'means the origin is still in a promptable state — the bell can be re-shown after a shorter cooldown (e.g., 7 days) if you track this outcome separately. Do not treat'default'the same as'denied'. -
The widget is rendered inside a cross-origin iframe.
Notification.requestPermission()requires the frame to have thenotificationsfeature policy enabled:<iframe allow="notifications">. Without it, the call throws immediately. Additionally, push subscriptions are scoped to the top-level origin’s service worker — a cross-origin iframe cannot register a service worker for the parent frame. Avoid embedding this widget inside iframes unless you fully control the iframe src origin.
Related
- Permission Prompt Timing Strategies — when to make the bell visible based on scroll depth and session duration signals
- Ideal page views before showing push prompt — engagement thresholds that predict opt-in likelihood
- Silent Permission Checks & Pre-qualification — gate the bell itself behind a capability and history check
- Opt-Out Preference Centers — where to route users who click “No” in the soft prompt
- Frontend Permission UX & Subscription Flows — complete architecture of the push permission and subscription layer
Parent section: UI Fallbacks & Soft Prompts
FAQ
Can I re-show the bell if the user previously clicked "No thanks"?
Yes, with a mandatory cooldown. Read the push-soft-declined key from localStorage at widget init time and compare Date.now() - ts against your cooldown threshold (30 days is a reasonable default). If the window has elapsed, delete the key and proceed with normal rendering. Never re-show the bell on the same session in which the user declined — that pattern consistently increases unsubscribe and churn rates.
Why use a native <button> for the bell rather than a custom element?
Native <button> elements have zero accessibility setup overhead: they are keyboard-focusable by default, respond to Enter and Space, announce their label via aria-label without extra attributes, and do not require tabindex management. Custom elements (a <div> styled as a button) require role="button", tabindex="0", explicit keydown handlers for Enter/Space, and are still rejected by some automated accessibility audits. The bell widget is a small, single-purpose component — using a native element is unambiguously correct.
What happens if the browser is in a state where it silently blocks permission prompts?
Chromium implements a “quieter notifications” UI for origins with high dismissal rates. In this mode, Notification.requestPermission() still resolves but the native dialog is replaced with a muted chip in the address bar that users rarely notice. You can detect this mode indirectly: if you are calling requestPermission() from a verified user gesture and the result comes back as 'default' (not 'granted' or 'denied') within milliseconds, the quiet UI is likely active. There is no direct API to detect quieter mode. The best prevention is the bell icon pattern itself — low dismissal rates from the soft-ask flow keep origins out of the quieter notifications blocklist.