Timing ring button: a tap game that always says yes

A ring closes in on the button and a well timed tap earns a cheer. Every tap still claims the offer, so the game never gets in the way.

A café posts a daily offer on its website, and most visitors scroll straight past it. A small game can change that. Here the "Claim" button has a gold ring that closes in on it, and tapping as the ring lands earns a "Perfect!" and a streak count. The catch is that there is no catch: every tap claims the offer, early or late. Timing only changes the cheer, so nobody is locked out of anything.

Today at the counter A free pastry with any coffee

Tap as the gold ring meets the button. Every tap claims the offer; good timing only earns a bigger cheer.

Live demo. It runs on exactly the code further down this page.

How it works

  1. A ring that closes in. The ring is an empty pill outline placed behind the button. One CSS custom property, --g, sets how far its edges sit from the button, and the script lowers it from 36 pixels to zero over 1.2 seconds, then a little past zero so the ring slips under the button and fades.
  2. Grade the moment. When the button is clicked, the script reads the current gap. Within 3 pixels is "Perfect!", within 9 is "Good", and anything else is "A bit early" or "A bit late".
  3. Always say yes. The click handler claims the offer before it looks at the grade. The grade is extra feedback, shown as a small floating word and added to the status message.
  4. Count a streak. Perfect hits in a row add up in a small counter. Any other grade sets it back to zero, which gives regulars a reason to try again.
  5. Rest when idle. The ring only runs while the demo is on screen, stops after eight untouched cycles, and starts again when someone hovers, tabs to the button or taps it.

The code

Three short files, with no library and no images.

HTML
<div class="tr" data-lab="timing-ring">
  <span class="tr-kicker">Today at the counter</span>
  <strong class="tr-title">A free pastry with any coffee</strong>
  <div class="tr-hit">
    <span class="tr-ring" aria-hidden="true"></span>
    <button class="tr-btn" type="button" aria-describedby="tr-help">Claim my pastry</button>
    <span class="tr-pop" aria-hidden="true"></span>
  </div>
  <p id="tr-help" class="tr-help">Tap as the gold ring meets the button. Every tap claims the offer; good timing only earns a bigger cheer.</p>
  <p class="tr-status" aria-live="polite"></p>
  <p class="tr-streak" aria-hidden="true">Perfect streak: <b>0</b></p>
