Lunia DS

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.

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-group

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
valuestring[]undefinedControlled current items. Holds at most one value unless multiple is set.
defaultValuestring[][]Current items when uncontrolled.
multiplebooleanfalseLets more than one item be current at the same time.
size"sm" | "md""sm"36 or 40px tall.
disabledbooleanfalseTurns off every item. Items also take their own disabled prop.
orientation"horizontal""horizontal"Arrow keys move along the row. Only horizontal is designed.
EventDescription
valueChangeThe current items changed. Payload: the new value array.
SlotDescription
defaultButtonGroupItem children. Each item takes value, disabled, aria-label (for icon-only items) and the slots icon and dot.
iconButtonGroupItem: leading icon (20px), or the only content for an icon-only item (Figma Icon Only).
dotButtonGroupItem: 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
BG1hover an itemitembackground-color, colortransparent, text-secondary → bg-primary_hover, text-secondary_hoverduration-sm (200)ease-outSwaps at once
BG2an item becomes currentitembackground-color, colortransparent, text-secondary → bg-secondary, text-secondary_hover (Figma Current=True)duration-sm (200)ease-outSwaps at once
BG3an item stops being currentitembackground-color, colorbg-secondary, text-secondary_hover → transparent, text-secondaryduration-2xs (120)ease-outSwaps at once
BG4keyboard focus on an itemitembox-shadownone → focus ring (2px bg-primary gap, 4px focus-ring), drawn above the neighborsduration-2xs (120)ease-outShows at once
BG5disabled -> trueitem contentopacity1 → 0.5duration-sm (200)ease-outSwaps 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.