← Scroll

Reading Time Left

A small badge counts down the minutes left to read as you scroll through an article. It starts as “6 min read”, rolls each new number into place as you go, fills a little ring with your progress, and says “Finished” at the end.

Scroll inside ↓

How to take a proper break

A proper break is not the same as stopping. Scrolling your phone at your desk keeps the same part of your brain busy, so you come back just as tired.

Stand up and move. A short walk, even round the room, lifts your mood and loosens the shoulders that tense up while you type.

Look at something far away. Twenty seconds on a distant tree or rooftop lets your eyes relax after an hour fixed on a screen.

Drink some water. Mild thirst feels a lot like tiredness, and the walk to the kitchen counts as moving.

Leave the problem behind. The answer you were stuck on often arrives while you’re thinking about something else.

Keep it short and often. Five minutes every hour does more than one long lunch, and it’s easier to fit in.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-reading-time-left to the article. It counts the words to work out the reading time.

Options

Add any of these attributes to the same element.

data-wpmReading speed in words per minute. Default 230.
data-minutesSet the total reading time yourself instead.
data-positionfixed: in the bottom corner of the screen (the default on a page), or sticky: pinned to the top of the article as it scrolls (the default inside a scrolling box).
data-doneWhat it says at the end. Default “Finished”.

The fixed badge fades away when the article is out of view. Works inside scrolling boxes too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Reading Time Left: a small badge that counts down the minutes left to
    // read as you scroll through an article. It starts as "6 min read", turns
    // to "4 min left" once you're reading, rolls each new number into place
    // and fills a little ring as you go, then says "Finished" at the end.
    // Usage: <article data-reading-time-left>…</article>
    // The reading time comes from the article's word count.
    // Optional: data-wpm (reading speed in words per minute, default 230),
    // data-minutes (set the total reading time yourself), data-position
    // ("fixed": the bottom corner of the screen, the default on a page, or
    // "sticky": pinned to the top of the article as it scrolls, the default
    // inside a scrolling box), data-done (the text at the end, default
    // "Finished").
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { wpm: 230, done: 'Finished' };

    var css =
      '.rtlf-sticky{position:sticky;top:10px;z-index:3;height:0;display:flex;align-items:flex-start;justify-content:flex-end;pointer-events:none}' +
      '.rtlf-fixed{position:fixed;right:16px;bottom:16px;z-index:50;pointer-events:none;transition:opacity .4s,transform .4s}' +
      '.rtlf-fixed.rtlf-away{opacity:0;transform:translateY(12px)}' +
      '.rtlf-badge{display:inline-flex;align-items:center;gap:4px;margin-right:10px;padding:6px 12px 6px 7px;border-radius:99px;background:#1e1b4b;color:#fff;font-family:inherit;font-size:13px;font-weight:600;line-height:1.2;white-space:nowrap;box-shadow:0 6px 18px rgba(30,27,75,.25)}' +
      '.rtlf-fixed .rtlf-badge{margin:0}' +
      '.rtlf-ring{width:20px;height:20px;flex:none;margin-right:3px;transform:rotate(-90deg)}' +
      '.rtlf-ring circle{fill:none;stroke-width:3}' +
      '.rtlf-num{display:inline-block;min-width:1ch;text-align:right;font-variant-numeric:tabular-nums}' +
      '.rtlf-roll{display:inline-flex;overflow:hidden;height:1.2em;line-height:1.2em;vertical-align:-0.18em}' +
      '.rtlf-text{display:inline-block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    function setup(el) {
      var d = el.dataset;
      var words = (el.textContent.trim().match(/\S+/g) || []).length;
      var total = Math.max(1, Math.round(num(d.minutes, words / num(d.wpm, DEFAULTS.wpm))));
      var doneText = d.done || DEFAULTS.done;
      var box = scroller(el);
      var mode = d.position || (box ? 'sticky' : 'fixed');
      var R = 7, C = 2 * Math.PI * R;

      var holder = document.createElement('div');
      holder.className = mode === 'fixed' ? 'rtlf-fixed rtlf-away' : 'rtlf-sticky';
      holder.setAttribute('aria-hidden', 'true');
      holder.innerHTML =
        '<span class="rtlf-badge">' +
        '<svg class="rtlf-ring" viewBox="0 0 20 20"><circle cx="10" cy="10" r="' + R + '" stroke="rgba(255,255,255,.22)"/>' +
        '<circle class="rtlf-arc" cx="10" cy="10" r="' + R + '" stroke="#c084fc" stroke-linecap="round" stroke-dasharray="' + C.toFixed(2) + '" stroke-dashoffset="' + C.toFixed(2) + '"/></svg>' +
        '<span class="rtlf-roll"><span class="rtlf-num"></span></span><span class="rtlf-text"></span></span>';
      if (mode === 'fixed') document.body.appendChild(holder);
      else el.insertBefore(holder, el.firstChild);

      var arc = holder.querySelector('.rtlf-arc');
      var numEl = holder.querySelector('.rtlf-num');
      var textEl = holder.querySelector('.rtlf-text');
      var shownState = '', queued = false;

      function show(n, label) {
        var state = n + '|' + label;
        if (state === shownState) return;
        var first = !shownState, up = shownState && n !== '' && +n < +shownState.split('|')[0];
        shownState = state;
        numEl.textContent = n;
        textEl.textContent = label;
        numEl.style.display = n === '' ? 'none' : '';
        if (first || reduced()) return;
        anime.utils.remove([numEl, textEl]);
        anime.animate(n === '' ? textEl : numEl, {
          y: [up ? '100%' : '-100%', '0%'], opacity: [0, 1], duration: 450, ease: 'outBack(1.6)'
        });
      }

      function update() {
        queued = false;
        var top = 0, h = window.innerHeight;
        if (box) { var b = box.getBoundingClientRect(); top = b.top + box.clientTop; h = box.clientHeight; }
        var r = el.getBoundingClientRect();
        var p = (top - r.top) / Math.max(1, r.height - h);
        if (r.height <= h) p = r.bottom <= top + h ? 1 : 0;
        p = Math.max(0, Math.min(1, p));
        arc.style.strokeDashoffset = (C * (1 - p)).toFixed(2);
        if (p >= 0.98) show('', doneText);
        else if (p <= 0.01) show(total, 'min read');
        else show(Math.max(1, Math.ceil(total * (1 - p))), 'min left');
        if (mode === 'fixed') holder.classList.toggle('rtlf-away', r.bottom < h * 0.25 || r.top > h * 0.75);
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }

      update();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onScroll);
    }

    function init() {
      document.querySelectorAll('[data-reading-time-left]').forEach(function (el) {
        if (el._rtlf) return;
        el._rtlf = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<article data-reading-time-left>
  <h1>How to take a proper break</h1>
  <p>…</p>
</article>