Show browser notifications from an HTML page

HTML has no notification tag. The browser's Notification API does the job: ask for permission once, then create a notification from JavaScript. The ask has to follow a click.

"Notifications" here means the browser's Notifications API: a web page shows a system notification outside the tab, after the user agrees. It is not an email or a banner inside the page.

In JavaScript it takes two calls: Notification.requestPermission(), then new Notification().

Try the button below. It asks only when you press it, then shows a notification, or a card in the page when that is not possible.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Show a notification</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  button {
    font: inherit; font-weight: 600; padding: 10px 16px; border: 0; border-radius: 8px;
    background: #2563eb; color: #fff; cursor: pointer;
  }
  #state { margin: 12px 0 0; font-size: 14px; }
  #state code { background: #e8ebf0; border-radius: 4px; padding: 1px 5px; }
  #toasts { margin-top: 14px; display: grid; gap: 8px; }
  .toast {
    padding: 10px 12px; border-radius: 10px; background: #fff; font-size: 14px;
    border-left: 4px solid #f97316; box-shadow: 0 4px 14px rgba(0, 0, 0, .1);
  }
  .toast.ok { border-left-color: #16a34a; }
</style>
</head>
<body>
<button id="go">Show a notification</button>
<p id="state"></p>
<div id="toasts" aria-live="polite"></div>

<script>
  const state = document.getElementById('state');
  const toasts = document.getElementById('toasts');

  function showState() {
    const p = 'Notification' in window ? Notification.permission : 'not supported';
    state.innerHTML = 'Permission: <code>' + p + '</code>';
  }

  // A plain on-page card, used whenever a system notification cannot be shown
  function toast(text, ok) {
    const t = document.createElement('div');
    t.className = 'toast' + (ok ? ' ok' : '');
    t.textContent = text;
    toasts.prepend(t);
    setTimeout(() => t.remove(), 8000);
  }

  document.getElementById('go').addEventListener('click', async () => {
    if (!('Notification' in window)) {
      toast('This browser has no Notification API.');
      return;
    }
    let permission = Notification.permission;
    if (permission === 'default') {
      permission = await Notification.requestPermission();  // asks only after a click
      showState();
    }
    if (permission !== 'granted') {
      toast('Permission is "' + permission + '", so no system notification.');
      return;
    }
    try {
      new Notification('Hello from the page', { body: 'This is a system notification.' });
      toast('Sent to the system.', true);
    } catch (err) {
      toast('The constructor threw ' + err.name + '. Phones need a service worker.');
    }
  });

  showState();
</script>
</body>
</html>
One button and a short script. If the browser blocks the system notification, a card appears in the page instead.

Inside this article, the example runs in an embedded frame. MDN says Chrome and Firefox no longer allow notification permission requests from cross-origin iframes, so the prompt may never appear here. That is why the example always has the on-page card.

How the code works: ask, then show

The whole flow is a permission check, an optional request and a constructor call.

button.addEventListener('click', async () => {
  let permission = Notification.permission;
  if (permission === 'default') {
    permission = await Notification.requestPermission();
  }
  if (permission === 'granted') {
    new Notification('Hello', { body: 'Shown by the browser.' });
  }
});

Notification.permission is a string with exactly three possible values. requestPermission() returns a promise that resolves to one of the same three.

The three values of Notification.permission, and what your code does for each.
The three values of Notification.permission, and what your code does for each.

Note that default means the user has not decided. MDN says the browser acts as if it were denied, so a page in that state cannot show anything until it asks.

The older form, requestPermission(callback), is deprecated. Use the promise.

Ask after a click, not on load

A permission prompt that appears the moment a page opens has no context. The reader has not asked for anything yet and cannot tell what the notifications would be for.

A prompt on page load has no reason behind it. A prompt after a click does.
A prompt on page load has no reason behind it. A prompt after a click does.

MDN says to request consent only in response to a user gesture such as a button click. It adds that browsers are moving toward refusing requests that are not triggered that way.

  • Put the request in a click handler. The button label says what the reader gets: "Remind me", "Tell me when it is done".
  • Ask once. Check Notification.permission first and skip the request when it is denied.
  • Keep the page usable without it. A refusal should leave a card, a banner or a status line, not a dead end.

Options: body, tag and more

new Notification(title, options) takes a required title and an optional object. These are the options you will use first.

Option What it does
body The text under the title
tag An ID. A new notification with the same tag replaces the older one
icon URL of an image to show beside the text
silent true asks for no sound or vibration
requireInteraction true keeps the notification until the user dismisses it
data Any value you want to carry along with the notification

The tag is the one that saves users from a pile of alerts. MDN describes it as a way to link notifications so one can be replaced by another instead of filling the screen with similar ones.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notification options and tag</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form { display: grid; gap: 8px; }
  label { font-size: 13px; display: grid; gap: 3px; }
  input[type=text] { font: inherit; padding: 8px 10px; border: 1px solid #c9cdd4; border-radius: 8px; }
  .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
  .row label { display: flex; gap: 6px; align-items: center; }
  button {
    font: inherit; font-weight: 600; padding: 9px 16px; border: 0; border-radius: 8px;
    background: #2563eb; color: #fff; cursor: pointer;
  }
  #note { margin: 10px 0 0; font-size: 13px; color: #5b6270; min-height: 18px; }
  #cards { margin-top: 8px; display: grid; gap: 8px; }
  .card {
    padding: 10px 12px; border-radius: 10px; background: #fff; border-left: 4px solid #2563eb;
    box-shadow: 0 4px 14px rgba(0, 0, 0, .1); font-size: 14px;
  }
  .card b { display: block; }
  .card small { color: #6b7280; }
</style>
</head>
<body>
<form id="f">
  <label>Title <input type="text" id="title" value="New message"></label>
  <label>Body <input type="text" id="body" value="Ana: are we still on for 3?"></label>
  <div class="row">
    <label><input type="checkbox" id="usetag" checked> Use tag "chat"</label>
    <button>Send</button>
  </div>
</form>
<p id="note"></p>
<div id="cards"></div>

<script>
  const cards = document.getElementById('cards');
  const note = document.getElementById('note');
  let count = 0;

  // The preview card shows what you passed. Cards with the same tag replace each other.
  function preview(title, body, tag) {
    count++;
    const old = tag ? cards.querySelector('[data-tag="' + tag + '"]') : null;
    const el = document.createElement('div');
    el.className = 'card';
    if (tag) el.dataset.tag = tag;
    el.innerHTML = '<b></b><span></span><br><small></small>';
    el.querySelector('b').textContent = title;
    el.querySelector('span').textContent = body;
    el.querySelector('small').textContent = '#' + count + (tag ? ' · tag: ' + tag : ' · no tag');
    if (old) old.replaceWith(el); else cards.prepend(el);
  }

  document.getElementById('f').addEventListener('submit', async (e) => {
    e.preventDefault();  // handled in the page, nothing is sent to a server
    const title = document.getElementById('title').value || 'Untitled';
    const body = document.getElementById('body').value;
    const tag = document.getElementById('usetag').checked ? 'chat' : undefined;
    preview(title, body, tag);

    if (!('Notification' in window)) { note.textContent = 'No Notification API here. Preview only.'; return; }
    let permission = Notification.permission;
    if (permission === 'default') permission = await Notification.requestPermission();
    if (permission !== 'granted') { note.textContent = 'Permission is "' + permission + '". Preview only.'; return; }
    try {
      new Notification(title, { body: body, tag: tag });
      note.textContent = 'Also sent to the system.';
    } catch (err) {
      note.textContent = err.name + ' from the constructor. Preview only.';
    }
  });
</script>
</body>
</html>
Send twice with the tag on, then off. The preview cards show the replacing. If permission is granted, the same options go to the system too.

Some combinations throw a TypeError: silent: true together with vibrate, or renotify: true with no tag.

The notification object also fires events. click fires when the user clicks it, and by default focuses the page. Call preventDefault() on the event to stop that. close, show and error fire as well. notification.close() closes it from code.

Phones need a service worker

The examples above use the Notification() constructor. MDN says that is fine on desktop, but that on mobile browsers it can throw a TypeError. For phones it recommends registering a service worker and calling showNotification() on the registration.

The constructor works on desktop. On phones, a registered service worker shows the notification instead.
The constructor works on desktop. On phones, a registered service worker shows the notification instead.

A service worker is registered from the URL of its own script, served from the same site as the page. A single pasted HTML page cannot supply that script, so this part does not run in the examples here. The code looks like this:

// in the page: needs HTTPS and a sw.js file next to it
const reg = await navigator.serviceWorker.register('/sw.js');
if (Notification.permission === 'granted') {
  reg.showNotification('Hello', { body: 'Shown through the worker.' });
}

Until you have that, wrap the constructor in try and catch, as the examples do, and fall back to a card in the page.

A notification while the site is closed is a different job. MDN's Push API page describes it: a server sends the message, and a service worker must be active to receive it.

A finished example: a reminder

Put the pieces together and you have a reminder. Type a message and a delay, press Start, and keep the tab open. When the time is up, the page shows a card and, if permission is granted, a system notification too.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reminder with a notification</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form { display: grid; grid-template-columns: 1fr 90px; gap: 8px; align-items: end; }
  label { font-size: 13px; display: grid; gap: 3px; }
  input { font: inherit; padding: 8px 10px; border: 1px solid #c9cdd4; border-radius: 8px; min-width: 0; }
  button {
    font: inherit; font-weight: 600; padding: 9px 14px; border: 0; border-radius: 8px;
    background: #2563eb; color: #fff; cursor: pointer;
  }
  button.start { grid-column: 1 / -1; }
  button.cancel { background: #e5e7eb; color: #1d2330; padding: 5px 10px; font-size: 13px; }
  #state { margin: 10px 0 4px; font-size: 13px; color: #5b6270; }
  ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
  li {
    display: flex; justify-content: space-between; align-items: center; gap: 8px;
    padding: 8px 10px; border-radius: 10px; background: #fff; font-size: 14px;
  }
  li span { overflow-wrap: anywhere; }
  #toasts { margin-top: 10px; display: grid; gap: 8px; }
  .toast {
    padding: 10px 12px; border-radius: 10px; background: #fff; font-size: 14px;
    border-left: 4px solid #16a34a; box-shadow: 0 4px 14px rgba(0, 0, 0, .1);
  }
</style>
</head>
<body>
<form id="f">
  <label>Remind me to <input id="text" value="Stand up and stretch"></label>
  <label>In (sec) <input id="secs" type="number" min="1" max="3600" value="5"></label>
  <button class="start">Start reminder</button>
</form>
<p id="state"></p>
<ul id="list"></ul>
<div id="toasts" aria-live="polite"></div>

<script>
  const list = document.getElementById('list');
  const toasts = document.getElementById('toasts');
  const state = document.getElementById('state');
  const reminders = [];

  function showState() {
    state.textContent = 'System notifications: ' +
      ('Notification' in window ? Notification.permission : 'not supported');
  }

  function fire(text) {
    // Always show a card in the page, then try the system notification too
    const t = document.createElement('div');
    t.className = 'toast';
    t.textContent = 'Reminder: ' + text;
    toasts.prepend(t);
    if (!('Notification' in window) || Notification.permission !== 'granted') return;
    try {
      const n = new Notification('Reminder', { body: text });
      n.onclick = () => { window.focus(); n.close(); };  // bring the tab back on click
    } catch (err) { /* phones: the constructor throws, the card above is enough */ }
  }

  function label(r) {
    return r.text + ' - ' + Math.max(0, Math.ceil((r.due - Date.now()) / 1000)) + 's';
  }

  // Rebuild the list only when a reminder is added or removed, so Cancel clicks are never lost
  function render() {
    list.innerHTML = '';
    for (const r of reminders) {
      const li = document.createElement('li');
      r.span = document.createElement('span');
      r.span.textContent = label(r);
      const b = document.createElement('button');
      b.className = 'cancel'; b.type = 'button'; b.textContent = 'Cancel';
      b.addEventListener('click', () => { reminders.splice(reminders.indexOf(r), 1); render(); });
      li.append(r.span, b);
      list.append(li);
    }
  }

  document.getElementById('f').addEventListener('submit', async (e) => {
    e.preventDefault();
    const text = document.getElementById('text').value || 'Time is up';
    const secs = Math.min(3600, Math.max(1, Number(document.getElementById('secs').value) || 5));
    // Ask for permission here, inside the click that started the reminder
    if ('Notification' in window && Notification.permission === 'default') {
      await Notification.requestPermission();
      showState();
    }
    reminders.push({ text: text, due: Date.now() + secs * 1000 });
    render();
  });

  // One timer checks every reminder twice a second
  setInterval(() => {
    let changed = false;
    for (const r of reminders.slice()) {
      if (Date.now() >= r.due) { reminders.splice(reminders.indexOf(r), 1); fire(r.text); changed = true; }
      else r.span.textContent = label(r);
    }
    if (changed) render();
  }, 500);

  showState();
</script>
</body>
</html>
Start a reminder for a few seconds. The permission request runs inside the click that starts it.

It uses one setInterval that checks every reminder twice a second. The same timer idea is covered in setTimeout and setInterval. Clicking the system notification calls window.focus() to bring the tab back.

The timer lives in the page. If the tab is closed, the reminder is gone. Pages that need to survive that need a server and a service worker.

When it does not work

What you see Cause Fix
No prompt, nothing appears The page is not in a secure context Serve it over HTTPS
No prompt inside an embedded frame Permission requests from cross-origin iframes are no longer allowed in Chrome and Firefox Open the page on its own, and keep an on-page fallback
Prompt never shows when the page loads The request did not come from a user gesture Call requestPermission() in a click handler
permission is denied The user refused earlier Do not ask again. Show a message in the page
TypeError from new Notification() A mobile browser, or silent with vibrate Use showNotification() through a service worker, or remove one option
Alerts pile up Every notification has a new identity Give related ones the same tag
Reminder never fires The tab was closed, so the timer stopped Keep the tab open, or use the Push API with a server

If a page does nothing at all, JavaScript not working in HTML lists the usual script mistakes first.

A notification page is hard to describe. A screenshot cannot ask for permission, and an .html attachment may not open properly on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can press the buttons themselves. If you change the code later, the same link shows the new version.

Keep the on-page fallback in anything you share. A reader may open the link where the permission prompt is not allowed, and the card is how they still see the result.

The sandbox attribute explains how frames limit a page.

Questions people ask

Is there an HTML tag or attribute for notifications?

No. Notifications come from JavaScript: Notification.requestPermission() asks the user, and new Notification() shows one. The only HTML involved is the button or message you build around it.

Do browser notifications need HTTPS?

Yes. MDN lists the Notification interface as available only in secure contexts, which means HTTPS. Pages that are not served securely cannot rely on it.

Can a page notify the user after the tab is closed?

Not with new Notification(). That code runs in the page, so the page has to be open. Showing a notification while the site is closed takes a service worker plus the Push API, where a server sends the message. That is a separate feature with its own setup.

Why does new Notification() fail on my phone?

MDN notes that the constructor can throw a TypeError on mobile browsers, and recommends registering a service worker and calling showNotification() on the registration instead. Wrap the constructor in try and catch, and keep an on-page message as a fallback.

The user pressed Block. How do I ask again?

Check Notification.permission before asking, as the MDN example does. When it is denied, do not call requestPermission(). Show the message inside the page instead, and explain that notifications are off for the site.

Keep reading