Navigation blueprint

Sidebar active state

A dot pulls left, travels between sidebar rows, then returns right. The new label follows with a delayed indent and a color sweep from left to right. Hover and focus preview another row; selection persists when the pointer leaves.

Inspired by Gustavo (@heyimgustavo) on X ↗

Playground

Playhead 0ms / 500ms

Build it

Choose a target and adapt the source to your interface.

378 lines
index.html
<nav class="active-sidebar" aria-label="Motion playground">
  <div class="active-sidebar-scroll">
    <div class="sidebar-content">
      <span class="sidebar-dot" aria-hidden="true"></span>
      <section
        class="sidebar-group"
        style="--section-light: #4964b2; --section-dark: #a5b8ff"
      >
        <h2 class="sidebar-heading">
          <svg
            class="sidebar-icon"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="1.8"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path
              d="M10 12a6 6 0 1 0 12 0a6 6 0 1 0-12 0M3 7h4M1 12h6M3 17h4"
            /></svg
          >Motion
        </h2>
        <button
          type="button"
          class="sidebar-row"
          data-active="true"
          aria-pressed="true"
        >
          <span class="sidebar-row-content"
            >Easing<span class="sidebar-label-color" aria-hidden="true"
              >Easing</span
            ></span
          ></button
        ><button
          type="button"
          class="sidebar-row"
          data-active="false"
          aria-pressed="false"
        >
          <span class="sidebar-row-content"
            >Duration<span class="sidebar-label-color" aria-hidden="true"
              >Duration</span
            ></span
          ></button
        ><button
          type="button"
          class="sidebar-row"
          data-active="false"
          aria-pressed="false"
        >
          <span class="sidebar-row-content"
            >Stagger<span class="sidebar-label-color" aria-hidden="true"
              >Stagger</span
            ></span
          >
        </button>
      </section>
      <section
        class="sidebar-group"
        style="--section-light: #a43e68; --section-dark: #f4a1c1"
      >
        <h2 class="sidebar-heading">
          <svg
            class="sidebar-icon"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="1.8"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path
              d="M12 3a9 9 0 1 0 0 18h1.5a2 2 0 0 0 1.4-3.4 1.3 1.3 0 0 1 .9-2.2H18a3 3 0 0 0 3-3.1A9 9 0 0 0 12 3M7.5 10h.01M10 6.5h.01M14.5 6.5h.01M17.5 10h.01"
            /></svg
          >Appearance
        </h2>
        <button
          type="button"
          class="sidebar-row"
          data-active="false"
          aria-pressed="false"
        >
          <span class="sidebar-row-content"
            >Theme<span class="sidebar-label-color" aria-hidden="true"
              >Theme</span
            ></span
          ></button
        ><button
          type="button"
          class="sidebar-row"
          data-active="false"
          aria-pressed="false"
        >
          <span class="sidebar-row-content"
            >Accent Color<span class="sidebar-label-color" aria-hidden="true"
              >Accent Color</span
            ></span
          ></button
        ><button
          type="button"
          class="sidebar-row"
          data-active="false"
          aria-pressed="false"
        >
          <span class="sidebar-row-content"
            >Contrast<span class="sidebar-label-color" aria-hidden="true"
              >Contrast</span
            ></span
          >
        </button>
      </section>
    </div>
  </div>
</nav>

