Skip to content
Plinth

Original by Plinth ยท MIT

Navigation

Segmented Control

A pill that slides and stretches between tabs, with arrow-key navigation.

#navigation#tabs#segmented#indicator#keyboard

Install

npx shadcn@latest add https://plinthui.com/r/segmented-control.json
Open in v0
'use client';

import { useEffect, useRef, useState } from 'react';

const css = `
.seg {
  position: relative;
  display: inline-flex;
  padding: 4px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  background: rgba(255, 255, 255, 0.05);
}

.seg__indicator {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 0;
  border-radius: 999px;
  background: #f3f0e9;
  /* Position and size both animate, so the pill stretches into a wider tab
     rather than jumping and then resizing. */
  transform: translateX(var(--x, 0px));
  width: var(--w, 0px);
  transition:
    transform 340ms cubic-bezier(0.22, 1, 0.36, 1),
    width 340ms cubic-bezier(0.22, 1, 0.36, 1);
}

.seg__tab {
  position: relative;
  z-index: 1;
  padding: 8px 18px;
  border: 0;
  border-radius: 999px;
  background: none;
  color: rgba(243, 240, 233, 0.62);
  font: 500 14px/1 ui-sans-serif, system-ui, sans-serif;
  cursor: pointer;
  transition: color 220ms ease;
}
.seg__tab:hover { color: rgba(243, 240, 233, 0.9); }
.seg__tab[aria-selected="true"] { color: #14121a; }
.seg__tab:focus-visible { outline: 2px solid #ffca7a; outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .seg__indicator { transition: none; }
}
`;

export function SegmentedControl({
  options = ['Preview', 'Code', 'Prompt'],
  onChange,
}: {
  options?: string[];
  onChange?: (value: string, index: number) => void;
}) {
  const [active, setActive] = useState(0);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const indicator = useRef<HTMLSpanElement>(null);

  // Measured from the DOM rather than computed, because tab widths depend on
  // the rendered text. offsetLeft is relative to .seg (the positioned
  // ancestor), so there is no rect maths and nothing to correct for scroll.
  useEffect(() => {
    function place() {
      const tab = tabs.current[active];
      const bar = indicator.current;
      if (!tab || !bar) return;
      bar.style.setProperty('--x', `${tab.offsetLeft}px`);
      bar.style.setProperty('--w', `${tab.offsetWidth}px`);
    }
    place();
    // Fonts land after first paint and change tab widths, so re-measure rather
    // than leaving the pill misaligned.
    void document.fonts?.ready.then(place);
    window.addEventListener('resize', place);
    return () => window.removeEventListener('resize', place);
  }, [active]);

  function select(i: number) {
    setActive(i);
    onChange?.(options[i] ?? '', i);
  }

  return (
    <>
      <style>{css}</style>
      <div className="seg" role="tablist" aria-label="View">
        {/* A sibling, not a child of the active tab: one element sliding
            between positions animates, whereas a per-tab background can only
            cross-fade. */}
        <span className="seg__indicator" ref={indicator} aria-hidden="true" />
        {options.map((option, i) => (
          <button
            key={option}
            ref={el => {
              tabs.current[i] = el;
            }}
            className="seg__tab"
            type="button"
            role="tab"
            aria-selected={i === active}
            onClick={() => select(i)}
            onKeyDown={e => {
              const next = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : -1;
              if (next < 0 || next >= options.length) return;
              e.preventDefault();
              tabs.current[next]?.focus();
              select(next);
            }}
          >
            {option}
          </button>
        ))}
      </div>
    </>
  );
}

More in Navigation

Similar tags