How to Make Your First Chrome Extension With JavaScript

One of the best advantages of being a developer is that you can create your own set of tools to optimize your workflow. Creating chrome extensions is no exception from this. We all use some from our day-to-day basis ā€”Ā probably most of you reading this article with an ad blocker installedĀ ā€” but some of us have special workflows for which we donā€™t have appropriate tools.

JavaScript Course

Creating them is not much hassle; You make it once, use it forever. The amount of time they can save is invaluable, so I decided to give it a go and write a tutorial about it. They are built using existing web technologies such as HTML, CSS, and JavaScript which means you donā€™t need to learn anything new, apart from what are the key components in a Chrome extension.


The Extension We Will Create

Iā€™ve created some simple designs inĀ Figma. The extension can be used to import ingredients fromĀ Tasty, which you can later use in your shopping list. I chose this example so we can see how to implement a popup with various UI elements with the logic behind it.

We will also have a look at how we can interact with the current page to do DOM manipulation through the use of content scripts. Last but not least, we will see how we can store information inside our extension for later use.

The front page of the extension
Importing ingredients into the extension
The imported ingredients in the extension

The extension will work in the following way:

If you would like to get the project in one go, you can clone it from GitHub.


Setting up the Chrome Extension

To get started, create an empty directory that will hold all the required files for the extension. All extensions need to have aĀ manifest.jsonĀ file. This is a configuration file that will hold information about our extension, such as the file used for the popup window, the scripts we are using for it, or even the permissions we want to request for the extension.

{
    "manifest_version": 2,

    "name": "Tasty",
    "description": "Import ingredients from a tasty page to create a shopping list",
    "version": "1.0"
}
manifest.json
Copied to clipboard!

We can get away using only four properties initially. At this stage, we can already import the extension to Chrome so we can check any further updates.

To do so, navigate toĀ chrome://extensionsĀ and enable developer mode, then click on the ā€œLoad unpackedā€ button and select the folder of the extension to import it.

Enable Developer mode and select load unpacked to load your extension

The extension is now installed and you should see a new icon appearing next to the address bar. Right at this stage, it looks pretty boring. We have the name, the version, and the description displayed, but we donā€™t have an icon, so letā€™s add that right now.

The extension shown in Google Chrome

Create four different sized png icons:Ā 16x16px,Ā 32x32px,Ā 48x48pxĀ andĀ 128x128px. You can do it with the help of online tools or with your favorite design tool. I use Figma throughout this tutorial. Create anĀ imgsĀ folder for them and pull it into your project. Now you should have a folder structure similar to the following:

The project folder structure

To use the images in the extension, we can expand theĀ manifest.jsonĀ file with anĀ iconsĀ property, holding the references to them:

{
    "manifest_version": 2,

    "name": "Tasty",
    "description": "Import ingredients from a tasty page to create a shopping list",
    "version": "1.0",

    "icons": {
        "16": "imgs/16.png",
        "32": "imgs/32.png",
        "48": "imgs/48.png",
        "128": "imgs/128.png"
    }
}
manifest.json
Copied to clipboard!

If you go back to the extensions tab and refresh the extension, youā€™ll see we have an icon now.


Adding The HTML Popup

With everything set up, letā€™s add the user interface to the extension. Create aĀ popup.htmlĀ file in the root of your project. Just as for the images, we need to reference it in theĀ manifest.jsonĀ file:

"browser_action": {
    "default_popup": "popup.html"
}
manifest.json
Copied to clipboard!

This can be done by adding theĀ browser_actionĀ property. TheĀ popup.htmlĀ holds all of the UI elements displayed in the extension. Right at this stage, it only includes a generic message for all sites:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title>Tasty</title>
        <link rel="stylesheet" href="css/app.css" />
    </head>
    <body>
        <img src="imgs/tasty.svg" class="logo" />
        <div class="disclaimer">
            To start importing<br />
            visit a recipe on
            <strong>Tasty</strong>
        </div>

        <div class="shopping-list hidden">
            <h1 class="recipe-name"></h1>
            <button class="import">Import</button>
            <ul class="ingredients"></ul>
            <button class="clear hidden">Clear</button>
        </div>
    </body>
