← Broadcast

News Ticker

Headlines crawl along a bar at the bottom of the screen, looping without a seam, behind a label block and with an optional clock on the right. Point at it, or press and hold on a phone, to pause it.

Markets close higher after a quiet weekCity council approves the new riverside parkHeatwave expected to break by the weekendLocal team reaches the cup final for the first time

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-news-ticker to a bar with one child per headline.

Options

Add any of these attributes to the same element.

data-labelText of the block on the left. Default “News”; set it empty for no block.
data-speedCrawl speed in pixels per second. Default 70.
data-colorColour of the label and the dots between headlines. Default #a855f7.
data-clockShow the current time at the right-hand end.
data-pause="false"Keep crawling when pointed at or touched.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // News Ticker: headlines crawl along a bar at the bottom of the screen,
    // looping seamlessly, with a label block at the left and an optional clock
    // at the right. Point at it (or press and hold on a phone) to pause.
    // Usage: <div data-news-ticker><span>First headline</span><span>Second headline</span></div>
    // Each child is one headline.
    // Optional: data-label (text of the block on the left, default "News";
    // data-label="" for none), data-speed (px per second, default 70),
    // data-color (label colour, default #a855f7), data-clock (show the time on
    // the right), data-pause="false" (don't pause on hover or touch).
    var DEFAULTS = { label: 'News', speed: 70, color: '#a855f7' };

    var css =
      '.ntk{display:flex;align-items:stretch;overflow:hidden;background:rgba(10,10,20,.88);color:#fff;line-height:1;font-weight:600;--ntk-c:' + DEFAULTS.color + '}' +
      '.ntk-label{flex:none;display:flex;align-items:center;padding:0 0.9em;background:var(--ntk-c);font-weight:800;text-transform:uppercase;letter-spacing:0.08em;font-size:0.85em;position:relative;z-index:1}' +
      '.ntk-label::after{content:"";position:absolute;top:0;bottom:0;right:-0.6em;width:0.6em;background:inherit;clip-path:polygon(0 0,100% 50%,0 100%)}' +
      '.ntk-view{flex:1;min-width:0;overflow:hidden;position:relative;-webkit-mask-image:linear-gradient(90deg,transparent,#000 1.5em,#000 calc(100% - 1.5em),transparent);mask-image:linear-gradient(90deg,transparent,#000 1.5em,#000 calc(100% - 1.5em),transparent)}' +
      '.ntk-track{display:flex;width:max-content;will-change:transform}' +
      '.ntk-item{display:flex;align-items:center;white-space:nowrap;padding:0.7em 0}' +
      '.ntk-item::after{content:"";display:inline-block;width:0.4em;height:0.4em;margin:0 1.1em;border-radius:50%;background:var(--ntk-c)}' +
      '.ntk-clock{flex:none;display:flex;align-items:center;padding:0 0.9em;background:rgba(255,255,255,.1);font-variant-numeric:tabular-nums;font-weight:700}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function pad(n) { return (n < 10 ? '0' : '') + n; }

    function setup(el) {
      var d = el.dataset;
      var heads = Array.prototype.slice.call(el.children).map(function (c) { return c.textContent.trim(); })
        .filter(Boolean);
      if (!heads.length) heads = [el.textContent.trim()];
      el.classList.add('ntk');
      el.style.setProperty('--ntk-c', d.color || DEFAULTS.color);
      el.textContent = '';

      var labelText = d.label !== undefined ? d.label : DEFAULTS.label;
      var label = null;
      if (labelText) {
        label = document.createElement('span');
        label.className = 'ntk-label';
        label.textContent = labelText;
        el.appendChild(label);
      }

      // Screen readers get the headlines as a plain list; the moving copy is hidden.
      var list = document.createElement('ul');
      list.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);margin:0;padding:0';
      heads.forEach(function (h) { var li = document.createElement('li'); li.textContent = h; list.appendChild(li); });
      el.appendChild(list);

      var view = document.createElement('div');
      view.className = 'ntk-view';
      view.setAttribute('aria-hidden', 'true');
      var track = document.createElement('div');
      track.className = 'ntk-track';
      view.appendChild(track);
      el.appendChild(view);

      var clock = null;
      if (d.clock !== undefined && d.clock !== 'false') {
        clock = document.createElement('span');
        clock.className = 'ntk-clock';
        clock.setAttribute('aria-hidden', 'true');
        el.appendChild(clock);
        var tick = function () {
          var now = new Date();
          clock.textContent = pad(now.getHours()) + ':' + pad(now.getMinutes());
        };
        tick();
        setInterval(tick, 10000);
      }

      // One "set" is every headline once. Repeat sets until two of them cover
      // the view, so shifting by one set width loops without a seam.
      var setW = 0;
      function fill() {
        track.textContent = '';
        var probe = document.createDocumentFragment();
        heads.forEach(function (h) {
          var s = document.createElement('span');
          s.className = 'ntk-item';
          s.textContent = h;
          probe.appendChild(s);
        });
        track.appendChild(probe);
        setW = track.scrollWidth;
        var copies = Math.max(2, Math.ceil((view.clientWidth * 2) / Math.max(1, setW)) + 1);
        var base = track.innerHTML;
        track.innerHTML = new Array(copies + 1).join(base);
      }
      fill();
      // Web fonts change the text width: measure again once they've loaded.
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(fill);

      if (reduced()) return;

      var speed = +d.speed || DEFAULTS.speed;
      var x = 0, last = 0, held = false;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          if (!held) {
            x -= (speed * dt) / 1000;
            if (setW && x <= -setW) x += setW;
          }
          track.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
        }
      });

      if (d.pause !== 'false') {
        el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') held = true; });
        el.addEventListener('pointerleave', function () { held = false; });
        el.addEventListener('pointerdown', function () { held = true; });
        el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') held = false; });
        el.addEventListener('pointercancel', function () { held = false; });
      }

      if (window.ResizeObserver) {
        var lastW = view.clientWidth;
        new ResizeObserver(function () {
          if (Math.abs(view.clientWidth - lastW) < 2) return;
          lastW = view.clientWidth;
          fill();
          if (setW) x = x % setW;
        }).observe(view);
      }

      // The label and bar slide up into place the first time it's seen, and
      // the crawl pauses while off screen.
      var shown = false;
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!shown) {
            shown = true;
            x = view.clientWidth * 0.35;
            anime.animate(el, { y: ['100%', '0%'], opacity: [0, 1], duration: 600, ease: 'out(4)' });
            if (label) anime.animate(label, { x: ['-100%', '0%'], duration: 700, delay: 200, ease: 'out(4)' });
          }
          last = 0;
          timer.play();
        } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-news-ticker]').forEach(function (el) {
        if (el._ntk) return;
        el._ntk = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-news-ticker data-label="Latest" data-clock>
  <span>Markets close higher after a quiet week</span>
  <span>City council approves the new riverside park</span>
  <span>Heatwave expected to break by the weekend</span>
</div>