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.

Learn JavaScript with Udemy

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: 16x16px32x32px48x48px 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

📚 Get access to exclusive content

Want to get access to exclusive content? Support webtips to get access to tips, checklists, cheatsheets, and much more. ☕

Get access Support us
Read more on
🎉 Thank you for subscribing to our newsletter. x