Electron and HTML: put a web page in a desktop window

Electron is a framework that shows an HTML file in its own desktop window. Your page stays ordinary HTML, CSS and JavaScript, and two small extra files open the window.

Here, Electron means the desktop app framework, not the particle. It bundles Chromium and Node.js, so a folder with an index.html can open in its own window on Windows, macOS and Linux. The HTML is the same HTML you would put on a web page.

Start with the page itself, since that is the part you write. This is the kind of index.html an Electron window loads. It runs here in a browser, and it checks for a desktop helper before using 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>Electron index.html</title>
<style>
  body {
    margin: 0; padding: 16px; font-family: system-ui, sans-serif;
    background: #f4f5f7; color: #1d2330;
  }
  h1 { font-size: 20px; margin: 0 0 6px; }
  p { margin: 0 0 12px; line-height: 1.45; }
  #info {
    padding: 12px 14px; border-radius: 10px; background: #fff;
    border: 1px solid #e1e4ea; font: 14px/1.5 ui-monospace, Consolas, monospace;
  }
  #info.desktop { border-color: #9ad2ad; background: #f4fbf6; }
  button {
    margin-top: 12px; padding: 9px 14px; font: inherit; border: 0;
    border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer;
  }
</style>
</head>
<body>
<h1>Hello from index.html</h1>
<p>The same file opens in a browser tab or in an Electron window.</p>
<div id="info"></div>
<button id="fake" type="button">Pretend the preload script ran</button>

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

  // In an Electron app, a preload script exposes window.versions. In a browser it does not exist.
  function show() {
    if (window.versions) {
      info.className = 'desktop';
      info.textContent = 'Inside a desktop shell. Chrome ' + window.versions.chrome() +
        ', Electron ' + window.versions.electron();
    } else {
      info.className = '';
      info.textContent = 'Plain browser: window.versions is undefined.';
    }
  }

  document.getElementById('fake').addEventListener('click', () => {
    // Stand-in for what preload.js would expose. The values are placeholders.
    window.versions = { chrome: () => '(from preload)', electron: () => '(from preload)' };
    show();
  });

  show();
</script>
</body>
</html>
The page asks whether window.versions exists. In a browser it does not. The button stands in for the preload script.

Nothing in that file is Electron-only. The only desktop-specific line is the check for window.versions, which is a name used in Electron's own tutorial.

The three files an Electron app needs

An Electron project is a small npm project. You need Node.js and npm installed, then you add Electron as a development dependency.

npm init
npm install --save-dev electron
The three files of the smallest Electron app. Only index.html is a web page.
The three files of the smallest Electron app. Only index.html is a web page.

In package.json, set the entry file and a start command:

{
  "main": "main.js",
  "scripts": { "start": "electron ." }
}

Then main.js opens a window and loads the HTML:

const { app, BrowserWindow } = require('electron')

const createWindow = () => {
  const win = new BrowserWindow({ width: 800, height: 600 })
  win.loadFile('index.html')   // path relative to the app folder
}

app.whenReady().then(createWindow)

Put your page in index.html next to it and run npm start. loadFile takes a path to an HTML file relative to the root of your application.

Two processes: your page and the main script

Electron runs two kinds of process. The main process starts the app, opens windows and can use Node.js. Each window runs a renderer process, which draws your HTML like a browser tab.

The main process and the renderer are separate. A preload script passes across only what you choose.
The main process and the renderer are separate. A preload script passes across only what you choose.

A page should not call Node.js directly. A preload script runs before the page and can hand it selected functions through contextBridge. Context isolation, which keeps the preload code and the page in separate contexts, has been the default since Electron 12.

// preload.js
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('versions', {
  electron: () => process.versions.electron
})

Point the window at that file through the webPreferences option in main.js:

const path = require('node:path')
const win = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: { preload: path.join(__dirname, 'preload.js') }
})

The page can now read versions.electron(). Expose small, named functions, never the whole ipcRenderer object.

Write the page so it works in both places

