Navigation blueprint
Segmented-control indicator
One shared indicator makes a section change feel like a continuation, not a redraw.
Build it
Choose a target and adapt the source to your interface.
<div class="demo-tabs">
<div class="demo-tablist" role="tablist" aria-label="Project view">
<span class="demo-tab-indicator" aria-hidden="true"></span
><button
type="button"
role="tab"
id="project-tab-0"
aria-controls="project-panel-0"
aria-selected="true"
tabindex="0"
>
Overview</button
><button
type="button"
role="tab"
id="project-tab-1"
aria-controls="project-panel-1"
aria-selected="false"
tabindex="-1"
>
Notes</button
><button
type="button"
role="tab"
id="project-tab-2"
aria-controls="project-panel-2"
aria-selected="false"
tabindex="-1"
>
Team
</button>
</div>
<div
role="tabpanel"
id="project-panel-0"
aria-labelledby="project-tab-0"
tabindex="0"
>
<strong>Overview</strong>
<p>A snapshot of the project and what comes next.</p>
</div>
<div
role="tabpanel"
id="project-panel-1"
aria-labelledby="project-tab-1"
tabindex="0"
hidden
>
<strong>Notes</strong>
<p>Ideas, decisions, and details worth keeping.</p>
</div>
<div
role="tabpanel"
id="project-panel-2"
aria-labelledby="project-tab-2"
tabindex="0"
hidden
>
<strong>Team</strong>
<p>The people working together on this project.</p>
</div>
</div>
<script>
const root = document.querySelector('.demo-tabs');
const tabs = [...root.querySelectorAll('[role=tab]')];
const panels = [...root.querySelectorAll('[role=tabpanel]')];
function select(index, focus = false) {
const indicator = root.querySelector('.demo-tab-indicator');
const previous = Number(root.style.getPropertyValue('--selected') || 0);
indicator.getAnimations().forEach((animation) => animation.cancel());
if (!matchMedia('(prefers-reduced-motion:reduce)').matches)
indicator.animate(
[
{ transform: 'translateX(' + previous * 100 + '%) scaleX(1)' },
{
transform:
'translateX(' + (previous + index) * 50 + '%) scaleX(1.25)',
},
{ transform: 'translateX(' + index * 100 + '%) scaleX(1)' },
],
{ duration: 300, easing: 'cubic-bezier(.22,1,.36,1)' },
);
root.style.setProperty('--selected', index);
tabs.forEach((tab, i) => {
tab.setAttribute('aria-selected', String(i === index));
tab.tabIndex = i === index ? 0 : -1;
panels[i].hidden = i !== index;
});
if (focus) tabs[index].focus();
}
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => select(index));
tab.addEventListener('keydown', (event) => {
const next =
event.key === 'ArrowRight'
? (index + 1) % tabs.length
: event.key === 'ArrowLeft'
? (index - 1 + tabs.length) % tabs.length
: event.key === 'Home'
? 0
: event.key === 'End'
? tabs.length - 1
: -1;
if (next < 0) return;
event.preventDefault();
select(next, true);
});
});
</script>
<style>
.demo-tabs {
max-width: 420px;
font: 14px/1.5 system-ui;
color: #20252d;
}
.demo-tablist {
position: relative;
display: flex;
padding: 4px;
border-radius: 8px;
background: #e7edf5;
}
.demo-tablist button {
position: relative;
z-index: 1;
flex: 1;
min-width: 0;
min-height: 44px;
padding: 8px;
border: 0;
border-radius: 5px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.demo-tablist button[aria-selected='true'] {
color: white;
}
.demo-tablist button:focus-visible {
outline: 2px solid #175bc5;
outline-offset: 4px;
}
.demo-tab-indicator {
position: absolute;
top: 4px;
bottom: 4px;
left: 4px;
width: calc((100% - 8px) / 3);
border-radius: 5px;
background: #175bc5;
transform: translateX(calc(var(--selected, 0) * 100%));
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.demo-tabs [role='tabpanel'] {
box-sizing: border-box;
margin-top: 12px;
min-height: 104px;
padding: 18px 20px;
border: 1px solid var(--stage-grid, #cbd0d7);
border-radius: 8px;
background: var(--stage-bg, #f8f9fb);
color: var(--stage-ink, #20252d);
}
.demo-tabs [role='tabpanel'] strong {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 600;
}
.demo-tabs [role='tabpanel'] p {
margin: 0;
color: var(--stage-muted, #68778c);
font-size: 13px;
line-height: 1.5;
}
@media (prefers-reduced-motion: reduce) {
.demo-tab-indicator {
transition: none;
}
}
</style>Animation breakdown
Frame phases, motion intent, implementation constraints and accessible alternatives.
Rest: indicator sits under the current label.
0–180ms: it stretches toward destination.
180–300ms: the trailing edge catches up while content fades.
Motion intent
One shared indicator makes a section change feel like a continuation, not a redraw.
This duration leaves enough time to read the state change while keeping the interface responsive.
The fast acceleration and long ease-out make the state feel responsive while giving the final pixels time to settle.
Motion specification
- Trigger
- A neighbouring view is selected.
- Timing
- 300ms
- Easing
- cubic-bezier(.22, 1, .36, 1)
Animated properties
Implementation notes
- Keep first segment and next segment aligned to the same visual anchor.
- Drive the transition from semantic state, not a timer, when the trigger is interactive.
- Use translate, background-color, shadow and avoid layout work inside the animation loop.
Reduced motion
Swap to the final state instantly, preserving colour, label, and accessible state.
- Use a minimum 44 by 44 pixel interactive target.
- Expose the first segment and next segment states with aria-pressed, aria-expanded, or an updated accessible label.
- Keep visible keyboard focus independent from the animation.
Avoid
- Do not add bounce unless a real object pickup or spring settle is part of the interaction.
- Do not hide the final state from assistive technology.
- Do not make the user wait for animation before the underlying state updates.