Accordion expandWeb · App
Target
Implement the "Sliding tab indicator" effect in vanilla HTML/CSS/JS. Context: The active-tab underline slides and stretches to the newly selected tab instead of jumping. Trigger: the element is pressed (pointerdown → pointerup). Timeline: time-based (duration + easing). Motion tokens (normal): - translateX (indicator): previous tab left → new tab left - scaleX (indicator): previous tab width → new tab width - duration: 280ms - easing: cubic-bezier(0.2, 0.8, 0.2, 1) - note: indicator is 1px wide at scaleX 1; width = scaleX × 1px, transform-origin left Motion tokens (prefers-reduced-motion): - translateX / scaleX: previous → new, instantly - duration: 0ms Technique: prefer pure CSS transitions/animations; use JS only to toggle classes; plain JavaScript (requestAnimationFrame / class toggles), no animation library required. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - indicator is a single element moved by transform; tabs themselves never re-layout - aria-selected drives the styling; arrow keys move selection
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-tab-indicator-slide" data-fx-root>
<div class="fx-tabs" role="tablist" aria-label="Sections">
<button type="button" role="tab" aria-selected="true">Overview</button>
<button type="button" role="tab" aria-selected="false">Specs</button>
<button type="button" role="tab" aria-selected="false">Reviews</button>
<button type="button" role="tab" aria-selected="false">Q&A</button>
<span class="fx-ind" aria-hidden="true"></span>
</div>
</div>
demo.css
.fx-tab-indicator-slide { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.fx-tab-indicator-slide .fx-tabs { position: relative; display: flex; gap: 4px; padding-bottom: 8px; border-bottom: 1px solid var(--fx-muted, #e4e4e7); }
.fx-tab-indicator-slide [role="tab"] { height: 36px; padding: 0 14px; border: 0; background: transparent; color: #71717a; font: 500 14px/1 Inter, system-ui, sans-serif; cursor: pointer; border-radius: 8px; transition: color 150ms ease; }
.fx-tab-indicator-slide [role="tab"][aria-selected="true"] { color: var(--fx-ink, #18181b); }
.fx-tab-indicator-slide .fx-ind { position: absolute; left: 0; bottom: -1px; width: 1px; height: 2px; background: var(--fx-accent, #4f46e5); transform-origin: left center; transition: transform 280ms cubic-bezier(.2, .8, .2, 1); }
@media (prefers-reduced-motion: reduce) { .fx-tab-indicator-slide .fx-ind { transition: none; } }
demo.js
export default function mount(root) {
const tabs = Array.from(root.querySelectorAll('[role="tab"]')), ind = root.querySelector('.fx-ind'), list = root.querySelector('.fx-tabs');
const select = (tab) => {
tabs.forEach((t) => t.setAttribute('aria-selected', String(t === tab)));
const x = tab.offsetLeft - list.clientLeft; ind.style.transform = `translateX(${x}px) scaleX(${tab.offsetWidth})`;
};
tabs.forEach((t) => t.addEventListener('click', () => select(t)));
list.addEventListener('keydown', (e) => { const i = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true'); if (e.key === 'ArrowRight') { select(tabs[(i + 1) % tabs.length]); tabs[(i + 1) % tabs.length].focus(); } if (e.key === 'ArrowLeft') { select(tabs[(i - 1 + tabs.length) % tabs.length]); tabs[(i - 1 + tabs.length) % tabs.length].focus(); } });
ind.style.transition = 'none'; select(tabs[0]); void ind.offsetWidth; ind.style.transition = '';
let demo; const seq = [2, 1, 3, 0];
return {
play() { clearTimeout(demo); let i = 0; const step = () => { if (i < seq.length) { select(tabs[seq[i++]]); demo = setTimeout(step, 700); } }; demo = setTimeout(step, 400); },
stop() { clearTimeout(demo); select(tabs[0]); },
};
}
Inspired by m3.material.io — Material tabs indicator. The preview is an original re-creation; the effect name belongs to its author.
The indicator is 1px wide and scaled to the tab width, so both position and width are transforms (compositor-only). Text inside a scaled element would distort, which is why the indicator is empty.
Added 2026-11-09 · Report an issue