electron-tabs: a Web Component tab bar that respects Electron's security model

August 16, 2023

|repo-review

by Florian Narr

electron-tabs: a Web Component tab bar that respects Electron's security model

electron-tabs is a tab bar component for Electron renderer processes. Drop <tab-group> into your HTML, load the script, and you get a full tab strip backed by Electron's <webview> tag — with drag-and-drop sorting, badge support, and an event system.

Why I starred it

Building a browser-like UI in Electron is one of those things that sounds simple and isn't. The <webview> tag is the right primitive, but wiring it up with tabs, activation state, close buttons, and position tracking from scratch takes a lot of boilerplate. electron-tabs handles all of that.

What caught my attention specifically was the architecture choice: it's a genuine Web Component, not a jQuery plugin bolted onto the DOM. That decision drives everything else about how the library works.

How it works

The entire library lives in two files: src/index.ts and src/style.css. The TypeScript compiles to a single bundle via Parcel.

TabGroup extends HTMLElement directly and registers as "tab-group" via customElements.define. The constructor reads its configuration from HTML attributes:

this.options = {
  closeButtonText: this.getAttribute("close-button-text") || "&#215;",
  newTabButton: !!this.getAttribute("new-tab-button") === true || false,
  sortable: !!this.getAttribute("sortable") === true || false,
  visibilityThreshold: Number(this.getAttribute("visibility-threshold")) || 0
};

That means you configure it declaratively in markup — no separate init() call needed.

The Shadow DOM is the interesting part. createComponent() calls this.attachShadow({mode: "open"}) and builds the entire nav + views structure inside it. The bundled CSS is injected as a <style> element inside the shadow root using Parcel's bundle-text: import:

import styles from "bundle-text:./style.css";
// later:
const style = document.createElement("style");
style.textContent = styles;
shadow.appendChild(style);

This is how the component can exist on any page without its styles leaking or being overridden by the host stylesheet — which is the correct behavior for a reusable UI component. If you need to customize styles from outside, you inject a <style> tag inside the <tab-group> element and connectedCallback() picks it up and moves it into the shadow root.

The active-tab tracking is worth noting. Rather than maintaining an activeTab property, TabGroup keeps the active tab at index 0 of this.tabs. setActiveTab() removes the tab from its current position and unshifts it:

setActiveTab(tab: Tab) {
  this.removeTab(tab);
  this.tabs.unshift(tab);
  this.emit("tab-active", tab, this);
}

getActiveTab() is then just this.tabs[0]. It's a simple trick that lets activateRecentTab() — called when you close the current tab — trivially grab the next most-recently-used tab.

The close() method on Tab uses AbortController to implement a cancellable close event:

close(force: boolean) {
  const abortController = new AbortController();
  const abort = () => abortController.abort();
  this.emit("closing", this, abort);
  if (this.isClosed || (!this.closable && !force) || abortSignal.aborted) return;
  // ...proceed with removal
}

You can listen to "closing" and call abort() to prevent the tab from closing — useful for "unsaved changes" prompts.

Drag-and-drop sorting is handled entirely by Sortable.js, initialized in initSortable() on the tab container div. No custom drag logic.

Using it

Basic setup in the main process:

new BrowserWindow({
  webPreferences: { webviewTag: true }
});

In the renderer HTML:

<tab-group new-tab-button="true" sortable="true"></tab-group>
<script src="node_modules/electron-tabs/dist/electron-tabs.js"></script>
<script>
  const tabGroup = document.querySelector("tab-group");

  tabGroup.setDefaultTab({
    title: "New Tab",
    src: "app://./index.html",
    active: true
  });

  const tab = tabGroup.addTab({
    title: "Dashboard",
    src: "app://./dashboard.html",
    active: true
  });

  tab.on("closing", (tab, abort) => {
    if (hasUnsavedChanges()) abort();
  });

  tabGroup.on("tab-active", (tab, tabGroup) => {
    document.title = tab.getTitle();
  });
</script>

Rough edges

The README opens with "electron-tabs is discontinued." The last real commit before the discontinuation notice was a bug fix for getTabByRelPosition from a community contributor — the maintainer had checked out. The latest version is 1.0.4, and it hasn't moved since.

The event system wraps the native CustomEvent / EventTarget API with thin helpers (emit, on, once in src/index.ts), but the ergonomics are a bit awkward — event listeners receive e.detail spread as arguments, which isn't obvious from the outside.

There are no tests. Zero. For a UI component with non-trivial state management (tab ordering, active-tab tracking via the unshift trick, position arithmetic), that's a real gap. The getPosition() implementation in particular — walking previousSibling in a loop — reads the DOM directly rather than tracking state internally, which will be slow if you have a lot of tabs.

The webviewTag: true requirement is worth flagging: Electron's own docs recommend against it in most cases because it expands the attack surface. electron-tabs is designed for this scenario (it's essentially a browser shell), but you need to understand what you're opting into.

Bottom line

If you're building an Electron app that needs a browser-style tab bar, this saves you a few days of work — even abandoned. The Web Component architecture is clean, the Shadow DOM isolation is correct, and the API covers 90% of what you'd want. Just don't expect updates or community support.

brrd/electron-tabs on GitHub
brrd/electron-tabs