How I built a LinkedIn 'inner circle' highlighter in a weekend
How to build and deploy a Chrome extension from scratch that highlights Linkedin posts from people you actually wanted to engage with.
Why I built thisโ
I kept realizing after scrolling that I'd missed Linkedin posts from people I actually wanted to engage with.
Fix: highlight "inner circle" directly in the feed:
- No backend
- No login
- Instant cues
I built a Chrome extension that highlights people I want to engage with
Constraints and design goalsโ
- Manifest V3, content-script only: no background service worker and no external backend calls
- No data collection: config stays in
chrome.storage.sync
, no network requests, no analytics - DOM resilience: target top-level feed cards only
- Language-aware-ish: detect POST / LIKE / COMMENT
Architecture in 60 secondsโ
- Options page writes
{overlayColor, overlayOpacity, icNames}
โchrome.storage.sync
ui.js
loads config, normalizes names, injects dynamic CSS, exposesIC_UI
- Content script scans feed, finds top-level cards, classifies reason, adds overlay + badge
- Debounced
MutationObserver
handles infinite scroll
I keep the UI helpers and config in a tiny global called IC_UI (Inner Circle UI). The content script reads names from it, injects styles, and adds badges.
Architecture overviewโ
Build the chrome extension from scratchโ
Create the foundationโ
This folder and file structure is the goal and what we'll create together:
.
โโโ README.md
โโโ host
โย ย โโโ content-linkedin.js
โโโ icons
โย ย โโโ icon128.png
โโโ lib
โย ย โโโ ui.js
โโโ manifest.json
โโโ options.html
โโโ options.js
โโโ styles
โโโ common.css
Let's start a new project by creating a new folder and navigate to the root of that folder, copy and paste this into your terminal:
mkdir inner-circle-extension
cd inner-circle-extension
Then create a file named
Copy and paste this minimal configuration intomanifest.json
:
{
"manifest_version": 3,
"name": "InnerCircle - Feed Highlighter",
"version": "0.1.0",
"description": "Highlights posts/likes/comments from your shortlist directly in the feed (works on linkedin.com). No tracking; settings stay in chrome.storage.sync.",
"action": { "default_title": "Inner Circle" },
"permissions": ["storage"],
"host_permissions": ["*://www.linkedin.com/*"],
"content_scripts": [
{
"matches": ["*://www.linkedin.com/*"],
"js": ["lib/ui.js", "host/content-linkedin.js"],
"css": ["styles/common.css"],
"run_at": "document_idle"
}
],
"options_page": "options.html",
"icons": { "128": "icons/icon128.png" },
"web_accessible_resources": [
{
"resources": [],
"matches": ["*://www.linkedin.com/*"]
}
]
}
What this code does ๐ก
- manifest_version: 3 - uses Chrome's MV3
- permissions: ["storage"] - read/write user settings
- host_permissions - allow scripts to run on LinkedIn only
- content_scripts - inject order matters:
lib/ui.js
first (exportsIC_UI
), thenhost/content-linkedin.js
run_at: "document_idle"
โ wait for initial DOM paintcss: ["styles/common.css"]
โ optional global styles (most styles are injected dynamically byui.js
)
- options_page - opens your settings UI
- icons - toolbar/extension page artwork
- web_accessible_resources - empty now, placeholder if you later expose assets to the page
- Load order:
lib/ui.js
runs beforehost/content-linkedin.js
, sowindow.IC_UI
exists when the content script executes
Organize iconsโ
The next step is to find a suitable icon.
Create a folder named icons
in the project root:
mkdir icons
I'll go with this icon
Go ahead and download the icon and save it to your icons
folder:
.
โโโ README.md
โโโ host
โย ย โโโ content-linkedin.js
โโโ icons
โย ย โโโ icon128.png # Save your icon here
โโโ lib
โย ย โโโ ui.js
โโโ manifest.json
โโโ options.html
โโโ options.js
โโโ styles
โโโ common.css
Create the UI surfaceโ
The next step is to create the UI surface and add dynamic styles and name set.
We'll expose a tiny global namespace - IC_UI - from ui.js
so the content script can reuse helpers/state without imports.
Create a new folder called lib
in your root project:
mkdir lib
Then, create a new file called in the folder lib
:
...
โโโ lib
โย ย โโโ ui.js # Create this file
...
ui.js
:
// lib/ui.js
(function (global) {
// ----- defaults -----
const DEFAULTS = {
overlayColor: "#ff0000",
overlayOpacity: 0.25,
icNames: "", // one per line
};
// ----- utils -----
function hexToRgb(hex) {
let h = (hex || "").trim().replace("#", "");
if (h.length === 3)
h = h
.split("")
.map((c) => c + c)
.join("");
return {
r: parseInt(h.slice(0, 2) || "ff", 16),
g: parseInt(h.slice(2, 4) || "00", 16),
b: parseInt(h.slice(4, 6) || "00", 16),
};
}
function rgba({ r, g, b }, a) {
return `rgba(${r},${g},${b},${a})`;
}
function darker({ r, g, b }, k) {
return { r: Math.round(r * k), g: Math.round(g * k), b: Math.round(b * k) };
}
function ensureAnchor(el) {
try {
if (getComputedStyle(el).position === "static")
el.style.position = "relative";
} catch {}
}
function norm(s) {
return (s || "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "") // strip diacritics
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
// ----- dynamic styles -----
let dynamicStyle = null;
function applyConfig(cfg) {
const base = hexToRgb(cfg.overlayColor);
const text = darker(base, 0.69);
const textHex = `#${[text.r, text.g, text.b]
.map((x) => x.toString(16).padStart(2, "0"))
.join("")}`;
if (!dynamicStyle) {
dynamicStyle = document.createElement("style");
dynamicStyle.id = "ic-dynamic";
(document.head || document.documentElement).appendChild(dynamicStyle);
}
dynamicStyle.textContent = `
.ic-overlay{ background:${rgba(base, cfg.overlayOpacity)} !important; }
.ic-badge{
background:${rgba(base, 0.14)} !important;
border:1px solid ${rgba(darker(base, 0.69), 0.35)} !important;
color:${textHex} !important;
position:absolute; top:8px; right:85px; z-index:2;
padding:2px 6px; border-radius:8px; font:12px/1.2 system-ui;
pointer-events:none;
}
.ic-marked{ outline:0 !important; }
`;
}
// ----- names state -----
let currentCfg = null;
let icSet = new Set();
function rebuildNameSet(cfg) {
icSet = new Set();
const lines = (cfg.icNames || "").split(/\r?\n/).map(norm).filter(Boolean);
for (const line of lines) icSet.add(line);
}
function getNameSet() {
return icSet;
} // always returns the latest Set
// ----- storage wiring -----
function loadConfig(cb) {
chrome.storage.sync.get(DEFAULTS, (cfg) => {
currentCfg = cfg;
applyConfig(cfg);
rebuildNameSet(cfg);
cb && cb(cfg);
});
}
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync") return;
chrome.storage.sync.get(DEFAULTS, (cfg) => {
currentCfg = cfg;
applyConfig(cfg);
if (changes.icNames) rebuildNameSet(cfg);
});
});
// ----- overlay api -----
function addOverlayAndBadge(hostCard, label) {
if (!hostCard || hostCard.classList.contains("ic-marked")) return;
hostCard.classList.add("ic-marked");
ensureAnchor(hostCard);
const overlay = document.createElement("div");
overlay.className = "ic-overlay";
overlay.style.cssText =
"position:absolute;inset:0;pointer-events:none;border-radius:8px;";
hostCard.appendChild(overlay);
const badge = document.createElement("div");
badge.className = "ic-badge";
badge.textContent = label || "INNER";
hostCard.appendChild(badge);
}
// expose
global.IC_UI = {
DEFAULTS,
loadConfig,
addOverlayAndBadge,
getNameSet,
norm,
get cfg() {
return currentCfg;
},
};
})(window);
We'll keep the UI helpers and config in IC_UI so we don't need a bundler/imports for MV3.
The content script just calls IC_UI.getNameSet()
and IC_UI.addOverlayAndBadge(...)
.
What this code does ๐ก
- Defines DEFAULTS and a
norm()
helper (lowercase, strip diacritics, collapse spaces) - Builds a Set of normalized names from the textarea (whole-word matching later)
- Injects a single dynamic
<style>
block so color/opacity changes reflect instantly - Exposes a tiny API on
window.IC_UI
:loadConfig(cb)
- readchrome.storage.sync
, apply styles, build name set, then callbackgetNameSet()
- latest normalized namesaddOverlayAndBadge(card, label)
- decorates a card (.ic-marked
guard)norm
,cfg
getters
- Subscribes to storage changes so Options edits auto-propagate without reload
- Adds a positioning guard (
ensureAnchor
) so absolutely-positioned overlay/badge attach correctly
Create classification coreโ
We have the UI and dynamic styles ready, let's create the classification core.
Create the folder host
in the project root:
mkdir host
Then create a new file named in the host
folder:
...
โโโ host # Create this folder
โย ย โโโ content-linkedin.js # Create this file
...
content-linkedin.js
:
// host/content-linkedin.js
(function () {
console.log("[Inner Circle] LinkedIn content script");
// Target ONLY top-level feed cards to avoid comment-level badges
const CARD_SEL = ".feed-shared-update-v2, article.update-components-article";
function topCard(node) {
// climb to the OUTERMOST ancestor that matches CARD_SEL
let best = null,
el = node;
while (el && el !== document.body) {
if (el.matches && el.matches(CARD_SEL)) best = el;
el = el.parentNode;
}
return best || node;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const norm = (s) => window.IC_UI.norm(s);
// --- language verbs (minimal; expand as needed) ---
const LIKE_WORDS = [
"like",
"liked",
"gillade",
"me gusta",
"gefรคllt",
"curtiu",
"likes",
"celebrates",
"reacted",
"insightful",
"loves",
];
const COMMENT_WORDS = [
"comment",
"commented",
"kommenterade",
"comentรณ",
"kommentiert",
"comentou",
];
const REPOSTED_WORDS = ["reposted"];
function anyWord(words, blob) {
const b = " " + norm(blob) + " ";
for (const w of words) {
const p = new RegExp(`\\b${escapeRegex(norm(w))}\\b`, "i");
if (p.test(b)) return true;
}
return false;
}
// Extract key text zones from the top-level card
function zones(card) {
const z = { author: "", byline: "", headerBlob: "" };
// 1) Actor (author)
const actor = card.querySelector(
".update-components-actor, .update-components-actor__meta, header"
);
if (actor) z.author = actor.textContent || "";
// 2) โX liked/commentedโ area
const byline = card.querySelector(
".update-components-actor__sub-description, .update-components-social-activity, .social-details-social-activity"
);
if (byline) z.byline = byline.textContent || "";
// 3) Conservative header scan to catch variants
const head = card.querySelector("header") || card;
let buf = "";
const small = head.querySelectorAll("span, a");
for (let i = 0; i < small.length && i < 40; i++) {
const t = small[i].textContent || "";
if (t && t.length <= 120) buf += " " + t;
}
z.headerBlob = buf;
return z;
}
// Decide if/why this card matches the inner circle
function detectReason(card, nameSet) {
if (!nameSet || nameSet.size === 0) return null;
const { author, byline, headerBlob } = zones(card);
const blobs = [author, byline, headerBlob].filter(Boolean).map(norm);
// helper: does any blob contain the full normalized name as whole phrase?
const hasName = (full) => {
const pat = new RegExp(`\\b${escapeRegex(full)}\\b`, "i");
return blobs.some((b) => pat.test(b));
};
// 1) Author match โ POST
for (const full of nameSet) {
if (!full) continue;
const authorNorm = norm(author);
if (
authorNorm &&
new RegExp(`\\b${escapeRegex(full)}\\b`, "i").test(authorNorm)
) {
return { label: "POST" };
}
}
// 2) Byline match โ LIKE / COMMENT
for (const full of nameSet) {
if (!full) continue;
if (hasName(full)) {
const by = norm(byline + " " + headerBlob);
if (by && anyWord(COMMENT_WORDS, by)) return { label: "COMMENT" };
if (by && anyWord(LIKE_WORDS, by)) return { label: "LIKE" };
if (by && anyWord(REPOSTED_WORDS, by)) return { label: "REPOST" };
// fallback when language pattern unknown
return { label: "INNER" };
}
}
return null;
}
function scan(root) {
const cards = (root || document).querySelectorAll(CARD_SEL);
for (let i = 0; i < cards.length; i++) {
const card = topCard(cards[i]);
if (!card || card.classList.contains("ic-marked")) continue;
try {
const match = detectReason(card, window.IC_UI.getNameSet());
if (match) window.IC_UI.addOverlayAndBadge(card, match.label);
} catch {}
}
}
// Debounced observer
let obs = null,
queued = false;
function onMut() {
if (queued) return;
queued = true;
setTimeout(() => {
queued = false;
try {
scan();
} catch {}
}, 150);
}
function start() {
try {
scan();
} catch {}
obs = new MutationObserver((muts) => {
for (const m of muts) {
for (const n of m.addedNodes || []) {
if (n && n.nodeType === 1) {
onMut();
break;
}
}
}
});
try {
obs.observe(document, { subtree: true, childList: true });
} catch {}
}
function removeUI(card) {
card.querySelectorAll(".ic-overlay,.ic-badge").forEach((el) => el.remove());
card.classList.remove("ic-marked");
}
function refreshAll() {
const nodes = document.querySelectorAll(
".feed-shared-update-v2, article.update-components-article"
);
for (const n of nodes) {
const card = n; // already top-level by our selector
const match = detectReason(card, window.IC_UI.getNameSet());
if (match) {
if (!card.classList.contains("ic-marked"))
window.IC_UI.addOverlayAndBadge(card, match.label);
else {
const b = card.querySelector(".ic-badge");
if (b && b.textContent !== match.label) b.textContent = match.label;
}
} else if (card.classList.contains("ic-marked")) {
removeUI(card);
}
}
}
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync") return;
if (changes.icNames) {
// ensure IC_UI has rebuilt its set, then refresh DOM
setTimeout(() => {
try {
refreshAll();
} catch {}
}, 50);
}
});
window.IC_UI.loadConfig(() => start());
})();
What this code does ๐ก
- Targets top-level feed cards only (
.feed-shared-update-v2, article.update-components-article
) to avoid double-marking comments - Extracts three text zones per card: author, byline ("X liked/commented"), and a conservative header blob
- Classifies why a card is relevant:
- If author matches a name โ
POST
- Else if byline/header mention a name โ try verbs โ
COMMENT
/LIKE
/REPOST
โ fallback is just an empty string โ
- If author matches a name โ
- Uses a MutationObserver with debouncing to rescan on infinite scroll
- Listens to storage changes (name edits) and refreshes existing badges
- Ensures no double-marking:
.ic-marked
prevents duplicates,removeUI()
cleans up when a card no longer matches
Create the options UIโ
We're almost ready to test our extension, the next step is to create the options HTML file.
Hard-coding names and colors in code is brittle. The Options page lets you:
- edit your inner-circle list without rebuilding the extension
- tweak color/opacity and see it live
- persist everything via
chrome.storage.sync
(and sync across Chrome profiles signed into the same account)
No backend. No analytics. Just local settings.
Add to the root of your folder structure:
...
โโโ options.html # Create this file
...
options.html
to create
an option page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Inner Circle - Options</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root {
--bg: #f7f8fb;
--card: #fff;
--muted: #667085;
--text: #101828;
--border: #e5e7eb;
--accent: #2563eb;
--shadow: 0 6px 18px rgba(17, 24, 39, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
color: var(--text);
background: var(--bg);
padding: 32px;
display: grid;
place-items: start center;
}
.card {
width: 100%;
max-width: 760px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 24px;
}
h1 {
margin: 0 0 8px;
font-size: 20px;
}
p.subtitle {
margin: 0 0 20px;
color: var(--muted);
}
.section {
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin: 0 0 16px;
background: #fff;
}
.section h2 {
margin: 0 0 12px;
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.row {
display: grid;
grid-template-columns: 160px 1fr auto;
gap: 12px;
align-items: center;
margin: 10px 0;
}
label {
color: var(--text);
}
input[type="color"] {
width: 42px;
height: 32px;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #fff;
padding: 0;
cursor: pointer;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: #f9fafb;
min-width: 44px;
text-align: center;
}
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
resize: vertical;
}
.hint {
color: var(--muted);
font-size: 12px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--muted);
margin-top: 12px;
}
.btn {
appearance: none;
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 12px;
background: #fff;
cursor: pointer;
}
.btn:hover {
border-color: #d0d5dd;
}
</style>
</head>
<body>
<main class="card">
<h1>Inner Circle - Settings</h1>
<p class="subtitle">
Pick your highlight style and define your Inner Circle (one name per
line).
</p>
<section class="section">
<h2>Appearance</h2>
<div class="row">
<label for="overlayColor">Overlay color</label>
<input type="color" id="overlayColor" />
<span class="hint">Any hex</span>
</div>
<div class="row">
<label for="overlayOpacity">Opacity</label>
<input
type="range"
id="overlayOpacity"
min="0"
max="0.95"
step="0.05"
/>
<span id="opVal" class="pill">0.25</span>
</div>
</section>
<section class="section">
<h2>Inner Circle</h2>
<div class="row" style="grid-template-columns: 1fr">
<label for="icNames">Names (one per line)</label>
<textarea
id="icNames"
rows="10"
placeholder="e.g.
Sundar Pichai"
></textarea>
<div class="hint">
Matching is case/diacritic-insensitive, whole-word. โJane Doeโ will
match exact tokens in author/byline.
</div>
</div>
<div class="footer">
<span>Changes save instantly. Refresh existing LinkedIn tabs.</span>
<button id="restoreDefaults" class="btn" type="button">
Restore defaults
</button>
</div>
</section>
</main>
<!-- <script src="options.js"></script> -->
</body>
</html>
What this code does ๐ก
- Pure HTML/CSS UI (no frameworks) with inputs for color, opacity, and names list
- The page itself doesn't save anything until
options.js
is wired, then each inputinput
event writes tochrome.storage.sync
- Copy explains matching rules (case/diacritic-insensitive, whole-word)
- Minimal, accessible layout that works without JS, JS enhances persistence only
Add CSS formattingโ
The last step before we check the progress of our extension is to add some formatting.
Go ahead and create a folder named :
mkdir styles
Then, create a file inside styles
called :
.
โโโ styles
โโโ common.css # Create this file
common.css
:
/* styles/common.css */
/* Keep minimal; runtime UI is injected by lib/ui.js */
.ic-overlay { border-radius: 8px; }
.ic-badge { -webkit-font-smoothing: antialiased; }
What this code does ๐ก
- Adds a common stylesheet for small visual tweaks
.ic-overlay
gets rounded corners.ic-badge
uses font smoothing for cleaner text- Keep this file minimal - the heavy lifting is done dynamically by
ui.js
Great, we're ready to check the progress of our extension.
Check the progressโ
Let's pack up our extension and try it out before we add the last logic.
Save all changes and open a new Chrome tab, then paste this into the address bar:
You should see an overview of all your extensions:
You should see an overview of existing Chrome extensions
Make sure the Developer mode is switched on:
Make sure the Developer mode is switched on
Having the Developer mode switched one will give you access to 3 new features:
- Load unpacked
- Pack extension
- Update
Having the Developer mode switched one will give you access to 3 new features
Then once switched on, click Load unpacked in the left upper corner:
Click Load unpacked
This will open your directory, click on the name of your project where we created all the files and click Select:
Click on the name of your project where we created all the files and click Select
You should now see the extension we just built appear in the All extensions overview:
You should now see the extension we just built
Now go ahead and check the options page, click on the extension icon in the right corner and then Options:
Click on the extension icon in the right corner and then Options
This will take you to the options.html
UI we just created:
The options.html
UI we just created
You can play with the overlay and names, but nothing appears in your Linkedin feed because we haven't connected the options.html
with Javascript logic.
Let's do that in the next step.
Add Javascript logicโ
We tried our frontend where you can add names and change the overlay, but there is no logic that saves the changes or make them appear.
Let's connect our options.html
with Javascript logic.
Create a new file called in your root folder:
...
โโโ options.js # Create this file
...
options.js
:
// options.js
const colorInput = document.getElementById("overlayColor");
const opacityInput = document.getElementById("overlayOpacity");
const opVal = document.getElementById("opVal");
const icNames = document.getElementById("icNames");
const restoreBtn = document.getElementById("restoreDefaults");
const DEFAULTS = {
overlayColor: "#ff0000",
overlayOpacity: 0.25,
icNames: "",
};
function save(partial) {
chrome.storage.sync.set(partial);
if (partial.overlayOpacity != null)
opVal.textContent = String(partial.overlayOpacity);
}
function load() {
chrome.storage.sync.get(DEFAULTS, (cfg) => {
colorInput.value = cfg.overlayColor;
opacityInput.value = cfg.overlayOpacity;
opVal.textContent = cfg.overlayOpacity;
icNames.value = cfg.icNames || "";
});
}
function restoreDefaults() {
chrome.storage.sync.set(DEFAULTS, load);
}
// live wiring
colorInput.addEventListener("input", () =>
save({ overlayColor: colorInput.value })
);
opacityInput.addEventListener("input", () =>
save({ overlayOpacity: parseFloat(opacityInput.value) })
);
icNames.addEventListener("input", () => save({ icNames: icNames.value }));
restoreBtn.addEventListener("click", restoreDefaults);
// init
load();
What this code does ๐ก
- Defines default settings for color, opacity, and names
- save() writes changes instantly to
chrome.storage.sync
- load() initializes the UI from saved settings
- restoreDefaults() resets everything with one click
- Live-wires inputs: color, opacity, and names update storage on every change
- Subscribes the UI so LinkedIn feed updates automatically without reload
Connect options html with javascriptโ
We have the Javascript logic, let's connect it with our options.html
file.
Open your options.html
file again.
options.html
and uncomment this line of code:
...
</main>
<script src="options.js"></script> // Uncomment this line
</body>
</html
...
Reload the changesโ
Save the changed and head back to chrome://extensions.
Click on Reload:
Click Reload to reload the changed we just made to the extension
Then navigate to https://www.linkedin.com/feed/.
Open the extension options again in a new tab alongside your LinkedIn feed:
Open the extension options again alongside your LinkedIn feed
Pick a name from the first post you see and add that full name to the textfield and you should immediately see a red overlay on the post:
Pick a name from the first post you see and add that full name to the textfield
Here's the working Chrome extension:
I built a Chrome extension that highlights people I want to engage with
Summaryโ
You just built a clean MV3, content-script-only extension:
- Config lives in
chrome.storage.sync
(no network calls) - UI layer (
ui.js
) injects dynamic styles and exposes a tiny API - Classifier (
content-linkedin.js
) marks cards as POST/LIKE/COMMENT/REPOST/INNER - Observer keeps up with infinite scroll without jank
Next stepsโ
If you like practical builds, I have a free mini-course on how to build you own first AI agent: [LINK]