
Chrome Extension Ui
- 615 installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
chrome-extension-ui is a frontend skill that generates and maintains Chrome extension interfaces following Manifest V3 UX rules so developers pass Chrome Web Store review.
About
chrome-extension-ui is a Manifest V3 UX/UI guide skill at version 0.1.0 from the Chrome Extensions Community, containing 42 rules across 8 categories prioritized from critical component selection and accessibility down to icons and branding. Agents follow incorrect-versus-correct examples when generating, refactoring, or reviewing popup, options page, and side-panel code for Chrome extensions. Developers reach for chrome-extension-ui when an extension must look credible, meet accessibility expectations, and avoid store rejection patterns during AI-assisted builds. The guidance is optimized for automated maintenance workflows while remaining useful for human reviewers auditing extension UI before publish.
- 42 rules across 8 categories prioritized from critical to incremental
- Focuses on side panel usage, single-purpose design, minimal permissions, content scripts, and accessibility
- Includes before-and-after implementation examples with quantified impact metrics
- Optimized for AI agents generating or refactoring extension UI code
- Critical rules improve engagement 5-10× and install conversion 40-60%
Chrome Extension Ui by the numbers
- 615 all-time installs (skills.sh)
- Ranked #526 of 1,888 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill chrome-extension-uiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 615 |
|---|---|
| repo stars | ★ 186 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you build Manifest V3 extension UI for store review?
Generate and maintain Chrome extension interfaces that follow Manifest V3 best practices and pass Chrome Web Store review.
Who is it for?
Frontend developers shipping or refactoring Chrome extensions who want agent-guided UI that aligns with Manifest V3 and Web Store policies.
Skip if: Teams building Safari or Firefox add-ons, backend-only browser automation, or extensions with no user-facing surfaces.
When should I use this skill?
The task involves creating, refactoring, or reviewing Chrome extension popup, options, side panel, or icon UI under Manifest V3.
What you get
Manifest V3-compliant popup, options, and side-panel UI with accessibility fixes and store-review-safe component patterns.
- popup UI markup
- options page layout
- accessibility-compliant components
By the numbers
- Contains 42 rules across 8 categories at version 0.1.0
Files
Chrome Extensions UX/UI Best Practices
Comprehensive UX/UI design guide for Chrome Extensions, optimized for Manifest V3. Contains 42 rules across 8 categories, prioritized by impact to guide extension UI development and code review.
When to Apply
Reference these guidelines when:
- Building new Chrome extension user interfaces
- Choosing between popup, side panel, or content script UI
- Implementing accessible, keyboard-navigable interfaces
- Designing loading states, error handling, and feedback patterns
- Creating options pages and settings persistence
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Component Selection | CRITICAL | comp- |
| 2 | Accessibility & Navigation | CRITICAL | access- |
| 3 | Popup Design | HIGH | popup- |
| 4 | Side Panel UX | HIGH | panel- |
| 5 | Content Script UI | MEDIUM-HIGH | inject- |
| 6 | Visual Feedback | MEDIUM | feedback- |
| 7 | Options & Settings | MEDIUM | options- |
| 8 | Icons & Branding | LOW-MEDIUM | brand- |
Quick Reference
1. Component Selection (CRITICAL)
- `comp-popup-vs-sidepanel` - Choose Side Panel for Persistent Tasks
- `comp-content-script-ui` - Use Content Scripts for In-Page UI
- `comp-single-purpose` - Design for Single Purpose
- `comp-minimal-permissions` - Request Minimal Permissions
- `comp-action-tooltip` - Provide Descriptive Action Tooltips
2. Accessibility & Navigation (CRITICAL)
- `access-keyboard-navigation` - Enable Complete Keyboard Navigation
- `access-focus-visible` - Maintain Visible Focus Indicators
- `access-aria-labels` - Use ARIA Labels for Icon-Only Buttons
- `access-color-contrast` - Ensure Sufficient Color Contrast
- `access-focus-trap` - Avoid Keyboard Focus Traps
- `access-semantic-html` - Use Semantic HTML Elements
3. Popup Design (HIGH)
- `popup-size-constraints` - Design Within Popup Size Limits
- `popup-instant-render` - Render Popup Content Instantly
- `popup-primary-action` - Make the Primary Action Obvious
- `popup-auto-close` - Handle Popup Auto-Close Gracefully
- `popup-external-js` - Keep JavaScript in External Files
- `popup-dynamic-switch` - Use Dynamic Popups for State-Based UI
4. Side Panel UX (HIGH)
- `panel-non-distracting` - Design Non-Distracting Side Panels
- `panel-tab-vs-window` - Choose Tab-Specific vs Window-Wide Panels
- `panel-responsive-width` - Design for Variable Panel Widths
- `panel-page-context` - Sync Panel Content with Page Context
- `panel-lazy-sections` - Lazy Load Panel Sections
5. Content Script UI (MEDIUM-HIGH)
- `inject-shadow-dom` - Use Shadow DOM for Style Isolation
- `inject-z-index` - Use Maximum Z-Index for Overlays
- `inject-document-ready` - Inject UI After DOM Ready
- `inject-unique-ids` - Use Unique IDs to Prevent Conflicts
- `inject-cleanup` - Clean Up Injected Elements on Removal
6. Visual Feedback (MEDIUM)
- `feedback-loading-states` - Show Loading States for Async Operations
- `feedback-error-messages` - Write Actionable Error Messages
- `feedback-badge-status` - Use Badge for At-a-Glance Status
- `feedback-success-confirmation` - Confirm Successful Actions
- `feedback-notifications` - Use Notifications Sparingly
- `feedback-progress-indication` - Show Progress for Long Operations
7. Options & Settings (MEDIUM)
- `options-embedded-page` - Use Embedded Options for Simple Settings
- `options-sync-storage` - Sync Settings Across Devices
- `options-auto-save` - Auto-Save Settings on Change
- `options-sensible-defaults` - Provide Sensible Default Settings
8. Icons & Branding (LOW-MEDIUM)
- `brand-icon-sizes` - Provide All Required Icon Sizes
- `brand-distinctive-icon` - Design a Distinctive Toolbar Icon
- `brand-badge-text` - Keep Badge Text Under 4 Characters
- `brand-consistent-styling` - Maintain Consistent Visual Style
- `brand-web-store-assets` - Create Quality Web Store Assets
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Chrome Extensions
Version 0.1.0 Chrome Extensions Community January 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive UX/UI design guide for Chrome Extensions, optimized for Manifest V3. Contains 42 rules across 8 categories, prioritized by impact from critical (component selection, accessibility) to incremental (icons and branding). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide extension development and code review.
---
Table of Contents
1. Component Selection — CRITICAL
- 1.1 Choose Side Panel for Persistent Tasks — CRITICAL (5-10× longer engagement sessions)
- 1.2 Design for Single Purpose — CRITICAL (required for Chrome Web Store approval)
- 1.3 Provide Descriptive Action Tooltips — CRITICAL (prevents user confusion and misdirected clicks)
- 1.4 Request Minimal Permissions — CRITICAL (40-60% higher install conversion rate)
- 1.5 Use Content Scripts for In-Page UI — CRITICAL (eliminates context switching entirely)
2. Accessibility & Navigation — CRITICAL
- 2.1 Avoid Keyboard Focus Traps — CRITICAL (WCAG 2.1.2 - users must be able to exit any UI)
- 2.2 Enable Complete Keyboard Navigation — CRITICAL (required for accessibility compliance)
- 2.3 Ensure Sufficient Color Contrast — CRITICAL (WCAG AA requires 4.5:1 for normal text)
- 2.4 Maintain Visible Focus Indicators — CRITICAL (WCAG 2.4.7 requirement for keyboard users)
- 2.5 Use ARIA Labels for Icon-Only Buttons — CRITICAL (makes UI comprehensible to screen reader users)
- 2.6 Use Semantic HTML Elements — CRITICAL (built-in keyboard and screen reader support)
3. Popup Design — HIGH
- 3.1 Design Within Popup Size Limits — HIGH (prevents content clipping and scroll issues)
- 3.2 Handle Popup Auto-Close Gracefully — HIGH (prevents data loss and user frustration)
- 3.3 Keep JavaScript in External Files — HIGH (required by Content Security Policy)
- 3.4 Make the Primary Action Obvious — HIGH (reduces time-to-action by 60-80%)
- 3.5 Render Popup Content Instantly — HIGH (200-500ms perceived as laggy by users)
- 3.6 Use Dynamic Popups for State-Based UI — HIGH (reduces cognitive load by showing relevant UI)
4. Side Panel UX — HIGH
- 4.1 Choose Tab-Specific vs Window-Wide Panels — HIGH (determines context relevance and user expectations)
- 4.2 Design for Variable Panel Widths — HIGH (prevents layout breaking when users resize)
- 4.3 Design Non-Distracting Side Panels — HIGH (increases session duration by 3-5×)
- 4.4 Lazy Load Panel Sections — HIGH (50-80% faster initial panel render)
- 4.5 Sync Panel Content with Page Context — HIGH (makes panel feel integrated rather than disconnected)
5. Content Script UI — MEDIUM-HIGH
- 5.1 Clean Up Injected Elements on Removal — MEDIUM-HIGH (prevents memory leaks and DOM pollution)
- 5.2 Inject UI After DOM Ready — MEDIUM-HIGH (prevents null reference errors and race conditions)
- 5.3 Use Maximum Z-Index for Overlays — MEDIUM-HIGH (ensures UI visibility on all websites)
- 5.4 Use Shadow DOM for Style Isolation — MEDIUM-HIGH (prevents 100% of CSS conflicts with host pages)
- 5.5 Use Unique IDs to Prevent Conflicts — MEDIUM-HIGH (prevents ID collision with page elements)
6. Visual Feedback — MEDIUM
- 6.1 Confirm Successful Actions — MEDIUM (builds user confidence and trust)
- 6.2 Show Loading States for Async Operations — MEDIUM (reduces perceived wait time by 40%)
- 6.3 Show Progress for Long Operations — MEDIUM (reduces abandonment during multi-step processes)
- 6.4 Use Badge for At-a-Glance Status — MEDIUM (communicates state without opening extension)
- 6.5 Use Notifications Sparingly — MEDIUM (prevents notification fatigue and user annoyance)
- 6.6 Write Actionable Error Messages — MEDIUM (60% faster error recovery when users know what to do)
7. Options & Settings — MEDIUM
- 7.1 Auto-Save Settings on Change — MEDIUM (eliminates lost settings and save button friction)
- 7.2 Provide Sensible Default Settings — MEDIUM (extension works immediately without configuration)
- 7.3 Sync Settings Across Devices — MEDIUM (seamless experience across user's computers)
- 7.4 Use Embedded Options for Simple Settings — MEDIUM (keeps users in context without tab switching)
8. Icons & Branding — LOW-MEDIUM
- 8.1 Create Quality Web Store Assets — LOW-MEDIUM (30-50% higher conversion from store listing)
- 8.2 Design a Distinctive Toolbar Icon — LOW-MEDIUM (improves findability in crowded toolbar)
- 8.3 Keep Badge Text Under 4 Characters — LOW-MEDIUM (ensures text is fully visible on toolbar)
- 8.4 Maintain Consistent Visual Style — LOW-MEDIUM (builds brand recognition and professional appearance)
- 8.5 Provide All Required Icon Sizes — LOW-MEDIUM (ensures crisp display across all contexts)
---
References
1. https://developer.chrome.com/docs/extensions/develop/ui 2. https://developer.chrome.com/docs/extensions/mv3/a11y/ 3. https://developer.chrome.com/docs/webstore/best-practices 4. https://developer.chrome.com/docs/extensions/develop/ui/add-popup 5. https://developer.chrome.com/docs/extensions/reference/api/sidePanel 6. https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts 7. https://developer.chrome.com/docs/webstore/branding 8. https://www.w3.org/WAI/WCAG21/Understanding/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters for Chrome extension UX/UI. Focus on user impact and development implications.
Incorrect (description of what's wrong):
// Bad code example - production-realistic
// Comment explaining the cost or problemCorrect (description of what's right):
// Good code example - minimal diff from incorrect
// Comment explaining the benefitWhen NOT to use this pattern:
- Exception 1
- Exception 2
Reference: Reference Title
{
"name": "chrome-extension-ui",
"description": "Chrome Extensions UX/UI design and implementation guidelines for popups, side panels, content scripts, and options pages",
"version": "1.0.5",
"organization": "Chrome Extensions Community",
"technology": "Chrome Extensions",
"date": "January 2026",
"tags": [
"chrome",
"extension",
"ui",
"ux",
"popup",
"side-panel",
"accessibility"
],
"totalRules": 42,
"abstract": "Comprehensive UX/UI design guide for Chrome Extensions, optimized for Manifest V3. Contains 42 rules across 8 categories, prioritized by impact from critical (component selection, accessibility) to incremental (icons and branding). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide extension development and code review.",
"references": [
"https://developer.chrome.com/docs/extensions/develop/ui",
"https://developer.chrome.com/docs/extensions/mv3/a11y/",
"https://developer.chrome.com/docs/webstore/best-practices",
"https://developer.chrome.com/docs/extensions/develop/ui/add-popup",
"https://developer.chrome.com/docs/extensions/reference/api/sidePanel",
"https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts",
"https://developer.chrome.com/docs/webstore/branding",
"https://www.w3.org/WAI/WCAG21/Understanding/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Component Selection (comp)
Impact: CRITICAL Description: Choosing the right UI surface (popup, side panel, content script) determines the extension's UX ceiling and user engagement patterns.
2. Accessibility & Navigation (access)
Impact: CRITICAL Description: Keyboard navigation, ARIA attributes, and focus management affect all users and are required for inclusive, usable extensions.
3. Popup Design (popup)
Impact: HIGH Description: Popup constraints (size limits, auto-close behavior, no state persistence) require specific patterns to maximize usability in limited space.
4. Side Panel UX (panel)
Impact: HIGH Description: Side panels enable persistent workflows alongside web content, requiring distraction-free design that enhances rather than interrupts browsing.
5. Content Script UI (inject)
Impact: MEDIUM-HIGH Description: Injecting UI into web pages requires style isolation, performance awareness, and careful conflict avoidance with host page elements.
6. Visual Feedback (feedback)
Impact: MEDIUM Description: Loading states, error handling, badges, and notifications communicate extension state and build user trust through responsive feedback.
7. Options & Settings (options)
Impact: MEDIUM Description: Settings pages need intuitive organization, proper persistence with chrome.storage, and sync capabilities across user devices.
8. Icons & Branding (brand)
Impact: LOW-MEDIUM Description: Icon design, badge text, and visual consistency reinforce brand recognition and professional appearance in the browser toolbar.
Use ARIA Labels for Icon-Only Buttons
Provide text alternatives for buttons that only display icons. Screen readers announce nothing useful for unlabeled icon buttons.
Incorrect (icon button with no accessible name):
<!-- popup.html - Screen reader announces "button" with no context -->
<button class="settings-btn">
<svg viewBox="0 0 24 24">
<path d="M19.14 12.94c.04-.31..."/>
</svg>
</button>
<button class="close-btn">
<span class="icon-x"></span>
</button>
<!-- Screen reader: "button" ... "button" - no information -->Correct (icon buttons with accessible names):
<!-- popup.html - Screen reader announces button purpose -->
<button class="settings-btn" aria-label="Open settings">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19.14 12.94c.04-.31..."/>
</svg>
</button>
<button class="close-btn" aria-label="Close panel">
<span class="icon-x" aria-hidden="true"></span>
</button>
<!-- Screen reader: "Open settings, button" ... "Close panel, button" -->Alternative: visually hidden text:
<button class="settings-btn">
<svg aria-hidden="true"><!-- icon --></svg>
<span class="visually-hidden">Open settings</span>
</button>
<style>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>Dynamic state in labels:
// Update label to reflect current state
function updateMuteButton(isMuted) {
const button = document.getElementById('mute-btn')
button.setAttribute('aria-label', isMuted ? 'Unmute audio' : 'Mute audio')
button.setAttribute('aria-pressed', isMuted)
}Reference: WAI-ARIA Authoring Practices
Ensure Sufficient Color Contrast
Maintain minimum contrast ratios between text and background colors. Low contrast text is unreadable for users with visual impairments and difficult for everyone in bright conditions.
Incorrect (insufficient contrast):
/* styles.css - Light gray on white fails WCAG */
.popup-container {
background: #ffffff;
}
.hint-text {
color: #aaaaaa; /* 2.3:1 contrast - FAILS */
}
.disabled-text {
color: #cccccc; /* 1.6:1 contrast - FAILS */
}
.link {
color: #6699ff; /* 2.9:1 contrast - FAILS for normal text */
}Correct (WCAG AA compliant contrast):
/* styles.css - All text meets 4.5:1 minimum */
.popup-container {
background: #ffffff;
}
.hint-text {
color: #767676; /* 4.5:1 contrast - PASSES */
}
.disabled-text {
color: #767676; /* 4.5:1 contrast - PASSES */
opacity: 0.7; /* Visual distinction through opacity */
}
.link {
color: #0066cc; /* 5.9:1 contrast - PASSES */
}
/* Large text (18px+ or 14px+ bold) only needs 3:1 */
.heading {
font-size: 18px;
color: #595959; /* 7:1 contrast - exceeds requirement */
}Contrast requirements summary:
| Element Type | Minimum Ratio |
|---|---|
| Normal text (< 18px) | 4.5:1 |
| Large text (≥ 18px or ≥ 14px bold) | 3:1 |
| UI components & graphics | 3:1 |
| Disabled elements | No requirement (but consider usability) |
Testing contrast in your extension:
// Add to development build - logs contrast issues
function checkContrast(element) {
const styles = getComputedStyle(element)
const textColor = styles.color
const bgColor = styles.backgroundColor
// Use a library like 'color-contrast-checker' for calculation
console.log(`${element.className}: ${textColor} on ${bgColor}`)
}Reference: WCAG 1.4.3 Contrast
Avoid Keyboard Focus Traps
Ensure keyboard users can always navigate out of any component. A focus trap occurs when Tab/Shift+Tab cycling never escapes an element, leaving users stuck.
Incorrect (modal traps focus permanently):
// modal.js - No way to close with keyboard
function openModal() {
const modal = document.getElementById('modal')
modal.style.display = 'block'
modal.querySelector('input').focus()
// Focus trapped - no Escape handler, no close button focus
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
const focusable = modal.querySelectorAll('input, button')
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
last.focus()
e.preventDefault()
} else if (!e.shiftKey && document.activeElement === last) {
first.focus()
e.preventDefault()
}
}
})
// Keyboard user cannot escape modal
}Correct (focus contained but escapable):
// modal.js - Escape key and close button allow exit
function openModal() {
const modal = document.getElementById('modal')
const closeButton = modal.querySelector('.close-btn')
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstFocusable = focusableElements[0]
const lastFocusable = focusableElements[focusableElements.length - 1]
modal.style.display = 'block'
firstFocusable.focus()
function handleKeydown(event) {
if (event.key === 'Escape') {
closeModal()
return
}
if (event.key === 'Tab') {
if (event.shiftKey && document.activeElement === firstFocusable) {
lastFocusable.focus()
event.preventDefault()
} else if (!event.shiftKey && document.activeElement === lastFocusable) {
firstFocusable.focus()
event.preventDefault()
}
}
}
function closeModal() {
modal.style.display = 'none'
document.removeEventListener('keydown', handleKeydown)
previouslyFocusedElement.focus() // Return focus to trigger
}
document.addEventListener('keydown', handleKeydown)
closeButton.addEventListener('click', closeModal)
}Focus trap requirements:
- Escape key must close dialogs/modals
- Close button must be keyboard accessible
- Focus returns to the element that opened the dialog
Reference: WCAG 2.1.2 No Keyboard Trap
Maintain Visible Focus Indicators
Never remove focus outlines without providing an alternative. Keyboard users must see which element is currently focused to navigate your extension.
Incorrect (focus indicator removed):
/* styles.css - Common anti-pattern removes all focus */
*:focus {
outline: none;
}
button:focus {
outline: 0;
}
/* Keyboard user has no idea which button is focused */Correct (clear focus indicator preserved):
/* styles.css - Custom focus style that's visible */
*:focus {
outline: 2px solid #4A90D9;
outline-offset: 2px;
}
/* Use :focus-visible for mouse vs keyboard differentiation */
button:focus {
outline: none;
}
button:focus-visible {
outline: 2px solid #4A90D9;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(74, 144, 217, 0.3);
}
/* High contrast mode support */
@media (forced-colors: active) {
button:focus-visible {
outline: 3px solid CanvasText;
}
}Focus indicator requirements:
- Minimum 3:1 contrast ratio against adjacent colors
- At least 2px thick outline or equivalent visual change
- Visible in both light and dark modes
- Not obscured by other elements
Testing keyboard focus:
// Debug focus visibility during development
document.addEventListener('focusin', (event) => {
console.log('Focused:', event.target)
const styles = getComputedStyle(event.target)
console.log('Outline:', styles.outline, 'Box-shadow:', styles.boxShadow)
})Reference: WCAG 2.4.7 Focus Visible
Enable Complete Keyboard Navigation
Ensure every interactive element is reachable and operable via keyboard. Users who cannot use a mouse, and power users who prefer keyboard, must access all features.
Incorrect (keyboard users blocked):
<!-- popup.html - Custom buttons not keyboard accessible -->
<div class="toolbar">
<div class="icon-button" onclick="handleSave()">
<img src="save.svg" alt="Save">
</div>
<div class="icon-button" onclick="handleShare()">
<img src="share.svg" alt="Share">
</div>
<div class="icon-button" onclick="handleDelete()">
<img src="delete.svg" alt="Delete">
</div>
</div>
<!-- Keyboard user: Tab key skips these entirely -->Correct (full keyboard support):
<!-- popup.html - All buttons keyboard accessible -->
<div class="toolbar" role="toolbar" aria-label="Actions">
<button type="button" class="icon-button" onclick="handleSave()">
<img src="save.svg" alt="">
<span class="visually-hidden">Save</span>
</button>
<button type="button" class="icon-button" onclick="handleShare()">
<img src="share.svg" alt="">
<span class="visually-hidden">Share</span>
</button>
<button type="button" class="icon-button" onclick="handleDelete()">
<img src="delete.svg" alt="">
<span class="visually-hidden">Delete</span>
</button>
</div>
<!-- Keyboard user: Tab navigates to each button, Enter/Space activates -->Custom element keyboard support:
// For custom interactive elements, add tabindex and key handlers
const customDropdown = document.querySelector('.custom-dropdown')
customDropdown.setAttribute('tabindex', '0')
customDropdown.setAttribute('role', 'combobox')
customDropdown.addEventListener('keydown', (event) => {
switch (event.key) {
case 'Enter':
case ' ':
toggleDropdown()
event.preventDefault()
break
case 'ArrowDown':
selectNextOption()
event.preventDefault()
break
case 'ArrowUp':
selectPreviousOption()
event.preventDefault()
break
case 'Escape':
closeDropdown()
break
}
})Reference: Support Accessibility
Use Semantic HTML Elements
Use native HTML elements (<button>, <input>, <select>) instead of styled <div> or <span> elements for interactive components. Native elements provide keyboard support and screen reader announcements automatically.
Incorrect (div-based interactive elements):
<!-- popup.html - Requires manual accessibility implementation -->
<div class="btn primary" onclick="handleSubmit()">
Submit
</div>
<div class="checkbox" onclick="toggleCheck()">
<span class="check-icon"></span>
Enable notifications
</div>
<div class="dropdown" onclick="openOptions()">
<span class="selected">Choose option</span>
<span class="arrow"></span>
</div>
<!-- No keyboard support, no screen reader roles, no form integration -->Correct (native HTML elements):
<!-- popup.html - Accessibility built-in -->
<button type="submit" class="btn primary">
Submit
</button>
<label class="checkbox">
<input type="checkbox" name="notifications">
<span class="check-icon" aria-hidden="true"></span>
Enable notifications
</label>
<select class="dropdown" name="option">
<option value="">Choose option</option>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
<!-- Keyboard accessible, screen reader compatible, form-ready -->Native element benefits:
| Native Element | Built-in Features |
|---|---|
<button> | Focus, Enter/Space activation, disabled state |
<input type="checkbox"> | Toggle with Space, checked state announced |
<select> | Arrow key navigation, type-ahead search |
<a href> | Focus, Enter activation, visited state |
<input type="text"> | Text editing, form validation, autocomplete |
When custom components are unavoidable:
<!-- Add ALL required ARIA attributes and keyboard handlers -->
<div role="checkbox"
tabindex="0"
aria-checked="false"
aria-labelledby="notif-label"
onkeydown="handleCheckboxKeydown(event)"
onclick="toggleCheck()">
<span class="check-icon" aria-hidden="true"></span>
</div>
<span id="notif-label">Enable notifications</span>Reference: Using ARIA
Keep Badge Text Under 4 Characters
Limit badge text to 4 characters maximum. Longer text gets clipped and becomes unreadable in the small badge space.
Incorrect (badge text too long):
// background.js - Text gets clipped
chrome.action.setBadgeText({ text: 'UPDATED' }) // Shows: "UPDA"
chrome.action.setBadgeText({ text: '12345' }) // Shows: "1234"
chrome.action.setBadgeText({ text: 'NEW!' }) // Shows: "NEW!" but crampedCorrect (concise badge text):
// background.js - Text fits badge
chrome.action.setBadgeText({ text: '3' }) // Single digit count
chrome.action.setBadgeText({ text: '99+' }) // Capped count
chrome.action.setBadgeText({ text: 'ON' }) // Status indicator
chrome.action.setBadgeText({ text: '!' }) // Alert symbol
chrome.action.setBadgeText({ text: '✓' }) // Success indicatorBadge text patterns:
// background.js - Smart badge text formatting
function formatBadgeCount(count) {
if (count === 0) return ''
if (count < 100) return String(count)
if (count < 1000) return '99+'
if (count < 10000) return `${Math.floor(count / 1000)}k`
return '9k+'
}
async function updateBadge(itemCount) {
await chrome.action.setBadgeText({ text: formatBadgeCount(itemCount) })
}
// Usage
updateBadge(5) // "5"
updateBadge(42) // "42"
updateBadge(150) // "99+"
updateBadge(2500) // "2k"
updateBadge(15000) // "9k+"Status badges using symbols:
// background.js - Symbol-based status badges
const STATUS_BADGES = {
syncing: '↻',
success: '✓',
error: '!',
paused: '⏸',
active: '●',
new: 'N'
}
async function setStatusBadge(status) {
const text = STATUS_BADGES[status] || ''
const colors = {
syncing: '#17a2b8',
success: '#28a745',
error: '#dc3545',
paused: '#6c757d',
active: '#28a745',
new: '#007bff'
}
await chrome.action.setBadgeText({ text })
await chrome.action.setBadgeBackgroundColor({ color: colors[status] || '#666' })
}Badge visibility rules:
- Empty string (
'') hides the badge completely - Single character is most readable
- 2-3 characters work well
- 4 characters maximum before clipping
- Avoid lowercase letters (harder to read at small size)
Reference: Action API Badge
Maintain Consistent Visual Style
Apply consistent colors, typography, and spacing across all extension surfaces (popup, side panel, options, injected UI). Inconsistent styling looks unprofessional and confuses users.
Incorrect (inconsistent styling):
/* popup.css */
.btn { background: #007bff; border-radius: 4px; font-size: 14px; }
/* options.css */
.button { background: #0066cc; border-radius: 8px; font-size: 16px; }
/* content.css */
.action-btn { background: blue; border-radius: 0; font-size: 12px; }
/* Three different blues, three different radii, three different sizes */Correct (unified design system):
/* shared/variables.css - Single source of truth */
:root {
/* Brand colors */
--color-primary: #4A90D9;
--color-primary-hover: #357ABD;
--color-primary-active: #2868A8;
--color-success: #28a745;
--color-warning: #ffc107;
--color-error: #dc3545;
/* Neutrals */
--color-text: #333333;
--color-text-secondary: #666666;
--color-border: #e0e0e0;
--color-background: #ffffff;
--color-background-secondary: #f5f5f5;
/* Typography */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-size-small: 12px;
--font-size-base: 14px;
--font-size-large: 16px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
/* Borders */
--border-radius: 6px;
--border-width: 1px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* shared/components.css - Reusable components */
.btn {
padding: var(--spacing-sm) var(--spacing-md);
font-family: var(--font-family);
font-size: var(--font-size-base);
border-radius: var(--border-radius);
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
.btn-primary:active {
background: var(--color-primary-active);
}Using the design system:
<!-- popup.html -->
<link rel="stylesheet" href="shared/variables.css">
<link rel="stylesheet" href="shared/components.css">
<link rel="stylesheet" href="popup.css">
<!-- options.html -->
<link rel="stylesheet" href="shared/variables.css">
<link rel="stylesheet" href="shared/components.css">
<link rel="stylesheet" href="options.css">For Shadow DOM (content scripts):
// content.js - Include design system in Shadow DOM
const styles = await fetch(chrome.runtime.getURL('shared/variables.css')).then(r => r.text())
const components = await fetch(chrome.runtime.getURL('shared/components.css')).then(r => r.text())
const shadow = host.attachShadow({ mode: 'closed' })
shadow.innerHTML = `
<style>${styles}\n${components}</style>
<div class="extension-ui">
<button class="btn btn-primary">Save</button>
</div>
`Reference: Web Content Accessibility Guidelines - Consistency
Design a Distinctive Toolbar Icon
Create a unique, recognizable icon that stands out in the browser toolbar. Generic icons blend into the crowd, making your extension hard to find.
Incorrect (generic, hard to distinguish):
Common mistakes:
- Generic gear/cog icon (looks like settings)
- Plain letter in a circle (looks like every other extension)
- Stock icon from free icon library (not unique)
- Too much detail at 16px (becomes muddy)
- Monochrome gray (blends with browser chrome)Correct (distinctive and recognizable):
Design principles for toolbar icons:
1. Simple, bold silhouette
- Recognizable at 16×16 pixels
- Clear shape even when squinting
2. Distinctive color
- Use your brand color if recognizable
- Avoid browser chrome colors (gray, black)
- Consider colorblind-friendly palettes
3. Unique concept
- Don't use generic symbols (gear, star, heart)
- Create something ownable to your brand
- Consider combining two concepts (e.g., magnifying glass + bookmark)
4. Consistent with purpose
- Icon should hint at functionality
- Screenshot tool → camera/crop marks
- Password manager → lock/key
- Note-taking → pencil/paperIcon design checklist:
// Programmatic icon state for different states
async function setIconState(state) {
const iconPaths = {
active: {
16: 'icons/active-16.png',
32: 'icons/active-32.png'
},
inactive: {
16: 'icons/inactive-16.png',
32: 'icons/inactive-32.png'
},
alert: {
16: 'icons/alert-16.png',
32: 'icons/alert-32.png'
}
}
await chrome.action.setIcon({ path: iconPaths[state] })
}
// Change icon based on extension state
chrome.storage.onChanged.addListener((changes) => {
if (changes.enabled) {
setIconState(changes.enabled.newValue ? 'active' : 'inactive')
}
})Testing icon visibility:
| Test | Pass Criteria |
|---|---|
| 16px preview | Shape clearly recognizable |
| Grayscale | Still distinguishable |
| On light/dark toolbar | Visible on both |
| Next to other extensions | Stands out from common icons |
| Color blindness simulation | Remains distinct |
Reference: Extension Branding
Provide All Required Icon Sizes
Supply icons in all required sizes (16, 32, 48, 128px) to ensure crisp display everywhere. Missing sizes cause Chrome to scale existing icons, resulting in blurry appearance.
Incorrect (only one icon size):
{
"icons": {
"128": "icon.png"
},
"action": {
"default_icon": "icon.png"
}
}
// Chrome scales 128px icon to 16px for toolbar → blurry messCorrect (all sizes provided):
{
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_icon": {
"16": "icons/icon-16.png",
"24": "icons/icon-24.png",
"32": "icons/icon-32.png"
}
}
}Icon size usage:
| Size | Where Used |
|---|---|
| 16×16 | Toolbar, favicon, context menu |
| 24×24 | Toolbar on Windows (125% DPI) |
| 32×32 | Toolbar on Retina/HiDPI displays |
| 48×48 | Extensions management page |
| 128×128 | Chrome Web Store, install dialog |
Creating optimized icons for each size:
# Don't just resize - optimize each size
# Small icons need simplified detail
# Generate from SVG with optimized rendering
convert icon.svg -resize 16x16 -density 96 icons/icon-16.png
convert icon.svg -resize 32x32 -density 96 icons/icon-32.png
convert icon.svg -resize 48x48 -density 96 icons/icon-48.png
convert icon.svg -resize 128x128 -density 96 icons/icon-128.pngIcon design per size:
| Size | Design Approach |
|---|---|
| 16px | Simplify to essential shape, thicken lines, remove fine detail |
| 32px | Moderate detail, clear silhouette |
| 48px | Good detail visible, balanced complexity |
| 128px | Full detail, gradients, shadows acceptable |
PNG requirements:
- Format: PNG-24 with alpha transparency
- Background: Transparent (no solid background)
- Shape: Square canvas, icon can be any shape within
- Color profile: sRGB
Reference: Configure Extension Icons
Create Quality Web Store Assets
Prepare high-quality screenshots and promotional images for the Chrome Web Store. Poor listing assets dramatically reduce install conversion rates.
Incorrect (minimal effort assets):
Common mistakes:
- Screenshots of empty/default state
- Low resolution or blurry images
- No annotations explaining features
- Generic promotional tile from template
- Screenshots showing errors or loading statesCorrect (professional store assets):
Required screenshots (1280×800 or 640×400):
Screenshot best practices:
1. Show the extension in action
- Populated with realistic data
- Demonstrating key features
- On a real-looking webpage
2. Annotate key features
- Callout boxes pointing to features
- Brief explanatory text
- Consistent annotation style
3. Show multiple use cases
- Screenshot 1: Primary feature
- Screenshot 2: Secondary feature
- Screenshot 3: Settings/customization
- Screenshot 4: Results/output
4. Use high contrast
- Extension UI should stand out
- Consider adding subtle shadow/border around extensionPromotional tile guidelines:
Small tile (440×280):
- Extension icon prominently displayed
- Extension name in clear, readable font
- Brief tagline (5-7 words max)
- Brand colors consistent with icon
Large tile (920×680) - optional but recommended:
- Feature showcase
- Before/after demonstration
- Multiple feature callouts
- Still readable at small display sizesScreenshot creation script:
// scripts/capture-screenshots.js
// Automate screenshot capture for consistency
async function captureScreenshot(scenario) {
// Set up extension with demo data
await chrome.storage.local.set(scenario.demoData)
// Navigate to appropriate page
await chrome.tabs.update({ url: scenario.url })
// Wait for page load
await new Promise(resolve => setTimeout(resolve, 2000))
// Open extension UI
if (scenario.openPopup) {
// Capture popup
}
// Take screenshot
const screenshot = await chrome.tabs.captureVisibleTab({
format: 'png',
quality: 100
})
return screenshot
}
const scenarios = [
{
name: 'main-feature',
demoData: { items: sampleItems },
url: 'https://example.com',
openPopup: true
},
// ... more scenarios
]Asset checklist:
| Asset | Size | Required |
|---|---|---|
| Icon 128×128 | 128×128 px | Yes |
| Screenshots | 1280×800 or 640×400 | Yes (1-5) |
| Small promo tile | 440×280 px | Recommended |
| Large promo tile | 920×680 px | Optional |
| Marquee | 1400×560 px | Optional |
Reference: Creating a Great Listing Page
Provide Descriptive Action Tooltips
Set a clear, action-oriented tooltip for your extension icon. The default shows only the extension name, which doesn't tell users what clicking will do.
Incorrect (no tooltip or name-only):
{
"name": "PageMark",
"action": {
"default_icon": "icon.png"
}
}
// Tooltip shows: "PageMark"
// User thinks: "What does clicking this do?"Correct (descriptive action tooltip):
{
"name": "PageMark",
"action": {
"default_icon": "icon.png",
"default_title": "Save this page to your reading list"
}
}
// Tooltip shows: "Save this page to your reading list"
// User knows exactly what clicking will doDynamic tooltips for state:
// Update tooltip to reflect current state
async function updateTooltipState(tabId, isSaved) {
await chrome.action.setTitle({
tabId,
title: isSaved
? 'Page saved - Click to view or remove'
: 'Save this page to your reading list'
})
}
// Update on tab change
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
const tab = await chrome.tabs.get(tabId)
const isSaved = await checkIfPageSaved(tab.url)
await updateTooltipState(tabId, isSaved)
})Tooltip best practices:
- Start with a verb: "Save", "Block", "Translate", "Capture"
- Keep under 45 characters for full visibility
- Update dynamically to reflect current state
- Match the tooltip to what actually happens on click
Reference: Action API
Use Content Scripts for In-Page UI
Inject UI directly into web pages when users need to interact with page content. Forcing users to switch between page and popup/panel creates friction and breaks flow.
Incorrect (context switch to popup):
// popup.js - User must click extension icon, breaking reading flow
document.getElementById('highlight-btn').addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
await chrome.tabs.sendMessage(tab.id, { action: 'highlight' })
})
// content.js
chrome.runtime.onMessage.addListener((message) => {
if (message.action === 'highlight') {
highlightSelectedText()
}
})
// User: select text → click icon → click button → return to pageCorrect (inline UI appears at selection):
// content.js - Toolbar appears directly at text selection
document.addEventListener('mouseup', (event) => {
const selection = window.getSelection()
if (selection.toString().trim()) {
showInlineToolbar(event.clientX, event.clientY)
}
})
function showInlineToolbar(x, y) {
const toolbar = document.createElement('div')
toolbar.className = 'extension-inline-toolbar'
toolbar.innerHTML = `
<button data-action="highlight">Highlight</button>
<button data-action="note">Add Note</button>
`
toolbar.style.cssText = `position: fixed; left: ${x}px; top: ${y}px;`
document.body.appendChild(toolbar)
}
// User: select text → toolbar appears → click → doneWhen NOT to use content script UI:
- Complex multi-step workflows requiring significant screen space
- Settings or configuration interfaces
- Features unrelated to current page content
Reference: Content Scripts
Request Minimal Permissions
Request only the permissions your extension absolutely needs. Excessive permissions trigger browser warnings that scare users away during installation.
Incorrect (over-permissioned manifest):
{
"name": "Reading Time Calculator",
"permissions": [
"tabs",
"activeTab",
"storage",
"history",
"bookmarks",
"webRequest",
"<all_urls>"
],
"host_permissions": [
"*://*/*"
]
}
// Warning: "Read and change all your data on all websites"
// User sees scary warning → abandons installationCorrect (minimal required permissions):
{
"name": "Reading Time Calculator",
"permissions": [
"activeTab"
]
}
// Warning: "Read and change data on the site you're viewing"
// Minimal warning → higher trust → more installationsPermission reduction strategies:
| Instead of... | Use... | Benefit |
|---|---|---|
tabs | activeTab | No warning about reading all tabs |
<all_urls> | Specific domains | Scoped access warning |
webRequest | declarativeNetRequest | Modern, safer API |
Upfront host_permissions | Optional permissions | Request when needed |
Using optional permissions:
// Request permission only when user needs the feature
document.getElementById('advanced-feature').addEventListener('click', async () => {
const granted = await chrome.permissions.request({
permissions: ['history'],
origins: ['https://example.com/*']
})
if (granted) {
enableAdvancedFeature()
}
})Reference: Declare Permissions
Choose Side Panel for Persistent Tasks
Use a side panel instead of a popup when users need to reference extension content while browsing. Popups close automatically when users click outside, forcing repeated re-opening for ongoing tasks.
Incorrect (popup closes on every page interaction):
// manifest.json
{
"action": {
"default_popup": "popup.html"
}
}
// popup.html - User loses context when clicking the page
<div class="note-taking-app">
<textarea id="notes">User's research notes...</textarea>
<button id="save">Save</button>
</div>
// User clicks page to copy text → popup closes → notes hiddenCorrect (side panel persists alongside browsing):
// manifest.json
{
"side_panel": {
"default_path": "sidepanel.html"
},
"permissions": ["sidePanel"]
}
// sidepanel.html - Remains visible while user browses
<div class="note-taking-app">
<textarea id="notes">User's research notes...</textarea>
<button id="save">Save</button>
</div>
// User clicks page to copy text → panel stays open → uninterrupted workflowWhen to use popup instead:
- Quick, one-shot actions (toggle setting, copy to clipboard)
- Actions completed in under 5 seconds
- No need to reference content while on the page
Reference: Side Panel API
Design for Single Purpose
Build extensions around one narrowly defined purpose. Chrome Web Store requires extensions to have a single, easily understood function. Multi-purpose extensions face rejection and user confusion.
Incorrect (multiple unrelated features):
// manifest.json - Bundled unrelated functionality
{
"name": "Super Browser Tools",
"description": "Screenshot capture, ad blocker, password manager, and weather widget",
"permissions": [
"tabs", "activeTab", "webRequest", "storage",
"geolocation", "clipboardWrite", "downloads"
]
}
// popup.html - Cluttered interface with unrelated tools
<div class="super-tools">
<section id="screenshots">Screenshot Tools</section>
<section id="adblock">Ad Blocking Settings</section>
<section id="passwords">Password Vault</section>
<section id="weather">Weather Widget</section>
</div>
// Violates single-purpose policy, confuses usersCorrect (focused, single-purpose extension):
// manifest.json - Clear, focused functionality
{
"name": "Quick Screenshot",
"description": "Capture, annotate, and share screenshots instantly",
"permissions": [
"activeTab", "clipboardWrite", "downloads"
]
}
// popup.html - All features serve one purpose
<div class="screenshot-tools">
<button id="capture-visible">Capture Visible Area</button>
<button id="capture-full">Capture Full Page</button>
<button id="capture-selection">Select Area</button>
<div id="annotation-tools"><!-- Drawing, text, shapes --></div>
<div id="share-options"><!-- Copy, download, upload --></div>
</div>
// Clear purpose: everything relates to screenshotsIdentifying your single purpose:
- Can you describe the extension in one sentence without "and"?
- Do all permissions directly support that sentence?
- Would removing any feature break the core value?
Reference: Extension Quality Guidelines
Use Badge for At-a-Glance Status
Use the action badge to show important status without requiring users to open the extension. Keep badge text short (4 characters max visible).
Incorrect (no badge feedback):
// background.js - User must open extension to see status
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'NEW_ITEMS') {
// Status only visible inside popup
chrome.storage.local.set({ unreadCount: message.count })
}
})
// User has no idea there are new items without opening extensionCorrect (badge shows status at a glance):
// background.js - Badge communicates status
async function updateBadge() {
const { unreadCount } = await chrome.storage.local.get('unreadCount')
if (unreadCount > 0) {
// Show count (max 4 chars visible)
const text = unreadCount > 99 ? '99+' : String(unreadCount)
await chrome.action.setBadgeText({ text })
await chrome.action.setBadgeBackgroundColor({ color: '#dc3545' })
} else {
await chrome.action.setBadgeText({ text: '' })
}
}
// Update badge when data changes
chrome.storage.onChanged.addListener((changes) => {
if (changes.unreadCount) {
updateBadge()
}
})Status-based badge colors:
// background.js - Color-coded status badges
async function setStatusBadge(status) {
const statusConfig = {
success: { text: '✓', color: '#28a745' },
warning: { text: '!', color: '#ffc107' },
error: { text: '✗', color: '#dc3545' },
syncing: { text: '↻', color: '#17a2b8' },
paused: { text: '⏸', color: '#6c757d' },
active: { text: 'ON', color: '#28a745' },
inactive: { text: 'OFF', color: '#6c757d' }
}
const config = statusConfig[status] || { text: '', color: '#666' }
await chrome.action.setBadgeText({ text: config.text })
await chrome.action.setBadgeBackgroundColor({ color: config.color })
}
// Usage examples
setStatusBadge('active') // Green "ON" badge
setStatusBadge('error') // Red "✗" badge
setStatusBadge('syncing') // Blue "↻" badgeTab-specific badges:
// background.js - Different badge per tab
async function updateBadgeForTab(tabId, count) {
const text = count > 0 ? String(count) : ''
await chrome.action.setBadgeText({ tabId, text })
await chrome.action.setBadgeBackgroundColor({
tabId,
color: count > 0 ? '#4A90D9' : '#666'
})
}
// Each tab shows its own count
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
const tabData = await getDataForTab(tabId)
updateBadgeForTab(tabId, tabData.itemCount)
})Badge best practices:
- Use sparingly—constant badges lose meaning
- Clear badge after user acknowledges (opens popup)
- Use color meaningfully (red = attention needed)
Reference: Action API Badge
Write Actionable Error Messages
Provide specific, actionable error messages that tell users what happened and how to fix it. Generic errors leave users confused and helpless.
Incorrect (vague, unhelpful errors):
// popup.js - Generic error messages
async function saveBookmark() {
try {
await chrome.bookmarks.create({ title, url })
} catch (error) {
showError('Error occurred') // User: "What error? What do I do?"
}
}
async function syncData() {
try {
await fetch(apiUrl)
} catch (error) {
showError('Something went wrong') // User: "Great, now what?"
}
}Correct (specific, actionable errors):
// popup.js - Helpful error messages
async function saveBookmark() {
try {
await chrome.bookmarks.create({ title, url })
} catch (error) {
if (error.message.includes('invalid url')) {
showError({
title: 'Invalid URL',
message: 'The URL format is not valid. Check for typos.',
action: { label: 'Edit URL', handler: focusUrlInput }
})
} else if (error.message.includes('quota')) {
showError({
title: 'Storage Full',
message: 'You\'ve reached the bookmark limit. Delete some bookmarks to add more.',
action: { label: 'Manage Bookmarks', handler: openBookmarkManager }
})
} else {
showError({
title: 'Could Not Save Bookmark',
message: 'An unexpected error occurred. Try again or restart Chrome.',
action: { label: 'Try Again', handler: () => saveBookmark() }
})
}
}
}
async function syncData() {
try {
const response = await fetch(apiUrl)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return await response.json()
} catch (error) {
if (!navigator.onLine) {
showError({
title: 'No Internet Connection',
message: 'Check your network connection and try again.',
action: { label: 'Retry', handler: () => syncData() }
})
} else if (error.message.includes('401')) {
showError({
title: 'Session Expired',
message: 'Please sign in again to continue.',
action: { label: 'Sign In', handler: openSignInPage }
})
} else {
showError({
title: 'Sync Failed',
message: 'Unable to reach our servers. Try again in a few minutes.',
action: { label: 'Retry', handler: () => syncData() }
})
}
}
}
function showError({ title, message, action }) {
const errorEl = document.getElementById('error-container')
errorEl.innerHTML = `
<div class="error-banner" role="alert">
<strong>${title}</strong>
<p>${message}</p>
${action ? `<button id="error-action">${action.label}</button>` : ''}
</div>
`
if (action) {
document.getElementById('error-action').onclick = action.handler
}
}Error message checklist:
- What happened? (specific title)
- Why did it happen? (explanation)
- What can the user do? (actionable button)
Reference: Error Message UX
Show Loading States for Async Operations
Display immediate feedback when operations take more than 100ms. Users perceive unresponsive UI as broken; loading indicators set expectations.
Incorrect (no feedback during loading):
// popup.js - User sees nothing while data loads
async function loadData() {
const response = await fetch('https://api.example.com/data')
const data = await response.json()
renderData(data)
}
document.addEventListener('DOMContentLoaded', loadData)
// User: clicks extension → blank screen for 2 seconds → content appears
// User thinks: "Is it broken?"Correct (immediate loading feedback):
// popup.js - Clear loading indication
async function loadData() {
const container = document.getElementById('content')
// Show loading state immediately
container.innerHTML = `
<div class="loading-state">
<div class="spinner" role="status" aria-label="Loading"></div>
<p>Loading your data...</p>
</div>
`
try {
const response = await fetch('https://api.example.com/data')
const data = await response.json()
renderData(data)
} catch (error) {
container.innerHTML = `
<div class="error-state">
<p>Unable to load data</p>
<button onclick="loadData()">Try again</button>
</div>
`
}
}Skeleton loading for better UX:
// popup.js - Skeleton screens reduce perceived wait
function showSkeleton() {
return `
<div class="item-skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-lines">
<div class="skeleton-line" style="width: 80%"></div>
<div class="skeleton-line" style="width: 60%"></div>
</div>
</div>
<div class="item-skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-lines">
<div class="skeleton-line" style="width: 70%"></div>
<div class="skeleton-line" style="width: 50%"></div>
</div>
</div>
`
}
// CSS for skeleton animation
const skeletonStyles = `
.skeleton-avatar, .skeleton-line {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`Button loading state:
// popup.js - Disable and indicate loading on buttons
async function handleSubmit(event) {
const button = event.target
const originalText = button.textContent
button.disabled = true
button.innerHTML = '<span class="spinner-small"></span> Saving...'
try {
await saveData()
button.textContent = 'Saved!'
setTimeout(() => {
button.textContent = originalText
button.disabled = false
}, 1500)
} catch (error) {
button.textContent = 'Failed - Retry'
button.disabled = false
}
}Reference: UX Pattern Analysis: Loading
Use Notifications Sparingly
Reserve system notifications for truly important events. Overusing notifications trains users to ignore them or disable your extension entirely.
Incorrect (notification spam):
// background.js - Too many notifications
async function onDataSync() {
await syncData()
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon.png',
title: 'Sync Complete',
message: 'Your data has been synced'
})
// Fires every 5 minutes → user disables notifications or extension
}
async function onNewItem(item) {
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon.png',
title: 'New Item',
message: `${item.title} was added`
})
// 20 new items = 20 notifications → user rage-quits
}Correct (meaningful notifications only):
// background.js - Thoughtful notification strategy
const NOTIFICATION_COOLDOWN = 30 * 60 * 1000 // 30 minutes
let lastNotificationTime = 0
async function shouldNotify(importance) {
// Check user preferences
const { notificationsEnabled } = await chrome.storage.sync.get('notificationsEnabled')
if (!notificationsEnabled) return false
// Respect cooldown for non-critical notifications
if (importance !== 'critical') {
const now = Date.now()
if (now - lastNotificationTime < NOTIFICATION_COOLDOWN) {
return false
}
}
return true
}
async function notifyIfImportant(event) {
const notificationRules = {
// Critical - always notify
'subscription_expiring': {
importance: 'critical',
title: 'Subscription Expiring',
message: 'Your subscription expires tomorrow'
},
// Important - notify with cooldown
'important_update': {
importance: 'high',
title: 'Important Update Available',
message: 'A security update is ready to install'
},
// Batched - collect and summarize
'new_items': {
importance: 'low',
batch: true
}
}
const rule = notificationRules[event.type]
if (!rule) return
if (rule.batch) {
// Batch low-priority events
await addToBatch(event)
return
}
if (await shouldNotify(rule.importance)) {
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon.png',
title: rule.title,
message: rule.message,
priority: rule.importance === 'critical' ? 2 : 0
})
lastNotificationTime = Date.now()
}
}
// Batch notifications - summarize multiple events
let batchedItems = []
let batchTimeout = null
async function addToBatch(event) {
batchedItems.push(event)
// Debounce batch notification
clearTimeout(batchTimeout)
batchTimeout = setTimeout(async () => {
if (batchedItems.length > 0 && await shouldNotify('low')) {
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon.png',
title: 'New Items',
message: `${batchedItems.length} new items since last check`
})
batchedItems = []
lastNotificationTime = Date.now()
}
}, 60000) // Wait 1 minute to batch
}Notification worthiness guide:
| Event | Notify? | Why |
|---|---|---|
| Sync complete | No | Expected background operation |
| New item added | Badge only | Low urgency, user can check later |
| Important deadline | Yes | Time-sensitive, user needs to act |
| Error requiring action | Yes | User must intervene |
| Extension updated | Once | Informational, not recurring |
Reference: Notifications API
Show Progress for Long Operations
Display progress indicators for operations taking more than 2 seconds. Show either percentage complete or steps remaining so users know the operation is progressing.
Incorrect (no progress feedback):
// popup.js - User has no idea how long this will take
async function processFiles(files) {
showMessage('Processing...')
for (const file of files) {
await processFile(file)
}
showMessage('Done!')
}
// User sees "Processing..." for 30 seconds with no indication of progress
// User thinks it's frozen and closes the popupCorrect (clear progress indication):
// popup.js - Progress visible throughout operation
async function processFiles(files) {
const progressBar = document.getElementById('progress-bar')
const progressText = document.getElementById('progress-text')
const progressContainer = document.getElementById('progress-container')
progressContainer.style.display = 'block'
progressBar.style.width = '0%'
const total = files.length
let completed = 0
for (const file of files) {
progressText.textContent = `Processing ${file.name}...`
await processFile(file)
completed++
const percentage = Math.round((completed / total) * 100)
progressBar.style.width = `${percentage}%`
progressText.textContent = `${completed} of ${total} files (${percentage}%)`
}
progressText.textContent = 'Complete!'
setTimeout(() => {
progressContainer.style.display = 'none'
}, 1500)
}Step-based progress for multi-stage operations:
// popup.js - Named steps for complex processes
async function setupExtension() {
const steps = [
{ id: 'auth', label: 'Signing in...', fn: authenticate },
{ id: 'sync', label: 'Syncing data...', fn: syncData },
{ id: 'index', label: 'Building index...', fn: buildIndex },
{ id: 'finalize', label: 'Finalizing...', fn: finalize }
]
const progressEl = document.getElementById('setup-progress')
for (let i = 0; i < steps.length; i++) {
const step = steps[i]
// Update UI to show current step
progressEl.innerHTML = `
<div class="step-progress">
<div class="step-indicator">Step ${i + 1} of ${steps.length}</div>
<div class="step-label">${step.label}</div>
<div class="step-dots">
${steps.map((s, idx) => `
<span class="dot ${idx < i ? 'complete' : ''} ${idx === i ? 'active' : ''}"></span>
`).join('')}
</div>
</div>
`
try {
await step.fn()
} catch (error) {
progressEl.innerHTML = `
<div class="step-error">
<p>Failed at: ${step.label}</p>
<button onclick="retryFrom(${i})">Retry</button>
</div>
`
return
}
}
progressEl.innerHTML = '<div class="step-complete">Setup complete!</div>'
}Indeterminate progress for unknown duration:
// popup.js - When you can't calculate percentage
async function searchDatabase(query) {
const progressEl = document.getElementById('search-progress')
progressEl.innerHTML = `
<div class="indeterminate-progress">
<div class="progress-bar-indeterminate"></div>
<span>Searching...</span>
</div>
`
const results = await performSearch(query)
progressEl.innerHTML = ''
return results
}/* Indeterminate progress animation */
.progress-bar-indeterminate {
height: 4px;
background: linear-gradient(90deg, transparent, #4A90D9, transparent);
background-size: 200% 100%;
animation: indeterminate 1.5s infinite linear;
}
@keyframes indeterminate {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}Reference: Progress Indicators
Confirm Successful Actions
Provide clear confirmation when actions complete successfully. Silent success leaves users uncertain whether their action worked.
Incorrect (silent success):
// popup.js - No feedback on success
async function saveNote() {
await chrome.storage.local.set({ note: noteContent })
// Nothing happens - user wonders if it saved
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
// Did it copy? User has no idea
}Correct (explicit success feedback):
// popup.js - Clear success confirmation
async function saveNote() {
const saveBtn = document.getElementById('save-btn')
try {
await chrome.storage.local.set({ note: noteContent })
// Visual confirmation
saveBtn.classList.add('success')
saveBtn.textContent = 'Saved!'
// Show toast notification
showToast('Note saved successfully', 'success')
// Reset after delay
setTimeout(() => {
saveBtn.classList.remove('success')
saveBtn.textContent = 'Save'
}, 2000)
} catch (error) {
showToast('Failed to save', 'error')
}
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text)
showToast('Copied to clipboard!', 'success')
} catch (error) {
showToast('Copy failed', 'error')
}
}
function showToast(message, type = 'info') {
const toast = document.createElement('div')
toast.className = `toast toast-${type}`
toast.setAttribute('role', 'status')
toast.setAttribute('aria-live', 'polite')
toast.innerHTML = `
<span class="toast-icon">${type === 'success' ? '✓' : '!'}</span>
<span class="toast-message">${message}</span>
`
document.getElementById('toast-container').appendChild(toast)
// Auto-dismiss
setTimeout(() => {
toast.classList.add('fade-out')
setTimeout(() => toast.remove(), 300)
}, 3000)
}Contextual success patterns:
// Different confirmation styles for different actions
const confirmationPatterns = {
// Inline confirmation - for quick actions
async quickSave() {
await save()
const indicator = document.getElementById('save-indicator')
indicator.textContent = '✓ Saved'
indicator.classList.add('visible')
setTimeout(() => indicator.classList.remove('visible'), 1500)
},
// Button state change - for form submissions
async submitForm(button) {
button.disabled = true
button.textContent = 'Submitting...'
await submit()
button.textContent = '✓ Submitted'
button.classList.add('success')
},
// Toast - for background operations
async backgroundSync() {
await sync()
showToast('All changes synced', 'success')
},
// Badge flash - for extension-wide actions
async extensionAction() {
await performAction()
await chrome.action.setBadgeText({ text: '✓' })
await chrome.action.setBadgeBackgroundColor({ color: '#28a745' })
setTimeout(async () => {
await chrome.action.setBadgeText({ text: '' })
}, 2000)
}
}Success feedback guidelines:
- Match feedback prominence to action importance
- Auto-dismiss after 2-3 seconds
- Use checkmarks (✓) universally understood as success
- Make feedback accessible (role="status", aria-live)
Reference: Toast Pattern
Clean Up Injected Elements on Removal
Remove all injected elements and event listeners when the extension is disabled or the UI is dismissed. Orphaned elements cause memory leaks and interfere with pages.
Incorrect (elements remain after dismissal):
// content.js - No cleanup mechanism
function showTooltip(target) {
const tooltip = document.createElement('div')
tooltip.className = 'ext-tooltip'
tooltip.textContent = 'Helpful information'
document.body.appendChild(tooltip)
// Tooltip never removed
// Event listeners never cleaned up
// Memory accumulates with each show
}
document.addEventListener('mouseover', handleHover)
// Listener remains even if extension disabledCorrect (proper cleanup on removal):
// content.js - Complete lifecycle management
class ExtensionUI {
constructor() {
this.elements = new Set()
this.listeners = []
this.root = null
}
init() {
this.root = document.createElement('div')
this.root.id = 'my-extension-root'
document.body.appendChild(this.root)
this.elements.add(this.root)
this.addListener(document, 'mouseover', this.handleHover.bind(this))
}
createElement(tag, parent = this.root) {
const element = document.createElement(tag)
parent.appendChild(element)
this.elements.add(element)
return element
}
addListener(target, event, handler) {
target.addEventListener(event, handler)
this.listeners.push({ target, event, handler })
}
showTooltip(content, position) {
this.hideTooltip() // Remove existing tooltip first
const tooltip = this.createElement('div')
tooltip.className = 'ext-tooltip'
tooltip.textContent = content
tooltip.style.cssText = `
position: fixed;
left: ${position.x}px;
top: ${position.y}px;
`
}
hideTooltip() {
const existing = this.root?.querySelector('.ext-tooltip')
if (existing) {
existing.remove()
this.elements.delete(existing)
}
}
destroy() {
// Remove all event listeners
this.listeners.forEach(({ target, event, handler }) => {
target.removeEventListener(event, handler)
})
this.listeners = []
// Remove all DOM elements
this.elements.forEach(element => {
element.remove()
})
this.elements.clear()
this.root = null
}
}
// Initialize
const extensionUI = new ExtensionUI()
extensionUI.init()
// Listen for extension disable/reload
chrome.runtime.onSuspend?.addListener(() => {
extensionUI.destroy()
})AbortController for cleaner listener cleanup:
// content.js - Modern cleanup with AbortController
const controller = new AbortController()
document.addEventListener('click', handleClick, { signal: controller.signal })
document.addEventListener('mouseover', handleHover, { signal: controller.signal })
window.addEventListener('scroll', handleScroll, { signal: controller.signal })
// Single call removes all listeners
function cleanup() {
controller.abort()
document.getElementById('my-extension-root')?.remove()
}Reference: AbortController
Inject UI After DOM Ready
Wait for the DOM to be ready before injecting UI elements. Injecting too early causes errors when target elements don't exist yet.
Incorrect (injects before DOM ready):
// content.js - Runs immediately at document_start
const targetElement = document.querySelector('.page-header')
targetElement.appendChild(myToolbar)
// Error: Cannot read properties of null (targetElement is null)Correct (waits for DOM ready):
// content.js - Proper initialization timing
function initExtension() {
const targetElement = document.querySelector('.page-header')
if (targetElement) {
injectToolbar(targetElement)
}
}
// Option 1: Use document_idle in manifest (default, recommended)
// manifest.json: "run_at": "document_idle"
initExtension()
// Option 2: Check DOM state explicitly
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initExtension)
} else {
initExtension()
}
// Option 3: For dynamically loaded content, use MutationObserver
function waitForElement(selector) {
return new Promise((resolve) => {
const element = document.querySelector(selector)
if (element) {
resolve(element)
return
}
const observer = new MutationObserver((mutations, obs) => {
const element = document.querySelector(selector)
if (element) {
obs.disconnect()
resolve(element)
}
})
observer.observe(document.body, {
childList: true,
subtree: true
})
})
}
// Usage for SPAs and dynamic content
async function initForDynamicSite() {
const header = await waitForElement('.page-header')
injectToolbar(header)
}Manifest timing options:
{
"content_scripts": [{
"matches": ["*://*.example.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}]
}| run_at | When it runs | Use case |
|---|---|---|
document_start | Before DOM exists | Inject early styles, intercept requests |
document_end | DOM ready, resources loading | Most UI injection |
document_idle | DOM ready, page loaded (default) | Non-critical UI, safest option |
Reference: Content Scripts run_at
Use Shadow DOM for Style Isolation
Wrap injected UI in Shadow DOM to prevent style leakage. Without isolation, your styles affect the page and page styles break your UI.
Incorrect (styles leak in both directions):
// content.js - Styles conflict with page
function injectToolbar() {
const toolbar = document.createElement('div')
toolbar.className = 'extension-toolbar'
toolbar.innerHTML = `
<button class="btn">Save</button>
<button class="btn">Share</button>
`
document.body.appendChild(toolbar)
}
// styles.css injected via manifest
.extension-toolbar {
position: fixed;
top: 10px;
right: 10px;
}
.btn {
padding: 8px 16px;
background: blue;
}
// Problem: Page's .btn styles override yours
// Problem: Your styles might affect page's .btn elementsCorrect (Shadow DOM isolates styles):
// content.js - Complete style isolation
function injectToolbar() {
const host = document.createElement('div')
host.id = 'my-extension-root'
const shadow = host.attachShadow({ mode: 'closed' })
shadow.innerHTML = `
<style>
.toolbar {
position: fixed;
top: 10px;
right: 10px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
}
.btn {
padding: 8px 16px;
background: #4A90D9;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn:hover {
background: #357ABD;
}
</style>
<div class="toolbar">
<button class="btn">Save</button>
<button class="btn">Share</button>
</div>
`
document.body.appendChild(host)
}
// Page styles cannot affect .btn
// Extension styles cannot leak to pageLoading external stylesheets in Shadow DOM:
// content.js - Use CSS file instead of inline styles
function injectToolbar() {
const host = document.createElement('div')
const shadow = host.attachShadow({ mode: 'closed' })
// Load CSS from extension
const styleLink = document.createElement('link')
styleLink.rel = 'stylesheet'
styleLink.href = chrome.runtime.getURL('toolbar.css')
const toolbar = document.createElement('div')
toolbar.className = 'toolbar'
toolbar.innerHTML = `
<button class="btn">Save</button>
<button class="btn">Share</button>
`
shadow.appendChild(styleLink)
shadow.appendChild(toolbar)
document.body.appendChild(host)
}Reference: Shadow DOM
Use Unique IDs to Prevent Conflicts
Prefix all injected element IDs with a unique namespace. Generic IDs like "container" or "modal" will conflict with page elements.
Incorrect (generic IDs collide with page):
// content.js - Common names cause conflicts
const container = document.createElement('div')
container.id = 'container'
const modal = document.createElement('div')
modal.id = 'modal'
const btn = document.createElement('button')
btn.id = 'save-btn'
// Later...
document.getElementById('container')
// Returns page's #container, not yours!Correct (namespaced IDs prevent collisions):
// content.js - Unique prefix for all elements
const EXTENSION_PREFIX = 'my-extension-12345'
function createId(name) {
return `${EXTENSION_PREFIX}-${name}`
}
const container = document.createElement('div')
container.id = createId('container') // 'my-extension-12345-container'
const modal = document.createElement('div')
modal.id = createId('modal') // 'my-extension-12345-modal'
const btn = document.createElement('button')
btn.id = createId('save-btn') // 'my-extension-12345-save-btn'
// Safe lookups
function getElement(name) {
return document.getElementById(createId(name))
}
getElement('container') // Always returns your elementUsing data attributes instead of IDs:
// content.js - Data attributes for element references
const EXTENSION_ATTR = 'data-my-extension'
function createElement(type, name) {
const element = document.createElement(type)
element.setAttribute(EXTENSION_ATTR, name)
return element
}
function getElement(name) {
return document.querySelector(`[${EXTENSION_ATTR}="${name}"]`)
}
// Usage
const toolbar = createElement('div', 'toolbar')
const saveBtn = createElement('button', 'save-btn')
// Later
getElement('toolbar') // Always finds your toolbar
getElement('save-btn') // Always finds your buttonClass name namespacing:
// content.js - Namespaced classes for styling
const CSS_PREFIX = 'ext-myapp'
const toolbar = document.createElement('div')
toolbar.className = `${CSS_PREFIX}-toolbar ${CSS_PREFIX}-visible`
// In CSS
.ext-myapp-toolbar { /* Won't conflict with .toolbar on page */ }
.ext-myapp-visible { /* Won't conflict with .visible on page */ }Reference: Content Scripts Isolated World
Use Maximum Z-Index for Overlays
Set z-index to the maximum value (2147483647) for injected overlays. Many sites use high z-index values that can hide your extension's UI.
Incorrect (arbitrary z-index gets buried):
/* toolbar.css - Arbitrary z-index */
.extension-overlay {
position: fixed;
z-index: 9999;
}
/* Problem: Many sites use z-index: 10000+ for modals/toasts */
/* Your overlay disappears behind site elements */Correct (maximum z-index guarantees visibility):
/* toolbar.css - Maximum possible z-index */
.extension-overlay {
position: fixed;
z-index: 2147483647; /* Maximum 32-bit signed integer */
}
/* Create stacking context to contain children */
.extension-root {
position: fixed;
z-index: 2147483647;
isolation: isolate; /* Creates new stacking context */
}
.extension-modal {
z-index: 2; /* Relative to .extension-root */
}
.extension-tooltip {
z-index: 3; /* Above modal within same context */
}Managing multiple overlay layers:
// content.js - Layered UI system
const Z_BASE = 2147483647
function createOverlaySystem() {
const root = document.createElement('div')
root.id = 'extension-overlay-root'
root.style.cssText = `
position: fixed;
top: 0;
left: 0;
z-index: ${Z_BASE};
pointer-events: none;
`
// Layer definitions within the root
const layers = {
toolbar: createLayer(1),
tooltip: createLayer(2),
modal: createLayer(3),
notification: createLayer(4)
}
function createLayer(level) {
const layer = document.createElement('div')
layer.style.cssText = `
position: fixed;
z-index: ${level};
pointer-events: auto;
`
root.appendChild(layer)
return layer
}
document.body.appendChild(root)
return layers
}
// Usage
const layers = createOverlaySystem()
layers.toolbar.appendChild(toolbarElement)
layers.modal.appendChild(modalElement)Handling sites with `!important`:
/* Sometimes you need !important to override aggressive sites */
.extension-overlay {
position: fixed !important;
z-index: 2147483647 !important;
display: block !important;
}Reference: CSS z-index
Auto-Save Settings on Change
Save settings immediately when users change them. Requiring manual save leads to lost changes and adds unnecessary friction.
Incorrect (requires manual save):
<!-- options.html - User must remember to save -->
<form id="settings-form">
<label>
<input type="checkbox" name="darkMode">
Dark mode
</label>
<label>
<input type="checkbox" name="notifications">
Enable notifications
</label>
<button type="submit">Save Settings</button>
</form>// options.js - Manual save required
document.getElementById('settings-form').addEventListener('submit', async (e) => {
e.preventDefault()
const formData = new FormData(e.target)
await chrome.storage.sync.set({
darkMode: formData.has('darkMode'),
notifications: formData.has('notifications')
})
showMessage('Settings saved!')
})
// User changes settings → closes tab → LOST because they forgot to saveCorrect (auto-save on every change):
<!-- options.html - Changes save automatically -->
<form id="settings-form">
<label>
<input type="checkbox" name="darkMode">
Dark mode
</label>
<label>
<input type="checkbox" name="notifications">
Enable notifications
</label>
<div class="save-status" id="save-status"></div>
</form>// options.js - Auto-save with feedback
const form = document.getElementById('settings-form')
const statusEl = document.getElementById('save-status')
let saveTimeout = null
// Load saved settings on open
chrome.storage.sync.get(['darkMode', 'notifications'], (settings) => {
form.elements.darkMode.checked = settings.darkMode ?? false
form.elements.notifications.checked = settings.notifications ?? true
})
// Save on any change
form.addEventListener('change', (event) => {
const input = event.target
const value = input.type === 'checkbox' ? input.checked : input.value
// Show saving status
statusEl.textContent = 'Saving...'
statusEl.className = 'save-status saving'
// Debounce for text inputs
clearTimeout(saveTimeout)
saveTimeout = setTimeout(async () => {
try {
await chrome.storage.sync.set({ [input.name]: value })
statusEl.textContent = 'Saved'
statusEl.className = 'save-status saved'
// Clear status after delay
setTimeout(() => {
statusEl.textContent = ''
}, 2000)
} catch (error) {
statusEl.textContent = 'Save failed'
statusEl.className = 'save-status error'
}
}, input.type === 'text' ? 500 : 0) // Immediate for checkboxes, debounced for text
})Auto-save with validation:
// options.js - Validate before saving
async function handleSettingChange(input) {
const value = input.value
const name = input.name
// Validate
const validationResult = validateSetting(name, value)
if (!validationResult.valid) {
showFieldError(input, validationResult.message)
return // Don't save invalid values
}
clearFieldError(input)
await saveSetting(name, value)
showSaveConfirmation()
}
function validateSetting(name, value) {
const validators = {
apiKey: (v) => v.length >= 32 ? { valid: true } : { valid: false, message: 'API key must be at least 32 characters' },
refreshInterval: (v) => v >= 1 && v <= 60 ? { valid: true } : { valid: false, message: 'Must be 1-60 minutes' }
}
return validators[name]?.(value) ?? { valid: true }
}Reference: Options Page Best Practices
Use Embedded Options for Simple Settings
Use embedded options pages for extensions with few settings. Embedded pages display inline on the extensions management page, avoiding unnecessary tab switches.
Incorrect (full-page options for simple settings):
{
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}<!-- options.html - Full page for just 2 settings -->
<!DOCTYPE html>
<html>
<head>
<title>Extension Settings</title>
<link rel="stylesheet" href="options.css">
</head>
<body>
<h1>Settings</h1>
<label>
<input type="checkbox" id="enabled">
Enable extension
</label>
<label>
<input type="checkbox" id="notifications">
Show notifications
</label>
<button id="save">Save</button>
</body>
</html>
<!-- Opens new tab for 2 checkboxes - overkill -->Correct (embedded options for simple settings):
{
"options_ui": {
"page": "options.html",
"open_in_tab": false
}
}<!-- options.html - Designed for embedded display -->
<!DOCTYPE html>
<html>
<head>
<style>
body {
min-width: 300px;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
.setting {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #e0e0e0;
}
.setting:last-child {
border-bottom: none;
}
.setting label {
flex: 1;
}
</style>
</head>
<body>
<div class="setting">
<label for="enabled">Enable extension</label>
<input type="checkbox" id="enabled">
</div>
<div class="setting">
<label for="notifications">Show notifications</label>
<input type="checkbox" id="notifications">
</div>
<script src="options.js"></script>
</body>
</html>
<!-- Displays inline in chrome://extensions without leaving page -->When to use full-page options:
- Many settings requiring scrolling
- Complex configuration with tabs/sections
- Settings requiring significant explanation
- Advanced features needing documentation
{
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}Providing both embedded and full-page access:
// options.js - Link to full page from embedded view
if (window.location.protocol === 'chrome-extension:' &&
window.parent !== window) {
// Running embedded - show link to full page
document.body.insertAdjacentHTML('beforeend', `
<a href="#" id="open-full" style="display: block; margin-top: 16px; font-size: 12px;">
Open full settings page
</a>
`)
document.getElementById('open-full').onclick = (e) => {
e.preventDefault()
chrome.runtime.openOptionsPage()
}
}Reference: Options Page
Provide Sensible Default Settings
Ship with carefully chosen defaults so the extension works immediately. Users should only visit settings to customize, not to make the extension functional.
Incorrect (requires configuration to work):
// background.js - Extension broken until configured
chrome.runtime.onInstalled.addListener(() => {
// No defaults set - extension does nothing
})
// popup.js - Forces user to settings
async function init() {
const { apiKey, endpoint } = await chrome.storage.sync.get(['apiKey', 'endpoint'])
if (!apiKey || !endpoint) {
document.body.innerHTML = `
<p>Please configure the extension in settings before use.</p>
<button onclick="chrome.runtime.openOptionsPage()">Open Settings</button>
`
return
}
}
// New user: installs → clicks icon → "go configure" → frustrationCorrect (works out of the box):
// background.js - Sensible defaults on install
const DEFAULT_SETTINGS = {
theme: 'system', // Follows OS preference
notifications: true, // Most users want notifications
refreshInterval: 15, // Reasonable balance
autoSave: true, // Prevent data loss
language: 'auto', // Detect from browser
fontSize: 'medium', // Accessible default
keyboardShortcuts: true, // Power user friendly
dataCollection: 'minimal' // Privacy conscious
}
chrome.runtime.onInstalled.addListener(async () => {
// Only set defaults for keys that don't exist
const existing = await chrome.storage.sync.get(Object.keys(DEFAULT_SETTINGS))
const toSet = {}
for (const [key, defaultValue] of Object.entries(DEFAULT_SETTINGS)) {
if (existing[key] === undefined) {
toSet[key] = defaultValue
}
}
if (Object.keys(toSet).length > 0) {
await chrome.storage.sync.set(toSet)
}
})
// Utility for getting settings with fallback to defaults
async function getSetting(key) {
const { [key]: value } = await chrome.storage.sync.get(key)
return value ?? DEFAULT_SETTINGS[key]
}Default selection guidelines:
| Setting Type | Good Default | Why |
|---|---|---|
| Theme | 'system' | Respects user's OS choice |
| Notifications | On (but not aggressive) | Features should be visible |
| Auto-save | On | Prevents data loss |
| Privacy options | Most private | Build trust first |
| Language | Auto-detect | Most convenient |
| Advanced features | Off | Don't overwhelm new users |
Optional features with smart detection:
// background.js - Smart defaults based on context
async function getSmartDefaults() {
const defaults = { ...DEFAULT_SETTINGS }
// Detect user's likely preferences
const browserLang = navigator.language
defaults.language = browserLang.startsWith('en') ? 'en' : browserLang
// Check if user prefers reduced motion
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
defaults.animations = false
}
// Check color scheme preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
defaults.theme = 'dark'
}
return defaults
}Reference: Extension Quality Guidelines
Sync Settings Across Devices
Use chrome.storage.sync for user preferences so settings follow users across their devices. Local-only storage forces users to reconfigure on each computer.
Incorrect (settings don't sync):
// options.js - Settings only on this device
async function saveSettings(settings) {
await chrome.storage.local.set({ settings })
}
async function loadSettings() {
const { settings } = await chrome.storage.local.get('settings')
return settings || defaultSettings
}
// User configures on laptop → sits at desktop → must reconfigureCorrect (settings sync across devices):
// options.js - Settings follow the user
async function saveSettings(settings) {
await chrome.storage.sync.set({ settings })
}
async function loadSettings() {
const { settings } = await chrome.storage.sync.get('settings')
return settings || defaultSettings
}
// User configures on laptop → sits at desktop → same settingsHandling sync quota limits:
// options.js - Sync has storage limits (102,400 bytes total)
const SYNC_QUOTA = chrome.storage.sync.QUOTA_BYTES // 102400
const SYNC_QUOTA_PER_ITEM = chrome.storage.sync.QUOTA_BYTES_PER_ITEM // 8192
async function saveWithFallback(key, data) {
const serialized = JSON.stringify(data)
if (serialized.length > SYNC_QUOTA_PER_ITEM) {
// Too large for sync - use local storage
console.warn(`${key} too large for sync, using local storage`)
await chrome.storage.local.set({ [key]: data })
return
}
try {
await chrome.storage.sync.set({ [key]: data })
} catch (error) {
if (error.message.includes('QUOTA_BYTES')) {
// Quota exceeded - fall back to local
console.warn('Sync quota exceeded, using local storage')
await chrome.storage.local.set({ [key]: data })
} else {
throw error
}
}
}
// Separate what should sync vs. stay local
const storageStrategy = {
// Small user preferences - SYNC
preferences: 'sync', // theme, language, notifications
shortcuts: 'sync', // keyboard shortcuts
// Large or device-specific data - LOCAL
cache: 'local', // cached API responses
largeDatasets: 'local', // user's full history
deviceSettings: 'local' // window positions, local paths
}
async function saveSetting(category, key, value) {
const storage = storageStrategy[category] === 'sync'
? chrome.storage.sync
: chrome.storage.local
await storage.set({ [`${category}.${key}`]: value })
}Sync storage limits:
| Limit | Value |
|---|---|
| Total storage | 102,400 bytes |
| Per item | 8,192 bytes |
| Max items | 512 |
| Write operations/hour | 1,800 |
Reference: Storage API
Lazy Load Panel Sections
Load only visible panel sections initially. Defer loading of collapsed or below-the-fold sections until users need them.
Incorrect (loads all sections upfront):
// sidepanel.js - Fetches everything on open
document.addEventListener('DOMContentLoaded', async () => {
// All these run before user sees anything
const recentItems = await fetchRecentItems() // 200ms
const savedPages = await fetchSavedPages() // 300ms
const analytics = await fetchAnalytics() // 400ms
const recommendations = await fetchRecommendations() // 500ms
renderRecentItems(recentItems)
renderSavedPages(savedPages)
renderAnalytics(analytics)
renderRecommendations(recommendations)
})
// Total: 1.4s before panel is usableCorrect (load sections on demand):
// sidepanel.js - Load primary section immediately, defer rest
document.addEventListener('DOMContentLoaded', async () => {
// Load only the most important section immediately
const recentItems = await fetchRecentItems()
renderRecentItems(recentItems)
// Panel usable in 200ms
// Set up lazy loading for other sections
setupLazySection('saved-pages', fetchSavedPages, renderSavedPages)
setupLazySection('analytics', fetchAnalytics, renderAnalytics)
setupLazySection('recommendations', fetchRecommendations, renderRecommendations)
})
function setupLazySection(sectionId, fetchFn, renderFn) {
const section = document.getElementById(sectionId)
const header = section.querySelector('.section-header')
let loaded = false
// Load when section is expanded
header.addEventListener('click', async () => {
const isExpanded = section.classList.toggle('expanded')
if (isExpanded && !loaded) {
section.querySelector('.content').innerHTML = '<div class="loading">Loading...</div>'
const data = await fetchFn()
renderFn(data)
loaded = true
}
})
}Intersection Observer for scroll-based loading:
// sidepanel.js - Load when section scrolls into view
function setupScrollLazyLoading() {
const lazySections = document.querySelectorAll('[data-lazy-section]')
const observer = new IntersectionObserver((entries) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting) {
const section = entry.target
const sectionType = section.dataset.lazySection
// Stop observing this section
observer.unobserve(section)
// Load content
section.innerHTML = '<div class="loading">Loading...</div>'
const data = await loadSectionData(sectionType)
renderSection(section, data)
}
})
}, {
rootMargin: '100px' // Start loading slightly before visible
})
lazySections.forEach(section => observer.observe(section))
}<!-- sidepanel.html - Mark sections for lazy loading -->
<section id="recent" class="panel-section">
<!-- Loaded immediately -->
</section>
<section data-lazy-section="saved" class="panel-section">
<div class="placeholder">Saved items will load here</div>
</section>
<section data-lazy-section="analytics" class="panel-section">
<div class="placeholder">Analytics will load here</div>
</section>Reference: Intersection Observer API
Design Non-Distracting Side Panels
Design side panels to enhance browsing without competing for attention. Panels that distract from page content get closed quickly and rarely reopened.
Incorrect (attention-grabbing panel):
<!-- sidepanel.html - Competes with page content -->
<div class="panel">
<div class="animated-banner">
<span class="pulse">NEW FEATURE!</span>
</div>
<div class="notification-badge">3</div>
<div class="auto-playing-content">
<!-- Carousel that auto-advances -->
</div>
</div>
<style>
.pulse {
animation: pulse 1s infinite;
background: linear-gradient(90deg, #ff0000, #ff6600);
color: white;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
/* User's eyes pulled away from page content */
</style>Correct (complementary panel):
<!-- sidepanel.html - Supports browsing workflow -->
<div class="panel">
<header class="panel-header">
<h1>Research Notes</h1>
<button class="minimize-btn" aria-label="Minimize panel">−</button>
</header>
<main class="panel-content">
<div class="current-page-section">
<h2>This Page</h2>
<div id="page-notes"></div>
</div>
<div class="saved-section">
<h2>Saved Highlights</h2>
<ul id="highlights-list"></ul>
</div>
</main>
</div>
<style>
.panel {
background: #fafafa;
color: #333;
font-size: 14px;
}
.panel-header {
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
background: white;
}
.panel-header h1 {
font-size: 16px;
font-weight: 500;
margin: 0;
}
/* Subtle, professional, doesn't fight for attention */
</style>Non-distracting panel guidelines:
- No animations unless user-initiated
- Muted color palette (complement browser chrome)
- No auto-playing media
- Notification badges only for user-relevant updates
- Easy minimize/collapse option
Reference: Designing a Superior User Experience with Side Panel
Sync Panel Content with Page Context
Update side panel content to reflect the current page when relevant. A panel showing stale or unrelated content feels disconnected and useless.
Incorrect (panel ignores page context):
// sidepanel.js - Loads once, never updates
document.addEventListener('DOMContentLoaded', async () => {
const data = await loadGenericData()
renderPanel(data)
})
// User navigates to different pages → panel shows same content
// Panel feels disconnected from browsingCorrect (panel responds to page changes):
// sidepanel.js - Updates with page context
async function initPanel() {
// Initial load
await updateForCurrentTab()
// Listen for tab changes
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
await updateForCurrentTab()
})
// Listen for URL changes within same tab
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete') {
await updateForCurrentTab()
}
})
}
async function updateForCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
// Update page-specific section
document.getElementById('current-url').textContent = tab.url
document.getElementById('page-title').textContent = tab.title
// Load related content
const relatedNotes = await getNotesForUrl(tab.url)
renderNotes(relatedNotes)
// Show relevant actions based on page type
const pageType = detectPageType(tab.url)
updateActionsForPageType(pageType)
}
function detectPageType(url) {
if (url.includes('github.com')) return 'github'
if (url.includes('docs.google.com')) return 'google-docs'
if (url.includes('youtube.com')) return 'youtube'
return 'generic'
}
function updateActionsForPageType(pageType) {
const actionsContainer = document.getElementById('context-actions')
const actionsByType = {
'github': '<button>Save Repository</button><button>View Issues</button>',
'youtube': '<button>Save Video</button><button>Add to Playlist</button>',
'google-docs': '<button>Export Notes</button>',
'generic': '<button>Save Page</button>'
}
actionsContainer.innerHTML = actionsByType[pageType]
}
initPanel()Showing "no content for this page" gracefully:
async function updateForCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
const content = await getContentForUrl(tab.url)
const container = document.getElementById('page-content')
if (content && content.length > 0) {
container.innerHTML = renderContent(content)
} else {
container.innerHTML = `
<div class="empty-state">
<p>No saved items for this page</p>
<button id="add-first">Add your first note</button>
</div>
`
}
}Reference: Tabs API
Design for Variable Panel Widths
Build side panel layouts that adapt to user-resized widths. Users can drag the panel edge to resize; fixed-width designs break when resized.
Incorrect (fixed-width layout breaks):
/* sidepanel.css - Fixed widths break on resize */
.panel-container {
width: 400px;
}
.two-column-layout {
display: flex;
}
.sidebar {
width: 150px;
}
.content {
width: 250px;
}
/* User resizes to 300px → content overflows or gets clipped */Correct (fluid responsive layout):
/* sidepanel.css - Adapts to any width */
.panel-container {
width: 100%;
min-width: 200px;
max-width: 100%;
}
.two-column-layout {
display: flex;
flex-wrap: wrap;
}
.sidebar {
flex: 0 0 auto;
width: 100%;
}
.content {
flex: 1 1 auto;
min-width: 0; /* Prevents flex item overflow */
}
/* Stack columns when narrow */
@media (min-width: 350px) {
.sidebar {
width: 120px;
}
}
/* Text truncation for narrow widths */
.item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Responsive spacing */
.panel-section {
padding: clamp(8px, 3vw, 16px);
}JavaScript-based responsive adjustments:
// sidepanel.js - Adapt UI based on panel width
const panelContainer = document.querySelector('.panel-container')
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width
panelContainer.classList.toggle('narrow', width < 280)
panelContainer.classList.toggle('medium', width >= 280 && width < 400)
panelContainer.classList.toggle('wide', width >= 400)
}
})
resizeObserver.observe(panelContainer)/* Width-based UI adaptations */
.panel-container.narrow .secondary-info {
display: none;
}
.panel-container.narrow .action-buttons {
flex-direction: column;
}
.panel-container.wide .expanded-view {
display: block;
}Reference: CSS Logical Properties
Choose Tab-Specific vs Window-Wide Panels
Decide whether your side panel should be tab-specific (different content per tab) or window-wide (same content across all tabs). The wrong choice confuses users.
Incorrect (window-wide panel for tab-specific content):
// background.js - Panel shows same content regardless of tab
chrome.sidePanel.setOptions({
enabled: true,
path: 'sidepanel.html'
})
// sidepanel.js - Tries to show "current page" info
// But doesn't update when user switches tabs
async function loadCurrentPage() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
document.getElementById('page-title').textContent = tab.title
}
document.addEventListener('DOMContentLoaded', loadCurrentPage)
// User switches tabs → panel still shows old tab's infoCorrect (tab-specific panel for page-related content):
// background.js - Enable per-tab panel behavior
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
// Set panel per tab for page-specific features
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
await chrome.sidePanel.setOptions({
tabId,
path: 'sidepanel.html',
enabled: true
})
})
// sidepanel.js - Update when tab changes
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
const tab = await chrome.tabs.get(tabId)
updatePanelForPage(tab)
})
async function updatePanelForPage(tab) {
document.getElementById('page-title').textContent = tab.title
const highlights = await getHighlightsForUrl(tab.url)
renderHighlights(highlights)
}When to use each mode:
| Use Tab-Specific When | Use Window-Wide When |
|---|---|
| Content relates to current page | Content is independent of page |
| Notes, highlights, page analysis | Task lists, timers, music players |
| Translation, reading tools | Global settings, dashboards |
| Page-specific data extraction | Cross-tab workflows |
Window-wide panel example:
// background.js - Global panel for task management
chrome.sidePanel.setOptions({
enabled: true,
path: 'sidepanel.html'
})
// sidepanel.js - Content doesn't depend on current tab
async function loadTasks() {
const { tasks } = await chrome.storage.sync.get('tasks')
renderTasks(tasks)
}
// Same task list regardless of which tab is activeReference: Side Panel API
Handle Popup Auto-Close Gracefully
Save user input continuously because popups close instantly when users click outside. Never require explicit save actions for in-progress work.
Incorrect (data lost on auto-close):
// popup.js - User input lost when popup closes
const textarea = document.getElementById('notes')
const saveButton = document.getElementById('save')
saveButton.addEventListener('click', () => {
chrome.storage.local.set({ notes: textarea.value })
})
// User types notes → clicks page to copy something → popup closes → notes LOSTCorrect (continuous auto-save):
// popup.js - Save on every change
const textarea = document.getElementById('notes')
let saveTimeout = null
// Load existing content on open
chrome.storage.local.get('notes', (result) => {
textarea.value = result.notes || ''
})
// Debounced auto-save on every keystroke
textarea.addEventListener('input', () => {
// Show saving indicator
showSavingIndicator()
// Debounce to avoid excessive writes
clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
chrome.storage.local.set({ notes: textarea.value }, () => {
showSavedIndicator()
})
}, 300)
})
// Also save immediately before popup closes
window.addEventListener('beforeunload', () => {
chrome.storage.local.set({ notes: textarea.value })
})For forms with multiple fields:
// popup.js - Auto-save entire form state
const form = document.getElementById('settings-form')
// Restore state on open
chrome.storage.local.get('formDraft', (result) => {
if (result.formDraft) {
Object.entries(result.formDraft).forEach(([name, value]) => {
const field = form.elements[name]
if (field) field.value = value
})
}
})
// Save state on any change
form.addEventListener('input', () => {
const formData = new FormData(form)
const draft = Object.fromEntries(formData.entries())
chrome.storage.local.set({ formDraft: draft })
})
// Clear draft only after successful submission
form.addEventListener('submit', async (event) => {
event.preventDefault()
await submitForm()
await chrome.storage.local.remove('formDraft')
})When NOT to auto-save:
- Destructive actions (delete, send)—always require explicit confirmation
- Sensitive data that shouldn't persist
Reference: Storage API
Use Dynamic Popups for State-Based UI
Use chrome.action.setPopup() to display different popups based on application state. Show users only the UI relevant to their current context.
Incorrect (single popup handles all states):
<!-- popup.html - Cluttered with conditional content -->
<div id="logged-out-section" style="display: none;">
<h2>Please Sign In</h2>
<button id="signin-btn">Sign in with Google</button>
</div>
<div id="logged-in-section" style="display: none;">
<h2>Welcome back!</h2>
<div id="user-content">...</div>
</div>
<div id="error-section" style="display: none;">
<h2>Connection Error</h2>
<button id="retry-btn">Retry</button>
</div>// popup.js - Complex state management in single popup
async function init() {
const sections = ['logged-out', 'logged-in', 'error']
sections.forEach(s => document.getElementById(`${s}-section`).style.display = 'none')
try {
const user = await checkAuth()
if (user) {
document.getElementById('logged-in-section').style.display = 'block'
} else {
document.getElementById('logged-out-section').style.display = 'block'
}
} catch {
document.getElementById('error-section').style.display = 'block'
}
}Correct (dedicated popup per state):
// background.js - Set appropriate popup based on state
chrome.runtime.onInstalled.addListener(updatePopupState)
chrome.storage.onChanged.addListener(updatePopupState)
async function updatePopupState() {
const { user, hasError } = await chrome.storage.local.get(['user', 'hasError'])
if (hasError) {
await chrome.action.setPopup({ popup: 'popups/error.html' })
await chrome.action.setBadgeText({ text: '!' })
await chrome.action.setBadgeBackgroundColor({ color: '#dc3545' })
} else if (user) {
await chrome.action.setPopup({ popup: 'popups/main.html' })
await chrome.action.setBadgeText({ text: '' })
} else {
await chrome.action.setPopup({ popup: 'popups/signin.html' })
await chrome.action.setBadgeText({ text: '' })
}
}popups/
├── signin.html <!-- Clean sign-in form only -->
├── main.html <!-- Full functionality for logged-in users -->
└── error.html <!-- Error state with retry options -->Benefits of dynamic popups:
- Each popup is simpler and focused
- Faster load times (less HTML/JS to parse)
- Easier to maintain and test
- Clearer user experience
Reference: Action API setPopup
Keep JavaScript in External Files
Place all JavaScript in separate .js files linked via <script src>. Manifest V3 prohibits inline scripts (onclick, <script> tags with code) due to Content Security Policy requirements.
Incorrect (inline JavaScript blocked by CSP):
<!-- popup.html - These will NOT work in Manifest V3 -->
<button onclick="handleClick()">Click me</button>
<script>
function handleClick() {
console.log('clicked')
}
document.getElementById('btn').addEventListener('click', () => {
toggleExtensionState()
})
</script>
<!-- Error: Refused to execute inline script because it violates CSP -->Correct (external JavaScript files):
<!-- popup.html - Script in external file -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<button id="action-btn">Click me</button>
<script src="popup.js"></script>
</body>
</html>// popup.js - All JavaScript here
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('action-btn')
button.addEventListener('click', () => {
handleClick()
})
})
function handleClick() {
console.log('clicked')
}Handling dynamic HTML:
// popup.js - Use event delegation instead of inline handlers
const container = document.getElementById('item-list')
// Event delegation - single listener handles dynamic content
container.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]')
if (!button) return
const action = button.dataset.action
const itemId = button.dataset.itemId
switch (action) {
case 'edit':
editItem(itemId)
break
case 'delete':
deleteItem(itemId)
break
}
})
function renderItems(items) {
container.innerHTML = items.map(item => `
<div class="item">
<span>${item.name}</span>
<button data-action="edit" data-item-id="${item.id}">Edit</button>
<button data-action="delete" data-item-id="${item.id}">Delete</button>
</div>
`).join('')
}Reference: Content Security Policy
Render Popup Content Instantly
Display meaningful content immediately when the popup opens. Users perceive delays over 100ms as laggy. Fetch data after rendering the initial UI.
Incorrect (popup waits for data before rendering):
// popup.js - Blank popup while fetching
document.addEventListener('DOMContentLoaded', async () => {
const container = document.getElementById('content')
// Nothing visible while this runs
const data = await fetch('https://api.example.com/data')
const items = await data.json()
container.innerHTML = items.map(item => `
<div class="item">${item.name}</div>
`).join('')
})
// User sees empty popup for 500ms-2sCorrect (instant shell, then populate):
// popup.js - Immediate skeleton, then hydrate
document.addEventListener('DOMContentLoaded', async () => {
const container = document.getElementById('content')
// Show skeleton immediately (already in HTML or inject fast)
container.innerHTML = `
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
`
// Fetch and replace
try {
const data = await fetch('https://api.example.com/data')
const items = await data.json()
container.innerHTML = items.map(item => `
<div class="item">${item.name}</div>
`).join('')
} catch (error) {
container.innerHTML = `
<div class="error">Unable to load. <button onclick="location.reload()">Retry</button></div>
`
}
})Even better - cache and show stale while revalidating:
// popup.js - Show cached immediately, update in background
document.addEventListener('DOMContentLoaded', async () => {
const container = document.getElementById('content')
// Render cached data instantly
const cached = await chrome.storage.local.get('cachedItems')
if (cached.cachedItems) {
renderItems(cached.cachedItems)
} else {
showSkeleton()
}
// Fetch fresh data in background
try {
const response = await fetch('https://api.example.com/data')
const freshItems = await response.json()
await chrome.storage.local.set({ cachedItems: freshItems })
renderItems(freshItems) // Update UI with fresh data
} catch (error) {
if (!cached.cachedItems) {
showError()
}
// If we have cache, silently keep showing it
}
})Reference: Extension Performance
Make the Primary Action Obvious
Design popups with a single, visually prominent primary action. Users open popups with intent—make the main action impossible to miss.
Incorrect (competing actions, unclear hierarchy):
<!-- popup.html - No clear primary action -->
<div class="popup-actions">
<button class="btn">Save</button>
<button class="btn">Share</button>
<button class="btn">Copy</button>
<button class="btn">Download</button>
<button class="btn">Settings</button>
</div>
<style>
.btn {
padding: 8px 16px;
background: #f0f0f0;
border: 1px solid #ccc;
}
/* All buttons look identical - user hesitates */
</style>Correct (clear visual hierarchy):
<!-- popup.html - Obvious primary action -->
<div class="popup-content">
<div class="quick-actions">
<button class="btn-primary">Save to Collection</button>
</div>
<div class="secondary-actions">
<button class="btn-secondary" aria-label="Share">
<svg aria-hidden="true"><!-- share icon --></svg>
</button>
<button class="btn-secondary" aria-label="Copy link">
<svg aria-hidden="true"><!-- copy icon --></svg>
</button>
<button class="btn-secondary" aria-label="Download">
<svg aria-hidden="true"><!-- download icon --></svg>
</button>
</div>
<a href="#" class="settings-link">Settings</a>
</div>
<style>
.btn-primary {
width: 100%;
padding: 12px 24px;
background: #4A90D9;
color: white;
font-weight: 600;
font-size: 16px;
border: none;
border-radius: 8px;
}
.btn-secondary {
padding: 8px;
background: transparent;
border: 1px solid #ddd;
border-radius: 4px;
}
.settings-link {
font-size: 12px;
color: #666;
}
/* Primary action dominates, secondary actions accessible but subdued */
</style>Visual hierarchy principles:
- Primary action: Largest, boldest, prominent color
- Secondary actions: Smaller, muted colors or outlined
- Tertiary actions: Text links or icon-only buttons
- Maximum one primary action per popup
Reference: Designing a Superior User Experience
Related skills
How it compares
Use chrome-extension-ui for store-facing extension surfaces; pair with general frontend-design skills for marketing sites outside the extension shell.
FAQ
How many UI rules does chrome-extension-ui include?
chrome-extension-ui bundles 42 UX/UI rules organized into 8 categories for Manifest V3 Chrome extensions. Rules span component selection, accessibility, layout patterns, and incremental branding guidance.
Does chrome-extension-ui target Manifest V3?
chrome-extension-ui is optimized specifically for Manifest V3 Chrome extensions at version 0.1.0. Agents apply its rules when generating or refactoring popup, options, and side-panel interfaces for store submission.
Is Chrome Extension Ui safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.