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.
Tap as the gold ring meets the button. Every tap claims the offer; good timing only earns a bigger cheer.
How it works
- 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. - 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".
- 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.
- 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.
- 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.
<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>.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; }
}(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:
CLOSEis how long the ring takes to reach the button. 900 feels quick, 1600 feels relaxed. - Difficulty:
PERFECTandGOODare the timing windows in pixels. Raise them for an easier game on phones. - The offer: change the
OFFERtext 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.


