Show a browser notification from an HTML page

HTML has no notification tag. The browser's Notification API, called from JavaScript, shows a system notification once the user has allowed it. This guide is about that API, not the alert box or a banner inside the page.

Notification HTML is really the browser's Notification API. HTML itself has no tag for it. You ask permission with Notification.requestPermission(), and once the user allows it, new Notification() shows a desktop-style notification outside the page.

Try the smallest version. Press the first button, answer the browser's prompt if it appears, then press the second.

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 basics</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
  button {
    font: inherit; font-weight: 600; padding: 10px 16px; border: 0; border-radius: 8px;
    background: #2563eb; color: #fff; cursor: pointer;
  }
  button.alt { background: #0f766e; }
  .state { font-size: 15px; margin: 0 0 14px; }
  #state { font-weight: 700; padding: 2px 9px; border-radius: 99px; background: #e5e7eb; }
  #state.granted { background: #d6f2df; color: #0f5132; }
  #state.denied { background: #fde2da; color: #9a3412; }
  #toast {
    min-height: 22px; padding: 12px 14px; border-radius: 10px; background: #fff;
    border: 1px dashed #c5cad3; font-size: 14px; color: #4b5563;
  }
  #toast.on { border-style: solid; border-color: #2563eb; color: #1d2330; box-shadow: 0 4px 14px rgba(0, 0, 0, .1); }
</style>
</head>
<body>
<p class="state">Permission: <span id="state">default</span></p>
<div class="row">
  <button id="ask">1. Ask permission</button>
  <button id="send" class="alt">2. Send notification</button>
</div>
<div id="toast" role="status">In-page message appears here when no system notification can be shown.</div>

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

  function inPage(msg) {                       // fallback the page controls itself
    toast.textContent = msg;
    toast.classList.add('on');
  }

  function refresh() {
    const p = 'Notification' in window ? Notification.permission : 'unsupported';
    stateEl.textContent = p;
    stateEl.className = p;
  }
  refresh();

  document.getElementById('ask').addEventListener('click', async () => {
    if (!('Notification' in window)) return inPage('This browser has no Notification API.');
    try {
      await Notification.requestPermission();  // must run inside a click
    } catch (err) {
      inPage('Could not ask: ' + err.message);
    }
    refresh();
  });

  document.getElementById('send').addEventListener('click', () => {
    if ('Notification' in window && Notification.permission === 'granted') {
      try {
        new Notification('Hello from the page', { body: 'This is a system notification.' });
        return;
      } catch (err) { /* some mobile browsers throw here */ }
    }
    inPage('Hello from the page (in-page fallback, permission: ' + stateEl.textContent + ')');
  });
</script>
</body>
</html>
Ask on a click, then send. If no system notification is possible, a message appears in the page instead.

The permission label is read from Notification.permission. In this article's example frame the browser may refuse to ask at all, and the label goes straight to denied.

MDN notes that Chrome and Firefox no longer allow cross-origin iframes to request this permission. The in-page message is there for that case.

How the code works: ask first, then show

Two calls do the whole job. The first needs a user action. The second needs the first to have said yes.

button.addEventListener('click', async () => {
  const result = await Notification.requestPermission();
  if (result === 'granted') {
    new Notification('Hello', { body: 'It works.' });
  }
});
  1. Put the code in a click handler. MDN says the request must be made in response to a user interaction.
  2. Wait for the promise. It resolves to a string with the user's decision.
  3. Create the notification only when the string is granted.

The older form of requestPermission takes a callback. MDN lists that form as deprecated, so use the promise.

The three permission values

Notification.permission is read-only and has three values. The page can look at it at any time, but only the user can change it.

The three values of Notification.permission and what the page should do with each.
The three values of Notification.permission and what the page should do with each.
Value Meaning What your page does
default The user has not decided. The page acts as if it were denied Show a button that asks
granted The user allowed notifications for this origin Call new Notification()
denied The user said no Show the message inside the page

If the user denied it, there is nothing the page can do to override that. The browser keeps the decision per site, and it is changed in the browser's own site settings.

Ask at the moment of a click

A prompt that appears on page load arrives before the reader knows what the page is for. A prompt that follows a button labelled "Remind me in 10 min" arrives with a reason attached.

The same prompt, once on page load with no context and once after a click that explains it.
The same prompt, once on page load with no context and once after a click that explains it.
  • Say why, next to the button. One short line, such as "Tells you when the timer ends."
  • Keep a way forward without it. The reminder still shows on screen if the answer is no.
  • Ask once. After the user has answered, read Notification.permission instead of asking again.

Options and events