<script>
  const sidebar = document.querySelector('.active-sidebar');
  const rows = [...sidebar.querySelectorAll('.sidebar-row')];
  const dot = sidebar.querySelector('.sidebar-dot');
  let selected = 0,
    hovered = null,
    focused = null;
  function moveDot(dot, row) {
    const y = row.offsetTop + (row.offsetHeight - 4) / 2,
      color = getComputedStyle(row).getPropertyValue('--section-color').trim();
    const sameRow = dot.dataset.y === String(y);
    const reduced = matchMedia('(prefers-reduced-motion:reduce)').matches;
    if (dot.dataset.y === String(y) && dot.dataset.color === color && !reduced)
      return;
    const current = new DOMMatrixReadOnly(getComputedStyle(dot).transform),
      oldColor = getComputedStyle(dot).backgroundColor;
    dot.getAnimations().forEach((animation) => animation.cancel());
    dot.style.transform = 'translate(0px,' + y + 'px)';
    dot.style.backgroundColor = color;
    dot.dataset.y = String(y);
    dot.dataset.color = color;
    if (reduced || sameRow || !dot.hasAttribute('data-ready')) return;
    const ease = 'cubic-bezier(.22,1,.36,1)';
    dot.animate(
      [
        {
          transform: 'translate(' + current.m41 + 'px,' + current.m42 + 'px)',
          backgroundColor: oldColor,
          offset: 0,
          easing: ease,
        },
        {
          transform: 'translate(-6px,' + current.m42 + 'px)',
          backgroundColor: oldColor,
          offset: 0.18,
          easing: ease,
        },
        {
          transform: 'translate(-6px,' + y + 'px)',
          backgroundColor: color,
          offset: 0.58,
          easing: 'linear',
        },
        {
          transform: 'translate(-6px,' + y + 'px)',
          backgroundColor: color,
          offset: 0.72,
          easing: ease,
        },
        {
          transform: 'translate(0px,' + y + 'px)',
          backgroundColor: color,
          offset: 1,
        },
      ],
      { duration: 380, easing: 'linear' },
    );
  }
  function positionDot() {
    moveDot(dot, rows[hovered ?? focused ?? selected]);
  }
  function render() {
    const active = hovered ?? focused ?? selected;
    rows.forEach((row, i) => {
      row.dataset.active = String(i === active);
      row.setAttribute('aria-pressed', String(i === selected));
    });
    positionDot();
  }
  rows.forEach((row, i) => {
    row.addEventListener('pointerenter', (event) => {
      if (event.pointerType === 'mouse') {
        hovered = i;
        render();
      }
    });
    row.addEventListener('pointerleave', () => {
      hovered = null;
      render();
    });
    row.addEventListener('focus', () => {
      focused = i;
      render();
    });
    row.addEventListener('blur', () => {
      focused = null;
      render();
    });
    row.addEventListener('click', () => {
      selected = i;
      render();
    });
    row.addEventListener('keydown', (event) => {
      const next =
        event.key === 'ArrowDown'
          ? (i + 1) % rows.length
          : event.key === 'ArrowUp'
            ? (i - 1 + rows.length) % rows.length
            : event.key === 'Home'
              ? 0
              : event.key === 'End'
                ? rows.length - 1
                : -1;
      if (next < 0) return;
      event.preventDefault();
      hovered = null;
      rows[next].focus();
    });
  });
  render();
  requestAnimationFrame(() => {
    dot.dataset.ready = '';
  });
  new ResizeObserver(positionDot).observe(
    sidebar.querySelector('.sidebar-content'),
  );
  matchMedia('(prefers-reduced-motion:reduce)').addEventListener(
    'change',
    positionDot,
  );
  matchMedia('(prefers-color-scheme:dark)').addEventListener(
    'change',
    positionDot,
  );
  new MutationObserver(positionDot).observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['data-theme', 'style'],
  });
</script>