</html>
popup.html
Copied to clipboard!

This is all the HTML we will need. For the styles, Iā€™ve created aĀ cssĀ folder and put all my rules into theĀ app.cssĀ file. The shopping list ā€”Ā which holds the name of the recipe, the import button, and the ingredientsĀ ā€” is hidden through CSS.

Once we are on the right page, we will show them to the user. The recipeā€™s name and its ingredients are also empty as they will be populated through JavaScript.


Showing The Import Button

Right now, even if you are on Tasty.co, the same message is shown to the user. We want to hide the disclaimer and show the import button if the user is viewing a recipe.

To do so, we need to add a script to ourĀ popup.htmlĀ file. Create anĀ app.jsĀ in your root directory and reference it insideĀ popup.html, right before the closing of the body tag.

<script src="app.js"></script>
popup.html
Copied to clipboard!

Weā€™re going to be using theĀ chrome.tabsĀ API. To have access to everything, we need to request permission for it, which we can do in ourĀ manifest.jsonĀ file. Expand it with the following line:

"permissions": ["tabs"]
manifest.json
Copied to clipboard!

Now we can start writing out the functionality for showing the import button to the user:

chrome.tabs.getSelected(null, tab => {
    if (tab.url.includes('tasty.co/recipe')) {
        document.querySelector('.disclaimer').classList.add('hidden');
        document.querySelector('.shopping-list').classList.remove('hidden');
    }
});
app.js
Copied to clipboard!

We use theĀ getSelectedĀ method of theĀ chrome.tabsĀ API which returns the currently active tab. If the tab URL matches the path we defined on line:2, we hide the disclaimer and show the shopping list to the user. After youā€™ve made the changes, remember to refresh the extension to see the results.


Content Scripts: Pulling In Ingredients

By clicking the button, we would like to get the list of ingredients from the page and populate theĀ ingredientsĀ node inside our extension. As you can see from the code example above, theĀ documentĀ node represents the HTML of the extension and not the document of the page. To get around this, we will use content scripts.

Content scripts have access to the pageā€™s DOM but have no access to the extensionā€™s HTML.

Extensions in Chrome are event-based, so to make things work when the user clicks the import button, we need to:

It may sound overly complicated at first but bear with me, each step can be done with only a couple of lines.

To add a content script, navigate back to yourĀ manifest.jsonĀ and extend it with theĀ content_scriptsĀ node:

{
    "manifest_version": 2,

    "name": "Tasty",
    "description": "Import ingredients from a tasty page to create a shopping list",
    "version": "1.0",

    "icons": {
        "16": "imgs/16.png",
        "32": "imgs/32.png",
        "48": "imgs/48.png",
        "128": "imgs/128.png"
    },

    "browser_action": {
        "default_popup": "popup.html"
    },

    "content_scripts": [
        {
            "matches": ["https://tasty.co/recipe/*"],
            "js": ["contentScript.js"]
        }
    ],

    "permissions": ["tabs"]
}
manifest.json
Copied to clipboard!

This is how ourĀ manifest.jsonĀ file looks like. TheĀ content_scriptsĀ property takes in an array of objects with two properties:

Inside theĀ matchesĀ property we can use regexes. WithĀ *Ā we tell it to match for every page that starts withĀ tasty.co/recipe/. Inside theĀ jsĀ property, we are referencingĀ contentScript.js, so create that in your root directory.

Debugging content script through the Sources tab
To debug, change the ā€œPageā€ on the left-hand side to Content scripts under the Sources tab

If everything is done correctly, you should see your content script loaded only for recipe pages inside Tasty. To debug content scripts, inspect the page and go to your ā€œSourcesā€ tab, then change ā€œPageā€ on the left-hand side to ā€œContent scriptsā€. There you will see your extension.

InsideĀ contentScript.jsĀ we are going to add a listener for the event when the user clicks the import button:

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if (request.action === 'fetchIngredients') {
        const recipeName = document.querySelector('.recipe-name').innerText;
        const ingredients = Array.from(document.querySelectorAll('.ingredient')).map(ingredient => ingredient.innerText);

        sendResponse({ recipeName, ingredients });
    }
});
contentScript.jsLater on, we are going to send a message which triggers `addListener`
Copied to clipboard!

