bottega

primitives

Segmented Button

A segmented button group: 2–N buttons sharing a single rounded outline with hairline dividers between each segment. Hovering tints the active segment via the accent token; focus-visible produces a crisp ring from --ring. Accepts an `items` prop of plain strings or `{ label, icon }` objects — icon-only segments expose the label as an accessible `aria-label`. The container carries `role="group"` with a configurable `aria-label`. Token-driven throughout; works on both light and dark themes without any hardcoded colours.

Ported from Bodyhc (MIT)


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/segmented-button

Usage

Usagetsx
<SegmentedButton />

Props

PropTypeDefaultDescription
itemsstring[] | SegmentedButtonItem[][ { label: "Previous" }, { label: "Current" }, { label: "Next" }, ]Segment definitions — plain strings for text-only segments, or `{ label, icon }` for icon-only / icon+label segments.
groupLabelstringOptionsAccessible label for the group container.
onSegmentClick(index: number, item: SegmentedButtonItem) => voidCalled with the 0-based index and normalised item when a segment is activated.

Source

segmented-button.tsxtsx
"use client";

import React from "react";
import styles from "./segmented-button.module.css";

function cn(...parts: Array<string | false | undefined | null>) {
  return parts.filter(Boolean).join(" ");
}

export interface SegmentedButtonItem {
  /** Accessible name for this segment. Rendered as visible text when no icon is present. */
  label: string;
  /** Optional icon rendered with aria-hidden; `label` provides the accessible name. */
  icon?: React.ReactNode;
}

export interface SegmentedButtonProps
  extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * Segment definitions — plain strings for text-only segments, or
   * `{ label, icon }` for icon-only / icon+label segments.
   */
  items?: string[] | SegmentedButtonItem[];
  /** Accessible label for the group container. */
  groupLabel?: string;
  /** Called with the 0-based index and normalised item when a segment is activated. */
  onSegmentClick?: (index: number, item: SegmentedButtonItem) => void;
}

const DEFAULT_ITEMS: SegmentedButtonItem[] = [
  { label: "Previous" },
  { label: "Current" },
  { label: "Next" },
];

function normalise(items: string[] | SegmentedButtonItem[]): SegmentedButtonItem[] {
  return items.map((it) => (typeof it === "string" ? { label: it } : it));
}

export function SegmentedButton({
  items = DEFAULT_ITEMS,
  groupLabel = "Options",
  onSegmentClick,
  className,
  ...props
}: SegmentedButtonProps) {
  const segments = normalise(items);

  return (
    <div
      role="group"
      aria-label={groupLabel}
      className={cn(
        "inline-flex overflow-hidden",
        "rounded-[var(--radius)] border border-border",
        "bg-background",
        className,
      )}
      {...props}
    >
      {segments.map((seg, i) => {
        // Icon-only: icon present means we render it instead of text;
        // the label becomes the aria-label for screen-reader accessibility.
        const iconOnly = !!seg.icon;

        return (
          <button
            key={i}
            type="button"
            aria-label={iconOnly ? seg.label : undefined}
            onClick={() => onSegmentClick?.(i, seg)}
            className={cn(
              styles.segment,
              "inline-flex items-center justify-center gap-2",
              "px-4 py-2 sm:px-6",
              "font-medium text-sm",
              "text-foreground bg-transparent",
              // ponytail: duration/ease CSS vars — no hardcoded timing per contract rule 6
              "transition-colors duration-[var(--duration)] ease-[var(--ease)]",
              "hover:bg-accent",
              "cursor-pointer",
              // Focus ring: z-10 + relative so ring isn't clipped by adjacent siblings
              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
              "focus-visible:ring-offset-0 focus-visible:relative focus-visible:z-10",
            )}
          >
            {seg.icon ? (
              // Decorative — accessible name comes from aria-label on the button
              <span
                aria-hidden="true"
                className="w-5 h-5 sm:w-6 sm:h-6 flex items-center justify-center"
              >
                {seg.icon}
              </span>
            ) : (
              <span>{seg.label}</span>
            )}
          </button>
        );
      })}
    </div>
  );
}

Dependencies

  • @bottega/tokens