← Buttons

Specular Glass Button

A frosted glass button that blurs whatever is behind it. A soft highlight on its face and a bright glint on its rim glide after the pointer wherever it is on the page, sliding round the edge when you are outside it and across the face when you are on top. On a phone the shine runs to your finger.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-specular-glass-button to a button that sits over a photo, gradient or anything colourful.

Options

Add any of these attributes to the same element.

data-tintColour of the glass. Default rgba(255,255,255,.14).
data-shineStrength of the highlight, from 0 to 1. Default 0.6.
data-autoplayWhen no one is pointing, the shine circles the rim by itself. Handy for demos.

The frosting uses backdrop-filter, so over a plain white page it just looks like a faint pill: put something behind it. With reduced motion the shine stays still at the top.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Specular Glass Button: a frosted glass button whose shine follows you.
    // A soft highlight and a bright glint on its rim glide towards the
    // pointer wherever it is on the page, sliding around the edge when the
    // pointer is outside and over the face when it is on top. On touch
    // screens the shine runs to your finger.
    // Usage: <button data-specular-glass-button>Get started</button>
    // Put it over something colourful: the glass blurs what is behind it.
    // Optional: data-tint (glass colour, default rgba(255,255,255,.14)),
    // data-shine (strength of the highlight from 0 to 1, default 0.6),
    // data-autoplay (when no one is pointing, the shine circles the rim by
    // itself; handy for demos).
    var DEFAULTS = { tint: 'rgba(255,255,255,.14)', shine: 0.6, idle: 2500 };

    var css =
      ':where(.sgl){font:inherit;font-weight:600;padding:.9em 1.9em;border-radius:999px;color:#fff}' +
      '.sgl{position:relative;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;gap:.4em;cursor:pointer;border:0;' +
      'background:var(--sgl-tint);-webkit-backdrop-filter:blur(14px) saturate(1.6);backdrop-filter:blur(14px) saturate(1.6);' +
      'text-shadow:0 1px 2px rgba(0,0,0,.18);-webkit-tap-highlight-color:transparent;' +
      'box-shadow:inset 0 1px 1px rgba(255,255,255,.3),inset 0 -1px 2px rgba(0,0,0,.08),0 .6em 1.6em rgba(10,10,40,.22);transition:transform .2s}' +
      '.sgl:active{transform:scale(.97)}' +
      '.sgl:focus-visible{outline:2px solid #fff;outline-offset:3px}' +
      '.sgl-hi,.sgl-rim{position:absolute;inset:0;border-radius:inherit;pointer-events:none;z-index:-1}' +
      '.sgl-hi{background:radial-gradient(circle at var(--sgl-x) var(--sgl-y),rgba(255,255,255,calc(var(--sgl-s)*.75)),rgba(255,255,255,calc(var(--sgl-s)*.18)) 28%,transparent 62%)}' +
      '.sgl-rim{padding:1.5px;background:radial-gradient(circle at var(--sgl-x) var(--sgl-y),#fff,rgba(255,255,255,.45) 22%,rgba(255,255,255,.12) 55%,rgba(255,255,255,.06));' +
      '-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('sgl');
      el.style.setProperty('--sgl-tint', d.tint || DEFAULTS.tint);
      var shine = clamp(d.shine !== undefined ? +d.shine : DEFAULTS.shine, 0, 1);
      el.style.setProperty('--sgl-s', isNaN(shine) ? DEFAULTS.shine : shine);
      ['sgl-hi', 'sgl-rim'].forEach(function (c) {
        var s = document.createElement('span');
        s.className = c;
        s.setAttribute('aria-hidden', 'true');
        el.insertBefore(s, el.firstChild);
      });

      // Highlight position in % of the button, gliding towards the target.
      var cur = { x: 30, y: 0 }, aim = { x: 30, y: 0 };
      function paint() {
        el.style.setProperty('--sgl-x', cur.x.toFixed(1) + '%');
        el.style.setProperty('--sgl-y', cur.y.toFixed(1) + '%');
      }
      paint();
      if (reduced()) return;

      // Point the shine at a page position: over the face when on top,
      // otherwise at the spot on the rim facing it.
      function aimAt(cx, cy) {
        var r = el.getBoundingClientRect();
        var x = (cx - r.left) / r.width * 100, y = (cy - r.top) / r.height * 100;
        if (x < 0 || x > 100 || y < 0 || y > 100) {
          var dx = (cx - (r.left + r.width / 2)) / r.width, dy = (cy - (r.top + r.height / 2)) / r.height;
          var k = 0.5 / Math.max(Math.abs(dx), Math.abs(dy));
          x = 50 + dx * k * 100;
          y = 50 + dy * k * 100;
        }
        aim.x = clamp(x, 4, 96);
        aim.y = clamp(y, 0, 100);
      }

      var lastReal = -1e9;
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      function onMove(e) {
        if (e.pointerType === 'touch' && e.type === 'pointermove' && !e.buttons) return;
        lastReal = performance.now();
        aimAt(e.clientX, e.clientY);
      }
      document.addEventListener('pointermove', onMove, { passive: true });
      document.addEventListener('pointerdown', onMove, { passive: true });

      var raf = 0, t0 = performance.now();
      function frame(now) {
        raf = requestAnimationFrame(frame);
        if (auto && now - lastReal > DEFAULTS.idle) {
          // A pretend pointer circling the button just outside it.
          var a = (now - t0) / 1400;
          var r = el.getBoundingClientRect();
          aimAt(r.left + r.width / 2 + Math.cos(a) * r.width, r.top + r.height / 2 + Math.sin(a) * r.height * 1.4);
        }
        var k = 0.12;
        if (Math.abs(aim.x - cur.x) < 0.05 && Math.abs(aim.y - cur.y) < 0.05) return;
        cur.x += (aim.x - cur.x) * k;
        cur.y += (aim.y - cur.y) * k;
        paint();
      }
      new IntersectionObserver(function (entries) {
        cancelAnimationFrame(raf);
        if (entries[0].isIntersecting) raf = requestAnimationFrame(frame);
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-specular-glass-button]').forEach(function (el) {
        if (el._sgl) return;
        el._sgl = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section style="background: url(hero.jpg) center / cover">
  <button data-specular-glass-button>Get started</button>
</section>