Button group
Buttons joined into one control, as a row, a stack or a split button with a menu.
One of 6 components in Buttons
Details
What Button group brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/button-group.tsx
- Brings along
- utilskitbuttondropdown-menuicon
- npm packages
- None
- Languages
- English and German, 1 text of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ButtonGroupProps interface from components/ui/button-group.tsx, with the comments from the code.
orientationoptional'horizontal' | 'vertical'horizontal is the default
sizeoptionalButtonSizeThe size of the buttons inside, sets the outer radius
labeloptionalstringAccessible name of the group
childrenrequiredReact.ReactNode
Built-in texts
Button group writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 1 texts
| Key | English | German |
|---|---|---|
| more | English More options | German Weitere Optionen |
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role group.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 142 lines
'use client';
import { useRef } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Button, type ButtonSize, type ButtonVariant } from '@/components/ui/button';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { Icon } from '@/components/ui/icon';
const TEXTS = { en: { more: 'More options' }, de: { more: 'Weitere Optionen' } };
const RADIUS: Record<ButtonSize, { h: string; v: string }> = {
sm: { h: '[&>*:first-child]:rounded-s-md [&>*:last-child]:rounded-e-md', v: '[&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md' },
md: { h: '[&>*:first-child]:rounded-s-lg [&>*:last-child]:rounded-e-lg', v: '[&>*:first-child]:rounded-t-lg [&>*:last-child]:rounded-b-lg' },
lg: { h: '[&>*:first-child]:rounded-s-lg [&>*:last-child]:rounded-e-lg', v: '[&>*:first-child]:rounded-t-lg [&>*:last-child]:rounded-b-lg' },
};
export interface ButtonGroupProps {
/** horizontal is the default */
orientation?: 'horizontal' | 'vertical';
/** The size of the buttons inside, sets the outer radius */
size?: ButtonSize;
/** Accessible name of the group */
label?: string;
className?: string;
children: React.ReactNode;
}
/**
* Buttons joined into one control. Borders overlap by one pixel, so no line is ever doubled.
* Pass Buttons (or IconButtons, DropdownMenus) as children.
* A thin accent line glides to the button under the mouse or with keyboard focus.
*/
export function ButtonGroup({ orientation = 'horizontal', size = 'md', label, className, children }: ButtonGroupProps) {
const h = orientation === 'horizontal';
const ref = useRef<HTMLDivElement>(null);
// The line is the group's own ::after, placed with CSS variables, so it never counts as a child
const place = (target: EventTarget | null) => {
const root = ref.current;
if (!root || prefersReducedMotion()) return;
const child = Array.from(root.children).find((c) => c.contains(target as Node)) as HTMLElement | undefined;
if (!child) return;
const st = root.style;
if (h) {
st.setProperty('--line-x', `${child.offsetLeft + 10}px`);
st.setProperty('--line-y', `${child.offsetTop + child.offsetHeight - 4}px`);
st.setProperty('--line-w', `${Math.max(8, child.offsetWidth - 20)}px`);
st.setProperty('--line-h', '2px');
} else {
st.setProperty('--line-x', `${child.offsetLeft + 4}px`);
st.setProperty('--line-y', `${child.offsetTop + 10}px`);
st.setProperty('--line-w', '2px');
st.setProperty('--line-h', `${Math.max(8, child.offsetHeight - 20)}px`);
}
if (root.dataset.line) return;
root.dataset.line = 'on';
// First placement without travel, the line only fades in; from then on it glides
requestAnimationFrame(() => requestAnimationFrame(() => ref.current?.dataset.line && (ref.current.dataset.glide = 'on')));
};
const hide = () => {
const root = ref.current;
if (!root || root.contains(document.activeElement)) return;
delete root.dataset.line;
delete root.dataset.glide;
};
return (
<div
ref={ref}
role="group"
aria-label={label}
onPointerOver={(e) => e.pointerType === 'mouse' && place(e.target)}
onPointerLeave={hide}
onFocus={(e) => place(e.target)}
onBlur={(e) => {
if (!ref.current?.contains(e.relatedTarget as Node)) {
const root = ref.current;
if (root) {
delete root.dataset.line;
delete root.dataset.glide;
}
}
}}
className={cn(
'relative isolate inline-flex',
'after:pointer-events-none after:absolute after:left-0 after:top-0 after:z-30 after:h-[var(--line-h,2px)] after:w-[var(--line-w,0px)] after:rounded-full after:bg-primary after:opacity-0 after:content-[""] after:[translate:var(--line-x,0px)_var(--line-y,0px)] after:transition-opacity after:duration-200',
'data-line:after:opacity-100 data-glide:after:transition-[opacity,translate,width,height] data-glide:after:duration-300 data-glide:after:ease-[var(--ease-kit)]',
h ? 'flex-row' : 'flex-col',
'[&>*]:rounded-none [&>*:hover]:z-10 [&>*:focus-visible]:z-20 [&>*]:relative',
'[&>span>*]:rounded-none [&>span:first-child>*]:rounded-[inherit] [&>span:last-child>*]:rounded-[inherit]',
h ? '[&>*:not(:first-child)]:-ms-px' : '[&>*:not(:first-child)]:-mt-px',
h ? RADIUS[size].h : RADIUS[size].v,
className,
)}
>
{children}
</div>
);
}
export interface SplitButtonProps {
label: React.ReactNode;
onClick?: () => void;
href?: string;
/** The other actions, behind the chevron */
items: MenuItem[];
variant?: Extract<ButtonVariant, 'primary' | 'outline' | 'secondary'>;
size?: ButtonSize;
iconLeft?: React.ComponentProps<typeof Button>['iconLeft'];
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
/** The main action plus a menu of related ones, e.g. Publish / Schedule / Save as draft. */
export function SplitButton({ label, onClick, href, items, variant = 'primary', size = 'md', iconLeft, labels, className }: SplitButtonProps) {
const t = useTexts(TEXTS, labels);
const divider =
variant === 'primary'
? 'before:absolute before:inset-y-2.5 before:start-0 before:w-px before:bg-[color-mix(in_oklab,var(--primary-foreground)_24%,transparent)]'
: '';
const chevronWidth = size === 'sm' ? 'w-9 px-0!' : size === 'md' ? 'w-11 px-0!' : 'w-12 px-0!';
return (
<ButtonGroup size={size} className={className}>
{href ? (
<Button href={href} variant={variant} size={size} iconLeft={iconLeft}>
{label}
</Button>
) : (
<Button onClick={onClick} variant={variant} size={size} iconLeft={iconLeft}>
{label}
</Button>
)}
<DropdownMenu
align="end"
items={items}
trigger={
<Button variant={variant} size={size} aria-label={t.more} className={cn('relative rounded-s-none rounded-e-[inherit]', chevronWidth, divider, variant === 'primary' && 'ms-0')}>
<Icon name="chevron-down" size={size === 'sm' ? 15 : 17} className="transition-[rotate] duration-300 ease-[var(--ease-kit)] group-aria-expanded/button:rotate-180 motion-reduce:transition-none" />
</Button>
}
/>
</ButtonGroup>
);
}Frequent questions
How do I install Button group?
With aiw add @ai-website/button-group. The command puts components/ui/button-group.tsx into your project, the code is yours from then on. It brings along button, dropdown-menu and icon. It needs no further npm packages.
Which variants does Button group have?
3 variants, Attached, Split button and Stacked.
Can I translate the texts of Button group?
Yes. Button group carries 1 texts of its own in English and German, such as “More options”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Button group?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Button group with aiw_components and install it with aiw_add @ai-website/button-group. Name the variant in your prompt, for example variant="outline", and the assistant writes your content straight into the props.