Links that get heavier with use
Any list you open every day, like a team’s documentation index, a catalogue of reference PDFs or a bookmarks page with a few hundred entries, has the same problem: you keep searching it for the same dozen items. The usual fixes don’t help much. The visited-link colour is binary, so after a week everything is visited. A favourites section needs curating. Sorting by recent use reorders the list, which breaks the spatial memory that makes a long list usable at all.
The method here keeps the order and changes one thing: font weight. Items you use often get bolder, items you stop using gradually fade, and anything you've never opened stays as it was, so the gaps show too. After a few weeks the list becomes a heatmap of your own habits, and nobody has to set anything up. Weight is a quiet signal: it helps when you scan and stays out of the way when you don’t.
It needs a variable font. With only regular and bold there’s nothing in between, but with a continuous weight axis a link can get a little heavier each time. The numbers below assume a 300–700 axis, as in Fira Code, which I use on my Scherbachords site. It’s monospaced, so heavier links don’t get wider and the list never reflows.
Favorite recipes
The rules
- A use counts only after dwell time. I count a use once the document has stayed open for about 20 seconds. Misclicks and quick skims don’t count. Without this filter, the weights mostly measure how often you misclick.
- Returns diminish within a day. Each item has a stored weight, starting at 400. The first use of the day adds 8, then 4, 2, 1, 1, and anything after the fifth use adds nothing, so a day is worth 16 at most. The point is to measure habit across days: opening one document twenty times in an afternoon says little about whether you’ll need it next week.
- Decay counts days of use, not calendar days. On each day the list is opened, every item loses 1. Items in regular use stay well ahead of that, and items you’ve stopped using sink below 400 and end up lighter than regular text, so the list also shows what you’ve drifted away from. A month of holiday costs 1, not 30, so coming back doesn’t mean starting over.
- One use revives an item. A faded item that gets used again jumps straight back above regular weight instead of climbing out of the hole it sank into.
- Stored weight isn’t rendered weight. The stored number goes through a curve that exaggerates small differences near 400 and flattens towards the top: 408 renders as 422, 450 as 525, 500 as 615, and the maximum of 700 is reached at 583. The first use is already visible, and each one after that matters less. On the way down, an item left at 400 reaches the minimum of 300 after about a month of days of use without it. A fully bold item takes about seven times as long.
In practice, an item used once a day reaches full bold in about four weeks, and one used five times a day gets there in under two. The rules are basically frecency, the frequency-plus-recency score behind Firefox's address bar. Firefox sorts by it, and here it only changes how items look.
The rules don’t depend on font weight. The same numbers can drive opacity or any other CSS property, and the code below takes the property and its range as options. With opacity, keep the range narrow: on the example’s colours, 0.6 drops text contrast to about 3.7:1, below the WCAG AA minimum for body text, while 0.7 still passes.
Where it doesn’t fit
It only helps with lists you come back to regularly — on a list you open once a month, the weights barely move. Also, (in this implementation) they live in the browser, so a new laptop starts from scratch.
Code
It’s one function with no dependencies. By default it sets font weight from 300 to 700, which needs a variable font; the property and range options switch it to opacity or any other CSS property.
function createLinkWeights({
storageKey = 'linkWeights',
dwell = 20000,
gains = [8, 4, 2, 1, 1],
idOf = link => link.getAttribute('href'),
// font-weight needs a weight range in @font-face (e.g. font-weight: 300 700),
// and any "wght" in font-variation-settings on these links overrides it.
property = 'font-weight',
range = [300, 400, 700], // [faded, unused, most used]
format = value => String(Math.round(value * 1000) / 1000),
} = {}) {
const [min, base, max] = range;
const today = () => new Date().toDateString();
let links = [];
let timer;
function load() {
try {
return JSON.parse(localStorage.getItem(storageKey)) || { day: today(), items: {} };
} catch {
return { day: today(), items: {} };
}
}
function save(state) {
try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch {}
}
// Stored weight → level from -1 (faded) through 0 (unused) to 1 (most used).
// The curve is tuned in font-weight units: it exaggerates small differences
// around 400, reaches 700 at stored 583 and 300 at stored 369.
function level(weight) {
if (weight < 300) return -1;
const x = Math.min(weight, 800) - 400;
const curved = Math.floor(x * ((400 - x) ** 1.8 * 0.00004 + 1) + 400);
return curved >= 400
? Math.min((curved - 400) / 300, 1)
: Math.max((curved - 400) / 100, -1);
}
// Level → a value in `range`, with each half scaled separately.
function output(weight) {
const t = level(weight);
return t >= 0 ? base + t * (max - base) : base + t * (base - min);
}
function paint(state) {
for (const link of links) {
const item = state.items[idOf(link)];
if (item) link.style.setProperty(property, format(output(item.weight)));
}
}
// Call with the full list: items missing from it are forgotten.
function apply(nodes) {
links = [...nodes];
const state = load();
const ids = new Set(links.map(idOf));
for (const id of Object.keys(state.items)) {
if (!ids.has(id)) delete state.items[id];
}
// Decay once per day of use, not per calendar day.
if (state.day !== today()) {
state.day = today();
for (const item of Object.values(state.items)) item.weight -= 1;
}
for (const id of ids) {
state.items[id] ??= { weight: 400, day: today(), uses: 0 };
}
save(state);
paint(state);
}
function record(id) {
const state = load();
const item = state.items[id];
if (!item) return;
if (item.day !== today()) {
item.day = today();
item.uses = 0;
}
if (item.uses >= gains.length) return;
item.weight += gains[item.uses++];
if (item.weight < 400) item.weight = 408;
item.weight = Math.min(item.weight, 800);
save(state);
paint(state);
}
// A use counts only if the item stays open for `dwell` ms.
function open(id) {
clearTimeout(timer);
timer = setTimeout(() => record(id), dwell);
}
function close() {
clearTimeout(timer);
}
return { apply, open, close };
}
How to use
const weights = createLinkWeights();
weights.apply(document.querySelectorAll('.catalogue a'));
// When a document opens, and when the reader comes back to the list.
// The list page has to stay open, so use a new tab or an in-page viewer.
weights.open(href);
weights.close();
// Opacity instead of weight
createLinkWeights({ property: 'opacity', range: [0.7, 0.85, 1] });
// If the @font-face rule has no weight range
createLinkWeights({ property: 'font-variation-settings', format: v => `"wght" ${v}` });