Swish UI
All componentsButtons and controls Free

Sliding segmented control

A dark pill slides between Day, Week and Month and stretches or shrinks to fit the label it lands on.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useLayoutEffect, useRef, useState } from "react";
import styles from "./demo.module.css";

const OPTIONS = [
  { label: "Day", note: "12 check-ins today" },
  { label: "Week", note: "64 check-ins this week" },
  { label: "Month", note: "248 check-ins this month" },
];

export default function Demo() {
  const group = useRef<HTMLDivElement>(null);
  const pill = useRef<HTMLSpanElement>(null);
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
  const [index, setIndex] = useState(1);

  // Size and place the pill under the selected option, again whenever the group resizes.
  useLayoutEffect(() => {
    const place = () => {
      const b = buttons.current[index];
      if (!b || !pill.current) return;
      pill.current.style.width = `${b.offsetWidth}px`;
      pill.current.style.transform = `translateX(${b.offsetLeft}px)`;
    };
    place();
    const raf = requestAnimationFrame(() => pill.current?.classList.add(styles.ready));
    const observer = new ResizeObserver(place);
    if (group.current) observer.observe(group.current);
    return () => {
      cancelAnimationFrame(raf);
      observer.disconnect();
    };
  }, [index]);

  const select = (i: number) => {
    const next = (i + OPTIONS.length) % OPTIONS.length;
    setIndex(next);
    buttons.current[next]?.focus();
  };

  const keyDown = (event: React.KeyboardEvent) => {
    const moves: Record<string, number> = { ArrowRight: index + 1, ArrowDown: index + 1, ArrowLeft: index - 1, ArrowUp: index - 1, Home: 0, End: OPTIONS.length - 1 };
    if (!(event.key in moves)) return;
    event.preventDefault();
    select(moves[event.key]);
  };

  return (
    <div className={styles.root}>
      <div className={styles.stack}>
        <div ref={group} className={styles.group} role="radiogroup" aria-label="Date range" onKeyDown={keyDown}>
          <span ref={pill} className={styles.pill} aria-hidden="true" />
          {OPTIONS.map((option, i) => (
            <button
              key={option.label}
              ref={(el) => {
                buttons.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={i === index}
              tabIndex={i === index ? 0 : -1}
              className={styles.option}
              onClick={() => select(i)}
            >
              {option.label}
            </button>
          ))}
        </div>
        <p className={styles.note} aria-live="polite">{OPTIONS[index].note}</p>
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.stack { display: grid; justify-items: center; gap: 14px; }
.group {
  position: relative; display: inline-flex; padding: 4px; border-radius: 999px;
  background: var(--white); border: 1px solid var(--line);
}
.pill {
  position: absolute; top: 4px; bottom: 4px; left: 0; border-radius: 999px; background: var(--ink);
  box-shadow: 0 6px 14px -8px var(--ink);
}
.ready { transition: transform .45s var(--out), width .45s var(--out); }
.option {
  position: relative; min-height: 42px; padding: 0 22px; border: 0; border-radius: 999px; background: none;
  color: var(--muted); font-size: 15px; font-weight: 600; cursor: pointer; -webkit-tap-highlight-color: transparent;
  transition: color .25s;
}
.option:hover { color: var(--ink); }
.option[aria-checked="true"] { color: var(--white); }
.option:focus-visible { outline-offset: 1px; }
.note { font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .ready { transition: none; } }

Use it for

  • Switching the time range or view of a chart or list
  • Two to five short, mutually exclusive options

Avoid

  • More than five options or long labels, where a select fits better
  • Navigation between pages, which should be links or tabs

How it works

One absolutely positioned pill sits behind the options. A layout effect reads the selected button offsetLeft and offsetWidth and writes them to the pill as translateX and width, both transitioned with the shared easing. The transition class is added a frame after the first placement so the pill does not slide in on load, and a ResizeObserver re-measures when fonts or the container change.

Accessibility

The control is a radiogroup with role radio buttons and aria-checked. Only the selected option is in the tab order, and arrow keys, Home and End move the selection and focus like native radios. The summary line below is a polite live region. Reduced motion moves the pill without sliding.

Found on