If the page only uses the web platform plus one optional object, the same file serves a desktop window and a browser tab. Check for the object, use it when it is there, and fall back when it is not.

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>One page, two homes</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .seg { display: flex; gap: 6px; margin-bottom: 12px; }
  .seg button {
    flex: 1; padding: 9px 8px; font: inherit; font-size: 14px; cursor: pointer;
    border: 1px solid #cfd5df; border-radius: 8px; background: #fff; color: #1d2330;
  }
  .seg button[aria-pressed="true"] { background: #1d2330; color: #fff; border-color: #1d2330; }
  textarea {
    width: 100%; box-sizing: border-box; height: 84px; padding: 10px; font: 15px/1.4 system-ui, sans-serif;
    border: 1px solid #cfd5df; border-radius: 8px; resize: none;
  }
  #save {
    margin-top: 10px; padding: 9px 16px; font: inherit; border: 0; border-radius: 8px;
    background: #2563eb; color: #fff; cursor: pointer;
  }
  #out {
    margin-top: 12px; padding: 12px 14px; min-height: 64px; border-radius: 10px;
    background: #fff; border: 1px solid #e1e4ea; font: 13.5px/1.5 ui-monospace, Consolas, monospace;
    white-space: pre-wrap; overflow-wrap: anywhere;
  }
  #out.native { background: #f4fbf6; border-color: #9ad2ad; }
  #out.web { background: #fff8f1; border-color: #f0c8a8; }
</style>
</head>
<body>
<div class="seg" role="group" aria-label="Where the page runs">
  <button type="button" id="b-web" aria-pressed="true">Browser tab</button>
  <button type="button" id="b-app" aria-pressed="false">Desktop shell (simulated)</button>
</div>

<textarea id="note" aria-label="Note">Buy coffee filters.</textarea>
<button id="save" type="button">Save note</button>
<div id="out">Press Save note.</div>

<script>
  const out = document.getElementById('out');
  const note = document.getElementById('note');
  const bWeb = document.getElementById('b-web');
  const bApp = document.getElementById('b-app');

  // A preload script would expose something like this. Here it is a stand-in that returns a promise.
  const fakeDesktop = {
    saveText: (text) => Promise.resolve('main process wrote ' + text.length + ' characters to disk')
  };

  function setMode(app) {
    bWeb.setAttribute('aria-pressed', String(!app));
    bApp.setAttribute('aria-pressed', String(app));
    if (app) window.desktop = fakeDesktop; else delete window.desktop;
    out.className = '';
    out.textContent = 'Press Save note.';
  }

  async function save() {
    const text = note.value;
    if (window.desktop && window.desktop.saveText) {
      // Desktop shell: hand the work to the main process
      out.className = 'native';
      out.textContent = 'window.desktop found.\n' + await window.desktop.saveText(text);
    } else {
      // Browser: fall back to something a web page can do on its own
      out.className = 'web';
      out.textContent = 'No window.desktop. Fallback: keep it on the page.\n"' + text + '"';
    }
  }

  bWeb.addEventListener('click', () => setMode(false));
  bApp.addEventListener('click', () => setMode(true));
  document.getElementById('save').addEventListener('click', save);
</script>
</body>
</html>
Switch between a plain browser and a simulated desktop shell. The same Save button takes a different path.

In the example, window.desktop plays the part of something a preload script would expose. The page decides at the moment of the click, so it needs no separate version for each home.

A finished example: a focus timer

