Content Scripts
Inject your own UI into any web page. Shadow DOM isolation keeps your styles safe, and ContentAnchor positions your UI next to any element on the page.
Example — positioning a Download button next to the Subscribe button on YouTube:

What you can build
- A floating download button next to any video or image on any page
- A toolbar that follows the user as they scroll
- An AI rewrite button next to every paragraph
- A data extractor that reads and exports page content
Add a content script
In Settings → Surfaces, click Content. ChromeShip creates src/app/content/index.tsx (the bridge) and src/app/content/injected-ui.tsx (your React component).

Architecture
Content scripts have two layers:
- Bridge (
src/app/content/index.tsx) — a thin message handler and DOM reader. Keep it small: read data, send messages, do light DOM manipulation. - Injected UI (
src/app/content/injected-ui.tsx) — your React component. Lives in a Shadow DOM, uses the same Tailwind and shadcn components as popup, side panel, or options.
ChromeShip builds the content script as a standalone IIFE bundle, inlines its CSS, creates a Shadow DOM root, and routes shadcn portals into it. You do not create a second React root or attach UI directly to the host page.
Shadow DOM isolation
The injected UI is mounted in a Shadow DOM with all: initial — host page CSS cannot leak in, and your Tailwind styles cannot leak out. The root element sits at z-index: 2147483647 with pointer-events: none on the host, and the inner container re-enables pointer events so only your actual UI is interactive.
Tailwind @property rules are rewritten for Shadow DOM compatibility. :root selectors become :host, and .dark becomes :host(.dark). Your components use the same CSS variables and tokens as every other surface.
ContentAnchor
Position your UI next to any element on the host page. ContentAnchor follows scrolling, resizing, SPA navigation, and target element replacement automatically — no manual position recalculation.
Props
| Prop | Type | Description |
|---|---|---|
selector | string | CSS selector for the target element to anchor to |
side | "auto" | "top" | "right" | "bottom" | "left" | "center" | Which side to attach to. auto selects the side with the most viewport space. |
align | "start" | "center" | "end" | Alignment along the chosen side. Default: center. |
offset | number | Distance in pixels from the target element. Default: 8. |
enabled | boolean | Show or hide the anchored UI. Default: true. |
observeMutations | boolean | Watch for DOM mutations (SPA, infinite scroll). Default: true. |
fallback | ReactNode | Content shown when the target element is not found. |
Real-world example — a Download button on YouTube video pages, anchored to the Subscribe button:
import { ContentAnchor } from "@/shared/dom";
import { Button } from "@/components/ui/button";
function isYouTubeWatchPage() {
const url = new URL(window.location.href);
return (
url.hostname === "www.youtube.com" &&
url.pathname === "/watch" &&
url.searchParams.has("v")
);
}
export function ContentApp() {
if (!isYouTubeWatchPage()) return null;
return (
<ContentAnchor selector="#subscribe-button" side="right" offset={16} align="center">
<Button className="bg-red-600 hover:bg-red-500 text-lg py-6 px-6 text-white">
Download
</Button>
</ContentAnchor>
);
}When to use each side
- side="auto" when you don't know where the target will be on screen. ChromeShip measures the viewport and picks the side with the most available space.
- side="right" for floating toolbars next to content — the default for download buttons and action menus.
- side="bottom" for expandable panels or comment sections below a post.
- side="center" for modals or overlays centered on a specific element.
DOM utilities
For lightweight DOM operations that don't need React, use the vanilla DOM helpers. Keep these in src/app/content/index.tsx — the bridge file.
import {
selectElement,
waitForElement,
ensureRoot,
removeElement,
} from "@/shared/dom";
// Read from the page
const title = selectElement<HTMLHeadingElement>("h1")?.innerText ?? "";
// Wait for an element that renders after hydration / SPA navigation
const button = await waitForElement<HTMLButtonElement>("button[type='submit']", 5000);
// Create a vanilla DOM node (for React UI, use mountInjectedUi instead)
const root = ensureRoot("chromeship-overlay-root", "chromeship-overlay");
root.textContent = "Injected";
// Clean up
removeElement("chromeship-overlay-root");Messaging from content scripts
Your content script communicates with popup, side panel, and background through typed messages. The bridge file handles incoming messages; use sendRuntimeMessage to talk back.
import { sendRuntimeMessage, registerMessageHandler } from "@/shared/messaging";
// In the bridge (src/app/content/index.tsx)
registerMessageHandler(async (message) => {
if (message.type === "EXTRACT_PAGE") {
const title = document.querySelector("h1")?.innerText ?? "";
return { ok: true, data: { title } };
}
return { ok: false, error: "Unsupported message" };
});
// From popup or side panel
import { sendTabMessage, getActiveInjectableTab, ensureContentScriptReady } from "@/shared/messaging";
const tab = await getActiveInjectableTab();
await ensureContentScriptReady(tab.id);
const response = await sendTabMessage(tab.id, { type: "EXTRACT_PAGE" });Important constraints
- No ESM imports in the content bundle. The content script is built as a classic IIFE. Static and dynamic imports are not available.
- No heavy libraries in the content script. PDF generators, canvas renderers, AI SDKs, and rich editors belong in popup, side panel, background, or a backend function. Collect data in the content script; process it elsewhere.
- Never inject into browser pages. ChromeShip automatically skips
chrome://,edge://,about:,chrome-extension://, Chrome Web Store, and empty new-tab pages. - Use
getActiveInjectableTab()andensureContentScriptReady()before sending messages to a tab. The content script may not be injected yet on pages opened before the extension was installed or reloaded.
