Sliding Tab Indicator

A single highlight slides and resizes between tabs instead of each tab lighting up on its own.

  • CSS
  • Forms & controls
  • Click
  • Added

AI prompt

Build accessible React tabs with one sliding indicator. Render a role="tablist" with buttons (role="tab", aria-selected) for "Overview", "Usage" and "API reference". Keep a single absolutely positioned indicator element behind the tabs. When the active tab changes, measure its offsetLeft and offsetWidth and set the indicator with transform: translateX(...) and a scaleX based on width (or width via a CSS variable), transitioning over 260ms with cubic-bezier(0.16, 1, 0.3, 1). Support Left and Right arrow keys to move between tabs. Re-measure on resize. Under prefers-reduced-motion, move the indicator instantly.

Source

Demo.tsx
'use client'

import { useLayoutEffect, useRef, useState } from 'react'
import s from './demo.module.css'

export default function SlidingTabs({ labels }: { labels: Record<string, string> }) {
  const tabs = labels.tabs.split('|')
  const [active, setActive] = useState(0)
  const list = useRef<HTMLDivElement>(null)
  const indicator = useRef<HTMLSpanElement>(null)

  useLayoutEffect(() => {
    const place = () => {
      const tab = list.current?.children[active + 1] as HTMLElement | undefined
      if (!tab || !indicator.current) return
      indicator.current.style.width = `${tab.offsetWidth}px`
      indicator.current.style.transform = `translateX(${tab.offsetLeft}px)`
    }
    place()
    window.addEventListener('resize', place)
    return () => window.removeEventListener('resize', place)
  }, [active, labels.tabs])

  const onKey = (e: React.KeyboardEvent) => {
    const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0
    if (!step) return
    const next = (active + step + tabs.length) % tabs.length
    setActive(next)
    ;(list.current?.children[next + 1] as HTMLElement | undefined)?.focus()
  }

  return (
    <div ref={list} className={s.list} role="tablist" onKeyDown={onKey}>
      <span ref={indicator} className={s.indicator} aria-hidden="true" />
      {tabs.map((t, i) => (
        <button
          key={t}
          type="button"
          role="tab"
          aria-selected={i === active}
          tabIndex={i === active ? 0 : -1}
          className={s.tab}
          onClick={() => setActive(i)}
        >
          {t}
        </button>
      ))}
    </div>
  )
}
demo.module.css
.list {
  position: relative;
  display: inline-flex;
  padding: 4px;
  border: 1px solid var(--stage-rule);
  border-radius: var(--radius-md);
  background: var(--stage-surface);
}

.indicator {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 0;
  border-radius: var(--radius-sm);
  background: var(--stage-ink);
  transition: transform 260ms var(--ease-out), width 260ms var(--ease-out);
}

.tab {
  position: relative;
  height: 36px;
  padding: 0 14px;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--stage-muted);
  font: 500 var(--text-sm) / 1 var(--font-body);
  white-space: nowrap;
  cursor: pointer;
  transition: color 260ms var(--ease-out);
}

.tab[aria-selected='true'] { color: var(--stage-bg); }
.tab:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .indicator, .tab { transition: none; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.