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 ↗
Build it
Choose a target and adapt the source to your interface.
<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.
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
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.