Build a Text Expander Chrome Extension: Complete Developer Guide
Text expander extensions have become essential productivity tools for anyone who types frequently. Whether you are a customer support representative responding to common inquiries, a developer writing repetitive code patterns, or simply someone who tired of typing their email address repeatedly, a text expander can dramatically increase your typing efficiency. In this comprehensive guide, we will walk you through building a fully functional text expander Chrome extension from scratch using modern JavaScript and the Chrome Extension Manifest V3 API.
This tutorial assumes you have basic familiarity with HTML, CSS, and JavaScript. By the end of this guide, you will have created a complete Chrome extension that can detect typed shortcuts and automatically replace them with longer text snippets, complete with a settings interface for managing your shortcuts.
Understanding Text Expander Extensions
A text expander is a program that automatically replaces typed abbreviations with predefined text passages. For example, when you type “;email” in any text field, the extension automatically expands it to your full email address. This simple concept saves thousands of keystrokes over time and eliminates the tedium of repetitive typing.
Text expander extensions work by monitoring keyboard input across web pages and detecting when users type specific trigger sequences. When a trigger is recognized, the extension replaces the trigger text with the associated expanded content. Modern text expanders offer additional features such as placeholder support, conditional formatting, and cloud synchronization of snippets across devices.
The core challenge in building a text expander lies in detecting typed shortcuts reliably across different input contexts. Text fields, textareas, contentEditable elements, and rich text editors all behave differently when it comes to text manipulation. A robust text expander must handle all these cases gracefully while maintaining performance and avoiding conflicts with other extensions or page scripts.
Project Setup and Structure
Let us begin by setting up the project structure for our text expander Chrome extension. Create a new folder named “text-expander-extension” and add the following files and directories:
text-expander-extension/
├── manifest.json
├── popup/
│ ├── popup.html
│ ├── popup.css
│ └── popup.js
├── content/
│ └── content.js
├── background/
│ └── background.js
├── icons/
│ └── icon.png
└── data/
└── snippets.json
The manifest.json file serves as the configuration file for your Chrome extension. It defines the extension’s permissions, content scripts, background scripts, and browser action. Here is the complete manifest for our text expander:
{
"manifest_version": 3,
"name": "QuickSnip - Text Expander",
"version": "1.0.0",
"description": "A powerful text expander extension for Chrome that saves you time by expanding typed shortcuts into full text snippets.",
"permissions": [
"storage",
"activeTab",
"scripting"
],
"host_permissions": [
"<all_urls>"
],
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon.png",
"48": "icons/icon.png",
"128": "icons/icon.png"
}
},
"background": {
"service_worker": "background/background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/content.js"],
"run_at": "document_idle"
}
]
}
This manifest requests the minimum permissions necessary for our extension to function. We use the “storage” permission to persist user snippets, “activeTab” and “scripting” for content script management, and host permissions to allow the extension to work on all websites.
Core Content Script Implementation
The content script is the heart of our text expander extension. It runs on every web page and monitors keyboard input to detect and expand shortcuts. Create the content.js file with the following implementation:
// content/content.js
// Store for snippets loaded from storage
let snippets = {};
// Initialize the content script
async function init() {
// Load snippets from chrome storage
const result = await chrome.storage.sync.get(['snippets']);
snippets = result.snippets || {};
console.log('QuickSnip initialized with', Object.keys(snippets).length, 'snippets');
// Attach keyboard listener
document.addEventListener('input', handleInput);
document.addEventListener('keydown', handleKeydown);
}
// Track typed characters to detect shortcuts
let typedText = '';
const MAX_TRACK_LENGTH = 50;
// Handle input events to capture typed text
function handleInput(event) {
const target = event.target;
// Only process text inputs
if (!isTextInput(target)) return;
// Handle deletion
if (event.inputType === 'deleteContentBackward' ||
event.inputType === 'deleteContentForward') {
typedText = typedText.slice(0, -1);
return;
}
// Handle text insertion
if (event.data) {
typedText += event.data;
// Keep only the last MAX_TRACK_LENGTH characters
if (typedText.length > MAX_TRACK_LENGTH) {
typedText = typedText.slice(-MAX_TRACK_LENGTH);
}
// Check for matching shortcut
checkForShortcut(target);
}
}
// Handle special keys
function handleKeydown(event) {
// Reset typed text on Escape or Enter
if (event.key === 'Escape' || event.key === 'Enter') {
typedText = '';
}
}
// Check if an element is a text input
function isTextInput(element) {
const tagName = element.tagName.toLowerCase();
return (tagName === 'input' &&
(element.type === 'text' ||
element.type === 'email' ||
element.type === 'password' ||
!element.type)) ||
tagName === 'textarea' ||
element.isContentEditable;
}
// Check for matching shortcut and expand
function checkForShortcut(inputElement) {
// Check each snippet key for matches
for (const [shortcut, expansion] of Object.entries(snippets)) {
if (typedText.endsWith(shortcut)) {
expandShortcut(inputElement, shortcut, expansion);
break;
}
}
}
// Expand the shortcut to full text
function expandShortcut(inputElement, shortcut, expansion) {
// Remove the shortcut text
const shortcutLength = shortcut.length;
if (inputElement.isContentEditable) {
// Handle contenteditable elements
expandContentEditable(inputElement, shortcut, expansion);
} else {
// Handle regular input and textarea elements
expandRegularInput(inputElement, shortcut, expansion);
}
// Reset tracking after expansion
typedText = '';
// Dispatch custom event for potential analytics
window.dispatchEvent(new CustomEvent('quicksnip-expansion', {
detail: { shortcut, expansion }
}));
}
// Expand text in contenteditable elements
function expandContentEditable(element, shortcut, expansion) {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType === Node.TEXT_NODE) {
const text = textNode.textContent;
const startOffset = range.startOffset - shortcut.length;
if (startOffset >= 0 && text.substring(startOffset, startOffset + shortcut.length) === shortcut) {
textNode.textContent = text.substring(0, startOffset) + expansion + text.substring(range.startOffset);
// Move cursor to end of expansion
const newRange = document.createRange();
newRange.setStart(textNode, startOffset + expansion.length);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
}
}
// Expand text in regular input/textarea elements
function expandRegularInput(element, shortcut, expansion) {
const start = element.selectionStart;
const end = element.selectionEnd;
const value = element.value;
// Verify shortcut exists at cursor position
const textBeforeCursor = value.substring(0, start);
const shortcutPosition = textBeforeCursor.lastIndexOf(shortcut);
if (shortcutPosition !== -1) {
// Replace shortcut with expansion
const newValue = value.substring(0, shortcutPosition) +
expansion +
value.substring(end);
element.value = newValue;
// Set cursor position after expansion
const newPosition = shortcutPosition + expansion.length;
element.setSelectionRange(newPosition, newPosition);
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Listen for snippet updates from background script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'snippetsUpdated') {
snippets = message.snippets;
console.log('Snippets updated:', Object.keys(snippets).length);
}
});
This content script implements the core functionality of detecting and expanding shortcuts. It maintains a buffer of recently typed characters and checks them against the snippet dictionary. When a match is found, it replaces the shortcut text with the expansion while preserving the cursor position appropriately.
Building the Popup Interface
Every good Chrome extension needs a user interface for managing settings. Our popup allows users to add, edit, and delete snippets without navigating to a separate options page. Let us create the popup files:
First, the popup HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QuickSnip - Text Expander</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<header>
<h1>QuickSnip</h1>
<p class="subtitle">Text Expander</p>
</header>
<main>
<div class="add-snippet-form">
<h2>Add New Snippet</h2>
<div class="form-group">
<label for="shortcut">Shortcut</label>
<input type="text" id="shortcut" placeholder="e.g., ;email" maxlength="20">
</div>
<div class="form-group">
<label for="expansion">Expansion Text</label>
<textarea id="expansion" placeholder="The text to expand to..." rows="3"></textarea>
</div>
<button id="addBtn" class="primary-btn">Add Snippet</button>
</div>
<div class="snippets-list">
<h2>Your Snippets</h2>
<div id="snippetsContainer" class="snippets-container">
<!-- Snippets will be rendered here -->
</div>
</div>
</main>
<footer>
<p>Type your shortcut anywhere in Chrome</p>
</footer>
</div>
<script src="popup.js"></script>
</body>
</html>
Now the popup CSS for styling:
/* popup/popup.css */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: #f5f5f5;
color: #333;
width: 350px;
min-height: 400px;
}
.container {
padding: 20px;
}
header {
text-align: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #e0e0e0;
}
header h1 {
font-size: 24px;
color: #4285f4;
margin-bottom: 5px;
}
.subtitle {
font-size: 14px;
color: #666;
}
h2 {
font-size: 16px;
margin-bottom: 12px;
color: #444;
}
.add-snippet-form {
background: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 5px;
color: #555;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #4285f4;
}
.primary-btn {
width: 100%;
padding: 10px;
background-color: #4285f4;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.primary-btn:hover {
background-color: #3367d6;
}
.snippets-list {
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.snippets-container {
max-height: 200px;
overflow-y: auto;
}
.snippet-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #f0f0f0;
}
.snippet-item:last-child {
border-bottom: none;
}
.snippet-content {
flex: 1;
}
.snippet-shortcut {
font-weight: 600;
color: #4285f4;
font-size: 14px;
}
.snippet-expansion {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.delete-btn {
background: none;
border: none;
color: #ea4335;
cursor: pointer;
padding: 5px;
font-size: 18px;
line-height: 1;
}
.delete-btn:hover {
color: #d33426;
}
.empty-state {
text-align: center;
padding: 20px;
color: #999;
font-size: 14px;
}
footer {
text-align: center;
margin-top: 15px;
font-size: 12px;
color: #999;
}
Finally, the popup JavaScript to handle user interactions:
// popup/popup.js
// Load snippets from storage and render
document.addEventListener('DOMContentLoaded', async () => {
const shortcutInput = document.getElementById('shortcut');
const expansionInput = document.getElementById('expansion');
const addBtn = document.getElementById('addBtn');
const snippetsContainer = document.getElementById('snippetsContainer');
let snippets = {};
// Load existing snippets
async function loadSnippets() {
const result = await chrome.storage.sync.get(['snippets']);
snippets = result.snippets || {};
renderSnippets();
}
// Render all snippets in the list
function renderSnippets() {
if (Object.keys(snippets).length === 0) {
snippetsContainer.innerHTML = '<div class="empty-state">No snippets yet. Add your first shortcut above!</div>';
return;
}
snippetsContainer.innerHTML = '';
for (const [shortcut, expansion] of Object.entries(snippets)) {
const item = document.createElement('div');
item.className = 'snippet-item';
item.innerHTML = `
<div class="snippet-content">
<div class="snippet-shortcut">${escapeHtml(shortcut)}</div>
<div class="snippet-expansion" title="${escapeHtml(expansion)}">${escapeHtml(expansion)}</div>
</div>
<button class="delete-btn" data-shortcut="${escapeHtml(shortcut)}" title="Delete snippet">×</button>
`;
snippetsContainer.appendChild(item);
}
// Attach delete handlers
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const shortcut = e.target.dataset.shortcut;
deleteSnippet(shortcut);
});
});
}
// Add a new snippet
addBtn.addEventListener('click', async () => {
const shortcut = shortcutInput.value.trim();
const expansion = expansionInput.value.trim();
if (!shortcut || !expansion) {
alert('Please fill in both shortcut and expansion fields.');
return;
}
if (shortcut.length > 20) {
alert('Shortcut must be 20 characters or less.');
return;
}
// Add to snippets object
snippets[shortcut] = expansion;
// Save to storage
await chrome.storage.sync.set({ snippets });
// Notify content scripts
await chrome.runtime.sendMessage({
type: 'snippetsUpdated',
snippets: snippets
});
// Clear inputs
shortcutInput.value = '';
expansionInput.value = '';
// Re-render list
renderSnippets();
});
// Delete a snippet
async function deleteSnippet(shortcut) {
delete snippets[shortcut];
await chrome.storage.sync.set({ snippets });
await chrome.runtime.sendMessage({
type: 'snippetsUpdated',
snippets: snippets
});
renderSnippets();
}
// Utility to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize
loadSnippets();
});
Background Service Worker
The background service worker acts as a central hub for communication between the popup and content scripts. It also handles extension lifecycle events:
// background/background.js
// Handle messages from popup and content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'snippetsUpdated') {
// Broadcast snippet updates to all tabs
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => {
chrome.tabs.sendMessage(tab.id, {
type: 'snippetsUpdated',
snippets: message.snippets
}).catch(() => {
// Ignore errors for tabs where content script isn't loaded
});
});
});
}
});
// Handle extension installation
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
// Set default snippets for new users
const defaultSnippets = {
';email': 'your.email@example.com',
';addr': '123 Main Street, City, State 12345',
';sig': 'Best regards,\nYour Name'
};
chrome.storage.sync.set({ snippets: defaultSnippets });
console.log('QuickSnip installed with default snippets');
}
});
console.log('QuickSnip background service worker started');
Testing Your Extension
Now that we have built all the components, let us test our text expander extension. Follow these steps to load it into Chrome:
- Open Chrome and navigate to
chrome://extensions/ - Enable “Developer mode” using the toggle in the top-right corner
- Click “Load unpacked” and select your extension folder
- Once loaded, you should see the QuickSnip icon in your Chrome toolbar
To test the extension:
- Click the QuickSnip icon in the toolbar to open the popup
- Add a new snippet with shortcut
;testand expansionHello, this is a test! - Open any text field in Chrome (like the address bar search or a textarea on a webpage)
- Type
;testand watch it automatically expand toHello, this is a test!
You can add more complex snippets like ;sig for your email signature or ;addr for your address. The extension works across all websites, making it a versatile productivity tool.
Advanced Features and Improvements
While our basic text expander extension is fully functional, there are several enhancements you can implement to make it even more powerful:
Dynamic Placeholders
Add support for placeholder tokens that prompt for user input during expansion. For example, ;date could expand to the current date, or {{name}} could prompt you to enter a name each time.
Clipboard History
Implement a clipboard history feature that allows you to quickly access and paste previously copied text using keyboard shortcuts.
Snippet Categories
Organize snippets into categories or folders for better management, especially when dealing with large numbers of snippets.
Import and Export
Add the ability to import and export snippets as JSON files, making it easy to backup your snippets or share them with others.
Cloud Synchronization
While our extension already syncs via Chrome’s storage API, you could add more sophisticated cloud sync using a backend service for cross-device synchronization.
Best Practices and Considerations
When building and using text expander extensions, keep these best practices in mind:
Security Considerations: Be cautious about where you store sensitive information in your snippets. While Chrome storage is encrypted, avoid storing passwords or highly sensitive data directly in your snippets.
Performance: The content script runs on every page you visit, so optimize your code to minimize performance impact. Our implementation uses efficient event handling and limits the tracking buffer to 50 characters.
Conflict Prevention: Be aware that other extensions or website scripts might also be listening for keyboard events. Test your extension alongside other extensions to ensure compatibility.
Cross-Platform Considerations: Remember that Chrome extensions do not sync automatically to other browsers. If you need text expansion across different browsers, consider using a cross-browser solution or building separate extensions for each platform.
Conclusion
Congratulations! You have successfully built a fully functional text expander Chrome extension from scratch. This extension demonstrates key concepts in Chrome extension development, including Manifest V3 configuration, content script implementation, popup interface design, and background service worker communication.
The text expander you built is not just a simple demo—it is a practical tool that can genuinely improve your productivity. You can use it immediately for common text patterns, customize it with your own snippets, and even extend it with additional features.
This guide covered the essential components needed to create a production-ready Chrome extension. With this foundation, you can explore more advanced Chrome extension APIs and build even more sophisticated extensions. The skills you have learned here apply broadly to many types of Chrome extensions beyond text expansion.
Remember to test your extension thoroughly across different websites and input types, and always consider user privacy and security when handling text data. Happy coding!