Base components
Button group
A row of connected buttons where one or more items can be the current one, and the current fill eases in place so the shared borders never move.
Live preview
Loading preview
A view switch with items "12 months", "30 days", "7 days" and "24 hours" at size sm, the same group with leading icons and with status dots at size md, and an icon-only text alignment group. Replay moves the current item from the first to the third.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add button-groupUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { ButtonGroup, ButtonGroupItem } from './button-group';
const PERIODS = [
{ value: '12m', label: '12 months' },
{ value: '30d', label: '30 days' },
{ value: '7d', label: '7 days' },
{ value: '24h', label: '24 hours' },
];
const ALIGN = [
{
value: 'left',
label: 'Align left',
path: 'M3.333 5h13.334M3.333 8.333h8.334M3.333 11.667h13.334M3.333 15h8.334',
},
{
value: 'center',
label: 'Align center',
path: 'M3.333 5h13.334M5.833 8.333h8.334M3.333 11.667h13.334M5.833 15h8.334',
},
{
value: 'right',
label: 'Align right',
path: 'M3.333 5h13.334M8.333 8.333h8.334M3.333 11.667h13.334M8.333 15h8.334',
},
];
const CALENDAR =
'M16.667 8.333H3.333M13.333 1.667V5M6.667 1.667V5M6.5 18.333h7c1.4 0 2.1 0 2.635-.272a2.5 2.5 0 0 0 1.093-1.093c.272-.535.272-1.235.272-2.635v-7c0-1.4 0-2.1-.272-2.635a2.5 2.5 0 0 0-1.093-1.093C15.6 3.333 14.9 3.333 13.5 3.333h-7c-1.4 0-2.1 0-2.635.272a2.5 2.5 0 0 0-1.093 1.093C2.5 5.233 2.5 5.933 2.5 7.333v7c0 1.4 0 2.1.272 2.635a2.5 2.5 0 0 0 1.093 1.093c.535.272 1.235.272 2.635.272Z';
function Icon({ path }: { path: string }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={path} strokeWidth={1.67} vectorEffect="non-scaling-stroke" />
</svg>
);
}
export default function ButtonGroupDemo({ replay = 0 }: { replay?: number }) {
const [period, setPeriod] = React.useState<string[]>(['12m']);
React.useEffect(() => {
if (replay === 0) return;
setPeriod(['12m']);
const timers = ['30d', '7d'].map((next, index) =>
window.setTimeout(() => setPeriod([next]), 400 * (index + 1)),
);
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div className="flex flex-col items-start gap-6">
<ButtonGroup aria-label="Time range" value={period} onValueChange={setPeriod}>
{PERIODS.map((item) => (
<ButtonGroupItem key={item.value} value={item.value}>
{item.label}
</ButtonGroupItem>
))}
</ButtonGroup>
<ButtonGroup aria-label="Report period" size="md" defaultValue={['30d']}>
{PERIODS.slice(0, 3).map((item) => (
<ButtonGroupItem key={item.value} value={item.value} icon={<Icon path={CALENDAR} />}>
{item.label}
</ButtonGroupItem>
))}
</ButtonGroup>
<ButtonGroup aria-label="Environment" size="md" defaultValue={['live']}>
<ButtonGroupItem value="live" dot>
Live
</ButtonGroupItem>
<ButtonGroupItem value="staging">Staging</ButtonGroupItem>
<ButtonGroupItem value="archived" disabled>
Archived
</ButtonGroupItem>
</ButtonGroup>
<ButtonGroup aria-label="Text alignment" defaultValue={['left']}>
{ALIGN.map((item) => (
<ButtonGroupItem
key={item.value}
value={item.value}
aria-label={item.label}
icon={<Icon path={item.path} />}
/>
))}
</ButtonGroup>
</div>
);
}
API
Parts: root (toggle group, draws the outer border and the rounded outer corners), item (ButtonGroupItem): leading icon or status dot, label, item divider (right edge of each item but the last).
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string[] | undefined | Controlled current items. Holds at most one value unless multiple is set. |
| defaultValue | string[] | [] | Current items when uncontrolled. |
| multiple | boolean | false | Lets more than one item be current at the same time. |
| size | "sm" | "md" | "sm" | 36 or 40px tall. |
| disabled | boolean | false | Turns off every item. Items also take their own disabled prop. |
| orientation | "horizontal" | "horizontal" | Arrow keys move along the row. Only horizontal is designed. |
| Event | Description |
|---|---|
| valueChange | The current items changed. Payload: the new value array. |
| Slot | Description |
|---|---|
| default | ButtonGroupItem children. Each item takes value, disabled, aria-label (for icon-only items) and the slots icon and dot. |
| icon | ButtonGroupItem: leading icon (20px), or the only content for an icon-only item (Figma Icon Only). |
| dot | ButtonGroupItem: status dot before the label (Figma Icon Dot). |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| BG1 | hover an item | item | background-color, color | transparent, text-secondary → bg-primary_hover, text-secondary_hover | duration-sm (200)ease-out | Swaps at once |
| BG2 | an item becomes current | item | background-color, color | transparent, text-secondary → bg-secondary, text-secondary_hover (Figma Current=True) | duration-sm (200)ease-out | Swaps at once |
| BG3 | an item stops being current | item | background-color, color | bg-secondary, text-secondary_hover → transparent, text-secondary | duration-2xs (120)ease-out | Swaps at once |
| BG4 | keyboard focus on an item | item | box-shadow | none → focus ring (2px bg-primary gap, 4px focus-ring), drawn above the neighbors | duration-2xs (120)ease-out | Shows at once |
| BG5 | disabled -> true | item content | opacity | 1 → 0.5 | duration-sm (200)ease-out | Swaps at once |
Accessibility
- Built on a toggle group: each item is a button with aria-pressed; one Tab stop for the group, and Left, Right, Home and End move between items.
- Space or Enter makes the focused item current; without multiple, it clears the previous item.
- Icon-only items need aria-label; icons and dots are aria-hidden, and a dot that shows status needs the status in the label.
- Items are 36 or 40px tall, so they meet the 24 x 24px target (WCAG 2.5.8).
- The focused item is raised with z-index so its ring is not covered by the next item.
- Reduced motion: fills swap at once.