Moving background indicators
Most navigation feels static. Click a link, the color changes. Functional, sure. But boring. I wanted something that moved with you a background that slides smoothly between options, tracking your every choice. The kind of detail that makes an interface feel polished.
Loading demo...
The simple math
Here's the whole technique in one line:
const offset = activeIndex * (buttonWidth + gap);
That's it. If your buttons are 40px wide with an 8px gap, each button occupies 48px of horizontal space. Button 0 is at 0px, button 1 is at 48px, button 2 is at 96px.
No curves, no easing functions, no animation loops. Just multiplication.
The markup structure
You need three pieces: a container, the buttons, and the background indicator.
<template>
<div class="nav-container">
<div class="buttons-wrapper">
<!-- The background indicator -->
<div
class="background"
:style="{ transform: backgroundTransform }"
/>
<!-- Your buttons -->
<button
v-for="(item, index) in items"
:key="item.label"
:class="{ active: activeIndex === index }"
@click="activeIndex = index"
>
{{ item.label }}
</button>
</div>
</div>
</template>
The key: the background is a sibling of the buttons, not a parent. This lets it slide independently while the buttons stay put.
The calculation
The computed property is one line:
const backgroundTransform = computed(() => {
const offset = activeIndex.value * 48; // 40px button + 8px gap
return `translateX(${offset}px)`;
});
Why translateX instead of left? Transforms are GPU-accelerated. The browser composites the layer instead of recalculating layout. Smoother animations, especially on mobile.
The CSS magic
Here's where the smoothness comes from:
.background {
position: absolute;
left: 0;
top: 0;
width: 40px;
height: 32px;
border-radius: 8px;
background: var(--indicator-color);
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 0;
}
Making it work with any size
Hardcoding 48px works perfectly fine if you're using fixed pixel values. Your CSS says width: 40px and gap: 8px? The hardcoded math will always be accurate.
But what if you're using:
- Relative units (
rem,em) that scale with font size - Media queries that change spacing on mobile
- Dynamic layouts where button sizes vary
Then you need ResizeObserver. It watches your container and recalculates whenever the layout actually changes.
const buttonsWrapper = ref(null);
const buttonWidth = ref(40);
const gap = ref(8);
// Responsive calculation
const backgroundTransform = computed(() => {
const spacing = buttonWidth.value + gap.value;
const offset = activeIndex.value * spacing;
return `translateX(${offset}px)`;
});
// Calculate dimensions from actual DOM
const calculateDimensions = () => {
if (!buttonsWrapper.value) return;
const firstButton = buttonsWrapper.value.querySelector('.nav-button');
if (!firstButton) return;
const computedStyle = getComputedStyle(buttonsWrapper.value);
buttonWidth.value = firstButton.clientWidth;
gap.value = parseFloat(computedStyle.gap) || 8;
};
// Watch for size changes
let resizeObserver = null;
onMounted(() => {
calculateDimensions();
if (buttonsWrapper.value) {
resizeObserver = new ResizeObserver(() => {
calculateDimensions();
});
resizeObserver.observe(buttonsWrapper.value);
}
});
// Clean up to prevent memory leaks
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
Don't forget to add the ref to your template:
<div ref="buttonsWrapper" class="buttons-wrapper">
<!-- buttons here -->
</div>
Now your background automatically adapts to everything: viewport resizes, CSS changes, dynamic content, font scaling. The indicator stays pixel-perfect.
When do you actually need this? If your buttons use fixed pixel values that never change, the simple hardcoded approach works fine. ResizeObserver is for responsive designs where spacing actually changes—media queries, relative units, or dynamic layouts. It's about future-proofing and making your component bulletproof.
The full implementation
Here's everything together:
<template>
<div class="buttons-wrapper">
<!-- The moving background -->
<div
class="background"
:style="{ transform: backgroundTransform }"
/>
<!-- Your buttons -->
<button
v-for="(item, index) in items"
:key="item.label"
class="nav-button"
:class="{ active: activeIndex === index }"
@click="activeIndex = index"
>
{{ item.label }}
</button>
</div>
</template>
<script setup>
const items = [
{ label: 'About' },
{ label: 'Work' },
{ label: 'Tools' },
{ label: 'Travel' },
];
const activeIndex = ref(0);
// Simple calculation-based approach
// Button width: 40px + Gap: 8px = 48px per button
const backgroundTransform = computed(() => {
const offset = activeIndex.value * 48;
return `translateX(${offset}px)`;
});
</script>
<style scoped>
.buttons-wrapper {
position: relative;
display: flex;
gap: 8px;
align-items: center;
padding: 8px 10px;
}
.background {
position: absolute;
left: 0;
top: 0;
width: 40px;
height: 32px;
border-radius: 8px;
background: var(--indicator-color, #e5e5e5);
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 0;
}
.nav-button {
position: relative;
width: 40px;
height: 32px;
border: none;
background: none;
cursor: pointer;
z-index: 1;
transition: transform 0.2s ease;
}
.nav-button:hover {
transform: scale(1.05);
}
.nav-button.active {
color: var(--active-color);
}
</style>
Things to watch out for
Don't animate width: If your buttons have different widths, animate both transform and width. But be careful—width changes trigger layout recalculations. Consider fixed-width buttons for better performance.
Mobile spacing: Button spacing might change on mobile. Either recalculate on resize or use CSS media queries with different calculations.
Fractional pixels: Sometimes the math lands on fractional pixels (48.7px). Browsers handle this fine, but if you see slight blurriness, round to the nearest pixel.
Initial state: Always set an initial activeIndex value. The background will start at position 0 by default.
Wrapping up
Moving backgrounds aren't magic. They're just absolute positioning, simple math, and CSS transitions working together.
The pattern:
- Position background absolutely at left: 0
- Calculate offset (index × spacing)
- Apply as
translateXtransform - Let CSS transition handle the animation
Use this for tab selectors, segmented controls, button groups, or any interface where you need a background indicator to slide between discrete positions.
Simple math. Smooth results.