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
| Prop | Type | Default | Description |
|---|---|---|---|
vertical | boolean | false | Stack the buttons top-to-bottom instead of side by side. |