new Notification(title, options) takes an options object. Every key is optional. The next example lets you switch three of them on and prints each event.

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 events</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .opts { display: grid; gap: 8px; margin: 0 0 12px; font-size: 14px; }
  .opts label { display: flex; gap: 8px; align-items: center; }
  .row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
  button {
    font: inherit; font-weight: 600; padding: 10px 16px; border: 0; border-radius: 8px;
    background: #2563eb; color: #fff; cursor: pointer;
  }
  button.alt { background: #6b7280; }
  h4 { margin: 4px 0 6px; font-size: 13px; color: #4b5563; text-transform: uppercase; letter-spacing: .4px; }
  pre {
    margin: 0 0 12px; padding: 10px 12px; background: #fff; border: 1px solid #e1e4ea; border-radius: 8px;
    font: 12.5px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-word;
  }
  #log { min-height: 90px; max-height: 150px; overflow: auto; }
</style>
</head>
<body>
<div class="opts">
  <label><input type="checkbox" id="useTag"> tag: "news" (a new one replaces the old one)</label>
  <label><input type="checkbox" id="useSilent"> silent: true (no sound or vibration)</label>
  <label><input type="checkbox" id="useStay"> requireInteraction: true (stays until dismissed)</label>
</div>
<div class="row">
  <button id="ask" class="alt">Ask permission</button>
  <button id="send">Send notification</button>
</div>

<h4>Options sent</h4>
<pre id="shown"></pre>
<h4>Event log</h4>
<pre id="log">Nothing yet.</pre>

<script>
  const $ = (id) => document.getElementById(id);
  const log = $('log');
  let n = 0;

  function line(text) {
    if (log.textContent === 'Nothing yet.') log.textContent = '';
    log.textContent += text + '\n';
    log.scrollTop = log.scrollHeight;
  }

  function buildOptions() {
    n += 1;
    const o = { body: 'Message number ' + n };
    if ($('useTag').checked) o.tag = 'news';
    if ($('useSilent').checked) o.silent = true;
    if ($('useStay').checked) o.requireInteraction = true;
    return o;
  }

  $('ask').addEventListener('click', async () => {
    if (!('Notification' in window)) return line('No Notification API here.');
    try {
      line('requestPermission -> ' + await Notification.requestPermission());
    } catch (err) {
      line('requestPermission failed: ' + err.message);
    }
  });

  $('send').addEventListener('click', () => {
    const options = buildOptions();
    $('shown').textContent = 'new Notification("Update", ' + JSON.stringify(options, null, 1) + ')';
    if (!('Notification' in window)) return line('No Notification API here.');
    try {
      const note = new Notification('Update', options);
      // each event fires on the notification object
      note.addEventListener('show', () => line('show'));
      note.addEventListener('click', () => { line('click'); note.close(); });
      note.addEventListener('close', () => line('close'));
      note.addEventListener('error', () => line('error (permission is "' + Notification.permission + '")'));
    } catch (err) {
      line('constructor threw ' + err.name);
    }
  });
</script>
</body>
</html>
Tick the options, then send. The event log shows what the notification reports back.
Option What it does
body The text shown under the title
tag A label. A new notification with the same tag replaces the old one
silent When true, no sound or vibration, whatever the device settings are
requireInteraction When true, it stays until the user clicks or dismisses it
icon The URL of an icon to show

The notification object also fires four events: show, click, close and error. By default a click moves focus to the page that created the notification. Call preventDefault() on the click event to stop that. close() removes a notification from code.

If permission is not granted when you call the constructor, the specification says an error event is queued. In a headless Chromium test, a page with permission denied logged exactly that, with no exception. Check Notification.permission first, and listen for error while you build.

Phones and the constructor

MDN's guide says the constructor is fine on desktop but throws a TypeError in most mobile browsers. For mobile it points to a service worker and showNotification().

The constructor route on desktop, and the service worker route that mobile browsers expect.
The constructor route on desktop, and the service worker route that mobile browsers expect.

A service worker is a separate script file, and registering one needs a secure context. That cannot live inside one pasted HTML page. It looks like this across two files:

// page.js
await navigator.serviceWorker.register('/sw.js');
const registration = await navigator.serviceWorker.ready;
registration.showNotification('Hello', { body: 'From a service worker.' });
// sw.js
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
});

In a single HTML file, wrap the constructor in try/catch and fall back to a message in the page. Every example here does that, so the page never breaks.

A finished example: a break reminder

