Skip to content
Plinth

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-checked="true"] { color: #14121a; }
.seg__tab:focus-visible { outline: 2px solid #ffca7a; outline-offset: 3px; }

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

const DEFAULT_OPTIONS = ['Preview', 'Code', 'Prompt'];

/** A single choice, so use radios rather than tabs without associated panels. */
export function SegmentedControl({
  options = DEFAULT_OPTIONS,
  label = 'View',
  value,
  defaultValue,
  onChange,
}: {
  options?: string[];
  label?: string;
  value?: string;
  defaultValue?: string;
  onChange?: (value: string, index: number) => void;
}) {
  const [internal, setInternal] = useState(defaultValue ?? options[0] ?? '');
  const requested = value ?? internal;
  const selected = options.includes(requested) ? requested : options[0];
  const active = selected === undefined ? -1 : options.indexOf(selected);
  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 (!bar) return;
      if (!tab) { bar.style.setProperty('--w', '0px'); 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, options]);

  function select(i: number) {
    if (value === undefined) setInternal(options[i]);
    onChange?.(options[i] ?? '', i);
  }

  return (
    <>
      <style>{css}</style>
      <div className="seg" role="radiogroup" aria-label={label}>
        {/* 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="radio"
            aria-checked={i === active}
            tabIndex={i === active ? 0 : -1}
            onClick={() => select(i)}
            onKeyDown={e => {
              if (e.altKey || e.ctrlKey || e.metaKey) return;
              const next = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? (i + 1) % options.length :
                e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? (i - 1 + options.length) % options.length :
                e.key === 'Home' ? 0 : e.key === 'End' ? options.length - 1 : -1;
              if (next < 0) return;
              e.preventDefault();
              tabs.current[next]?.focus();
              select(next);
            }}
          >
            {option}
          </button>
        ))}
      </div>
    </>
  );
}

More in Navigation

Similar tags