v1.0

ButtonGroup

Preview

Basic

Loading…

Preview

Code

ts
import ButtonGroup from '@/components/layout/ButtonGroup';

src/components/layout/ButtonGroup.tsx

AI prompt

text
Build a button group and a segmented control (two exports, one file) in React + TypeScript + Tailwind CSS.

## ButtonGroup (default export)
Joins adjacent buttons into one control: shared borders, only the outer corners rounded.
- `<div role="group">`, `isolate inline-flex` (`flex-col` when `vertical`).
- It restyles its CHILDREN through arbitrary child selectors, so any button, link or your own Button works without knowing it is grouped:
  - horizontal: `[&>*:not(:first-child)]:-ml-px [&>*:not(:first-child)]:rounded-l-none [&>*:not(:last-child)]:rounded-r-none`
  - vertical: `[&>*:not(:first-child)]:-mt-px [&>*:not(:first-child)]:rounded-t-none [&>*:not(:last-child)]:rounded-b-none`
  - The `:not(...)` pseudo-classes are load-bearing: they add the specificity needed to beat a child's own `rounded-lg` without a class-merging utility.
- Neighbours overlap by 1px so two 1px borders read as one. A focused child is lifted (`[&>*:focus-visible]:relative [&>*:focus-visible]:z-10`) so its ring isn't painted under the next button; `isolate` keeps that z-index local.
- Props: `vertical?` (false), `aria-label` (names the group: "Text alignment", "Pagination"), plus any div attributes.

## SegmentedControl (named export)
A single-choice toggle — list/grid, day/week/month.
- Track: `inline-flex items-center gap-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 p-0.5`.
- Option: `inline-flex items-center justify-center rounded-md font-medium leading-none`, optional 14px icon before the label; `md` `px-2.5 py-1 text-xs gap-1.5`, `sm` `px-2 py-0.5 text-[11px] gap-1`. Selected: `bg-white text-indigo-700 shadow-sm`, dark `bg-slate-700 text-indigo-300`. Others: `text-slate-500 hover:text-slate-800`, dark `slate-400 → slate-100`. Disabled `opacity-40 cursor-not-allowed`. Focus ring indigo-400.
- Radio semantics, not `aria-pressed` buttons: `role="radiogroup"` (with `aria-label` — required in practice), each option `role="radio"` + `aria-checked`, so screen readers announce "2 of 3".
- Keyboard per the radio pattern: ONE Tab stop (roving tabindex on the checked option — or the first enabled option if none matches `value`); Arrow Right/Down and Left/Up move AND select, wrapping, Home/End jump to the ends, all skipping disabled options.
- Always controlled. Props: `options: { value: T; label: ReactNode; icon?: ComponentType<{ className?: string }>; disabled?: boolean; 'aria-label'?: string }[]` (per-option label for icon-only options), `value: T`, `onChange(value: T)`, `size?: 'sm' | 'md'` ('md'), `aria-label`, `className`. Generic over `T extends string`.

## Demo
- Three secondary icon buttons (AlignLeft / AlignCenter / AlignRight) grouped as "Text alignment".
- A small "‹ Prev · 1 · 2 · Next ›" pagination group.
- A vertical group of plain bordered Open / Save / Close buttons.
- A List / Grid segmented control with icons, and a small Day / Week / Month / Year (Year disabled).

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useRef } from 'react';
import { cn } from '@/lib/cn';

export interface ButtonGroupProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Stack the buttons top-to-bottom instead of side by side. */
  vertical?: boolean;
  /** Names the group for a screen reader ("Text alignment", "Pagination"). */
  'aria-label'?: string;
}

/**
 * Joins adjacent buttons into one control: shared borders, only the outer
 * corners rounded.
 *
 * It restyles its CHILDREN through `>` selectors rather than asking each
 * button for a `position` prop, so it works on the kit's `<Button>`, a plain
 * `<button>` or a link without any of them knowing they are grouped. The
 * selectors are `:not(:first-child)` / `:not(:last-child)`, and that choice is
 * load-bearing: `cn()` does not merge classes, so the group has to beat the
 * child's own `rounded-lg` on specificity — the pseudo-class adds the point a
 * bare `> *` would not have.
 *
 * Neighbours overlap by a pixel so two 1px borders read as one. A focused
 * button is lifted with `z-10` so its ring is not painted under the next
 * button; `isolate` keeps that z-index local, where it would otherwise tie
 * with a sticky table header in the 1–20 band.
 */
