Download the followers or the following list of any X account as a spreadsheet: name, @handle, user ID, bio, verified, and whether each account follows you. Free, with no 150-account cap, and without connecting your account to any app.
Sign in on x.com, open a profile and click Followers or Following, then run the script. Works on your own lists and on any public account.
// Responsive Muse · X (Twitter) Followers & Following Exporter · v1.0 · 2026-10-31
// Saves the followers or the following list of any X account you can open (yours or a public
// one) to CSV or TXT: name, @handle, user ID, bio, verified, whether they follow you and the
// profile link. On your own Following list it can keep only the accounts that don't follow
// you back. X only keeps on screen the people you can see, so the script collects them while
// it scrolls. It makes no network requests of its own and never reads cookies, passwords or tokens.
(async () => {
const TOOL = 'Export X followers';
const FORMAT = 'csv'; // 'csv' (Excel, Sheets) or 'txt' (one @handle per line)
const ONLY = 'all'; // 'all' or 'not-following-back' (only accounts that don't follow you)
const LIMIT = 2000; // stop after this many accounts (0 = no limit; big accounts take a while)
const page = location.pathname.match(/^\/([A-Za-z0-9_]{1,15})\/(followers|following|verified_followers)\/?$/);
if (!/^(x|twitter)\.com$/.test(location.hostname) || !page) {
alert('Open a followers or following list on X first (signed in), for example:\nhttps://x.com/YourName/following');
return;
}
// A small box on the page shows progress and asks before changing anything,
// so you never need to read or edit the console.
const many = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; // 1 video, 2 videos
const panel = (() => {
const box = document.createElement('div');
box.style.cssText = 'display:block!important;position:fixed;right:16px;bottom:16px;z-index:2147483647;width:340px;max-width:calc(100vw - 32px);' +
'background:#111318;color:#e6e8ee;font:14px/1.5 system-ui,sans-serif;border-radius:12px;padding:16px;' +
'box-shadow:0 8px 30px rgba(0,0,0,.4);text-align:left';
const head = document.createElement('strong');
head.textContent = TOOL;
const msg = document.createElement('p');
msg.style.cssText = 'margin:8px 0 12px;white-space:pre-line';
const row = document.createElement('div');
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end';
box.append(head, msg, row);
document.body.append(box);
const button = (label, primary, onClick) => {
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = 'font:600 14px system-ui,sans-serif;padding:8px 14px;border-radius:8px;cursor:pointer;' +
(primary ? 'background:#0f766e;color:#fff;border:1px solid #0f766e' : 'background:none;color:#e6e8ee;border:1px solid #3a3f4b');
b.onclick = onClick;
row.append(b);
};
return {
text(t) { msg.textContent = t; },
ask(t, yes) {
msg.textContent = t;
row.replaceChildren();
return new Promise(r => {
button('Cancel', false, () => { row.replaceChildren(); r(false); });
button(yes, true, () => { row.replaceChildren(); r(true); });
});
},
stoppable(onStop) { row.replaceChildren(); button('Stop', false, onStop); },
actions(t, list) {
msg.textContent = t;
row.replaceChildren();
list.forEach(([label, primary, run]) => button(label, primary, run));
},
finish(t) { this.actions(t, [['Close', true, () => box.remove()]]); },
close() { box.remove(); },
};
})();
// Offer the result as a downloaded file or as clipboard text (a real click, so it always works)
const deliver = (summary, body, file) => {
const save = () => {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([/\.csv$/.test(file) ? '\ufeff' + body : body], { type: 'text/plain;charset=utf-8' }));
a.download = file;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
panel.text(`${summary}\nSaved as ${file} (check your Downloads).`);
};
const copy = () => navigator.clipboard.writeText(body)
.then(() => panel.text(`${summary}\nCopied. Paste it into a spreadsheet or a note.`))
.catch(() => panel.text(`${summary}\nThe browser blocked the clipboard: use Download instead.`));
panel.actions(summary, [['Close', false, () => panel.close()], ['Copy', false, copy], ['Download', true, save]]);
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
const people = new Map();
let total = 0; // accounts seen, before the "not following back" filter
// Read the accounts currently on screen
const collect = () => {
for (const cell of document.querySelectorAll('[data-testid="UserCell"]')) {
// Skip the "Who to follow" suggestions in the sidebar
if (cell.closest('aside') || !cell.closest('[data-testid="primaryColumn"]')) continue;
// Profile links are plain "/handle" (links in the bio carry ?src= or point to /status)
const prof = [...cell.querySelectorAll('a[href^="/"]')].find(a => /^\/[A-Za-z0-9_]{1,15}$/.test(a.getAttribute('href')) && a.textContent.trim());
if (!prof) continue;
const handle = prof.getAttribute('href').slice(1);
if (people.has(handle)) continue;
// The Follow / Following button carries the numeric user ID: "<id>-follow" or "<id>-unfollow"
const btn = cell.querySelector('[data-testid$="-follow"], [data-testid$="-unfollow"]');
// Bio: the visible text block outside the links (hidden ones with an id are screen-reader labels)
const bio = [...cell.querySelectorAll('div[dir="auto"]')].find(d => !d.closest('a') && !d.id);
const followsYou = !!cell.querySelector('[data-testid="userFollowIndicator"]');
total++;
people.set(handle, {
name: prof.textContent.trim(),
handle: '@' + handle,
id: (btn?.dataset.testid.match(/^(\d+)-/) || [])[1] || '',
bio: (bio?.innerText || '').replace(/\s+/g, ' ').trim(),
verified: cell.querySelector('[data-testid="icon-verified"]') ? 'yes' : '',
followsYou: followsYou ? 'yes' : '',
youFollow: btn && /-unfollow$/.test(btn.dataset.testid) ? 'yes' : '',
url: 'https://x.com/' + handle,
});
}
};
// 1. Scroll down step by step, collecting as we go. X loads the next accounts only when you reach
// the bottom and can take a few seconds, so we stop after ~10 s at the bottom with nothing new.
let stop = false;
panel.stoppable(() => { stop = true; });
window.scrollTo(0, 0);
await sleep(1000);
let idle = 0;
while (idle < 8 && !stop && !(LIMIT && people.size >= LIMIT)) {
const before = people.size;
collect();
const atBottom = scrollY + innerHeight >= document.scrollingElement.scrollHeight - 50;
window.scrollBy(0, innerHeight * 0.8);
await sleep(1200);
idle = people.size !== before ? 0 : atBottom ? idle + 1 : idle;
panel.text(`Collecting accounts… ${people.size}${LIMIT ? ' of up to ' + LIMIT : ''}`);
}
let list = [...people.values()];
if (LIMIT) list = list.slice(0, LIMIT);
if (!list.length) {
panel.finish('No accounts found. Is the list empty or private? If not, X may have changed its layout: please report it on responsive-muse.com.');
return 0;
}
if (ONLY === 'not-following-back') list = list.filter(p => !p.followsYou);
// 2. Build the file and offer it
const cell = v => '"' + String(v).replace(/"/g, '""') + '"';
const body = FORMAT === 'txt'
? list.map(p => p.handle).join('\n')
: ['#,Name,Handle,User ID,Bio,Verified,Follows you,You follow,URL', ...list.map((p, i) =>
[i + 1, p.name, p.handle, p.id, p.bio, p.verified, p.followsYou, p.youFollow, p.url].map(cell).join(','))].join('\n');
const what = ONLY === 'not-following-back' ? `${many(list.length, 'account')} that don't follow you back (of ${total})` : many(list.length, 'account');
const file = `x-${page[1]}-${ONLY === 'not-following-back' ? 'not-following-back' : page[2]}.${FORMAT}`;
deliver(`${stop ? 'Stopped early. ' : ''}Found ${what}.`, body, file);
return list.length;
})();
🛡️ Chrome says “Don't paste code…”? That's normal. Type allow pasting in the console, press Enter and paste again. You only need to do it once.
Why does Chrome warn me?
Chrome shows this the first time anyone pastes into the console, to protect people from scams that ask them to paste unknown code. It's right to warn you, and that's why this script is fully visible above: 145 commented lines that send nothing anywhere and never touch your passwords or cookies. Why it's safe.
Rather not use the console? Drag the Export followers & following button to your bookmarks bar and click it on x.com. No warning that way.
Bookmarklet: drag the middle button to your bookmarks bar, open x.com and click the bookmark. It runs the same script with the options above.
- Sends nothing anywhere. No fetch, XHR or external scripts. The only network activity is x.com itself loading your list.
- Never touches your login. It doesn't read cookies, passwords, tokens or browser storage.
- Only runs on x.com. Anywhere else it stops before doing anything.
- Short and readable. 145 commented lines, not minified or obfuscated.
- Shows its progress on the page. A small box tells you what it's doing and offers Download or Copy at the end.
- Same code for everyone. SHA-256 of the default version:
ace2a1cd24863101…
Verify it yourself in 30 seconds
- Read it: every line is above, with comments.
- Search the code for
fetch,XMLHttpRequest,cookie,localStorageoreval. None of them appear. - Open the Network tab before running it: you'll only see requests to x.com.
How to use it
- Sign in on x.com, open the profile and click Followers or Following.
- Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If Chrome asks, type
allow pastingand press Enter. - Copy the script, paste it in the console and press Enter. It scrolls through the list and then offers the file.
What you get
One row per account: display name, @handle, the numeric user ID (it never changes, even if they rename), bio, verified, Follows you and You follow. Choose TXT for a plain list of @handles.
Why not an extension or an online exporter?
Most follower exporters are extensions that need permission to read every site you visit, or websites that ask you to connect your X account, and the free versions stop at 150 or 200 accounts. This script is short, readable and runs only on x.com, in your own tab.
FAQ
Is it free? Other exporters stop at 150 or 200 accounts.
Yes, with no limit and no sign-up. The script runs in your own X tab and reads the list you can already see, so there is no server to pay for and nothing to unlock.
Do I have to give it my X password or connect an app?
No. You never log in anywhere else or authorize any app. The script sends nothing anywhere: the file is created in your browser.
Why are there fewer accounts than the number on the profile?
X doesn't list suspended, deactivated or some restricted accounts, and on very big accounts it only lets anyone scroll through part of the followers. The script exports everything X shows you.
Can I move the list to Bluesky or Mastodon?
Export it as CSV: the Handle, Name and Bio columns are what the migration tools ask for. Use the TXT format for a plain list of @handles.