To add the event listener we useĀ chrome.runtime.onMessage.addListener. Inside its callback, we check if the request is coming from our app, and if it does, we send back the name of the recipe alongside the ingredients in an object.

To send this event, we need to go back to ourĀ app.jsĀ file and add the event listener to our button.

document.querySelector('.import').addEventListener('click', () => {
    chrome.tabs.sendMessage(tab.id, { action: 'fetchIngredients' }, response => {
        console.log(response);
    });
});
app.js
Copied to clipboard!

Here we useĀ chrome.tabs.sendMessageĀ with the id of the current tab and the same action we are listening for insideĀ contentScript.js. After extendingĀ app.jsĀ with the lines above, clicking on the import button should grant you the information we are longing for:

The received response from our content script

All thatā€™s left to do for us is populating the DOM inside the extension with the information we got from our content script.


Populating The Extension With The Ingredients

InsideĀ chrome.tabs.sendMessageĀ when the response comes back, we want to populate the extension with the title and the ingredients. Starting with the title we can get it done using one single line:

document.querySelector('.recipe-name').innerText = response.recipeName;
app.js
Copied to clipboard!

Doing the ingredients will be a little bit more tricky but nothing impossible. Since we have multiple elements, we need to loop over them. We will also have checkboxes inside the list item so we need to create additional DOM elements as well.

const ingredientsList = document.querySelector('.ingredients');

response.ingredients.forEach(ingredient => {
    const listItem = document.createElement('li');
    const checkbox = document.createElement('input');

    checkbox.type = 'checkbox';

    listItem.appendChild(checkbox);
    listItem.appendChild(document.createTextNode(ingredient));

    ingredientsList.appendChild(listItem);
});
app.js
Copied to clipboard!

Here we first get theĀ .ingredientsĀ list, we want to query it only once before the loop. Next, we loop overĀ response.ingredientsĀ usingĀ forEach. Inside each ingredient, we create theĀ liĀ and theĀ input, we set theĀ typeĀ for the input, and append the checkbox to the list item and the name of the ingredient after. Then we append the whole thing to theĀ ul.

Last but not least, we can hide the import button to prevent the user from importing the same list again and again.

document.querySelector('.import').classList.add('hidden');
app.js
Copied to clipboard!

Now if we refresh the extension and test it out on a site, you should see the ingredients getting pulled in. But if we try to check one of the items, nothing happens. Thatā€™s because we havenā€™t attached any listener to the checkboxes yet.

Importing works, but checking doesnā€™t

Checking The Checkboxes

To add the functionality to the checkboxes, letā€™s create an event listener next to the import buttons event listener. However, we canā€™t just attach listeners to theĀ liĀ elements, as initially, they donā€™t exist in the DOM. Instead, we can attach an event listener to the parent container which is theĀ ul.

document.querySelector('.ingredients').addEventListener('click', e => {
    if (e.target.nodeName === 'INPUT') {
        if (e.target.checked) {
            e.target.parentElement.classList.add('checked');
        } else {
            e.target.parentElement.classList.remove('checked');
        }
    }
});
app.js
Copied to clipboard!

To check whether the input has been clicked inside the list item we can check forĀ e.target.nodeName. Giving the items a strike-through is just a matter of switching a class based on whether the checkbox is checked or not:

Checkboxes with event listeners
Now itā€™s looking good

Storing Previously Fetched Data

Of course, when we close the extension and reopen it later, we need to import the same recipes again as everything is lost. To get around this, letā€™s add some functionality to store previously imported data. For that, weā€™re going to use theĀ storageĀ API, so we need to request permission for it in ourĀ manifest.json:

"permissions": [
    "tabs",
    "storage"
]
manifest.json
Copied to clipboard!

To store the response returned from the content script, we can callĀ storage.sync.setĀ after we clicked the import button and the response is returned:

