
A JavaScript implementation that creates a moving background highlight effect for toggle buttons, tabs UI, navigation menus, and segmented controls.
It uses the native View Transition API to animate an active indicator as users switch between options. Works with any button group structure and requires minimal setup.
See it in action:
How to use it:
1. Start with a semantic list structure for your toggle buttons. Each button sits inside a list item:
<ul class="toggle-buttons" aria-label="Frameworks">
<li class="toggle-buttons__item">
<button class="toggle-buttons__button" type="button">CSSScript</button>
</li>
<li class="toggle-buttons__item toggle-buttons__item--react">
<button class="toggle-buttons__button" type="button">jQueryScript</button>
</li>
<li class="toggle-buttons__item toggle-buttons__item--vue">
<button class="toggle-buttons__button" type="button">ReactScript</button>
</li>
<li class="toggle-buttons__item toggle-buttons__item--angular">
<button class="toggle-buttons__button" type="button">VueScript</button>
</li>
<li class="toggle-buttons__item toggle-buttons__item--svelte">
<button class="toggle-buttons__button" type="button">ScriptByAI</button>
</li>
</ul>2. The following CSS handles both the CSS styles and the View Transition API:
The View Transition API properties (
view-transition-nameandview-transition-class) tell the browser which elements to animate. The pseudo-elements control the animation behavior.
/* Remove default button styling */
button {
background: none;
border: none;
font: inherit;
color: inherit;
outline: none;
}
button:focus-visible {
outline-style: solid;
outline-color: hsl(0 0% 100% / 0.2);
outline-width: 2px;
}
/* Container for all toggle buttons */
.toggle-buttons {
list-style: none;
display: flex;
gap: 0.25rem; /* Space between buttons */
padding: 0.25rem;
border-radius: 3rem; /* Pill shape */
background-color: #000;
}
/* Each button wrapper - uses CSS Grid for layering */
.toggle-buttons__item {
display: grid;
position: relative;
}
/* Stack button and active indicator in the same grid cell */
.toggle-buttons__button,
.toggle-buttons__active {
grid-area: 1 / 1; /* Both occupy the first cell */
}
.toggle-buttons__button {
padding: 0.5rem 1.25rem;
position: relative;
z-index: 1; /* Keeps button above the background */
view-transition-class: toggle-button; /* Groups all buttons for transitions */
}
/* The moving active background indicator */
.toggle-buttons__active {
background-color: deeppink; /* Default color */
border-radius: 3rem;
view-transition-name: active-toggle-button; /* Unique identifier for animation */
}
/* Custom colors for different framework options */
.toggle-buttons__item--react .toggle-buttons__active {
background-color: #4e98b6;
}
.toggle-buttons__item--vue .toggle-buttons__active {
background-color: #42b883;
}
.toggle-buttons__item--angular .toggle-buttons__active {
background-color: #dd0031;
}
.toggle-buttons__item--svelte .toggle-buttons__active {
background-color: #ff3e00;
}
/* View Transition API pseudo-elements */
::view-transition-group(.toggle-button) {
z-index: 2; /* Buttons stay on top during transition */
}
::view-transition-group(active-toggle-button) {
z-index: 1; /* Background animates beneath */
animation-timing-function: cubic-bezier(0.8, -0.4, 0.5, 1); /* Custom easing */
animation-duration: 0.25s;
}
/* Ensure background fills the full height during animation */
::view-transition-old(active-toggle-button),
::view-transition-new(active-toggle-button) {
height: 100%;
}3. Create the active indicator and manages click events. The script checks for View Transition API support through document.startViewTransition. Browsers without support get instant transitions instead of animations.
// Select all toggle buttons
const buttons = document.querySelectorAll(".toggle-buttons__button");
// Create the moving background element
const activeSpan = document.createElement("span");
activeSpan.classList.add("toggle-buttons__active");
activeSpan.setAttribute("aria-hidden", "true"); // Hide from screen readers
// Place the active indicator in the first button by default
const firstItem = buttons[0].parentElement;
firstItem.appendChild(activeSpan);
// Set up each button
buttons.forEach((button, index) => {
// Assign unique view transition name for animation tracking
button.style.viewTransitionName = `button-${index + 1}`;
// Mark first button as pressed, others as not pressed
button.setAttribute("aria-pressed", index === 0);
// Handle button clicks
button.addEventListener("click", () => {
// Reset all buttons to unpressed state
buttons.forEach((b) => b.setAttribute("aria-pressed", "false"));
// Mark clicked button as pressed
button.setAttribute("aria-pressed", "true");
// Get the parent list item of the clicked button
const newItem = button.parentElement;
// Check if browser supports View Transition API
if (!document.startViewTransition) {
// Fallback: move indicator without animation
newItem.appendChild(activeSpan);
return;
}
// Use View Transition API for smooth animation
document.startViewTransition(() => {
newItem.appendChild(activeSpan);
});
});
});FAQs:
Q: How do I add more toggle buttons?
A: Add new list items to the HTML with the same structure. The JavaScript automatically detects all buttons with the .toggle-buttons__button class.
Q: Why does the animation feel sluggish?
A: Check your animation-duration value in the ::view-transition-group(active-toggle-button) rule. The default is 0.25 seconds. Lower values create snappier transitions.
Q: How do I change the animation easing?
A: Modify the animation-timing-function property in the ::view-transition-group(active-toggle-button) CSS rule. The current cubic-bezier creates a bouncy effect. Use ease-in-out for smoother motion or create custom cubic-bezier values.







