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:

A red Download button injected next to the YouTube Subscribe button using ContentAnchor

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).

Surfaces tab with Content enabled

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

PropTypeDescription
selectorstringCSS 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.
offsetnumberDistance in pixels from the target element. Default: 8.
enabledbooleanShow or hide the anchored UI. Default: true.
observeMutationsbooleanWatch for DOM mutations (SPA, infinite scroll). Default: true.
fallbackReactNodeContent 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() and ensureContentScriptReady() before sending messages to a tab. The content script may not be injected yet on pages opened before the extension was installed or reloaded.