export default function ButtonGroup({ vertical = false, className, children, ...rest }: ButtonGroupProps) {
  return (
    <div
      role="group"
      className={cn(
        'isolate inline-flex',
        '[&>*:focus-visible]:relative [&>*:focus-visible]:z-10',
        vertical
          ? cn(
              'flex-col',
              '[&>*:not(:first-child)]:-mt-px [&>*:not(:first-child)]:rounded-t-none [&>*:not(:last-child)]:rounded-b-none',
            )
          : '[&>*:not(:first-child)]:-ml-px [&>*:not(:first-child)]:rounded-l-none [&>*:not(:last-child)]:rounded-r-none',
        className,
      )}
      {...rest}
    >
      {children}
    </div>
  );
}

export type SegmentedOption<T extends string = string> = {
  value: T;
  label: React.ReactNode;
  /** An icon component, sized by the control. */
  icon?: React.ComponentType<{ className?: string }>;
  disabled?: boolean;
  /** Accessible name, for an icon-only option. */
  'aria-label'?: string;
};

const SEG_SIZE = {
  sm: 'px-2 py-0.5 text-[11px] gap-1',
  md: 'px-2.5 py-1 text-xs gap-1.5',
} as const;

/**
 * A single-choice toggle group — list/grid, day/week/month.
 *
 * Radio semantics, not buttons with `aria-pressed`: exactly one option is
 * chosen, so it is a `radiogroup`, and the ARIA radio pattern is what screen
 * readers announce as "2 of 3". That pattern also dictates the keyboard: the
 * group is ONE Tab stop (roving tabindex on the checked option) and the arrow
 * keys move AND select, skipping disabled options — same as native radios.
 */
export function SegmentedControl<T extends string = string>({
  options,
  value,
  onChange,
  size = 'md',
  className,
  'aria-label': ariaLabel,
}: {
  options: SegmentedOption<T>[];
  value: T;
  onChange: (value: T) => void;
  size?: keyof typeof SEG_SIZE;
  className?: string;
  /** Names the choice ("View", "Range"). Required in practice — a radiogroup needs a name. */
  'aria-label'?: string;
}) {
  const refs = useRef<Record<string, HTMLButtonElement | null>>({});
  // With no option matching `value`, the first enabled one takes the Tab stop
  // so the group is still reachable.
  const tabStop = options.some((o) => o.value === value && !o.disabled)
    ? value
    : options.find((o) => !o.disabled)?.value;

  const onKey = (e: React.KeyboardEvent) => {
    const usable = options.filter((o) => !o.disabled);
    if (!usable.length) return;
    const i = usable.findIndex((o) => o.value === value);
    let next: SegmentedOption<T> | undefined;
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = usable[(i + 1) % usable.length];
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = usable[(i - 1 + usable.length) % usable.length];
    else if (e.key === 'Home') next = usable[0];
    else if (e.key === 'End') next = usable[usable.length - 1];
    if (!next) return;
    e.preventDefault();
    onChange(next.value);
    refs.current[next.value]?.focus();
  };

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabel}
      onKeyDown={onKey}
      className={cn('inline-flex items-center gap-0.5 rounded-lg bg-slate-100 p-0.5 dark:bg-slate-800', className)}
    >
      {options.map((o) => {
        const on = o.value === value;
        const Icon = o.icon;
        return (
          <button
            key={o.value}
            ref={(el) => {
              refs.current[o.value] = el;
            }}
            type="button"
            role="radio"
            aria-checked={on}
            aria-label={o['aria-label']}
            tabIndex={o.value === tabStop ? 0 : -1}
            disabled={o.disabled}
            onClick={() => onChange(o.value)}
            className={cn(
              'inline-flex items-center justify-center rounded-md font-medium leading-none transition-colors',
              'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400',
              'disabled:cursor-not-allowed disabled:opacity-40',
              SEG_SIZE[size],
              on
                ? 'bg-white text-indigo-700 shadow-sm dark:bg-slate-700 dark:text-indigo-300'
                : 'text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100',
            )}
          >
            {Icon && <Icon className="h-3.5 w-3.5" aria-hidden />}
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

Props

PropTypeDefaultDescription
verticalbooleanfalseStack the buttons top-to-bottom instead of side by side.