Build a Website-Blocking Chrome Extension
Build a Chrome extension that blocks distracting sites during focus sessions.
Introduction
30 Second Summary
You sit down to work on something important. You open your browser to look up one quick thing, and thirty minutes later you are deep in a social media scroll with no memory of what you were supposed to be doing.
In this project, you will build a Chrome extension called Focus Guard that blocks distracting websites during timed focus sessions and redirects you to a motivational page until your break starts.
What You'll Build
Click the Focus Guard icon and a full-page dashboard opens where you add sites to block, set your timers, and hit Start Session. Try visiting youtube.com during focus time and you are instantly redirected to a "Stay Focused" page showing a live countdown to your next break.
By the end of this project, you'll have:
- A block list on a full-page dashboard where you add sites like youtube.com, close your browser, reopen it, and find them still there.
- Automatic focus/break cycling that keeps running in the background. Close the dashboard tab, come back minutes later, and the timer has advanced on its own.
- Real-time site blocking that redirects any blocked site to a custom "Stay Focused" page with a live countdown ticking down to your next break.
- Secret Mission: A session history section on the dashboard that shows your completed sessions, dates, and total focus hours.
Are there any prerequisites?
You should have basic TypeScript knowledge and Google Chrome installed. You will also need Node.js and VS Code, which you will set up in Step 1.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Scaffold the Project
Chrome extensions require a specific build pipeline that understands the Manifest V3 format. A standard bundler like Vite needs extra help to output the correct file structure for Chrome to load.
In this step, you will scaffold a TypeScript project using Vite, add a plugin called CRXJS that handles Chrome extension bundling, and verify that Chrome can load your extension with no errors.
In this step, get ready to:
- Scaffold a Vite project with TypeScript and install extension development dependencies.
- Create the Chrome extension manifest and configure the Vite build.
- Create placeholder source files and verify the extension loads in Chrome.
Scaffold the project and install dependencies
The CRXJS plugin transforms a standard Vite project into a Chrome extension build. You will start by creating a Vite project and then adding the extension-specific packages.
- Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project scaffold by running this command:
npm create vite@latest focus-guard -- --template vanilla-ts
What does this command do?
This uses the Vite project creator to scaffold a new folder called focus-guard with a vanilla TypeScript template. The -- separator passes the --template flag through to the underlying tool.
The command may ask whether to install and start immediately. Enter no if prompted. You will install dependencies separately.
- Move into the new project folder and install base dependencies by running these commands:
cd focus-guard
npm install
You should see npm download packages and print a success message with the number of packages installed.
- Install the Chrome extension build plugin and TypeScript types by running:
npm install -D @crxjs/vite-plugin@2.7.1 chrome-types@0.1.425
What are these packages?
The @crxjs/vite-plugin package teaches Vite how to bundle your code into a Chrome extension. It reads your manifest, handles hot-reload during development, and outputs the correct folder structure.
The chrome-types package provides TypeScript type definitions for all Chrome extension APIs so you get autocomplete and type-checking.
- Confirm the packages installed by running:
ls node_modules/@crxjs
You should see vite-plugin listed in the output.
Command not found or install failed?
Make sure you ran cd focus-guard before running npm install. You need to be inside the project folder.
If you see a permissions error, try running the install command again. Temporary network issues can cause partial installs.
help me fix my npm install error for @crxjs/vite-plugin
Now you need to tell TypeScript about the Chrome API types. The scaffold generated a tsconfig.json file, and you will add one line to it.
- Open tsconfig.json in the VS Code editor by clicking it in the sidebar.
- Find the compilerOptions object and add the following line inside it:
"types": ["chrome-types"]
Where exactly does this line go?
Place it as the last entry inside the compilerOptions object, just before the closing } brace. Make sure the previous line ends with a comma.
- Replace the entire content of tsconfig.json with this to ensure it matches the project configuration exactly:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["chrome-types"]
},
"include": ["src"]
}
- Save tsconfig.json.
Create the manifest and configure Vite
Every Chrome extension needs a manifest.json file that declares its name, permissions, and entry points. CRXJS reads this manifest to know how to structure the build output.
- Create a new file called manifest.json in the root of your focus-guard folder (the same level as package.json).
- Paste the following content into manifest.json:
{
"manifest_version": 3,
"name": "Focus Guard",
"version": "1.0.0",
"description": "Block distracting websites during focus sessions",
"permissions": [
"declarativeNetRequest",
"storage",
"alarms"
],
"host_permissions": [
"*://*/*"
],
"background": {
"service_worker": "src/background.ts"
},
"action": {
"default_title": "Open Focus Guard"
},
"web_accessible_resources": [
{
"resources": ["src/blocked.html"],
"matches": ["*://*/*"]
}
]
}
What does this manifest declare?
- declarativeNetRequest lets the extension redirect blocked websites.
- storage lets the extension save data like the blocked sites list.
- alarms lets the extension fire timers even when the service worker is suspended.
- The action field has no default_popup, so clicking the extension icon fires an event instead of opening a popup.
- web_accessible_resources allows the redirect to reach src/blocked.html from any tab.
- Save manifest.json.
- Now create the Vite configuration file. Create a new file called vite.config.ts in the project root and paste this content:
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json'
export default defineConfig({
plugins: [crx({ manifest })],
})
What does this config do?
This imports your manifest.json and passes it to the CRXJS plugin. The plugin reads the manifest to figure out which files to bundle and how to structure the dist output folder for Chrome.
- Save vite.config.ts.
- Confirm both new files appear by running:
ls manifest.json vite.config.ts
You should see both filenames printed without errors.
CRXJS requires your project to use ES modules. Open package.json and verify the "type": "module" field is present near the top. The Vite scaffold should have added it already.
- Open package.json in the editor.
- Confirm you see this line inside the top-level object:
"type": "module"
- If the line is missing, add it after the "version" line. Save package.json.
Not sure if it's already there?
Press Cmd+F (macOS) or Ctrl+F (Windows) in VS Code and search for "type". If you find "type": "module", you are set.
Create placeholder files and verify the build
The manifest references several source files that do not exist yet. You need to create them as placeholders so the build succeeds. Then you will run the dev server and load the extension in Chrome.
- Create the placeholder files by running these commands:
echo "// placeholder" > src/background.ts
echo "<!-- placeholder -->" > src/dashboard.html
echo "// placeholder" > src/dashboard.ts
echo "<!-- placeholder -->" > src/blocked.html
echo "// placeholder" > src/blocked.ts
- Confirm the files exist by running:
ls src/
You should see background.ts, dashboard.html, dashboard.ts, blocked.html, and blocked.ts listed alongside the existing scaffold files.
- Start the Vite dev server by running:
npm run dev
Vite should print a message showing the local server URL and that the CRXJS plugin is active. A dist folder is generated in your project root.
Why a dist folder?
CRXJS compiles your TypeScript and copies your manifest into dist. This is the folder you will point Chrome at. The dev server keeps it updated as you edit files.
Before you load the extension, what do you think will happen when Chrome reads the dist folder? Will it succeed or fail, given that the source files are just placeholders?
- Open Chrome and type chrome://extensions in the address bar, then press Enter.
- Enable Developer mode by clicking the toggle switch in the top-right corner.
- Click the Load unpacked button.
- Navigate to your Desktop/focus-guard/dist folder and select it.
The extension loads successfully. You should see a card titled Focus Guard on the extensions page with no red error badges.
Seeing an error when loading?
Make sure you selected the dist folder inside focus-guard, not the focus-guard folder itself. Chrome needs the compiled output, not the source.
If you see a manifest error, confirm your npm run dev is still running in the terminal. The dist folder is only generated while the dev server is active.
help me fix the error I see when loading my Chrome extension
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files and that npm run dev is still running in your terminal.
ⓧ I'd like to double check the full code
Here are the key files you should have at this point. Compare them with yours:
manifest.json
{
"manifest_version": 3,
"name": "Focus Guard",
"version": "1.0.0",
"description": "Block distracting websites during focus sessions",
"permissions": [
"declarativeNetRequest",
"storage",
"alarms"
],
"host_permissions": [
"*://*/*"
],
"background": {
"service_worker": "src/background.ts"
},
"action": {
"default_title": "Open Focus Guard"
},
"web_accessible_resources": [
{
"resources": ["src/blocked.html"],
"matches": ["*://*/*"]
}
]
}
vite.config.ts
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json'
export default defineConfig({
plugins: [crx({ manifest })],
})
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["chrome-types"]
},
"include": ["src"]
}
Your development environment is ready. Next up, you will build the dashboard UI where users manage their blocked sites and start focus sessions.
Build the Dashboard UI
Your extension loads in Chrome, but right now it does nothing when you click it. The dashboard is the user's control center for Focus Guard.
In this step, you will build the full-page dashboard where users add blocked sites, configure timers, and start focus sessions. You will also wire up the extension icon so clicking it opens the dashboard in a new tab.
In this step, get ready to:
- Build the dashboard HTML layout and dark-theme CSS.
- Wire the extension icon to open the dashboard in a new tab.
- Implement the blocked sites list that persists with chrome.storage.
Build the dashboard HTML structure
The dashboard needs an input for adding sites, a list to display them, timer configuration fields, and a start button. Every interactive element gets a unique id so your TypeScript can find it later.
- Open src/dashboard.html in VS Code and replace the placeholder with this first section of HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Focus Guard</title>
<link rel="stylesheet" href="dashboard.css" />
</head>
<body>
<div class="container">
<header>
<h1>Focus Guard</h1>
<p class="subtitle">Block distractions. Build focus.</p>
</header>
<section class="status-section" id="status-section">
<div class="status-badge" id="status-badge">Inactive</div>
<div class="timer-display" id="timer-display">--:--</div>
</section>
- Directly below that code, add the sites section, config fields, and closing tags:
<section class="sites-section">
<h2>Blocked Sites</h2>
<div class="input-row">
<input type="text" id="site-input" placeholder="e.g. youtube.com" />
<button id="add-site-btn">Add</button>
</div>
<ul id="sites-list"></ul>
</section>
<section class="config-section">
<h2>Session Config</h2>
<div class="config-fields">
<label>
Focus (min)
<input type="number" id="focus-input" value="25" min="1" max="120" />
</label>
<label>
Break (min)
<input type="number" id="break-input" value="5" min="1" max="30" />
</label>
<label>
Total (min)
<input type="number" id="total-input" value="120" min="1" max="480" />
</label>
</div>
</section>
<button class="start-btn" id="start-btn">Start Session</button>
</div>
<script type="module" src="dashboard.ts"></script>
</body>
</html>
What does this HTML do?
The page has four main sections. The status section shows whether a session is active. The sites section holds an input and list for blocked domains. The config section has number inputs for focus, break, and total minutes. The start button kicks everything off.
Every element the TypeScript will interact with has a unique id attribute. The script tag at the bottom loads dashboard.ts as a module.
- Save src/dashboard.html.
- Check your terminal where npm run dev is running. You should see [crx] files ready with no red errors.
Seeing a red error in the terminal?
Make sure the <script> tag uses src="dashboard.ts" (not src="./dashboard.ts"). CRXJS requires paths without a leading ./ or /.
Also confirm you closed every HTML tag. A missing </section> or </div> will cause Vite to fail.
help me fix a Vite build error in my Chrome extension HTML
Style the dashboard with a dark theme
The dashboard needs a dark color scheme with card-based sections. You will create a new CSS file and build the styles in sections.
- Create a new file called src/dashboard.css and add the base layout styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eaeaea;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 40px 20px;
}
.container {
max-width: 520px;
width: 100%;
}
header {
text-align: center;
margin-bottom: 32px;
}
- Below that, add the header text and status section styles:
header h1 {
font-size: 2rem;
color: #4fc3f7;
}
.subtitle {
color: #888;
margin-top: 4px;
}
.status-section {
text-align: center;
margin-bottom: 32px;
padding: 24px;
background: #16213e;
border-radius: 12px;
}
.status-badge {
display: inline-block;
padding: 4px 16px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
background: #333;
color: #aaa;
margin-bottom: 12px;
}
- Save src/dashboard.css and confirm no red errors appear in your terminal.
- Continue adding the badge state and timer styles below your existing CSS:
.status-badge.focusing {
background: #1b5e20;
color: #a5d6a7;
}
.status-badge.break {
background: #e65100;
color: #ffcc80;
}
.timer-display {
font-size: 2.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #fff;
}
.sites-section,
.config-section {
background: #16213e;
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
}
h2 {
font-size: 1rem;
color: #4fc3f7;
margin-bottom: 12px;
}
- Add the input row and button styles next:
.input-row {
display: flex;
gap: 8px;
}
.input-row input {
flex: 1;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 8px;
background: #0f3460;
color: #eaeaea;
font-size: 0.95rem;
}
.input-row button {
padding: 10px 20px;
background: #4fc3f7;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.input-row button:hover {
background: #81d4fa;
}
- Save src/dashboard.css and check the terminal is still error-free.
- Add the list and config field styles below:
#sites-list {
list-style: none;
margin-top: 12px;
}
#sites-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #0f3460;
border-radius: 8px;
margin-bottom: 6px;
font-size: 0.9rem;
}
#sites-list li button {
background: none;
border: none;
color: #ef5350;
cursor: pointer;
font-size: 1.1rem;
}
.config-fields {
display: flex;
gap: 12px;
}
- Add the config inputs and Start button styles to complete the file:
.config-fields label {
flex: 1;
font-size: 0.8rem;
color: #888;
}
.config-fields input {
display: block;
width: 100%;
margin-top: 4px;
padding: 10px;
border: 1px solid #333;
border-radius: 8px;
background: #0f3460;
color: #eaeaea;
font-size: 1rem;
text-align: center;
}
.start-btn {
width: 100%;
padding: 16px;
background: #4fc3f7;
color: #1a1a2e;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
- Finally, add the button hover and stop-state styles:
.start-btn:hover {
background: #81d4fa;
}
.start-btn.stop {
background: #ef5350;
color: #fff;
}
.start-btn.stop:hover {
background: #e53935;
}
- Save src/dashboard.css. Your terminal should still show [crx] files ready with no errors.
What does this styling do?
The dark background (#1a1a2e) with light blue accents (#4fc3f7) creates a focused, low-distraction interface. Each section lives inside a card with a slightly lighter background (#16213e) and rounded corners.
The .status-badge.focusing and .status-badge.break classes will be toggled by your TypeScript later to visually indicate the current session state.
Wire the action button to open the dashboard
Chrome extensions can open a popup or a full-page tab when you click their icon. Focus Guard uses a full tab because the dashboard has too many controls for a tiny popup. Since the manifest has no default_popup, the chrome.action.onClicked event fires when the icon is clicked.
- Open src/background.ts and replace the placeholder with this listener:
chrome.action.onClicked.addListener(() => {
chrome.tabs.create({ url: chrome.runtime.getURL('src/dashboard.html') })
})
What does this code do?
When the learner clicks the Focus Guard icon in the toolbar, chrome.action.onClicked fires. Inside the callback, chrome.tabs.create opens a brand-new tab.
The URL uses chrome.runtime.getURL() to build the full chrome-extension:// path to your dashboard HTML. This ensures Chrome loads it as an internal extension page with full access to Chrome APIs.
- Save src/background.ts.
- Before you test, what do you think will happen when you click the Focus Guard icon in the toolbar?
- Click the Focus Guard icon in Chrome's toolbar.
A new tab opens showing your Focus Guard dashboard with the dark theme, status badge, input field, timer config fields, and the Start Session button.
Don't see the extension icon?
Click the puzzle piece icon in Chrome's toolbar to see all extensions. Pin Focus Guard by clicking the pin icon next to it.
If the icon does nothing when clicked, go to chrome://extensions, find Focus Guard, and click the refresh icon. CRXJS sometimes needs a manual reload after changing the service worker.
help me fix my Chrome extension icon not opening the dashboard
Implement the blocked sites list with chrome.storage
The dashboard looks great, but it cannot do anything yet. You need TypeScript to read and write blocked sites to chrome.storage.local so the list persists even after the browser restarts.
- Open src/dashboard.ts and replace the placeholder with these DOM references and the load/render functions:
const siteInput = document.getElementById('site-input') as HTMLInputElement
const addSiteBtn = document.getElementById('add-site-btn') as HTMLButtonElement
const sitesList = document.getElementById('sites-list') as HTMLUListElement
const focusInput = document.getElementById('focus-input') as HTMLInputElement
const breakInput = document.getElementById('break-input') as HTMLInputElement
const totalInput = document.getElementById('total-input') as HTMLInputElement
const startBtn = document.getElementById('start-btn') as HTMLButtonElement
const statusBadge = document.getElementById('status-badge') as HTMLDivElement
const timerDisplay = document.getElementById('timer-display') as HTMLDivElement
let timerInterval: number | null = null
// Load blocked sites from persistent storage
async function loadSites(): Promise<void> {
const result = await chrome.storage.local.get('blockedSites')
const sites: string[] = result.blockedSites || []
renderSites(sites)
}
// Render the list of blocked sites to the DOM
function renderSites(sites: string[]): void {
sitesList.innerHTML = ''
sites.forEach((site) => {
const li = document.createElement('li')
li.innerHTML = `<span>${site}</span><button data-site="${site}">×</button>`
sitesList.appendChild(li)
})
}
What does this code do?
The top section grabs references to every interactive element by its id. TypeScript's as HTMLInputElement casts tell the compiler the exact element type so you get autocomplete on properties like .value.
The loadSites() function reads an array called blockedSites from chrome.storage.local. If nothing is stored yet, it defaults to an empty array. Then renderSites() builds a list item for each domain with a remove button.
- Below the renderSites function, add the functions for adding and removing sites:
// Add a new site to the block list
async function addSite(): Promise<void> {
const site = siteInput.value.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '')
if (!site) return
const result = await chrome.storage.local.get('blockedSites')
const sites: string[] = result.blockedSites || []
if (sites.includes(site)) return
sites.push(site)
await chrome.storage.local.set({ blockedSites: sites })
siteInput.value = ''
renderSites(sites)
}
// Remove a site from the block list
async function removeSite(site: string): Promise<void> {
const result = await chrome.storage.local.get('blockedSites')
const sites: string[] = result.blockedSites || []
const updated = sites.filter(s => s !== site)
await chrome.storage.local.set({ blockedSites: updated })
renderSites(updated)
}
What does addSite do?
The addSite() function strips the protocol (https://) and any path from the input, leaving just the domain like youtube.com. It skips empty inputs and duplicates. After pushing the new site to the array, it saves the updated list back to chrome.storage.local and re-renders.
The removeSite() function filters the clicked site out of the array and saves the shorter list.
- Below removeSite, add the event listeners and the initial load call to finish the file:
// Handle remove button clicks via event delegation
sitesList.addEventListener('click', (e) => {
const target = e.target as HTMLElement
if (target.tagName === 'BUTTON' && target.dataset.site) {
removeSite(target.dataset.site)
}
})
// Wire up the add button and Enter key
addSiteBtn.addEventListener('click', addSite)
siteInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addSite()
})
// Load the saved sites on page open
loadSites()
Why use event delegation on the list?
Instead of attaching a click handler to every remove button individually, you attach one listener to the parent <ul> element. When a button inside the list is clicked, the event bubbles up to the <ul> and the handler checks target.dataset.site to identify which site to remove.
This works even when list items are added or removed dynamically. You never have to re-bind listeners after the list updates.
- Save src/dashboard.ts.
- Click the Focus Guard icon to open the dashboard. Type youtube.com in the input field and click Add.
- You should see youtube.com appear in the blocked sites list with a red × button next to it.
- Close the dashboard tab and reopen it by clicking the icon again. The site should still be listed because it persists in chrome.storage.local.
Site not appearing in the list?
Open DevTools on the dashboard tab (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows) and check the Console for errors.
A common issue is a mismatch between the id in your HTML and the string in getElementById(). Double-check that site-input in the HTML matches 'site-input' in the TypeScript exactly.
help me debug why my Chrome extension dashboard isn't saving sites to chrome.storage
✔️ Awesome, I've got everything!
Great. Double-check you have saved all three files: src/dashboard.html, src/dashboard.css, and src/dashboard.ts.
ⓧ I'd like to double check the full code
Since these files are long, scroll up and confirm each section is present in order:
- src/dashboard.html: DOCTYPE, head with CSS link, body with container, header, status-section, sites-section, config-section, start button, script tag.
- src/dashboard.css: reset, body, container, header, header h1, subtitle, status-section, status-badge (default + focusing + break), timer-display, sites/config sections, h2, input-row, input-row input, input-row button + hover, sites-list, sites-list li, sites-list li button, config-fields, config-fields label, config-fields input, start-btn + hover + stop + stop:hover.
- src/dashboard.ts: 9 DOM references, timerInterval variable, loadSites, renderSites, addSite, removeSite, sitesList click delegation, addSiteBtn click, siteInput keydown, loadSites() call.
- src/background.ts: single chrome.action.onClicked listener calling chrome.tabs.create.
Your dashboard is alive. You can add and remove sites, and the list survives closing and reopening the tab. Next up, you will make the extension actually block those sites by redirecting them during focus sessions.
Block Sites with DeclarativeNetRequest
Your dashboard looks great and can store blocked sites, but visiting those sites still works just fine. The extension has no teeth yet.
In this step you will build the core blocking mechanism. When a focus session starts, the extension dynamically adds redirect rules that intercept requests to blocked domains and send the user to a custom "Stay Focused" page instead. When the session ends, those rules are removed and browsing returns to normal.
In this step, get ready to:
- Create a full-page "Stay Focused" blocked page with a countdown placeholder.
- Write the service worker logic that adds and removes redirect rules via the Declarative Net Request API.
- Wire the dashboard Start button to trigger blocking through messaging.
Create the blocked page
When a user tries to visit a blocked site during a focus session, they need to land somewhere encouraging instead of a blank error. You will create a dedicated HTML page that shows a motivational message and a countdown placeholder.
- In VS Code, open src/blocked.html and replace the placeholder comment with the following full-page layout:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stay Focused</title>
<link rel="stylesheet" href="blocked.css" />
</head>
<body>
<div class="container">
<div class="icon">💪</div>
<h1>Stay Focused!</h1>
<p class="message">This site is blocked during your focus session.</p>
<div class="countdown">
<p>Break starts in</p>
<div class="time" id="countdown">--:--</div>
</div>
<p class="encouragement">You are building something great. Keep going.</p>
</div>
<script type="module" src="blocked.ts"></script>
</body>
</html>
What does this page do?
This is the page users see when they try to visit a blocked site. The #countdown div is a placeholder for a live timer you will wire up in a later step.
The 💪 entity renders a flexed-bicep emoji without needing an actual emoji character in the source.
- Save src/blocked.html.
- Confirm the file was saved by running:
ls src/blocked.html
You should see src/blocked.html listed in the output.
Now style it. Create src/blocked.css and add the page layout styles first:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eaeaea;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
max-width: 400px;
padding: 40px;
}
.icon {
font-size: 4rem;
margin-bottom: 20px;
}
- Below the .icon rule, add the content and heading styles:
h1 {
font-size: 2.2rem;
color: #4fc3f7;
margin-bottom: 12px;
}
.message {
color: #aaa;
font-size: 1.1rem;
margin-bottom: 32px;
}
.countdown {
background: #0f3460;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
- Finally, add the countdown display and encouragement styles at the bottom of src/blocked.css:
.countdown p {
color: #888;
font-size: 0.9rem;
margin-bottom: 8px;
}
.time {
font-size: 3rem;
font-weight: 700;
color: #4fc3f7;
font-variant-numeric: tabular-nums;
}
.encouragement {
color: #666;
font-style: italic;
}
What do these styles do?
The page uses a dark gradient background that matches the dashboard theme. The .time class uses font-variant-numeric: tabular-nums so the countdown digits stay perfectly aligned as numbers change.
- Save src/blocked.css.
- To preview the page, open src/blocked.html directly in Chrome by dragging the file from your file explorer into a browser tab.
You should see a dark gradient page with a flexed-arm emoji, the heading "Stay Focused!", a blue countdown card showing --:--, and an italic encouragement message.
Page looks unstyled?
Make sure blocked.css is in the same src/ folder as blocked.html. The <link> tag uses a relative path, so both files must be siblings.
Also check that you saved blocked.css before opening the HTML file. Help me troubleshoot my blocked page styling.
What about web_accessible_resources?
For the redirect to this page to work inside the extension, the file must be declared in the manifest under web_accessible_resources. If you check your manifest.json, you will see this is already configured from the previous step.
Implement the blocking logic in the service worker
The Declarative Net Request API lets you intercept and redirect network requests without reading their content. You will add functions that read the blocked sites list from storage, create redirect rules for each one, and remove those rules when blocking stops.
- In src/background.ts, below the existing chrome.action.onClicked listener, add the session state type and default values:
interface SessionState {
isActive: boolean
isFocusPeriod: boolean
focusMinutes: number
breakMinutes: number
totalMinutes: number
startTime: number
periodEndTime: number
currentCycle: number
}
const DEFAULT_STATE: SessionState = {
isActive: false,
isFocusPeriod: false,
focusMinutes: 25,
breakMinutes: 5,
totalMinutes: 120,
startTime: 0,
periodEndTime: 0,
currentCycle: 0,
}
What does SessionState track?
This interface describes everything the extension needs to know about a running session. isActive tracks whether a session is in progress. isFocusPeriod distinguishes focus time from break time. periodEndTime stores when the current period expires as a Unix timestamp.
The DEFAULT_STATE object represents an inactive session. You will save and load this from chrome.storage.local so the state survives even when the service worker gets terminated.
- Below DEFAULT_STATE, add the helper functions to read/write state and the enableBlocking function:
async function getState(): Promise<SessionState> {
const result = await chrome.storage.local.get('sessionState')
return result.sessionState || DEFAULT_STATE
}
async function setState(state: SessionState): Promise<void> {
await chrome.storage.local.set({ sessionState: state })
}
async function enableBlocking(): Promise<void> {
const result = await chrome.storage.local.get('blockedSites')
const sites: string[] = result.blockedSites || []
const oldRules = await chrome.declarativeNetRequest.getDynamicRules()
const oldRuleIds = oldRules.map(rule => rule.id)
const newRules = sites.map((site, index) => ({
id: index + 1,
priority: 1,
action: {
type: 'redirect' as chrome.declarativeNetRequest.RuleActionType,
redirect: { extensionPath: '/src/blocked.html' },
},
condition: {
urlFilter: `||${site}`,
resourceTypes: ['main_frame' as chrome.declarativeNetRequest.ResourceType],
},
}))
await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: oldRuleIds, addRules: newRules })
}
How does enableBlocking work?
First it reads all current dynamic rules and stores their IDs. Then it builds a new rule for each blocked domain. The || prefix in urlFilter matches any scheme and subdomain, so ||youtube.com catches both https://youtube.com and https://www.youtube.com.
The updateDynamicRules call atomically removes old rules and adds the new set. Each rule redirects matching main_frame requests to /src/blocked.html inside the extension.
- Save src/background.ts.
- Switch to chrome://extensions and click the reload icon on the Focus Guard card to pick up the changes.
The extension should reload without showing any red Errors link. If you see an error, check that your TypeScript has no missing braces or commas.
- Return to src/background.ts and add the disableBlocking, startSession, and stopSession functions below enableBlocking:
async function disableBlocking(): Promise<void> {
const oldRules = await chrome.declarativeNetRequest.getDynamicRules()
const oldRuleIds = oldRules.map(rule => rule.id)
await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: oldRuleIds })
}
async function startSession(focusMinutes: number, breakMinutes: number, totalMinutes: number): Promise<void> {
const now = Date.now()
const periodEndTime = now + focusMinutes * 60 * 1000
const state: SessionState = {
isActive: true,
isFocusPeriod: true,
focusMinutes,
breakMinutes,
totalMinutes,
startTime: now,
periodEndTime,
currentCycle: 1,
}
await setState(state)
await enableBlocking()
}
async function stopSession(): Promise<void> {
await disableBlocking()
await setState({ ...DEFAULT_STATE })
}
What do these functions do?
disableBlocking removes all dynamic rules so blocked sites become accessible again.
startSession saves the new session state to storage, calculates when the first focus period ends, and immediately enables blocking. stopSession reverses both: disables blocking and resets the stored state to the default.
- Save src/background.ts.
Seeing a TypeScript error?
Make sure the SessionState interface is defined above DEFAULT_STATE, and DEFAULT_STATE is defined above all functions that reference it. Order matters because these are not hoisted like function declarations.
Help me fix a TypeScript error in my background.ts file.
Connect the dashboard Start button to the service worker
The dashboard and the service worker run in separate contexts. They communicate through chrome.runtime messaging. The dashboard sends a message when the user clicks Start or Stop, and the service worker handles that message.
- At the bottom of src/background.ts, add the message listener:
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'START_SESSION') {
startSession(message.focusMinutes, message.breakMinutes, message.totalMinutes)
.then(() => sendResponse({ success: true }))
.catch((err) => sendResponse({ success: false, error: err.message }))
return true
}
if (message.type === 'STOP_SESSION') {
stopSession()
.then(() => sendResponse({ success: true }))
.catch((err) => sendResponse({ success: false, error: err.message }))
return true
}
})
Why return true?
Returning true from the listener tells Chrome to keep the message channel open until sendResponse is called asynchronously. Without it, the channel closes before the await in startSession or stopSession resolves.
- Save src/background.ts and reload the extension in chrome://extensions.
- Confirm the extension still shows no errors.
Now wire the dashboard to send those messages.
- In src/dashboard.ts, find the line addSiteBtn.addEventListener('click', addSite) and the siteInput keydown listener below it. After those two listeners, add the Start/Stop button handler:
startBtn.addEventListener('click', async () => {
const result = await chrome.storage.local.get('sessionState')
const state = result.sessionState
if (state && state.isActive) {
await chrome.runtime.sendMessage({ type: 'STOP_SESSION' })
} else {
const focusMinutes = parseInt(focusInput.value) || 25
const breakMinutes = parseInt(breakInput.value) || 5
const totalMinutes = parseInt(totalInput.value) || 120
await chrome.runtime.sendMessage({ type: 'START_SESSION', focusMinutes, breakMinutes, totalMinutes })
}
setTimeout(updateStatus, 200)
})
What does this handler do?
It checks the current session state in storage. If a session is active, it sends STOP_SESSION to the service worker. Otherwise it reads the timer inputs and sends START_SESSION with those values.
The setTimeout(updateStatus, 200) gives the service worker a moment to write the new state before the dashboard re-reads it.
- Below the startBtn listener, add the updateStatus function that reflects the session state in the UI:
async function updateStatus(): Promise<void> {
const result = await chrome.storage.local.get('sessionState')
const state = result.sessionState
if (!state || !state.isActive) {
statusBadge.textContent = 'Inactive'
statusBadge.className = 'status-badge'
timerDisplay.textContent = '--:--'
startBtn.textContent = 'Start Session'
startBtn.className = 'start-btn'
if (timerInterval) { clearInterval(timerInterval); timerInterval = null }
return
}
statusBadge.textContent = state.isFocusPeriod ? 'Focusing' : 'Break'
statusBadge.className = state.isFocusPeriod ? 'status-badge focusing' : 'status-badge break'
startBtn.textContent = 'Stop Session'
startBtn.className = 'start-btn stop'
}
What does updateStatus do?
It reads the current session state from storage and updates the dashboard badge, timer display, and button text to match. When a session is active, the badge turns green and reads "Focusing", and the button text switches to "Stop Session".
- At the very bottom of src/dashboard.ts, find the loadSites() call. Add updateStatus() on the line below it so the dashboard checks session state on load:
loadSites()
updateStatus()
- Save src/dashboard.ts.
Time to test the full blocking flow. Before you run the test, predict: if you add a site, start a session, then navigate to that site, what do you think you will see?
- Click the Focus Guard extension icon to open the dashboard.
- Type example.com into the site input and click Add.
- Click Start Session.
- Open a new tab and navigate to https://example.com.
You are redirected to the "Stay Focused" page showing the bicep emoji, the blocking message, and the --:-- countdown placeholder.
Not seeing the redirect?
Go to chrome://extensions and check if the Focus Guard card shows a red Errors link. Click it to read the error details.
If there are no errors, try reloading the extension manually (click the reload arrow on the card) and re-start the session from the dashboard. CRXJS hot-reload occasionally misses service worker changes.
Also confirm you typed the domain without https:// in the input. The addSite function strips protocols, but double-check that the sites list shows just example.com.
Help me debug why my blocked sites are not being redirected.
- Return to the dashboard tab and click Stop Session to clean up the blocking rules.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/background.ts and src/dashboard.ts.
ⓧ I'd like to double check the full code
Your src/background.ts is long now. Scroll up through the chunks you added and confirm each is present in this order:
- chrome.action.onClicked listener (from before).
- SessionState interface.
- DEFAULT_STATE object.
- getState and setState helpers.
- enableBlocking function.
- disableBlocking function.
- startSession and stopSession functions.
- chrome.runtime.onMessage.addListener at the bottom.
For src/dashboard.ts, confirm you have:
- All original DOM references and loadSites, renderSites, addSite, removeSite functions.
- The startBtn.addEventListener('click', ...) handler sending messages.
- The updateStatus function.
- loadSites() and updateStatus() calls at the bottom.
Your extension now has real teeth. Blocked sites are completely intercepted during a session. Next up, you will add automatic timers so the extension alternates between focus periods and breaks without you having to click anything.
Add Timer Logic with chrome.alarms
In the last step, you proved that site blocking works. But there is a gap: the session runs indefinitely until you manually stop it. There is no automatic cycling between focus and break periods.
Regular JavaScript timers like setTimeout are unreliable in a service worker because Chrome terminates idle workers to save resources. chrome.alarms is the MV3 primitive designed for this exact problem. It fires reliably even when the service worker is suspended, waking it just to handle the event.
In this step, get ready to:
- Add alarm-based timers to the session lifecycle.
- Handle alarm events to toggle between focus and break periods.
- Display a live countdown on the dashboard that updates reactively.
Set up alarms in the session lifecycle
When a session starts, you need two alarms: one that fires when the current period (focus or break) ends, and one that fires when the entire session is over. When a session stops, you clear all alarms so nothing fires after the user cancels.
Why chrome.alarms instead of setTimeout?
A service worker can be terminated at any time by Chrome. When that happens, any running setTimeout or setInterval is silently destroyed.
chrome.alarms persists independently of the worker lifecycle. When an alarm fires, Chrome wakes the service worker and delivers the event. Your timer survives even if the worker restarts ten times in between.
- In src/background.ts, find the startSession function.
- Find the last line inside it:
await enableBlocking()
- Add these two lines directly after await enableBlocking(), so the end of your function looks like this:
await setState(state)
await enableBlocking()
await chrome.alarms.create('period-end', { delayInMinutes: focusMinutes })
await chrome.alarms.create('session-end', { delayInMinutes: totalMinutes })
What do these alarms do?
- 'period-end' fires after the focus period elapses. This is what triggers the switch to a break.
- 'session-end' fires after the total session duration. It stops everything and removes blocking rules.
- Now find the stopSession function. It currently looks like this:
async function stopSession(): Promise<void> {
await disableBlocking()
await setState({ ...DEFAULT_STATE })
}
- Add await chrome.alarms.clearAll() between the two existing lines, so it becomes:
async function stopSession(): Promise<void> {
await disableBlocking()
await chrome.alarms.clearAll()
await setState({ ...DEFAULT_STATE })
}
- Save src/background.ts.
- Confirm VS Code shows no red underlines in the file.
Seeing a TypeScript error on chrome.alarms?
Make sure your tsconfig.json has "types": ["chrome-types"] in compilerOptions. The chrome-types package provides type definitions for all Chrome extension APIs including chrome.alarms.
help me fix TypeScript errors with chrome.alarms in my extension
Handle alarm events for period transitions
The alarms are created, but nothing responds to them yet. You need a listener that runs whenever an alarm fires, checks which alarm it was, and performs the correct transition.
- In src/background.ts, add the following alarm listener directly above the chrome.runtime.onMessage block:
chrome.alarms.onAlarm.addListener(async (alarm) => {
const state = await getState()
if (!state.isActive) return
if (alarm.name === 'session-end') {
await stopSession()
return
}
if (alarm.name === 'period-end') {
if (state.isFocusPeriod) {
// Focus ended, start a break
await disableBlocking()
const periodEndTime = Date.now() + state.breakMinutes * 60 * 1000
await setState({ ...state, isFocusPeriod: false, periodEndTime })
await chrome.alarms.create('period-end', { delayInMinutes: state.breakMinutes })
} else {
// Break ended, start a new focus period
await enableBlocking()
const periodEndTime = Date.now() + state.focusMinutes * 60 * 1000
await setState({ ...state, isFocusPeriod: true, periodEndTime, currentCycle: state.currentCycle + 1 })
await chrome.alarms.create('period-end', { delayInMinutes: state.focusMinutes })
}
}
})
What does this listener do?
- If session-end fires, the entire session is over. It calls stopSession() which removes blocking rules, clears all alarms, and resets state.
- If period-end fires during focus, it disables blocking (break starts) and schedules a new period-end alarm for breakMinutes later.
- If period-end fires during a break, it re-enables blocking (focus restarts), increments currentCycle, and schedules the next focus alarm.
This creates a self-perpetuating cycle: each alarm schedules the next one until session-end fires and stops everything.
- Save src/background.ts.
Before you test, what do you think will happen if you start a 1-minute focus session and then visit a blocked site after 1 minute has passed?
- Return to Chrome and click the Focus Guard extension icon to open the dashboard.
- Make sure you have at least one site in your blocked list (add example.com if the list is empty).
- Set Focus (min) to 1, Break (min) to 1, and Total (min) to 5.
- Click Start Session.
- Open a new tab and navigate to example.com. You should be redirected to the Stay Focused page.
- Wait 1 minute, then try example.com again. This time it should load normally because the break period has started.
- Wait another minute, then try once more. You should be blocked again because the next focus cycle began.
Testing tip for short alarms
Because your extension is loaded unpacked (developer mode), Chrome does not enforce the 30-second minimum alarm interval. Your 1-minute alarms fire exactly on time. In a published extension, alarms shorter than 30 seconds would be clamped.
Site is not unblocking after 1 minute?
- Open chrome://extensions and click service worker under Focus Guard to open its DevTools console. Look for errors.
- Make sure the chrome.alarms.onAlarm listener is at the top level of the file, not inside another function or callback. Service worker event listeners must be registered synchronously on load.
- Confirm you placed the listener above the chrome.runtime.onMessage block and saved the file.
my chrome.alarms listener is not firing in my MV3 service worker
Update the dashboard to show live status
The automatic transitions work, but the dashboard does not reflect them yet. You need the dashboard to reactively update when the service worker changes the session state, and to show a live countdown of the current period.
- In src/dashboard.ts, find the existing updateStatus function and replace it entirely with this version:
async function updateStatus(): Promise<void> {
const result = await chrome.storage.local.get('sessionState')
const state = result.sessionState
if (!state || !state.isActive) {
statusBadge.textContent = 'Inactive'
statusBadge.className = 'status-badge'
timerDisplay.textContent = '--:--'
startBtn.textContent = 'Start Session'
startBtn.className = 'start-btn'
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
return
}
if (state.isFocusPeriod) {
statusBadge.textContent = 'Focusing'
statusBadge.className = 'status-badge focusing'
} else {
statusBadge.textContent = 'Break'
statusBadge.className = 'status-badge break'
}
startBtn.textContent = 'Stop Session'
startBtn.className = 'start-btn stop'
startCountdown(state.periodEndTime)
}
What changed in updateStatus?
- The inactive branch now properly clears the countdown interval so the timer stops ticking.
- The active branch now calls startCountdown(state.periodEndTime) to show a live countdown of the current period.
- This function gets called every time session state changes, so it always reflects the latest status.
- Add the startCountdown function directly below updateStatus:
function startCountdown(endTime: number): void {
if (timerInterval) clearInterval(timerInterval)
function tick(): void {
const remaining = Math.max(0, endTime - Date.now())
const minutes = Math.floor(remaining / 60000)
const seconds = Math.floor((remaining % 60000) / 1000)
timerDisplay.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
tick()
timerInterval = window.setInterval(tick, 1000)
}
What does startCountdown do?
- It calculates the remaining milliseconds until endTime (the end of the current focus or break period).
- It formats the remaining time as MM:SS and writes it to the timerDisplay element.
- setInterval calls tick() every second so the countdown visually decrements.
The last piece is making the dashboard react to state changes pushed by the service worker. Right now, updateStatus only runs on page load and after button clicks.
- At the bottom of src/dashboard.ts, find the line loadSites() and add this listener directly above it:
chrome.storage.onChanged.addListener((changes) => {
if (changes.sessionState) updateStatus()
})
Why use chrome.storage.onChanged?
When the service worker transitions between focus and break, it calls setState() which writes to chrome.storage.local. The onChanged event fires in every page that has registered a listener. This means the dashboard updates itself instantly when a period ends, even if the learner is not interacting with it.
- Save src/dashboard.ts.
What do you expect to see on the dashboard now when you start a new 1-minute focus session?
- Return to the Focus Guard dashboard tab in Chrome.
- If your previous test session is still running, click Stop Session first.
- Set Focus (min) to 1, Break (min) to 1, and Total (min) to 5.
- Click Start Session.
You should see the status badge turn green with "Focusing" and a countdown ticking down from 00:59. After 1 minute, the badge switches to orange with "Break" and the timer resets to the break duration. All of this happens without you clicking anything.
Dashboard not updating when the period changes?
- Make sure the chrome.storage.onChanged.addListener call is at the top level of the file (not inside another function).
- Close and reopen the dashboard tab. The onChanged listener only applies to pages that have loaded the script.
- Check the dashboard DevTools console for errors by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
my dashboard is not reacting to storage changes from the service worker
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/background.ts and src/dashboard.ts.
ⓧ I'd like to double check the full code
Both files are over 30 lines, so confirm each section is present in order:
src/background.ts should contain these sections from top to bottom:
- chrome.action.onClicked listener (opens dashboard).
- SessionState interface and DEFAULT_STATE constant.
- getState() and setState() helper functions.
- enableBlocking() and disableBlocking() functions.
- startSession() ending with two chrome.alarms.create calls.
- stopSession() with chrome.alarms.clearAll() in the middle.
- chrome.alarms.onAlarm.addListener handling session-end and period-end.
- chrome.runtime.onMessage.addListener handling START_SESSION and STOP_SESSION.
src/dashboard.ts should contain these sections from top to bottom:
- DOM element references (siteInput, addSiteBtn, sitesList, focusInput, breakInput, totalInput, startBtn, statusBadge, timerDisplay).
- timerInterval variable.
- loadSites(), renderSites(), addSite(), removeSite() functions and event listeners.
- updateStatus() function ending with startCountdown(state.periodEndTime).
- startCountdown() function with tick() and setInterval.
- startBtn click listener sending messages.
- chrome.storage.onChanged.addListener calling updateStatus().
- loadSites() and updateStatus() calls at the bottom.
Your extension now automatically cycles between focus and break periods. The dashboard reflects every transition in real time with a live countdown. Next up, you will bring that same countdown to the blocked page so users know exactly when their break starts.
Display Remaining Time on the Blocked Page
Your timer logic is working. The service worker alternates between focus and break periods automatically, and the dashboard shows live status updates.
But when you visit a blocked site right now, the blocked page shows a static --:-- placeholder. Without knowing when the break starts, you feel locked out indefinitely. In this step, you will add a live countdown so users can see exactly when access returns.
In this step, get ready to:
- Read the active session state from chrome.storage to get the focus period end time.
- Display a live countdown that updates every second on the blocked page.
Read session state on the blocked page
The blocked page needs to know when the current focus period ends. That timestamp is already stored in chrome.storage.local as periodEndTime. You just need to read it and calculate the difference from now.
- In VS Code, open src/blocked.ts and replace the placeholder comment with this code:
// Grab the countdown display element
const countdownEl = document.getElementById('countdown') as HTMLDivElement
// Track the interval so we can clear it later
let interval: number | null = null
// Read session state on page load and start countdown if active
async function init(): Promise<void> {
const result = await chrome.storage.local.get('sessionState')
const state = result.sessionState
// If no active focus session, show zeroed display
if (!state || !state.isActive || !state.isFocusPeriod) {
countdownEl.textContent = '00:00'
return
}
startCountdown(state.periodEndTime)
}
What does this code do?
- countdownEl caches a reference to the #countdown element in the blocked page HTML so you can update its text.
- init() reads sessionState from storage. If there is no active focus period, it shows 00:00. Otherwise it passes periodEndTime to a startCountdown function you will write next.
- Save src/blocked.ts.
The file will show a TypeScript error because startCountdown does not exist yet. You will define it in the next chunk.
Add a live countdown
Now you need a function that takes the end-time timestamp and ticks down every second, displaying the remaining minutes and seconds in MM:SS format.
- In src/blocked.ts, add the startCountdown function below the init function:
// Calculate and display time remaining, updating every second
function startCountdown(endTime: number): void {
function tick(): void {
const remaining = Math.max(0, endTime - Date.now())
const minutes = Math.floor(remaining / 60000)
const seconds = Math.floor((remaining % 60000) / 1000)
countdownEl.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
// Stop ticking once the period is over
if (remaining <= 0) {
if (interval) clearInterval(interval)
countdownEl.textContent = '00:00'
}
}
tick()
interval = window.setInterval(tick, 1000)
}
What does this code do?
- tick() calculates the milliseconds between now and endTime, then converts that into a padded MM:SS string.
- window.setInterval(tick, 1000) calls tick every second so the display updates in real time.
- When remaining hits zero the interval is cleared. The break has started and blocking rules have been removed by the service worker.
- Save src/blocked.ts.
The countdown now works on initial page load. But what happens if the session is stopped while you are looking at the blocked page? The countdown would keep ticking into negative territory. You need a storage change listener to react in real time.
- Add the storage listener and the init() call at the bottom of src/blocked.ts:
// React to session state changes while this page is open
chrome.storage.onChanged.addListener((changes) => {
if (changes.sessionState) {
const newState = changes.sessionState.newValue
// If session ended or switched to break, stop counting
if (!newState || !newState.isActive || !newState.isFocusPeriod) {
if (interval) clearInterval(interval)
countdownEl.textContent = '00:00'
} else {
// New focus period started, restart countdown
startCountdown(newState.periodEndTime)
}
}
})
// Kick off on page load
init()
Why listen for storage changes here?
The blocked page might stay open while the service worker transitions to a break period or stops the session entirely. chrome.storage.onChanged fires whenever the service worker writes new state, so the blocked page can immediately stop the countdown or restart it.
- Save src/blocked.ts.
Seeing TypeScript errors?
- Make sure startCountdown is defined before the chrome.storage.onChanged listener. Function declarations are hoisted, so order matters less, but placing them in sequence avoids confusion.
- Check that init() is the very last line in the file, after the listener registration.
Why is my blocked page countdown not working?
Time to test the full flow. Before you run the next test, what do you think you will see when you navigate to a blocked site during a focus period?
- Switch to Chrome and open the Focus Guard dashboard by clicking the extension icon in the toolbar.
- Make sure you have at least one site in your blocked list (for example example.com).
- Set **Focus (min)** to 1, **Break (min)** to 1, and **Total (min)** to 5 for a quick test.
- Click Start Session.
- Open a new tab and navigate to the blocked site.
You should see the "Stay Focused" page with a live countdown (for example 00:47) updating every second. The number decreases towards zero as your break approaches.
Countdown stuck at --:-- or 00:00?
- Confirm the session is actually active by checking the dashboard. If it says "Inactive", the session did not start. Try adding a site to the block list first, then starting.
- If the blocked page does not load at all (you see the actual site instead), go to chrome://extensions and click the reload button on the Focus Guard card, then try again.
- Open DevTools on the blocked page (right-click, **Inspect**, then **Console** tab). Any errors here point to which line in blocked.ts needs fixing.
Help me debug my blocked page countdown.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/blocked.ts.
ⓧ I'd like to double check the full code
Scroll up and confirm your src/blocked.ts contains these sections in order:
- The countdownEl element reference and the interval variable.
- The init() function that reads state and calls startCountdown.
- The startCountdown() function with a nested tick() that formats MM:SS and clears the interval at zero.
- The chrome.storage.onChanged listener that stops or restarts the countdown.
- The init() call on the last line.
Your Focus Guard extension is now feature-complete. Blocked sites show a polished countdown, breaks auto-unlock access, and the cycle repeats until the full session is over. You have a real productivity tool running in your browser.
Secret mission
Session History and Stats
Your Focus Guard blocks sites and manages timers, but once a session ends, there is no record it ever happened. What if you could track every completed session and see your total focus hours grow over time? Add a session history tracker that logs completed sessions and displays stats on the dashboard.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no paid cloud services, so there are no ongoing costs.
Resources you used:
- The focus-guard project folder on your Desktop (includes all source code and node_modules).
- The Focus Guard extension loaded as an unpacked extension in Chrome.
- The Vite dev server process running in your terminal.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue working on Focus Guard soon.
- Your Vite dev server stays running and hot-reloads changes as you edit.
- The extension stays loaded in Chrome and continues to function.
- Your project files remain on your Desktop.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay on disk so you can resume later.
- Switch to the terminal running the Vite dev server and press Ctrl+C to stop it.
- To resume later, open the focus-guard folder in VS Code and run npm run dev again.
- The unpacked extension in Chrome will stay loaded but may show errors until the dev server is running again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your code and removes the extension from Chrome.
- Switch to the terminal running the Vite dev server and press Ctrl+C to stop it.
- Remove the extension from Chrome by navigating to chrome://extensions, finding Focus Guard, and clicking Remove.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/focus-guard
Windows
Remove-Item -Recurse -Force ~\Desktop\focus-guard
Nice Work!
Nice Work!
You just built a fully functional Chrome extension that enforces distraction-free focus periods by dynamically blocking websites. Focus Guard combines multiple Chrome Manifest V3 APIs into a cohesive productivity tool you can use every day.
You've learned how to:
- Build a complete Chrome extension from scratch using TypeScript, Vite, and CRXJS with hot-reload development.
- Implement dynamic website blocking using the Declarative Net Request API with redirect rules that persist across browser sessions.
- Use chrome.alarms for reliable background timers that survive service worker suspension, paired with chrome.storage for persistent state and chrome.runtime messaging to connect your dashboard to the background script.
- Secret Mission: Added session history tracking with stats to turn the blocker into a full productivity tool.
Ready to quiz yourself?