Tailwind CSS’s default breakpoint scale is designed for full-page browser windows — its md: prefix fires at 768 px, its container class adds horizontal padding designed for viewports hundreds of pixels wide. Apply those defaults to an extension popup and you get immediate layout failure: horizontal overflow, clipped flex containers, and scrollbars that appear before you’ve added a single element. This guide is part of Popup Interface Design and covers the exact Tailwind config changes, CSS resets, and build wiring you need to make a popup that fits inside the 800 × 600 px browser-enforced ceiling and opens fast.
The root cause is that extension popups have no standard viewport. The popup window is sized by its rendered content, not the other way around. Tailwind’s responsive utilities evaluate against the document’s own layout width — which, in a popup, can be as narrow as 280 px. Any utility that assumes a wider context breaks silently.
Prerequisites checklist
- Vite (or another bundler) configured to emit external JS and CSS files — MV3 CSP blocks inline
<style> injected by some Tailwind setups. popup.html linked to an external popup.css in <head>, and an external popup.js before </body>."permissions": ["storage"] in manifest.json if you plan to hydrate state from chrome.storage.local on open.- Node 18+ and
tailwindcss v3.4+ (v4 is also supported; config paths differ — see Step 1 variant below).
Step 1: Override breakpoints in tailwind.config.js
Replace the default sm/md/lg breakpoints with popup-scoped thresholds, or extend the theme with a parallel set of popup-* screens. The extension-specific prefixes prevent collision with any standard breakpoints and make intent obvious in markup.
1// tailwind.config.js (Tailwind v3)
2/** @type {import('tailwindcss').Config} */
3module.exports = {
4 content: ['./popup.html', './src/**/*.{ts,tsx}'],
5 theme: {
6 extend: {
7 screens: {
8 'popup-sm': '320px', // compact popup
9 'popup-md': '400px', // standard popup
10 'popup-lg': '600px', // wide popup (rare)
11 },
12 maxHeight: {
13 'popup-safe': '560px', // 600 px cap minus OS frame allowance
14 },
15 width: {
16 'popup': '360px', // default popup width token
17 },
18 },
19 },
20 plugins: [],
21};
Execution context: Build time only. Tailwind reads this file during vite build or npx tailwindcss --watch. The custom popup-* screens generate responsive utility variants you can use in HTML like popup-md:px-4. Nothing from this file ships to the browser.
For Tailwind v4, create a @theme block in your CSS entry file instead:
1/* popup.css (Tailwind v4 entry) */
2@import "tailwindcss";
3
4@theme {
5 --breakpoint-popup-sm: 320px;
6 --breakpoint-popup-md: 400px;
7 --breakpoint-popup-lg: 600px;
8 --max-height-popup-safe: 560px;
9 --width-popup: 360px;
10}
Execution context: Build time. Tailwind v4 reads @theme blocks from the CSS entry point at compile time. The resulting CSS is identical — only the config format differs.
Popup window size is driven by the rendered <body> dimensions. Set explicit pixel bounds so the window does not collapse or expand as dynamic content loads.
1/* popup.css */
2*, *::before, *::after {
3 box-sizing: border-box;
4}
5
6html {
7 height: auto; /* do NOT set height: 100% — see note below */
8}
9
10body {
11 margin: 0;
12 min-width: 320px;
13 width: 360px; /* drives the native popup window width */
14 max-height: 560px; /* prevents overflow past the 600 px cap */
15 overflow-y: auto;
16 overflow-x: hidden; /* suppress horizontal scrollbar from sub-pixel rounding */
17 scrollbar-width: thin; /* Firefox: avoid wide native scrollbar */
18 scrollbar-color: #94a3b8 transparent;
19}
Execution context: Parsed in the popup renderer process. The browser host measures the <body> box model after first paint to size the native OS window. Avoid height: 100vh on html or body — in a popup there is no viewport height to reference, and the result is a zero-height or unconstrained window depending on the browser.
With the custom screens in place, write markup using popup-sm: and popup-md: prefixes instead of sm: and md:. The root container should carry explicit pixel widths rather than percentage or w-full alone.
1<!-- popup.html -->
2<!DOCTYPE html>
3<html lang="en">
4<head>
5 <meta charset="UTF-8">
6 <link rel="stylesheet" href="popup.css">
7 <title>Extension</title>
8</head>
9<body>
10 <div id="app" class="w-popup max-h-popup-safe overflow-y-auto flex flex-col">
11
12 <header class="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700">
13 <span class="text-sm font-semibold">My Extension</span>
14 <button id="settings-btn" class="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800" aria-label="Open settings">
15 ⚙
16 </button>
17 </header>
18
19 <main id="content" class="flex-1 overflow-y-auto px-4 py-3 space-y-2" aria-live="polite">
20 <!-- Hydrated by popup.ts via chrome.storage.local -->
21 <div id="skeleton" class="animate-pulse space-y-2">
22 <div class="h-8 bg-slate-200 dark:bg-slate-700 rounded"></div>
23 <div class="h-8 bg-slate-200 dark:bg-slate-700 rounded w-3/4"></div>
24 </div>
25 </main>
26
27 <footer class="px-4 py-2 border-t border-slate-200 dark:border-slate-700 text-xs text-slate-500">
28 <span id="last-synced">Loading…</span>
29 </footer>
30
31 </div>
32 <script src="popup.js"></script>
33</body>
34</html>
Execution context: Rendered in the popup’s isolated page context. Inline <script> blocks would be blocked by the MV3 script-src 'self' CSP — all JavaScript must come from external files. The aria-live="polite" region lets screen readers announce when content changes after storage hydration.
Step 4: Hydrate state from storage without layout shift
The popup starts blank on every open. Load state immediately on DOMContentLoaded and replace the skeleton in one synchronous DOM update to avoid a visible frame of empty content.
1// popup.ts — compiled to popup.js by Vite
2document.addEventListener('DOMContentLoaded', async () => {
3 const { settings, lastSyncedAt } = await chrome.storage.local.get([
4 'settings',
5 'lastSyncedAt',
6 ]);
7
8 const content = document.getElementById('content')!;
9 const skeleton = document.getElementById('skeleton');
10 const syncedEl = document.getElementById('last-synced');
11
12 // Replace skeleton with real content in one update
13 const items = (settings?.items ?? []) as string[];
14 content.innerHTML = items.length
15 ? items.map(item => `<div class="px-3 py-2 rounded bg-slate-50 dark:bg-slate-800 text-sm">${escapeHtml(item)}</div>`).join('')
16 : `<p class="text-sm text-slate-500 text-center py-6">No items yet.</p>`;
17
18 skeleton?.remove();
19
20 if (lastSyncedAt) {
21 syncedEl!.textContent = `Last synced: ${new Date(lastSyncedAt).toLocaleTimeString()}`;
22 }
23});
24
25function escapeHtml(str: string): string {
26 return str.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
27}
Execution context: Runs in the popup renderer process. chrome.storage.local.get is asynchronous and does not block the main thread — the browser paints the skeleton first, then the await resolves and the DOM update runs. Firefox uses browser.storage.local with identical Promise behaviour; the cross-browser adapter from Popup Interface Design removes the namespace branch from call sites. Avoid innerHTML with unsanitized user-controlled strings — the escapeHtml call above is mandatory.
Vite’s default dev-server mode injects inline scripts that violate MV3 CSP. Use the library/multi-page build mode and turn off code splitting for popup assets.
1// vite.config.ts
2import { defineConfig } from 'vite';
3
4export default defineConfig({
5 build: {
6 rollupOptions: {
7 input: {
8 popup: 'popup.html',
9 },
10 output: {
11 // Emit flat files without content-hash chunking
12 entryFileNames: '[name].js',
13 assetFileNames: '[name].[ext]',
14 // Inline small chunks into the main bundle — avoid dynamic imports in popups
15 inlineDynamicImports: false,
16 manualChunks: undefined,
17 },
18 },
19 // Disable CSS code splitting — popup.css must be a single file
20 cssCodeSplit: false,
21 // Keep bundle readable for extension store review
22 minify: true,
23 sourcemap: false,
24 },
25});
Execution context: Node process at build time. The inlineDynamicImports: false setting, combined with manualChunks: undefined, lets Rollup bundle everything into a single popup.js and popup.css — no dynamic import() chunks that the popup’s CSP would block. Vite HMR is not available in extension contexts; reload the extension manually after each build during development.
Cross-browser variation
- Chrome / Edge: Enforce the 800 × 600 ceiling exactly.
box-sizing: border-box is required on the root — without it, padding adds to the declared width and produces a horizontal scrollbar at borderline widths. - Firefox: Respects
overflow-y: auto but renders wide native scrollbars by default. Add scrollbar-width: thin to body. Firefox also applies a small internal offset from the toolbar that can shift layout 1–2 px; verify at 320 px width. - Safari: Uses
SFSafariExtensionViewController for rendering. Explicit pixel width on body is mandatory — max-width: 100% alone collapses the popup. Sub-pixel font rendering differs from Chrome; test text that sits at the edge of the container. - Arc / Brave: Inherit Chromium’s cap. Brave’s aggressive content filtering can rewrite inline styles; keep styles in the external CSS file.
Verification
- Build with
vite build and load the unpacked extension from the dist/ folder in chrome://extensions. - Open the popup and right-click → Inspect. In the Elements panel, select
body and verify the computed width matches your declared value (e.g. 360 px) and max-height is at or below 560 px. - In the Console run
document.documentElement.scrollWidth > document.documentElement.clientWidth — it must return false (no horizontal overflow). - Resize DevTools to simulate 320 px with
popup-sm: breakpoint active and verify no horizontal scrollbar appears. - Check the Network panel for any
blob: or data: script URLs — their presence indicates an inline script violation that will be blocked in production.
FAQ
w-full resolves to width: 100% of the containing block. If a parent element is wider than 360 px — for example, a flex child that grew — w-full on a child will match that larger parent, causing overflow. Use w-popup (your token for 360px) on the root container and max-w-full on children instead.
Can I use Tailwind’s container class?
The container class applies max-width values from your breakpoints — by default these are 640 px, 768 px, etc. In a 360 px popup they have no effect, but container also adds responsive padding that can conflict with your px-* utilities. Use a plain div with explicit width utilities instead.
The white flash is the background colour before the skeleton renders. Add background-color to body in your CSS so it matches the skeleton colour on first paint, before the script runs. Using a skeleton <div> already in the HTML (not injected by JS) ensures the browser paints it on the first frame.
Why does overflow-y: auto show a scrollbar even when content fits?
Browser scrollbar gutter calculation can reserve space before deciding scrollbars are needed. Add overflow-y: overlay as a fallback (Chromium-only, deprecated but still works) or use scrollbar-gutter: stable both-edges to reserve the space without showing the track. The simplest fix is overflow-y: auto plus setting a max-height that leaves at least 20 px of headroom below your tallest content state.