Introduction
In this article we build a Bouncing Radio Button — a custom radio selector where a glowing blue dot hops from one option to another when you click. Instead of the browser's default radio circle just switching state, you get a smooth animated dot that moves down to the new option, bounces slightly to the side as it travels, and lights up the label when it arrives. The whole effect is built with CSS transitions and a small bit of JavaScript to set up the timing. There is no animation library and no animation loop running in the background.
The dot is actually 30 small circles sitting on top of each other in the same spot. When you click an option, all 30 circles move to the new position — but each one starts moving a tiny bit later than the one before it. The first circle moves right away, the last one waits about 116ms before it starts. Because they all start at slightly different times, they spread out during the move and look like a flowing trail rather than one solid block jumping instantly.
How making elements start their animation at different times creates the look of a flowing trail — how CSS can detect which radio is checked and move elements around the page without any JavaScript — why you need three separate bounce animations instead of one shared one — how to set timing on a ::before pseudo-element when inline styles cannot reach it — how to delay the label colour change so it only lights up once the dot has arrived.
How It Works
The moving dot is made up of 30 small circles all placed at the same position on the page. When you click a radio option, a CSS rule tells all 30 circles to move to a new Y position. Because each circle has a slightly different delay before it starts moving, the circles peel away from each other one at a time and create the look of a trail. Once the delay is over, each circle uses a smooth transition to slide to its destination. At the same time, a short animation makes each circle dip sideways and come back — that is the bounce.
The flowing look does not come from complex physics. It comes from 30 identical circles moving to the same place, each starting a few milliseconds after the one before it.
The colour change on the label is also delayed on purpose. The label only turns blue after the dot has finished travelling — so it feels like the dot "switched on" the option rather than the click doing it straight away.
Step 1 — A Form With Three Radio Options
The HTML is a <form> with three radio inputs, a label for each one, and a single container at the end that holds all the dot circles. Each radio input must sit directly before its label with nothing in between — the CSS relies on input + label to change the label colour, and input ~ .trail to move the dot. If you wrap the input in another element, those selectors stop working.
<form class="picker">
<input class="picker-input" id="opt-a" type="radio" name="choice" value="a" checked>
<label class="picker-label" for="opt-a">
<span class="picker-ring"></span>A
</label>
<input class="picker-input" id="opt-b" type="radio" name="choice" value="b">
<label class="picker-label" for="opt-b">
<span class="picker-ring"></span>B
</label>
<input class="picker-input" id="opt-c" type="radio" name="choice" value="c">
<label class="picker-label" for="opt-c">
<span class="picker-ring"></span>C
</label>
<div class="trail" aria-hidden="true" id="trail"></div>
</form>The .trail container gets aria-hidden="true" because the dots inside it are decoration — screen readers should read the radio labels, not 30 empty divs. The id="trail" is the hook JavaScript uses to inject the dots.
Step 2 — Move the Input Off-Screen
The browser's built-in radio circle is hidden by moving the input element off-screen using position: fixed with a negative top and left value. This is different from display: none. Using display: none removes the input completely — keyboard users cannot tab to it and screen readers cannot find it. Moving it off-screen keeps it working normally for everyone, it just cannot be seen.
.picker-input {
position: fixed;
top: -1.5em;
left: -1.5em;
opacity: 0;
}display: none and visibility: hidden both hide an element from keyboard users and assistive technology. Moving it off-screen with position: fixed and a negative offset is the standard way to visually hide an input while keeping it fully functional for everyone.
Step 3 — Add 30 Dot Elements in One Go
Instead of writing 30 identical <div> lines in the HTML by hand, JavaScript creates them in a loop and adds them all to the page in one operation. The trick is a DocumentFragment — you build up all the elements inside the fragment first, then append the fragment to the page once. This is faster than appending each div one by one because the browser only has to recalculate the page layout a single time.
const DOT_COUNT = 30;
const WORM_DUR = 0.4; // seconds — keep this in sync with --worm-dur in CSS
const trail = document.getElementById('trail');
const fragment = document.createDocumentFragment();
for (let i = 0; i < DOT_COUNT; i++) {
const dot = document.createElement('div');
dot.className = 'trail-dot';
dot.dataset.dotIndex = i; // needed in Step 4 to target ::before timing
fragment.appendChild(dot);
}
trail.appendChild(fragment); // one DOM update instead of 30Each dot gets a data-dot-index number. This number is used in the next step as a way to give each dot its own timing through CSS — something that cannot be done with inline styles alone.
Step 4 — The Timing That Creates the Trail Look
Each dot needs to start its move a little later than the one before it. For the dot's own movement this is straightforward — dot.style.transitionDelay sets it directly on the element. But each dot also has a small coloured circle inside it, made with a CSS ::before pseudo-element, which needs its own matching delay for the bounce animation. You cannot set a style on a pseudo-element using JavaScript directly — ::before is not a real DOM element. The workaround is to write a CSS rule for each dot and inject them all as a <style> block.
// Set the movement delay directly on each dot element
dots.forEach((dot, i) => {
const delay = ((WORM_DUR / 100) * i).toFixed(4) + 's';
dot.style.transitionDelay = delay;
});
// Set the bounce animation delay on each dot's ::before
// — cannot use inline style on a pseudo-element, so inject a stylesheet
const rules = Array.from({ length: DOT_COUNT }, (_, i) => {
const delay = ((WORM_DUR / 100) * i).toFixed(4);
return `.trail-dot[data-dot-index="${i}"]::before { animation-delay: ${delay}s; }`;
}).join('\n');
const styleEl = document.createElement('style');
styleEl.textContent = rules;
document.head.appendChild(styleEl);You cannot set a style on a ::before pseudo-element with JavaScript. The only way to give each one different timing is to write CSS rules and inject them into the page as a stylesheet.
Step 5 — CSS Detects the Click and Moves the Dot
No JavaScript watches for clicks or calculates where the dot should go. CSS handles it entirely using the ~ selector, which means "any matching element that comes after this one inside the same parent". When radio 1 is checked, all dots stay at the top. When radio 2 is checked, they all move down by 3em. When radio 3 is checked, they move down 6em. The 3em step is chosen to exactly match the height of each label row including its spacing.
/* Radio 1 selected — dot stays at the top */
.picker-input:nth-of-type(1):checked ~ .trail .trail-dot {
transform: translateY(0em);
}
/* Radio 2 selected — dot moves one row down */
.picker-input:nth-of-type(2):checked ~ .trail .trail-dot {
transform: translateY(3em);
}
/* Radio 3 selected — dot moves two rows down */
.picker-input:nth-of-type(3):checked ~ .trail .trail-dot {
transform: translateY(6em);
}Step 6 — Why You Need Three Separate Bounce Animations
While the dots move up or down, each one also plays a short animation that pushes it to the left and brings it back to centre. This sideways dip is what makes the movement feel like a hop rather than a straight mechanical slide. The animation is defined with @keyframes and assigned through CSS when a radio is checked.
There are three keyframe blocks — hop1, hop2, hop3 — and they all do exactly the same thing. They exist as three separate names for one important reason: when a CSS animation name changes, the browser restarts the animation from the beginning. If all three states used the same name hop, clicking from option B back to option A would not play the bounce — the browser would see the same name is already assigned and do nothing. By using a different name for each option, every click triggers a fresh restart of the bounce.
/* Each radio triggers a different animation name so the bounce always restarts */
.picker-input:nth-of-type(1):checked ~ .trail .trail-dot::before { animation-name: hop1; }
.picker-input:nth-of-type(2):checked ~ .trail .trail-dot::before { animation-name: hop2; }
.picker-input:nth-of-type(3):checked ~ .trail .trail-dot::before { animation-name: hop3; }
/* All three do the same thing — the unique names are the point */
@keyframes hop1 { from, to { transform: translateX(0); } 50% { transform: translateX(-1.5em); } }
@keyframes hop2 { from, to { transform: translateX(0); } 50% { transform: translateX(-1.5em); } }
@keyframes hop3 { from, to { transform: translateX(0); } 50% { transform: translateX(-1.5em); } }If you use a single animation name for all three states, the bounce only plays the very first time. After that, switching between any two options does nothing — the browser thinks the animation is already assigned and does not replay it. Give each option its own name and the bounce fires reliably every single time. If you add a fourth option later, add hop4 too.
Step 7 — The Label Turns Blue After the Dot Arrives
When a radio is checked, its label text and the ring around it change colour to blue. Both of these colour changes have a delay equal to the total travel time of the dot — 400ms. This means the label stays grey while the dot is in motion and only switches to blue right as the dot finishes arriving. Without this delay, the label would flash blue the moment you click, while the dot is still halfway through its journey, which breaks the feeling that the dot is what's activating the option.
/* Label and ring both wait for the dot to finish before turning blue */
.picker-input:checked + .picker-label {
color: var(--accent);
transition-delay: var(--worm-dur);
}
.picker-input:checked + .picker-label .picker-ring {
color: var(--accent);
transform: scale(1.2);
transition-delay: var(--worm-dur);
}
/* When deselecting, the colour change back is instant — no delay */
.picker-label {
transition: color var(--radio-dur) var(--ease-smooth);
}Delaying the colour change by the travel time makes it feel like the dot switched the option on — not the click.
Why Not Use JavaScript for the Animation?
You could write a JavaScript loop that watches for clicks, calculates where each dot should go, and moves them one by one with a timer. It would work. The CSS approach is better for two reasons. First, CSS transitions and animations run on a separate thread inside the browser — they are not slowed down by anything else happening on the page. A JavaScript loop runs on the main thread alongside everything else, and if the page is busy, the animation can stutter. Second, the CSS version is simpler to read and maintain — the state machine is three lines of CSS, not a function tracking clicks and positions.
Tuning Reference
| Setting | Default | What it does |
|---|---|---|
| DOT_COUNT | 30 | How many dots make up the trail — more dots make it longer and denser, fewer make it lighter |
| WORM_DUR (--worm-dur) | 0.4s | How long the dot takes to travel to the new option — also controls the bounce duration |
| --radio-dur | 0.2s | How fast the label colour and ring size change — keep this shorter than the travel time |
| translateY step | 3em | How far apart the options are — must match your label height and spacing exactly |
| translateX bounce | -1.5em | How far the dot dips sideways during travel — increase for a more dramatic hop |
| Delay per dot | worm-dur/100 | How much later each dot starts compared to the one before it — bigger gap means more spread |
| Label transition-delay | var(--worm-dur) | How long before the label lights up — should equal the travel time so it activates on arrival |
| --accent | #008BD9 | The colour of the dot and active label — change this to retheme everything at once |
Full Source Code
Save the file as bouncing-radio.html. Open it directly in any modern browser — no build step, no install, no dependencies needed.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/bouncing-radio.htmlThirty dots, three keyframe names, one CSS selector — and a click that feels alive.