chrome.storage.sync.set({ response }, () => {});
app.js
Copied to clipboard!

To retrieve the response, we can callĀ storage.sync.getĀ right after we check if we are on Tasty.co. Since we would populate the same way we do for the import button, we can extract the code into a function and call it for both importing andĀ storage.sync.get.

chrome.storage.sync.get(['response'], (result) => {
    populateIngredients(result.response);
});

document.querySelector('.import').addEventListener('click', () => {
    chrome.tabs.sendMessage(tab.id, { action: 'fetchIngredients' }, response => {
        populateIngredients(response);
    });
});
app.js
Copied to clipboard!

We can also introduce a clear button that callsĀ storage.sync.clearĀ to get rid of previously-stored data:

document.querySelector('.clear').addEventListener('click', () => {
    chrome.storage.sync.clear();
});
app.js
Copied to clipboard!

This is how our wholeĀ app.jsĀ file will look in the end, with everything in place:

const populateIngredients = (response) => {
    document.querySelector('.recipe-name').innerText = response.recipeName;

    const ingredientsList = document.querySelector('.ingredients');

    response.ingredients.forEach(ingredient => {
        const listItem = document.createElement('li');
        const checkbox = document.createElement('input');

        checkbox.type = 'checkbox';

        listItem.appendChild(checkbox);
        listItem.appendChild(document.createTextNode(ingredient));

        ingredientsList.appendChild(listItem);
    });

    document.querySelector('.import').classList.add('hidden');
    document.querySelector('.clear').classList.remove('hidden');

    chrome.storage.sync.set({ response }, () => {});
}

chrome.tabs.getSelected(null, tab => {
    if (tab.url.includes('tasty.co/recipe')) {
        document.querySelector('.disclaimer').classList.add('hidden');
        document.querySelector('.clear').classList.add('hidden');
        document.querySelector('.shopping-list').classList.remove('hidden');

        chrome.storage.sync.get(['response'], (result) => {
            populateIngredients(result.response);
        });

        document.querySelector('.import').addEventListener('click', () => {
            chrome.tabs.sendMessage(tab.id, { action: 'fetchIngredients' }, response => {
                populateIngredients(response);
            });
        });

        document.querySelector('.clear').addEventListener('click', () => {
            chrome.storage.sync.clear();

            document.querySelector('.disclaimer').classList.remove('hidden');
            document.querySelector('.shopping-list').classList.add('hidden');
        });

        document.querySelector('.ingredients').addEventListener('click', e => {
            if (e.target.nodeName === 'INPUT') {
                if (e.target.checked) {
                    e.target.parentElement.classList.add('checked');
                } else {
                    e.target.parentElement.classList.remove('checked');
                }
            }
        });
    }
});
app.js
Copied to clipboard!

Summary

We looked at how we can initialize a Chrome extension with a manifest file, how we can request permissions to various APIs such asĀ tabsĀ orĀ storage, and we also looked at creating a popup from scratch and using content scripts to retrieve data from a website.

If you would like to tweak around with the project in one piece ā€” with CSS included ā€” you can clone the full source code from GitHub.

This is just the tip of the iceberg. If you would like to learn more, the officialĀ Chrome documentationĀ is pretty extensive. You can find different pages for the differentĀ APIs. I would like to encourage you to take a look around and experiment with different setups and try to extend the chrome extension created in this tutorial with extra functionality. šŸŒ¶ļø

Have you worked with Chrome extensions before? Do you have questions or concerns? Let us know your thoughts in the comments down below!

If you think a Chrome extension would be an overkill for your solution, you might also want to check out how to create bookmarklets:

Learn How to Easily Make Bookmarklets
Remove ads
Remove ads

šŸ“š Get access to exclusive content

Want to get access to exclusive content? Support webtips with the price of a coffee to get access to tips, checklists, cheatsheets, and much more. ā˜•

Get access Support us
Remove ads Read more on
Remove ads
Remove ads
šŸŽ‰ Thank you for subscribing to our newsletter. x This site uses cookies We use cookies to understand visitors and create a better experience for you. By clicking on "Accept", you accept its use. To find out more, please see our privacy policy.