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-buttonUsage
Usagetsx
<SegmentedButton />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | string[] | SegmentedButtonItem[] | [ { label: "Previous" }, { label: "Current" }, { label: "Next" }, ] | Segment definitions — plain strings for text-only segments, or `{ label, icon }` for icon-only / icon+label segments. |
| groupLabel | string | Options | Accessible label for the group container. |
| onSegmentClick | (index: number, item: SegmentedButtonItem) => void | — | Called 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