Navigation blueprint

Segmented-control indicator

One shared indicator makes a section change feel like a continuation, not a redraw.

Playground

Playhead 0ms / 300ms

Build it

Choose a target and adapt the source to your interface.

184 lines
index.html
<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.

300ms
01

Rest: indicator sits under the current label.

02

0–180ms: it stretches toward destination.

03

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

translatebackground-colorshadow

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.

Related blueprints

Browse all blueprints