Decoy shuffle button: find the real offer among the copies

Tap the offer and it splits into four lookalikes that trade places. Decoys puff away, and the real one hands over the code.

A bakery runs a small weekly offer: ten percent off a loaf for anyone who visits the website. A plain "Show code" link works, but nobody remembers it. Turn the button into a tiny shell game instead. Tap it, it splits into four identical copies, they trade places a few times, and only the real one hands over the code. The decoys puff away when tapped. The whole game takes about ten seconds.

This week at the bakery

Tap the button. Then keep your eye on it.

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

How it works

  1. Four buttons, one real. The HTML holds the real button plus three copies. The copies are hidden from screen readers with aria-hidden and taken out of the tab order, so only the real one exists for assistive technology.
  2. Spots, not coordinates. The script works out four spots in a two by two grid from the size of the box, then moves each button there with a CSS transform. A transition with a slight overshoot makes the moves look lively.
  3. The shuffle. A short timer picks two buttons at random and swaps their spots, five times in a row. While that runs, the buttons ignore taps, so nobody can win by tapping early.
  4. Picking. A decoy fades out with a blur when tapped. The real one turns gold, the other decoys disappear, and the code appears as normal text in a live region.
  5. Keyboard shortcut. A click made with a keyboard reports detail as 0. When that happens the game is skipped and the code is shown straight away, because following moving buttons with the Tab key would be no fun at all.

The code

Three self contained blocks: the markup, the styles scoped to the ds prefix, and one small script.

HTML
<div class="ds" data-lab="decoy-shuffle">
  <p class="ds-kicker">This week at the bakery</p>
  <div class="ds-stage">
    <button type="button" class="ds-btn ds-real">Unlock 10% off</button>
    <button type="button" class="ds-btn ds-fake" tabindex="-1" aria-hidden="true">Unlock 10% off</button>
    <button type="button" class="ds-btn ds-fake" tabindex="-1" aria-hidden="true">Unlock 10% off</button>
    <button type="button" class="ds-btn ds-fake" tabindex="-1" aria-hidden="true">Unlock 10% off</button>
  </div>
  <p class="ds-status" aria-live="polite">Tap the button. Then keep your eye on it.</p>
  <button type="button" class="ds-again" hidden>Play again</button>