This one puts the pieces together. You pick a time, press Start, and when the countdown hits zero the page shows a banner, marks the tab title, and sends a notification if you allowed it.

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>Break reminder</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .card { background: #fff; border-radius: 14px; padding: 18px; box-shadow: 0 6px 20px rgba(0, 0, 0, .1); }
  .time { font: 700 44px/1 ui-monospace, Consolas, monospace; margin: 4px 0 14px; }
  .row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
  select, button { font: inherit; padding: 9px 12px; border-radius: 8px; border: 1px solid #c5cad3; background: #fff; }
  button { border: 0; background: #2563eb; color: #fff; font-weight: 600; cursor: pointer; }
  button.alt { background: #6b7280; }
  .meta { font-size: 13px; color: #4b5563; margin: 0 0 12px; }
  #banner {
    display: none; padding: 12px 14px; border-radius: 10px; background: #fff7e6;
    border: 1px solid #f2d28b; font-weight: 600;
  }
  #banner.on { display: block; }
</style>
</head>
<body>
<div class="card">
  <div class="time" id="time">00:10</div>
  <div class="row">
    <label for="len">Remind me in</label>
    <select id="len">
      <option value="5">5 seconds</option>
      <option value="10" selected>10 seconds</option>
      <option value="30">30 seconds</option>
    </select>
    <button id="start">Start</button>
    <button id="ask" class="alt">Allow notifications</button>
  </div>
  <p class="meta" id="meta">Permission: default</p>
  <div id="banner" role="status">Time for a break. Stand up and stretch.</div>
</div>

<script>
  const $ = (id) => document.getElementById(id);
  const baseTitle = document.title;
  let timer = null;

  const fmt = (s) => String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
  const perm = () => ('Notification' in window ? Notification.permission : 'unsupported');
  const showPerm = () => { $('meta').textContent = 'Permission: ' + perm(); };
  showPerm();
  $('time').textContent = fmt(Number($('len').value));
  $('len').addEventListener('change', () => { $('time').textContent = fmt(Number($('len').value)); });

  $('ask').addEventListener('click', async () => {
    if ('Notification' in window) {
      try { await Notification.requestPermission(); } catch (e) { /* keep going */ }
    }
    showPerm();
  });

  function done() {
    $('banner').classList.add('on');          // always works, even if notifications are blocked
    document.title = '(!) ' + baseTitle;
    if (perm() === 'granted') {
      try {
        const n = new Notification('Break time', { body: 'Your timer is up.', tag: 'break' });
        n.addEventListener('click', () => { window.focus(); n.close(); });
      } catch (e) { /* constructor can throw on some mobile browsers */ }
    }
  }

  $('start').addEventListener('click', () => {
    clearInterval(timer);
    $('banner').classList.remove('on');
    document.title = baseTitle;
    let left = Number($('len').value);
    $('time').textContent = fmt(left);
    timer = setInterval(() => {
      left -= 1;
      $('time').textContent = fmt(left);
      if (left <= 0) { clearInterval(timer); done(); }
    }, 1000);
  });
</script>
</body>
</html>
The banner and the title mark always work. The system notification is added when permission is granted.

The order matters. The in-page banner comes first, so the reminder is never lost. The notification is an extra for people who have allowed it and switched to another tab. It uses a tag, so a second reminder replaces the first instead of stacking up.

Notifications need a secure context. MDN describes that as HTTPS, and its secure contexts page also lists localhost and file: URLs. For a page you share, check window.isSecureContext if you need to know. What HTTPS is explains the rest.

When it does not work

What you see Cause Fix
No prompt appears requestPermission() ran on page load, not on a click Move it into a click handler
No prompt inside an embedded example Cross-origin iframes cannot request the permission in Chrome and Firefox Open the page on its own, and keep the in-page fallback
permission is denied The user declined, now or earlier Ask them to allow it in the browser's site settings
No notification and no exception Permission was not granted, so an error event fired Check Notification.permission; listen for error
TypeError on a phone The constructor is not supported in most mobile browsers Use try/catch, or a service worker
The second one replaces the first Both share a tag Remove the tag, or give each its own
It vanishes by itself Notifications close on their own by default Set requireInteraction: true

For the click handlers themselves, see addEventListener. For frames and what they block, see the sandbox attribute. Another API that asks first and needs a secure page is Geolocation.

A notification demo is hard to show with a screenshot, and an .html attachment may not open at all on a phone. Opening an HTML file on a phone covers why.

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 in-page fallback in whatever you share. If a browser does not allow the prompt in that setting, the banner still tells the reader what would have happened.

Questions people ask

Is there an HTML tag for notifications?

No. A notification comes from JavaScript: new Notification(title, options). The only HTML involved is a button the user clicks, because the permission request has to follow a user action.

Why does the permission prompt not appear?

MDN says the request must be made in response to a user interaction, so call requestPermission() from a click handler. MDN also notes that Chrome and Firefox no longer allow notification permission to be requested from cross-origin iframes, so open the page on its own to test.

Do I need a service worker?

Not for the examples here, which create the notification from the page's own script. A service worker is the other route, and MDN points to it for mobile browsers. It is a separate JavaScript file that needs a secure context, so it does not fit in a single HTML file.

Why do I get a TypeError on my phone?

MDN says the Notification constructor throws a TypeError in nearly all mobile browsers. Wrap the call in try/catch and show a message inside the page instead, or register a service worker and call showNotification() on its registration.

What is the difference between this and alert()?

MDN describes alert() as a modal dialog: the browser waits until the user dismisses it. A notification needs the user's permission first, and MDN describes it as a way to provide information asynchronously, without waiting.

Keep reading