Skip to content
All Taste components

Tabs

Full-width segmented tabs

Equal-width segments that fill their container, with a badge on one option and the content switching below.

neutral · brand-filled · badge in a segment · 2 and 3 segments

€24per seat / month

Cancel any time.

€24€19per seat / month

€228 billed once a year.

"use client";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";

type Option = { id: string; label: string; badge?: string };

export function SegmentedTabs({ options, value, onChange, label, tone = "neutral" }: {
  options: Option[];
  value: string;
  onChange: (id: string) => void;
  label: string;
  tone?: "neutral" | "brand";
}) {
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const index = Math.max(0, options.findIndex((o) => o.id === value));

  const onKeyDown = (e: React.KeyboardEvent) => {
    const last = options.length - 1;
    const keys: Record<string, number> = {
      ArrowRight: index === last ? 0 : index + 1,
      ArrowLeft: index === 0 ? last : index - 1,
      Home: 0,
      End: last,
    };
    const next = keys[e.key];
    if (next === undefined) return;
    e.preventDefault();
    onChange(options[next].id);
    refs.current[next]?.focus();
  };

  return (
    <div role="tablist" aria-label={label} className="grid auto-cols-fr grid-flow-col gap-1 rounded-lg bg-surface-2 p-1 font-sans">
      {options.map((o, i) => {
        const selected = i === index;
        return (
          <button
            key={o.id}
            ref={(el) => { refs.current[i] = el; }}
            id={"seg-tab-" + o.id}
            type="button"
            role="tab"
            aria-selected={selected}
            aria-controls={"seg-panel-" + o.id}
            tabIndex={selected ? 0 : -1}
            onClick={() => onChange(o.id)}
            onKeyDown={onKeyDown}
            className={cn(
              "inline-flex h-9 min-w-0 items-center justify-center gap-1.5 whitespace-nowrap rounded px-2 text-[13.5px] font-medium transition",
              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
              !selected && "text-fg-muted hover:text-fg",
              selected && tone === "neutral" && "bg-bg text-fg shadow-sm ring-1 ring-border",
              selected && tone === "brand" && "bg-primary text-primary-fg",
            )}
          >
            {o.label}
            {o.badge && <span className="rounded-full bg-primary-soft px-1.5 py-0.5 text-[11px] text-primary-soft-fg">{o.badge}</span>}
          </button>
        );
      })}
    </div>
  );
}

// Example: the price below follows the chosen period.
export function BillingPeriod() {
  const [period, setPeriod] = useState("monthly");
  return (
    <div className="grid max-w-[340px] gap-3.5">
      <SegmentedTabs
        label="Billing period"
        value={period}
        onChange={setPeriod}
        options={[{ id: "monthly", label: "Monthly" }, { id: "yearly", label: "Yearly", badge: "−20%" }]}
      />
      <div id={"seg-panel-" + period} role="tabpanel" aria-labelledby={"seg-tab-" + period} tabIndex={0}>
        <p className="text-[34px] font-semibold tracking-tight text-fg">
          {period === "monthly" ? "€24" : "€19"} <span className="text-sm font-normal text-fg-subtle">per seat / month</span>
        </p>
      </div>
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.