</div>
CSS
.ds {
  display: grid;
  justify-items: center;
  gap: 10px;
  width: 100%;
  max-width: 460px;
  margin: 0 auto;
  font-family: system-ui, sans-serif;
  color: #101a14;
}
.ds-kicker {
  margin: 0;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: #46534a;
}
.ds-stage {
  position: relative;
  width: 100%;
  height: 170px;
  border: 1px dashed #c9cec4;
  border-radius: 14px;
  overflow: hidden;
}
/* Every copy sits at the top left corner; the script moves it with transform. */
.ds-btn {
  position: absolute;
  top: 0;
  left: 0;
  width: min(150px, 44%);
  height: 46px;
  margin: 0;
  padding: 0 10px;
  border: 0;
  border-radius: 999px;
  background: #133621;
  color: #f4d96c;
  font: 600 15px/1 system-ui, sans-serif;
  cursor: pointer;
  transition: transform .45s cubic-bezier(.5, 0, .3, 1.3), opacity .35s, filter .35s, background-color .3s;
}
.ds-btn:focus-visible { outline: 3px solid #f4d96c; outline-offset: 3px; }
.ds-fake { opacity: 0; pointer-events: none; }
.ds.is-split .ds-fake { opacity: 1; pointer-events: auto; }
/* A decoy that was tapped puffs away like smoke. */
.ds .ds-fake.is-gone { opacity: 0; filter: blur(8px); pointer-events: none; }
.ds-real.is-won { background: #f4d96c; color: #133621; }
.ds-stage.is-busy .ds-btn { pointer-events: none; }
.ds-status { min-height: 1.4em; margin: 0; font-size: 15px; font-weight: 600; color: #133621; text-align: center; }
.ds-status strong { padding: 2px 8px; border-radius: 6px; background: #f4d96c; letter-spacing: .06em; }
.ds-again {
  padding: 8px 16px;
  border: 1px solid #c9cec4;
  border-radius: 999px;
  background: #fff;
  color: #133621;
  font: 600 14px/1 system-ui, sans-serif;
  cursor: pointer;
}
.ds-again:focus-visible { outline: 3px solid #133621; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
  .ds-btn { transition: opacity .2s, background-color .2s; }
  .ds .ds-fake.is-gone { filter: none; }
}
JavaScript
(function () {
  var root = document.querySelector('[data-lab="decoy-shuffle"]');
  if (!root) return;
  var stage = root.querySelector('.ds-stage');
  var real = root.querySelector('.ds-real');
  var again = root.querySelector('.ds-again');
  var status = root.querySelector('.ds-status');
  var btns = [].slice.call(root.querySelectorAll('.ds-btn'));
  var CODE = 'BREAD10';    // the reward shown at the end
  var SWAPS = 5;           // how many times the buttons trade places
  var STEP = 520;          // milliseconds between swaps
  var calm = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var slot = [];           // slot[i] = which of the four spots button i is in
  var state = 'idle', timer = 0;

  // Four spots in a 2 by 2 grid, measured from the stage so it fits any width.
  function spot(n) {
    var w = stage.clientWidth, h = stage.clientHeight;
    var bw = real.offsetWidth, bh = real.offsetHeight;
    var x = n < 0 ? w / 2 : w * (n % 2 ? 0.73 : 0.27);
    var y = n < 0 ? h / 2 : h * (n < 2 ? 0.3 : 0.7);
    return 'translate(' + Math.round(x - bw / 2) + 'px,' + Math.round(y - bh / 2) + 'px)';
  }

  function place() {
    for (var i = 0; i < btns.length; i++) {
      btns[i].style.transform = spot(state === 'idle' ? -1 : slot[i]);
    }
  }

  function reset() {
    clearTimeout(timer);
    state = 'idle';
    slot = [0, 1, 2, 3];
    root.classList.remove('is-split');
    real.classList.remove('is-won');
    real.textContent = 'Unlock 10% off';
    btns.forEach(function (b) { b.classList.remove('is-gone'); });
    again.hidden = true;
    status.textContent = 'Tap the button. Then keep your eye on it.';
    place();
  }

  // Split into four, then trade two random spots a few times.
  function split() {
    state = 'shuffling';
    root.classList.add('is-split');
    stage.classList.add('is-busy');
    status.textContent = 'Watch closely...';
    place();
    var left = calm ? 0 : SWAPS;
    function swap() {
      if (left-- <= 0) {
        if (calm) slot.sort(function () { return Math.random() - 0.5; });
        place();
        state = 'picking';
        stage.classList.remove('is-busy');
        status.textContent = 'Which one is real? Tap it.';
        return;
      }
      var a = Math.floor(Math.random() * 4), b = (a + 1 + Math.floor(Math.random() * 3)) % 4;
      var t = slot[a]; slot[a] = slot[b]; slot[b] = t;
      place();
      timer = setTimeout(swap, STEP);
    }
    timer = setTimeout(swap, calm ? 0 : STEP);
  }

  function win() {
    state = 'won';
    btns.forEach(function (b) { if (b !== real) b.classList.add('is-gone'); });
    real.classList.add('is-won');
    real.textContent = 'You found it';
    status.innerHTML = 'Your code: <strong>' + CODE + '</strong>';
    again.hidden = false;
  }

  real.addEventListener('click', function (e) {
    // Keyboard clicks report detail 0: skip the game and hand over the code.
    if (state === 'idle' && e.detail === 0) return win();
    if (state === 'idle') return split();
    if (state === 'picking') win();
  });

  btns.forEach(function (b) {
    if (b === real) return;
    b.addEventListener('click', function () {
      if (state !== 'picking') return;
      b.classList.add('is-gone');
      status.textContent = 'Just a decoy. Try another.';
    });
  });

  again.addEventListener('click', function () { reset(); real.focus(); });
  window.addEventListener('resize', place);
  reset();
})();

When to use it, and when not to

This is a toy, and it should only guard toys. Good fits are a weekly treat code, a hidden extra on a thank you page, a holiday surprise, or a teaser for an event announcement. In each case the reward is small and the game is the point.

Bad fits are anything people came to do: booking, paying, contacting you, or reading prices and terms. Putting a game between a customer and a real task is a quick way to lose them. Also, never let the outcome depend on luck if the prize has value. Here every player gets the same code in the end, because tapping all three decoys leaves only the real one. If you ever change that so some people miss out, check the contest rules in your province before you publish.

On accessibility, the game itself relies on sight and quick tracking of moving shapes, which some people cannot do or find stressful. That is why screen reader and keyboard users get the code with one press, and why people who ask their device to reduce motion see the buttons jump into a new order instead of sliding around.

Make it yours

  • The reward: change CODE in the script and the button text in the HTML. The text sits in four places, one per button, so keep them identical.
  • Difficulty: SWAPS sets how many times the buttons trade places and STEP sets the pause between swaps. Fewer swaps and a longer step make it easier.
  • Look: the button size is the width and height of .ds-btn, and the box height is .ds-stage. The spot positions are the 0.27, 0.73, 0.3 and 0.7 values in spot().

Frequently asked questions

Can someone find the code without playing?

Yes. It is written in the script, so anyone who reads the page source can see it. That is fine for an open offer. For a one time code, fetch it from your own server inside win() instead.

Does it work on phones?

Yes. The four spots are measured from the width of the box, so the layout fits a narrow screen, and taps work like clicks. The grid is recalculated when the window is resized or the phone is rotated.

Will it slow my page down?

No. There is no animation loop running in the background. The script only sets a short timer during the five swaps, and the browser handles the movement with CSS transitions.

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