plasmo
Expert guidance for Plasmo, the framework for building browser extensions with React, TypeScript, and modern tooling. Helps developers create Chrome and Firefox extensions with content scripts, background workers, popup UIs, and messaging — all with hot reload and zero webpack config.
Usage
Getting Started
- Install the skill using the command above
- Open your AI coding agent (Claude Code, Codex, Gemini CLI, or Cursor)
- Reference the skill in your prompt
- The AI will use the skill's capabilities automatically
Example Prompts
- "Review the open pull requests and summarize what needs attention"
- "Generate a changelog from the last 20 commits on the main branch"
Documentation
Overview
Plasmo, the framework for building browser extensions with React, TypeScript, and modern tooling. Helps developers create Chrome and Firefox extensions with content scripts, background workers, popup UIs, and messaging — all with hot reload and zero webpack config.
Instructions
Project Setup
Create a new extension with Plasmo CLI:
# Create a new extension project
pnpm create plasmo my-extension
# Or with a specific template
pnpm create plasmo --with-tailwindcss my-extension
# Development with hot reload
pnpm dev
# Build for production
pnpm build
# Package for Chrome Web Store
pnpm package
Popup UI
Build the extension popup as a React component:
// src/popup.tsx — Main popup UI (click the extension icon)
import { useState, useEffect } from "react";
import { Storage } from "@plasmohq/storage";
const storage = new Storage();
function IndexPopup() {
const [savedCount, setSavedCount] = useState(0);
const [isEnabled, setIsEnabled] = useState(true);
useEffect(() => {
// Load state from extension storage (persists across sessions)
storage.get<number>("savedCount").then((count) => setSavedCount(count ?? 0));
storage.get<boolean>("isEnabled").then((enabled) => setIsEnabled(enabled ?? true));
}, []);
const toggleExtension = async () => {
const newState = !isEnabled;
setIsEnabled(newState);
await storage.set("isEnabled", newState);
// Notify content scripts about the state change
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id) {
chrome.tabs.sendMessage(tabs[0].id, { type: "TOGGLE", enabled: newState });
}
};
return (
<div style={{ padding: 16, width: 320 }}>
<h2>My Extension</h2>
<p>Saved items: {savedCount}</p>
<button onClick={toggleExtension}>
{isEnabled ? "🟢 Enabled" : "🔴 Disabled"}
</button>
</div>
);
}
export default IndexPopup;
Content Scripts
Inject UI and logic into web pages:
// src/contents/overlay.tsx — Content script that renders a React component on any page
import type { PlasmoCSConfig, PlasmoGetOverlayAnchor } from "plasmo";
import { useState } from "react";
// Configure which pages this content script runs on
export const config: PlasmoCSConfig = {
matches: ["https://github.com/*"], // Only on GitHub
css: ["contents/overlay.css"], // Optional custom CSS
};
// Anchor the overlay to a specific DOM element (optional)
export const getOverlayAnchor: PlasmoGetOverlayAnchor = async () => {
return document.querySelector(".repository-content");
};
// The React component renders as an overlay on the page
function GitHubOverlay() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="plasmo-overlay">
<button onClick={() => setIsOpen(!isOpen)}>
📋 Quick Actions
</button>
{isOpen && (
<div className="plasmo-panel">
<button onClick={() => copyRepoInfo()}>Copy repo info</button>
<button onClick={() => analyzeReadme()}>Analyze README</button>
</div>
)}
</div>
);
}
async function copyRepoInfo() {
const title = document.querySelector("[itemprop='name'] a")?.textContent?.trim();
const desc = document.querySelector("[itemprop='about']")?.textContent?.trim();
const stars = document.querySelector("#repo-stars-counter-star")?.textContent?.trim();
await navigator.clipboard.writeText(`${title}: ${desc} (⭐ ${stars})`);
}
export default GitHubOverlay;
Background Service Worker
Handle long-running tasks, alarms, and cross-tab communication:
// src/background.ts — Background service worker (Manifest V3)
import { Storage } from "@plasmohq/storage";
const storage = new Storage();
// Listen for messages from content scripts and popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
switch (message.type) {
case "SAVE_ITEM":
handleSaveItem(message.data).then(sendResponse);
return true; // Keep the message channel open for async response
case "GET_STATS":
getStats().then(sendResponse);
return true;
}
});
async function handleSaveItem(data: { url: string; title: string; content: string }) {
const items = (await storage.get<any[]>("savedItems")) ?? [];
items.push({ ...data, savedAt: Date.now() });
await storage.set("savedItems", items);
// Update badge count
const count = items.length;
chrome.action.setBadgeText({ text: count > 0 ? String(count) : "" });
chrome.action.setBadgeBackgroundColor({ color: "#6366f1" });
return { success: true, count };
}
// Set up periodic tasks with alarms
chrome.alarms.create("sync-data", { periodInMinutes: 30 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "sync-data") {
const items = (await storage.get<any[]>("savedItems")) ?? [];
if (items.length > 0) {
// Sync to your backend API
await fetch("https://api.example.com/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
}
}
});
// Context menu (right-click menu)
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: "save-selection",
title: "Save selection to My Extension",
contexts: ["selection"],
});
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === "save-selection" && info.selectionText) {
handleSaveItem({
url: tab?.url ?? "",
title: tab?.title ?? "",
content: info.selectionText,
});
}
});
Messaging Between Components
Type-safe communication between popup, content scripts, and background:
// src/messaging.ts — Type-safe messaging with Plasmo messaging API
import { sendToBackground, sendToContentScript } from "@plasmohq/messaging";
// Define message types
interface SaveRequest { url: string; title: string; }
interface SaveResponse { success: boolean; id: string; }
// From content script → background
async function saveFromContentScript(data: SaveRequest): Promise<SaveResponse> {
return sendToBackground({
name: "save-item", // Maps to src/background/messages/save-item.ts
body: data,
});
}
// From popup → content script
async function highlightElements() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
return sendToContentScript({
name: "highlight",
tabId: tabs[0].id!,
body: { selector: "h1, h2, h3" },
});
}
// src/background/messages/save-item.ts — Background message handler
import type { PlasmoMessaging } from "@plasmohq/messaging";
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
const { url, title } = req.body;
// Process the save request
const id = crypto.randomUUID();
await chrome.storage.local.set({ [id]: { url, title, savedAt: Date.now() } });
res.send({ success: true, id });
};
export default handler;
Storage with React Hooks
Reactive storage that syncs across extension components:
// src/popup.tsx — Using storage hooks for reactive state
import { useStorage } from "@plasmohq/storage/hook";
function SettingsPopup() {
// useStorage provides React state that persists and syncs
// Changes in popup instantly reflect in content scripts and vice versa
const [theme, setTheme] = useStorage<string>("theme", "light");
const [apiKey, setApiKey] = useStorage<string>("apiKey", "");
const [savedItems, setSavedItems] = useStorage<any[]>("savedItems", []);
return (
<div style={{ padding: 16, width: 350 }}>
<h3>Settings</h3>
<label>Theme</label>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<label>API Key</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter your API key"
/>
<h3>Saved Items ({savedItems.length})</h3>
<ul>
{savedItems.slice(-5).map((item, i) => (
<li key={i}>{item.title}</li>
))}
</ul>
</div>
);
}
Options Page
Full-page settings interface:
// src/options.tsx — Extension options page (chrome-extension://id/options.html)
function OptionsPage() {
const [settings, setSettings] = useStorage("settings", {
autoSave: true,
notifications: true,
syncInterval: 30,
excludedSites: [] as string[],
});
const updateSetting = (key: string, value: any) => {
setSettings({ ...settings, [key]: value });
};
return (
<div style={{ maxWidth: 600, margin: "40px auto", padding: 20 }}>
<h1>Extension Settings</h1>
<div>
<label>
<input type="checkbox" checked={settings.autoSave}
onChange={(e) => updateSetting("autoSave", e.target.checked)} />
Auto-save highlighted text
</label>
</div>
<div>
<label>Sync interval (minutes)</label>
<input type="number" value={settings.syncInterval}
onChange={(e) => updateSetting("syncInterval", parseInt(e.target.value))} />
</div>
</div>
);
}
export default OptionsPage;
Installation
pnpm create plasmo my-extension
cd my-extension
pnpm dev # Start dev server with hot reload
Examples
Example 1: Setting up Plasmo with a custom configuration
User request:
I just installed Plasmo. Help me configure it for my TypeScript + React workflow with my preferred keybindings.
The agent creates the configuration file with TypeScript-aware settings, configures relevant plugins/extensions for React development, sets up keyboard shortcuts matching the user's preferences, and verifies the setup works correctly.
Example 2: Extending Plasmo with custom functionality
User request:
I want to add a custom popup ui to Plasmo. How do I build one?
The agent scaffolds the extension/plugin project, implements the core functionality following Plasmo's API patterns, adds configuration options, and provides testing instructions to verify it works end-to-end.
Guidelines
- Manifest V3 by default — Plasmo generates MV3 manifests; service workers replace persistent background pages
- Use Plasmo storage over chrome.storage —
@plasmohq/storageprovides React hooks and cross-component sync - Content script isolation — Plasmo uses Shadow DOM for content script UIs; your CSS won't leak into the page
- Minimize permissions — Request only the permissions you need in
package.jsonundermanifest.permissions - Hot reload in dev —
pnpm devauto-reloads the extension on file changes; no manual refresh needed - Type your messages — Use Plasmo's messaging API for type-safe communication between components
- Test across browsers — Build for both Chrome (
pnpm build --target=chrome-mv3) and Firefox (--target=firefox-mv3) - Bundle size matters — Extensions load on every page; keep content scripts small, lazy-load heavy logic
Information
- Version
- 1.0.0
- Author
- terminal-skills
- Category
- Development
- License
- Apache-2.0