Skip to contentEnma

Chrome extensions

Enma suits extensions well: one stylesheet, local fonts, no runtime dependencies. This guide sets up a Manifest V3 extension with Vite, then covers each surface: popup, options page, side panel and UI injected into web pages. The chrome-extension starter has all of it wired up.

Graphite is the usual theme for extensions (tab managers, command palettes, dev tools). Pick another when the extension belongs to a product with its own theme.

Set up

sh
pnpm add enma@npm:enma-design
pnpm add -D vite @crxjs/vite-plugin
json
// manifest.json
{
  "manifest_version": 3,
  "name": "Tab tidy",
  "version": "0.1.0",
  "action": { "default_popup": "src/popup/index.html" },
  "options_page": "src/options/index.html",
  "side_panel": { "default_path": "src/sidepanel/index.html" },
  "permissions": ["storage", "sidePanel"],
  "content_scripts": [{ "matches": ["https://*/*"], "js": ["src/content/main.ts"] }],
  "web_accessible_resources": [{ "resources": ["assets/*.woff2"], "matches": ["https://*/*"] }]
}
ts
// vite.config.ts
import { crx } from '@crxjs/vite-plugin';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import manifest from './manifest.json' with { type: 'json' };

export default defineConfig({
  plugins: [react(), crx({ manifest })],
  build: { assetsInlineLimit: 0 }, // keep fonts as files for the content script
});

Each page imports Enma once. Vite copies the woff2 files into the extension, so nothing loads from the network.

html
<!-- src/popup/index.html -->
<!doctype html>
<html lang="en" data-enma="graphite" data-theme="dark">
  <head>
    <meta charset="utf-8" />
    <title>Tab tidy</title>
  </head>
  <body class="e-canvas">
    <main id="app"></main>
    <script type="module" src="./main.ts"></script>
  </body>
</html>
ts
// src/popup/main.ts
import 'enma/css';
import 'enma/fonts/graphite';
import { followSystemMode } from '../shared/mode';

followSystemMode(document.documentElement);
ts
// src/shared/mode.ts: follow the browser's light/dark setting, live
export function followSystemMode(root: HTMLElement) {
  const query = matchMedia('(prefers-color-scheme: dark)');
  const apply = () => (root.dataset.theme = query.matches ? 'dark' : 'light');
  apply();
  query.addEventListener('change', apply);
}

With React, render into #app with enma/react as usual.

UI inside web pages (content scripts)

Injected UI goes inside a Shadow DOM root with Enma's CSS inside it, so the page's styles can't break Enma and Enma can't leak into the page. Two details matter: CSS custom properties defined in the page don't reach into the shadow root unless inherited, and @font-face rules don't work inside a shadow root, so fonts are declared on the page.

ts
// src/content/main.ts
import enmaCss from 'enma/css?inline';
import fontsCss from 'enma/fonts/graphite?inline';

/** Fonts must be declared in the page (they don't load from inside a shadow root), pointing at the extension's files. */
function declareFonts() {
  const style = document.createElement('style');
  style.textContent = fontsCss.replace(
    /url\("?\/?(assets\/[^")]+)"?\)/g,
    (_match, path: string) => `url("${chrome.runtime.getURL(path)}")`,
  );
  document.head.append(style);
}

export function showToast(title: string) {
  declareFonts();
  const host = document.createElement('div');
  host.style.all = 'initial'; // the page's inherited styles stop here
  const shadow = host.attachShadow({ mode: 'open' });

  const style = document.createElement('style');
  style.textContent = enmaCss;
  const root = document.createElement('div');
  root.dataset.enma = 'graphite';
  root.dataset.theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  root.className = 'e-root';
  root.innerHTML = `<div class="e-toast e-sq e-toast--positive" role="status">
    <div class="e-toast__body"><div class="e-toast__title"></div></div></div>`;
  root.querySelector('.e-toast__title')!.textContent = title;

  shadow.append(style, root);
  document.body.append(host);
  setTimeout(() => host.remove(), 4000);
}

Set build.assetsInlineLimit: 0 in the Vite config so fonts stay files, and list assets/*.woff2 in web_accessible_resources. The starter has this exact setup, tested.

Do

  • Keep popups compact and keyboard first. Design for 360 to 400px wide and under about 600px tall. Use compact density: sm controls, 13px text, TextField size="sm". Focus the main input when the popup opens; Enter runs the primary action; Escape closes.
  • Bundle fonts locally. Never depend on Google Fonts or any CDN at runtime. Extensions run offline and under strict content security policies.
  • Follow the browser's light/dark setting, live, unless the extension has its own toggle.
  • Isolate injected UI in a Shadow DOM root with all: initial on the host.
  • Keep injected UI light. A toast, a small panel or a badge. Render lazily, on user action, and remove it when it's done.
  • Respect the page. Don't steal focus on load, don't override the page's shortcuts (or the browser's), and return focus to where it was when your UI closes.
  • Keep permission and onboarding screens calm and clear. Say what each permission is for in one line ("Read tab titles to group them"), one primary action, no dark patterns, no confetti.

Don't

  • Load Enma's CSS into the page itself from a content script. It would restyle the host page.
  • Put data-enma on the page's <html>. Set it on your own root inside the shadow DOM.
  • Block the page with heavy injected UI, full-screen overlays or anything that runs on every page load without a reason.
  • Use emoji as icons or invent colors to match the host site. The extension wears its own theme.

↑ ↓ to move, Enter to open, Esc to close

Keyboard shortcuts

Open the command palette
Ctrl then K
Search
/
Show keyboard shortcuts
?
Close the top overlay
Esc
Go to Home
G then H
Go to Themes
G then T
Go to Playground
G then P
Go to Components
G then C
Go to Guides
G then G
Go to Samples
G then S