A timer is a good first window. It needs no files on disk, no network and no Node.js. It is plain HTML that happens to feel like an app.

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>Focus timer</title>
<style>
  body {
    margin: 0; min-height: 100vh; display: grid; place-items: center;
    font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330;
  }
  .app { text-align: center; padding: 16px; width: 100%; max-width: 340px; box-sizing: border-box; }
  #time { font: 700 64px/1 ui-monospace, Consolas, monospace; margin: 8px 0 14px; }
  #time.done { color: #0f5132; }
  .row { display: flex; gap: 8px; justify-content: center; margin: 10px 0; }
  button {
    padding: 10px 14px; font: inherit; font-size: 15px; border-radius: 8px;
    border: 1px solid #cfd5df; background: #fff; color: #1d2330; cursor: pointer;
  }
  button.main { background: #2563eb; border-color: #2563eb; color: #fff; min-width: 96px; }
  button[aria-pressed="true"] { background: #1d2330; color: #fff; border-color: #1d2330; }
  #hint { font-size: 13px; color: #5b6472; min-height: 18px; }
</style>
</head>
<body>
<div class="app">
  <div class="row" id="presets" role="group" aria-label="Length">
    <button type="button" data-min="5">5 min</button>
    <button type="button" data-min="25" aria-pressed="true">25 min</button>
    <button type="button" data-min="50">50 min</button>
  </div>
  <div id="time" aria-live="off">25:00</div>
  <div class="row">
    <button type="button" class="main" id="go">Start</button>
    <button type="button" id="reset">Reset</button>
  </div>
  <div id="hint">Pick a length and press Start.</div>
</div>

<script>
  const timeEl = document.getElementById('time');
  const go = document.getElementById('go');
  const hint = document.getElementById('hint');
  let total = 25 * 60 * 1000;   // chosen length in ms
  let left = total;             // time remaining while paused
  let endAt = 0;                // clock time when it reaches zero while running
  let timer = null;

  function fmt(ms) {
    const s = Math.ceil(ms / 1000);
    return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
  }

  function paint() {
    if (timer) left = Math.max(0, endAt - Date.now());   // subtract from the clock, do not count ticks
    timeEl.textContent = fmt(left);
    document.title = fmt(left) + ' - Focus timer';
    if (timer && left === 0) {
      clearInterval(timer); timer = null;
      go.textContent = 'Start'; timeEl.classList.add('done');
      hint.textContent = 'Done. Take a break.';
    }
  }

  go.addEventListener('click', () => {
    if (timer) {                                        // pause
      clearInterval(timer); timer = null; go.textContent = 'Resume';
      hint.textContent = 'Paused.';
      return;
    }
    if (left === 0) left = total;
    timeEl.classList.remove('done');
    endAt = Date.now() + left;
    timer = setInterval(paint, 250);
    go.textContent = 'Pause'; hint.textContent = 'Running.';
    paint();
  });

  function reset() {
    clearInterval(timer); timer = null;
    left = total; go.textContent = 'Start';
    timeEl.classList.remove('done'); hint.textContent = 'Pick a length and press Start.';
    paint();
  }
  document.getElementById('reset').addEventListener('click', reset);

  document.querySelectorAll('#presets button').forEach((b) => {
    b.addEventListener('click', () => {
      document.querySelectorAll('#presets button').forEach((x) => x.setAttribute('aria-pressed', 'false'));
      b.setAttribute('aria-pressed', 'true');
      total = Number(b.dataset.min) * 60 * 1000;
      reset();
    });
  });
</script>
</body>
</html>
A countdown with presets, pause and reset. It works as a link, as an opened file, or as index.html in an Electron window.

The timer subtracts the clock instead of counting ticks, so it stays right if the window is in the background. The page title shows the remaining time too, so a tab or window caption stays useful.

The security setting that blanks your page

The Electron tutorial puts a Content Security Policy meta tag in index.html:

<meta http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'" />

Per MDN, when a policy has default-src or script-src, inline script tags do not run.

With that policy, a script written inside the page is refused. The same code in a file loads.
With that policy, a script written inside the page is refused. The same code in a file loads.

The demos on this page keep their code inline, so under that policy their buttons would do nothing. Put the code in renderer.js and load it with a src attribute.

To load a library from a web address, add its host to script-src, as Electron's security guide does with its example host.

To ship the app to other people, Electron's distribution guide recommends Electron Forge.

npx electron-forge import
npm run make

When it does not work

What you see Cause Fix
Window opens, buttons do nothing A CSP blocks inline script Move the code to a script file
require is not defined in the page Node.js integration is off by default Use preload and contextBridge
window.versions is undefined The page is in a browser, or no preload is set Check for it before use
Blank window, file not found The loadFile path is wrong Use a path relative to the app folder
Library does not load in the window The CSP does not list its host Add the host to script-src
npm start does nothing useful main in package.json is missing Set main to main.js

An Electron app is a folder that other people have to install and run. Your page does not have to wait for that. A screenshot cannot be clicked. An .html attachment may open as plain code on a phone.

Opening an HTML file on a phone covers why.

To send the working page, paste it 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.

A page that checks for the desktop helper, like the ones above, simply takes its browser path. If you change the code later, the same link shows the new version.

Questions people ask

Can I run an HTML file in Electron without any other files?

No. Electron starts from a main script that opens a window, and an npm project points to that script. The official tutorial uses package.json, main.js and index.html. Only index.html is the web page.

Will the same index.html also open in a normal browser?

Yes, as long as the page does not call Node.js or Electron functions directly. Ask for desktop features through one object that a preload script adds, and check that it exists before using it.

Why does my script not run in the Electron window?

Often a Content Security Policy meta tag such as default-src 'self' is in the page. With that policy inline script tags are not allowed to run. Move the code into a separate file and load it with a script tag and a src attribute.

Why is require not defined in my page?

Node.js integration in the page has been off by default since Electron 5.0.0. Use a preload script and contextBridge to hand the page only the functions it needs.

How do I let someone try my page without installing anything?

Send the HTML as a link. A browser opens it, runs its scripts, and the parts that need the desktop shell fall back to the plain web version. See the last section.

Keep reading