<style>
  .active-sidebar {
    --sidebar-accent: #4964b2;
    --sidebar-bg: var(--stage-bg, #fafaf8);
    --sidebar-ink: var(--stage-ink, #30332f);
    --sidebar-muted: var(--stage-muted, #686b65);
    position: relative;
    width: 320px;
    max-width: 100%;
    background: var(--sidebar-bg);
    color: var(--sidebar-ink);
    font:
      13px/1.4 system-ui,
      sans-serif;
  }
  .active-sidebar-scroll {
    max-height: 400px;
    overflow: auto;
    padding: 22px 24px 12px;
  }
  .sidebar-content {
    position: relative;
  }
  .sidebar-group {
    --section-color: var(--section-light);
  }
  .sidebar-group + .sidebar-group {
    margin-top: 28px;
  }
  .sidebar-heading {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0 0 8px;
    font-size: 13px;
    font-weight: 500;
  }
  .sidebar-icon {
    width: 16px;
    height: 16px;
    flex: none;
    color: var(--section-color);
  }
  .sidebar-row {
    display: flex;
    position: relative;
    align-items: center;
    gap: 8px;
    width: 100%;
    min-height: 36px;
    padding: 0;
    border: 0;
    background: transparent;
    color: var(--sidebar-muted);
    text-align: left;
    font: inherit;
    cursor: pointer;
  }
  .sidebar-row-content {
    position: relative;
    display: inline-block;
    transform: translateX(0);
    transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .sidebar-label-color {
    position: absolute;
    inset: 0;
    color: var(--section-color);
    white-space: nowrap;
    clip-path: inset(0 100% 0 0);
    transition: clip-path 160ms cubic-bezier(0.22, 1, 0.36, 1) 60ms;
    pointer-events: none;
  }
  .sidebar-dot {
    position: absolute;
    top: 0;
    left: 0;
    width: 4px;
    height: 4px;
    border-radius: 50%;
    background: var(--sidebar-accent);
    pointer-events: none;
  }
  .sidebar-row[data-active='true'] .sidebar-row-content {
    transform: translateX(12px);
    transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1) 260ms;
  }
  .sidebar-row[data-active='true'] .sidebar-label-color {
    clip-path: inset(0 0 0 0);
    transition-delay: 340ms;
  }
  .sidebar-row:focus-visible {
    outline: 2px solid var(--section-color);
    outline-offset: 2px;
    border-radius: 3px;
  }
  :root[data-theme='dark'] .active-sidebar {
    --sidebar-accent: #a5b8ff;
    --sidebar-bg: var(--stage-bg, #191c20);
    --sidebar-ink: var(--stage-ink, #edf0f4);
    --sidebar-muted: var(--stage-muted, #adb5c0);
  }
  :root[data-theme='dark'] .sidebar-group {
    --section-color: var(--section-dark);
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme='light']) .active-sidebar {
      --sidebar-accent: #a5b8ff;
      --sidebar-bg: var(--stage-bg, #191c20);
      --sidebar-ink: var(--stage-ink, #edf0f4);
      --sidebar-muted: var(--stage-muted, #adb5c0);
    }
    :root:not([data-theme='light']) .sidebar-group {
      --section-color: var(--section-dark);
    }
  }
  @media (pointer: coarse) {
    .sidebar-row {
      min-height: 44px;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .sidebar-row-content,
    .sidebar-row[data-active='true'] .sidebar-row-content,
    .sidebar-label-color,
    .sidebar-row[data-active='true'] .sidebar-label-color {
      transition: none;
    }
  }
</style>

Animation breakdown

Frame phases, motion intent, implementation constraints and accessible alternatives.

500ms
01

Rest: the selected row shows its section color and a small dot. 0–70ms: the dot pulls 6px into the left gutter as the old label retreats. 70–220ms: the dot travels vertically and changes section color. 220–274ms: it holds in the gutter. 274–380ms: it returns right. The new label starts its 12px indent at 260ms; its accent color sweeps left to right from 340ms and fully covers the label by 500ms. Leaving restores the selected row.

Motion intent

A dot pulls left, travels between sidebar rows, then returns right. The new label follows with a delayed indent and a color sweep from left to right. Hover and focus preview another row; selection persists when the pointer leaves.

The dot follows a 380ms path; staggered label movement and a horizontal color reveal finish at 500ms.

Labels depart promptly and settle without overshoot.

Motion specification

Trigger
Hover or focus a row to preview it. Click, tap, Enter, or Space selects it. Arrow keys move focus.
Timing
500ms
Easing
cubic-bezier(.22, 1, .36, 1)

Animated properties

transformclip-pathbackground-color

Implementation notes

  • Keep row geometry stable; translate the label rather than changing padding.
  • Mount one dot in the scrolling content. Measure the target row center with offsetTop and offsetHeight so scaled previews and scroll do not change its local coordinates.
  • Read the dot’s current rendered position before canceling its previous Web Animation. Animate through a left gutter, the target row center, a brief hold, then the resting x position.
  • Retreat the outgoing label immediately. Delay the incoming indent by 260ms and its color reveal by 340ms. Clip a decorative accent-colored copy of the label from left to right over its neutral text; hide the copy from assistive technology.
  • Each section owns its accent color. The shared dot changes color when crossing sections; each section has light and dark accents.
  • Track selection, hover, and focus independently; restore selection after preview ends.
  • The web examples use Web Animations for the dot path and CSS transforms and clip-path transitions for staggered labels. Native uses Reanimated sequences, delays, and a clipped accent-text layer for touch selection.
  • Use the host data-theme attribute or system color scheme for standalone examples. Theme changes retint the dot without replaying its travel.
  • The demo selects a topic locally. Connect selection to your application navigation and expose aria-current on real links.

Reduced motion

Apply label position, color, and dot visibility instantly.

  • Use labeled buttons with aria-pressed for demo selection; Enter and Space activate them.
  • Keep visible focus outlines and support Up, Down, Home, and End.
  • Use 44px touch targets and scroll focused rows into view.

Avoid

  • Do not slide a large filled pill behind the labels.
  • Do not leave multiple rows visually active after rapid pointer changes.

Related blueprints

Browse all blueprints