</div>
CSS
.tr {
  width: min(100%, 380px);
  display: grid;
  justify-items: center;
  gap: 8px;
  font-family: system-ui, sans-serif;
  color: #101a14;
  text-align: center;
}
.tr-kicker { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: #46534a; }
.tr-title { font: 700 20px/1.3 system-ui, sans-serif; color: #133621; }
/* The hit area leaves room for the ring to start wide and close in. */
.tr-hit {
  position: relative;
  display: grid;
  place-items: center;
  padding: 44px 40px;
}
.tr-btn {
  position: relative;
  z-index: 1;
  height: 54px;
  padding: 0 28px;
  border: 0;
  border-radius: 999px;
  background: #133621;
  color: #f4d96c;
  font: 600 16px/1 system-ui, sans-serif;
  cursor: pointer;
  touch-action: manipulation;
  transition: transform .12s ease, background-color .3s;
}
.tr-btn:active { transform: scale(.96); }
.tr-btn:focus-visible { outline: 3px solid #101a14; outline-offset: 3px; }
.tr-btn.is-claimed { background: #2f6b45; }
/* The ring is a pill outline around the button; the script moves its edges in. */
.tr-ring {
  --g: 36px;
  position: absolute;
  top: calc(44px - var(--g));
  bottom: calc(44px - var(--g));
  left: calc(40px - var(--g));
  right: calc(40px - var(--g));
  border: 3px solid #f4d96c;
  border-radius: 999px;
  box-shadow: 0 0 0 1px rgba(19, 54, 33, .25);
  opacity: 0;
  pointer-events: none;
}
.tr-pop {
  position: absolute;
  left: 50%;
  top: 8px;
  transform: translateX(-50%);
  font-weight: 800;
  font-size: 18px;
  color: #133621;
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
}
.tr-pop.is-on { animation: tr-pop .8s ease-out; }
@keyframes tr-pop {
  0% { opacity: 0; transform: translate(-50%, 10px) scale(.8); }
  20% { opacity: 1; transform: translate(-50%, 0) scale(1.1); }
  100% { opacity: 0; transform: translate(-50%, -14px) scale(1); }
}
.tr-help { margin: 0; font-size: 14px; line-height: 1.45; color: #46534a; }
.tr-status { min-height: 2.8em; margin: 0; font-size: 15px; line-height: 1.4; font-weight: 600; color: #133621; }
.tr-streak { margin: 0; font-size: 13px; color: #46534a; }
.tr-streak b { color: #133621; }
@media (prefers-reduced-motion: reduce) {
  .tr-btn, .tr-btn:active { transition: none; transform: none; }
  .tr-pop.is-on { animation: none; }
}
JavaScript
(function () {
  var root = document.querySelector('[data-lab="timing-ring"]');
  if (!root) return;
  var btn = root.querySelector('.tr-btn');
  var ring = root.querySelector('.tr-ring');
  var pop = root.querySelector('.tr-pop');
  var status = root.querySelector('.tr-status');
  var streakEl = root.querySelector('.tr-streak b');
  var START = 36;        // pixels between ring and button when a cycle begins
  var CLOSE = 1200;      // milliseconds for the ring to reach the button
  var PAUSE = 500;       // rest between cycles
  var IDLE_CYCLES = 8;   // stop after this many untouched cycles
  var PERFECT = 3, GOOD = 9;   // timing windows, in pixels of gap
  var OFFER = 'Claimed: one free pastry with any coffee today. Show this screen at the counter.';
  var calm = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var raf = 0, cycleStart = 0, idle = 0, gap = START, visible = false, streak = 0;

  // Gap shrinks steadily, passes zero (the ring slips under the button), then fades.
  function frame(t) {
    var e = t - cycleStart;
    var overshoot = CLOSE * 1.25;
    if (e > overshoot + PAUSE) {
      idle++;
      if (idle >= IDLE_CYCLES || !visible) return stop();
      cycleStart = t;
      e = 0;
    }
    gap = START * (1 - Math.min(e, overshoot) / CLOSE);
    ring.style.setProperty('--g', gap + 'px');
    ring.style.opacity = e > overshoot ? 0 : (gap < 0 ? 1 + gap / 9 : 1);
    raf = requestAnimationFrame(frame);
  }

  function run() {
    idle = 0;
    if (calm || raf) return;
    cycleStart = performance.now();
    raf = requestAnimationFrame(frame);
  }

  function stop() {
    cancelAnimationFrame(raf);
    raf = 0;
    ring.style.opacity = 0;
  }

  // Grade the tap by how far the ring was from the button at that moment.
  function grade() {
    if (calm || !raf) return '';
    var d = Math.abs(gap);
    if (d <= PERFECT) return 'Perfect!';
    if (d <= GOOD) return 'Good';
    return gap > 0 ? 'A bit early' : 'A bit late';
  }

  btn.addEventListener('click', function () {
    var g = grade();
    btn.classList.add('is-claimed');
    btn.textContent = 'Pastry claimed';
    streak = g === 'Perfect!' ? streak + 1 : 0;
    streakEl.textContent = streak;
    status.textContent = OFFER + (g ? ' Timing: ' + g : '');
    if (g) {
      pop.textContent = g;
      pop.classList.remove('is-on');
      void pop.offsetWidth;  // restart the CSS animation
      pop.classList.add('is-on');
      cycleStart = performance.now();  // start a fresh ring for the next try
    }
    run();
  });

  btn.addEventListener('pointerenter', run);
  btn.addEventListener('focus', run);

  // Only animate while the demo is on screen.
  if ('IntersectionObserver' in window) {
    new IntersectionObserver(function (entries) {
      visible = entries[0].isIntersecting;
      if (visible) run(); else stop();
    }).observe(root);
  } else {
    visible = true;
    run();
  }
})();

When to use it, and when not to

It fits light, friendly moments where a bit of play makes the offer memorable: a daily treat on a café page, a loyalty stamp on a thank you page, a playful reveal for a new menu launch, or a staff training page that rewards attention. It works best when the thing behind the button is the same for everyone, so the game adds fun without adding pressure.

Keep it away from anything people need to get done: booking, paying, contacting you, or submitting a form. A moving target there is a barrier, especially for people with slower reactions, tremors or vision loss. If you ever make the reward depend on timing, it becomes a game of skill with a prize, and promotional contest rules can apply, so check your local requirements first. For screen reader users, the result is announced in a live region, and the timing grade is skipped entirely when the operating system asks for reduced motion.

Make it yours

  • Speed: CLOSE is how long the ring takes to reach the button. 900 feels quick, 1600 feels relaxed.
  • Difficulty: PERFECT and GOOD are the timing windows in pixels. Raise them for an easier game on phones.
  • The offer: change the OFFER text and the button label in the HTML, and the ring colour in .tr-ring.

Frequently asked questions

Does it work on phones?

Yes. The ring runs on its own while the demo is visible, so there is nothing to hover, and a normal tap is graded the same way as a mouse click.

Can keyboard users play?

Yes. It is a real button, so Enter and Space click it and are graded like any other press. Tabbing to it also restarts the ring if it has gone quiet.

Will it slow my page down?

No. It updates one CSS property per frame, only while the demo is on screen, and stops completely after a few idle cycles.

What should your website do?

Describe a feature you wish your site had. If it is useful to other owners too, we build it here as a free Lab post with a live demo and code.

We never publish your name, email or website. Privacy

More from the Website Lab

All experiments
Buttons

Hold to confirm button in plain JavaScript

A press and hold button for risky actions like delete, with a filling progress ring, keyboard support and live demo code you can copy.

3 min read