Sliding tab indicators

Author Jesse Breneman Published on November 27, 2023

I recently implemented a sliding active indicator for a tab-style nav at work, and thought it would be interesting to document how I did it.

This can be marked up however fits your needs. I'm going to demo this using a <nav> filled with anchors, but this technique can be applied to a tab setup, etc. I've also added a data-index to each link, just to make our lives a little easier here. The JavaScript here will all be vanilla, but's relatively simple and should be easy to convert to React, Vue, Svelte, et al.

html
<nav class="links">
    <a class="link" href="#" data-index="0">Link 1</a>
    <a class="link" href="#" data-index="1">Link 2</a>
    <a class="link" href="#" data-index="2">Link 3</a>
</nav>

To style this, we're going to set the container to be a flex container and then set the links to all grow at the same rate. This will ensure that the links are all the same width. From there, all we need to do is create a pseudo-element on the container that will act as our indicator, set the width to 100% divided by the total amount of links, and then set it to translate based on the offset. From there, all we really need is a little JavaScript to move the offset around as links are clicked on and we're all set!

css
.links {
    --amount: 3;
    --offset: 0;
    position: relative;
    display: flex;
    width: 100%;
    background: white;
}

.links::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: calc(100% / var(--amount));
    height: 2px;
    background: linear-gradient(to right, #ff82b8, #ff8f34);
    transform: translateX(calc(100% * var(--offset)));
    transition: transform 0.24s ease;
    pointer-events: none;
}

.link {
    flex-grow: 1;
    padding: 1rem;
    display: flex;
    justify-content: center;
    color: black;
    text-decoration: none;
    font-weight: 700;
}

Here, all we're doing is updating the offset property on the container to the index set on the individual link.

js
const container = document.querySelector('.links');

container.addEventListener('click', (e) => {
    container.style.setProperty('--offset', e.target.dataset.index);
});

Now, this technique makes some assumptions. This only works for links that are the same size, but that can easily be changed by changing how we calculate offset and width. We can have our JavaScript calculate the current link's width and offset from the edge of the container, and use that to set our CSS variables instead.

css
.links {
    --width: 0;
    --offset: 0;
    position: relative;
    display: flex;
    width: 100%;
    background: white;
}

.links::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: var(--width);
    height: 2px;
    background: linear-gradient(to right, #ff82b8, #ff8f34);
    transform: translateX(var(--offset));
    transition: transform 0.24s ease;
    pointer-events: none;
}

.link {
    padding: 1rem;
    display: flex;
    justify-content: center;
    color: black;
    text-decoration: none;
    font-weight: 700;
}
js
const container = document.querySelector('.links');
const firstLink = container.querySelector('.link');

const update = (el) => {
    container.style.setProperty(
        '--width',
        `${el.getBoundingClientRect().width}px`,
    );
    container.style.setProperty('--offset', `${el.offsetLeft}px`);
};

// Set the initial state
update(firstLink);

container.addEventListener('click', (e) => {
    update(e.target);
});

You can see in the JavaScript that we also need to set the initial state as well, otherwise we won't have the indicator until a link is clicked.

That's it! For me, the first solution worked well, but the second one will work for almost any situation.

About the author