Limit visible posts on X/Twitter and reveal more on demand.
// ==UserScript==
// @name X/Twitter Scroll Brake
// @namespace https://github.com/local/x-scroll-brake
// @version 1.2.5
// @description Limit visible posts on X/Twitter and reveal more on demand.
// @license MIT
// @match https://x.com/*
// @match https://twitter.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
"use strict";
const INITIAL_VISIBLE_POSTS = 10;
const POSTS_PER_CLICK = 5;
const BRAKE_ID = "x-scroll-brake";
const STYLE_ID = "x-scroll-brake-styles";
const STORAGE_KEY = "x-scroll-brake-posts-per-click";
const SCAN_DELAY_MS = 75;
const ROUTE_SETTLE_DELAY_MS = 300;
const BRAKE_PANEL_HEIGHT = 118;
const TOP_RESET_SCROLL_Y = 120;
const TOP_RESET_MIN_DISTANCE = 600;
const MIN_POSTS_PER_CLICK = 1;
const MAX_POSTS_PER_CLICK = 100;
const MAX_SAVED_ROUTE_STATES = 12;
let visibleItemLimit = getInitialVisiblePostLimit();
let postsPerClick = readSavedPostsPerClick();
let currentRouteKey = getRouteKey();
let scanTimer = null;
let lastTouchY = null;
let lastScrollY = window.scrollY;
let routeSettlesAt = 0;
let statusRootWasSeen = false;
let isLocked = false;
let maxScrollY = null;
const orderedKeys = [];
const routeStates = new Map();
function addStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${BRAKE_ID} {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 2147483647;
box-sizing: border-box;
width: min(560px, calc(100vw - 32px));
transform: translateX(-50%);
padding: 14px 16px;
border: 1px solid rgb(207, 217, 222);
border-radius: 16px;
background: white;
color: rgb(15, 20, 25);
box-shadow: 0 12px 36px rgba(15, 20, 25, 0.22);
}
#${BRAKE_ID}[hidden] {
display: none !important;
}
#${BRAKE_ID} .x-scroll-brake-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
font: 15px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#${BRAKE_ID} button {
border: 0;
border-radius: 999px;
padding: 10px 16px;
background: rgb(29, 155, 240);
color: white;
font: 700 15px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
cursor: pointer;
}
#${BRAKE_ID} button:hover {
background: rgb(26, 140, 216);
}
#${BRAKE_ID} button:focus-visible,
#${BRAKE_ID} input:focus-visible {
outline: 3px solid rgba(29, 155, 240, 0.35);
outline-offset: 3px;
}
#${BRAKE_ID} label {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
#${BRAKE_ID} input {
box-sizing: border-box;
width: 64px;
border: 1px solid rgb(207, 217, 222);
border-radius: 8px;
padding: 8px;
background: white;
color: inherit;
font: 600 15px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#${BRAKE_ID} .x-scroll-brake-status {
width: 100%;
text-align: center;
color: rgb(83, 100, 113);
font-size: 13px;
}
@media (prefers-color-scheme: dark) {
#${BRAKE_ID} {
border-color: rgb(47, 51, 54);
background: black;
color: rgb(231, 233, 234);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
}
#${BRAKE_ID} input {
border-color: rgb(83, 100, 113);
background: black;
}
#${BRAKE_ID} .x-scroll-brake-status {
color: rgb(113, 118, 123);
}
}
`;
document.head.appendChild(style);
}
function clampWholeNumber(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.floor(number)));
}
function getInitialVisiblePostLimit() {
return clampWholeNumber(INITIAL_VISIBLE_POSTS, 10, 1, Number.MAX_SAFE_INTEGER);
}
function getRouteKey() {
return `${location.pathname}${location.search}`;
}
function readSavedPostsPerClick() {
let savedValue = null;
try {
savedValue = window.localStorage.getItem(STORAGE_KEY);
} catch (error) {
savedValue = null;
}
return clampWholeNumber(
savedValue || POSTS_PER_CLICK,
POSTS_PER_CLICK,
MIN_POSTS_PER_CLICK,
MAX_POSTS_PER_CLICK
);
}
function savePostsPerClick() {
try {
window.localStorage.setItem(STORAGE_KEY, String(postsPerClick));
} catch (error) {
// The visible control still works for this page if storage is unavailable.
}
}
function getMainColumn() {
return document.querySelector('main[role="main"]');
}
function getTimelineRecords() {
const main = getMainColumn();
if (!main) {
return [];
}
return getTimelineRecordsFromMain(main);
}
function getTimelineRecordsFromMain(main) {
const seenItems = new Set();
const records = [];
Array.from(main.querySelectorAll('article[data-testid="tweet"]')).forEach((article) => {
if (article.parentElement && article.parentElement.closest('article[data-testid="tweet"]')) {
return;
}
const item = article.closest('[data-testid="cellInnerDiv"]') || article;
if (seenItems.has(item)) {
return;
}
const key = getPostKey(article);
if (!key) {
return;
}
seenItems.add(item);
records.push({ article, item, key });
});
return records;
}
function normalizePostHref(href) {
if (!href) {
return "";
}
try {
const url = new URL(href, location.origin);
const statusMatch = url.pathname.match(/^\/([^/]+)\/status\/(\d+)/);
if (statusMatch) {
return `${statusMatch[1]}/status/${statusMatch[2]}`;
}
return url.pathname;
} catch (error) {
return href;
}
}
function getPostKey(article) {
const timestampLink = Array.from(article.querySelectorAll('a[href*="/status/"] time'))
.map((time) => time.closest('a[href*="/status/"]'))
.find((link) => link && link.closest('article[data-testid="tweet"]') === article);
if (timestampLink) {
return normalizePostHref(timestampLink.getAttribute("href"));
}
const statusLink = Array.from(article.querySelectorAll('a[href*="/status/"]'))
.find((link) => link.closest('article[data-testid="tweet"]') === article);
return statusLink ? normalizePostHref(statusLink.getAttribute("href")) : "";
}
function isStatusPage() {
return /^\/[^/]+\/status\/\d+/.test(location.pathname);
}
function getStatusIdFromPath() {
const match = location.pathname.match(/^\/[^/]+\/status\/(\d+)/);
return match ? match[1] : "";
}
function getStatusRootIndex(records) {
const statusId = getStatusIdFromPath();
if (!statusId) {
return -1;
}
return records.findIndex((record) => record.key.includes(`/status/${statusId}`));
}
function getCountableRecords(records) {
if (!isStatusPage()) {
return records;
}
const rootIndex = getStatusRootIndex(records);
if (rootIndex !== -1) {
statusRootWasSeen = true;
return records.slice(rootIndex + 1);
}
return statusRootWasSeen ? records : [];
}
function rememberItemOrder(records) {
records.forEach((record, index) => {
if (orderedKeys.includes(record.key)) {
return;
}
const previousKnownKey = findNearbyKnownKey(records, index - 1, -1);
const nextKnownKey = findNearbyKnownKey(records, index + 1, 1);
if (previousKnownKey) {
orderedKeys.splice(orderedKeys.indexOf(previousKnownKey) + 1, 0, record.key);
} else if (nextKnownKey) {
orderedKeys.splice(orderedKeys.indexOf(nextKnownKey), 0, record.key);
} else {
orderedKeys.push(record.key);
}
});
}
function findNearbyKnownKey(records, startIndex, direction) {
for (
let index = startIndex;
index >= 0 && index < records.length;
index += direction
) {
if (orderedKeys.includes(records[index].key)) {
return records[index].key;
}
}
return "";
}
function applyBrake() {
resetAfterNavigation();
const settleTimeLeft = routeSettlesAt - Date.now();
if (settleTimeLeft > 0) {
scheduleApplyBrake(settleTimeLeft);
return;
}
const main = getMainColumn();
if (!main) {
unlockBrake();
return;
}
const records = getTimelineRecordsFromMain(main);
const countableRecords = getCountableRecords(records);
rememberItemOrder(countableRecords);
if (orderedKeys.length < visibleItemLimit) {
unlockBrake();
return;
}
const anchorRecord = findAnchorRecord(countableRecords);
if (!anchorRecord) {
unlockBrake();
updateBrakeText();
return;
}
lockAtRecord(anchorRecord);
}
function findAnchorRecord(records) {
const anchorKey = orderedKeys[visibleItemLimit - 1];
const anchorRecord = records.find((record) => record.key === anchorKey);
if (anchorRecord) {
return anchorRecord;
}
return records.find((record) => orderedKeys.indexOf(record.key) >= visibleItemLimit) || null;
}
function lockAtRecord(record) {
const rect = record.item.getBoundingClientRect();
const anchorBottom = window.scrollY + rect.bottom;
const nextMaxScrollY = Math.max(0, anchorBottom - window.innerHeight + BRAKE_PANEL_HEIGHT);
setMaxScrollY(nextMaxScrollY);
isLocked = true;
showBrake();
updateBrakeText();
clampScrollToBrake();
pauseMediaAfterLimit();
}
function setMaxScrollY(nextMaxScrollY) {
if (!isLocked || maxScrollY === null || nextMaxScrollY < maxScrollY) {
maxScrollY = nextMaxScrollY;
}
}
function unlockBrake() {
isLocked = false;
maxScrollY = null;
hideBrake();
}
function ensureBrake() {
const existingBrake = document.getElementById(BRAKE_ID);
if (existingBrake) {
return existingBrake;
}
const brake = document.createElement("div");
brake.id = BRAKE_ID;
brake.hidden = true;
brake.innerHTML = `
<div class="x-scroll-brake-controls">
<button type="button">Load more posts</button>
<label>
<span>Posts</span>
<input type="number" min="${MIN_POSTS_PER_CLICK}" max="${MAX_POSTS_PER_CLICK}" step="1" inputmode="numeric">
</label>
<div class="x-scroll-brake-status" aria-live="polite"></div>
</div>
`;
const input = brake.querySelector("input");
const button = brake.querySelector("button");
input.value = String(postsPerClick);
input.addEventListener("change", syncPostsPerClickFromInput);
input.addEventListener("blur", syncPostsPerClickFromInput);
input.addEventListener("input", updateBrakeText);
button.addEventListener("click", () => {
resetAfterNavigation();
clearScheduledScan();
syncPostsPerClickFromInput();
visibleItemLimit += postsPerClick;
unlockBrake();
scheduleApplyBrake();
});
document.body.appendChild(brake);
return brake;
}
function showBrake() {
ensureBrake().hidden = false;
}
function hideBrake() {
const brake = document.getElementById(BRAKE_ID);
if (brake) {
brake.hidden = true;
}
}
function syncPostsPerClickFromInput() {
const input = document.querySelector(`#${BRAKE_ID} input`);
if (!input) {
return;
}
postsPerClick = clampWholeNumber(
input.value,
postsPerClick,
MIN_POSTS_PER_CLICK,
MAX_POSTS_PER_CLICK
);
input.value = String(postsPerClick);
savePostsPerClick();
updateBrakeText();
}
function updateBrakeText() {
const brake = ensureBrake();
const input = brake.querySelector("input");
const button = brake.querySelector("button");
const status = brake.querySelector(".x-scroll-brake-status");
const typedCount = input ? input.value : postsPerClick;
const previewCount = clampWholeNumber(
typedCount,
postsPerClick,
MIN_POSTS_PER_CLICK,
MAX_POSTS_PER_CLICK
);
const itemLabel = isStatusPage() ? "items" : "posts";
const shownCount = Math.min(visibleItemLimit, orderedKeys.length);
const buttonText = `Load ${previewCount} more ${itemLabel}`;
if (button && button.textContent !== buttonText) {
button.textContent = buttonText;
}
if (button) {
button.disabled = false;
}
if (input && document.activeElement !== input) {
input.value = String(postsPerClick);
}
if (status && status.textContent !== `Showing ${shownCount} ${itemLabel}`) {
status.textContent = `Showing ${shownCount} ${itemLabel}`;
}
}
function pauseMediaAfterLimit() {
const records = getCountableRecords(getTimelineRecords());
records.forEach((record) => {
const keyIndex = orderedKeys.indexOf(record.key);
if (keyIndex >= visibleItemLimit) {
pauseMediaInElement(record.item);
}
});
}
function pauseMediaInElement(element) {
element.querySelectorAll("audio, video").forEach((media) => {
media.pause();
media.autoplay = false;
media.removeAttribute("autoplay");
});
}
function scheduleApplyBrake(delay) {
const routeChanged = resetAfterNavigation();
if (scanTimer !== null) {
return;
}
const wait = routeChanged
? ROUTE_SETTLE_DELAY_MS
: clampWholeNumber(delay, SCAN_DELAY_MS, 0, ROUTE_SETTLE_DELAY_MS);
scanTimer = window.setTimeout(() => {
scanTimer = null;
applyBrake();
}, wait);
}
function clearScheduledScan() {
if (scanTimer === null) {
return;
}
window.clearTimeout(scanTimer);
scanTimer = null;
}
function resetAfterNavigation() {
const nextRouteKey = getRouteKey();
if (nextRouteKey === currentRouteKey) {
return false;
}
saveCurrentRouteState();
currentRouteKey = nextRouteKey;
if (!restoreRouteState(nextRouteKey)) {
resetCurrentTimelineProgress();
}
return true;
}
function saveCurrentRouteState() {
routeStates.delete(currentRouteKey);
routeStates.set(currentRouteKey, {
visibleItemLimit,
statusRootWasSeen,
orderedKeys: orderedKeys.slice(),
});
while (routeStates.size > MAX_SAVED_ROUTE_STATES) {
const oldestKey = routeStates.keys().next().value;
routeStates.delete(oldestKey);
}
}
function restoreRouteState(routeKey) {
const state = routeStates.get(routeKey);
if (!state) {
return false;
}
visibleItemLimit = clampWholeNumber(
state.visibleItemLimit,
getInitialVisiblePostLimit(),
1,
Number.MAX_SAFE_INTEGER
);
statusRootWasSeen = Boolean(state.statusRootWasSeen);
orderedKeys.length = 0;
state.orderedKeys.forEach((key) => {
if (key && !orderedKeys.includes(key)) {
orderedKeys.push(key);
}
});
routeSettlesAt = Date.now() + ROUTE_SETTLE_DELAY_MS;
clearScheduledScan();
unlockBrake();
return true;
}
function resetCurrentTimelineProgress() {
routeStates.delete(currentRouteKey);
visibleItemLimit = getInitialVisiblePostLimit();
routeSettlesAt = Date.now() + ROUTE_SETTLE_DELAY_MS;
statusRootWasSeen = false;
orderedKeys.length = 0;
clearScheduledScan();
unlockBrake();
}
function clampScrollToBrake() {
const currentScrollY = window.scrollY;
if (resetAfterNavigation()) {
lastScrollY = currentScrollY;
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
return;
}
if (shouldResetAfterTopJump(currentScrollY)) {
resetCurrentTimelineProgress();
lastScrollY = currentScrollY;
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
return;
}
if (!isLocked || maxScrollY === null || window.scrollY <= maxScrollY) {
lastScrollY = window.scrollY;
return;
}
window.scrollTo({
top: maxScrollY,
left: window.scrollX,
behavior: "auto",
});
lastScrollY = maxScrollY;
}
function shouldResetAfterTopJump(currentScrollY) {
return (
isLocked &&
maxScrollY !== null &&
currentScrollY <= TOP_RESET_SCROLL_Y &&
(maxScrollY >= TOP_RESET_MIN_DISTANCE ||
lastScrollY - currentScrollY >= TOP_RESET_MIN_DISTANCE)
);
}
function preventScrollPastBrake(event) {
if (resetAfterNavigation()) {
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
return;
}
if (!isLocked || maxScrollY === null || isEditableTarget(event.target)) {
return;
}
const deltaY = getScrollDeltaY(event);
if (deltaY <= 0 || window.scrollY + deltaY <= maxScrollY) {
return;
}
event.preventDefault();
if (window.scrollY < maxScrollY) {
window.scrollTo({
top: maxScrollY,
left: window.scrollX,
behavior: "auto",
});
}
}
function isEditableTarget(target) {
if (!(target instanceof Element)) {
return false;
}
return Boolean(target.closest('input, textarea, select, [contenteditable="true"]'));
}
function getScrollDeltaY(event) {
if (event.type === "wheel") {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
return event.deltaY * 16;
}
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return event.deltaY * window.innerHeight;
}
return event.deltaY;
}
if (event.type === "touchmove") {
const touch = event.touches[0];
if (!touch || lastTouchY === null) {
return 0;
}
return lastTouchY - touch.clientY;
}
if (event.type !== "keydown") {
return 0;
}
const keyDeltas = {
ArrowDown: 40,
PageDown: window.innerHeight * 0.85,
End: Number.MAX_SAFE_INTEGER,
" ": window.innerHeight * 0.85,
};
return keyDeltas[event.key] || 0;
}
function rememberTouchPosition(event) {
const touch = event.touches[0];
lastTouchY = touch ? touch.clientY : null;
}
function forgetTouchPosition() {
lastTouchY = null;
}
function handleProgressResetClick(event) {
if (!shouldResetProgressForClick(event.target)) {
return;
}
resetCurrentTimelineProgress();
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
}
function shouldResetProgressForClick(target) {
if (!(target instanceof Element)) {
return false;
}
if (target.closest('a[href="/home"], a[href$="/home"]')) {
return true;
}
const control = target.closest('button, [role="button"], a');
if (!control) {
return false;
}
const label = `${control.getAttribute("aria-label") || ""} ${control.textContent || ""}`;
return /\b(back|scroll)\s+to\s+top\b|\bshow\s+\d+\s+(new\s+)?posts?\b/i.test(label);
}
function handleTopResetShortcut(event) {
if (isEditableTarget(event.target)) {
return;
}
if (event.key !== "Home" && !((event.metaKey || event.ctrlKey) && event.key === "ArrowUp")) {
return;
}
resetCurrentTimelineProgress();
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
}
function installNavigationHooks() {
const notifyNavigation = () => {
if (resetAfterNavigation()) {
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
}
};
["pushState", "replaceState"].forEach((methodName) => {
const originalMethod = history[methodName];
if (typeof originalMethod !== "function") {
return;
}
history[methodName] = function () {
const result = originalMethod.apply(this, arguments);
window.setTimeout(notifyNavigation, 0);
return result;
};
});
window.addEventListener("popstate", notifyNavigation);
}
function boot() {
addStyles();
ensureBrake();
installNavigationHooks();
applyBrake();
const observer = new MutationObserver(() => {
scheduleApplyBrake();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
window.addEventListener("scroll", clampScrollToBrake, { passive: true });
window.addEventListener("click", handleProgressResetClick, {
capture: true,
passive: true,
});
window.addEventListener("wheel", preventScrollPastBrake, {
capture: true,
passive: false,
});
window.addEventListener("keydown", handleTopResetShortcut, {
capture: true,
});
window.addEventListener("keydown", preventScrollPastBrake, {
capture: true,
});
window.addEventListener("touchstart", rememberTouchPosition, {
capture: true,
passive: true,
});
window.addEventListener("touchmove", preventScrollPastBrake, {
capture: true,
passive: false,
});
window.addEventListener("touchmove", rememberTouchPosition, {
capture: true,
passive: true,
});
window.addEventListener("touchend", forgetTouchPosition, {
capture: true,
passive: true,
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})();