Skip to content
UI Kit
Accent color

Toggle button group

Pressable buttons for one or many values with aria-pressed and one tab stop. Five looks, tooltips on icons, limits for multiple choice, crossed out sizes and a toolbar that walks across several groups.

5 variantsOne of 9 components in Buttons

Pressable groups for one or many values

A toggle group joins pressable buttons for one value or for many. It uses aria-pressed with a single roving tab stop, so a group of ten buttons costs one Tab, and comes in five looks, attached, separate, ghost, round and cards.

Text format toolbar holds three groups in one toolbar after the APG pattern, styles stack, alignment takes one, lists can be cleared, and tooltips name each icon with its shortcut. View switcher joins icon segments that switch a project list between list, gallery, board and calendar. Weekdays offers round day buttons for a repeating plan, any number but at least one, with presets and a sentence that reads the choice back. Sizes uses separate buttons for a product size, sold out sizes stay in reach and read as unavailable. Vertical cards stacks map layers as cards with icon, description and a check, up to three stay on.

Set a minimum or a maximum where the choice needs it, at least one weekday, at most three layers. Keep sold out options visible and crossed out, people want to know a size exists. Give every icon only button a tooltip with its name.

Variants

Text format toolbarNew in 1.2

Three groups in one toolbar after the APG pattern. Styles stack, alignment takes one, lists can be cleared. One tab stop, arrows walk across all groups, tooltips name each icon with its shortcut.

View switcherNew in 1.2

Joined icon segments that switch a project list between list, gallery, board and calendar. Each icon names itself in a tooltip, the content below changes with it.

WeekdaysNew in 1.2

Round day buttons for a repeating plan, any number of them but at least one. Presets fill the week in one tap, the sentence below reads the choice back.

SizesNew in 1.2

Separate buttons for a product size. Sold out sizes stay in reach and read as unavailable, a small tag warns before the last pieces go. Below a joined group for the fit.

Vertical cardsNew in 1.2

Layers of a map as a vertical group of cards with icon, description and a check. Arrow up and down move, up to three stay on, the map beside follows every change.

Details

What Toggle group brings along, read from the registry and the source.

Category
Buttons
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/toggle-group.tsx
Brings along
utilskiticontooltip
npm packages
None
Languages
English and German, 4 texts of its own
Studio
Adjust in code

Props

The ToggleGroupProps interface from components/ui/toggle-group.tsx, with the comments from the code.

  • itemsrequired
    ToggleGroupItem[]
  • typeoptional
    'single' | 'multiple'

    single: one value like a radio set. multiple: any number like checkboxes.

  • valueoptional
    string[]
  • defaultValueoptional
    string[]
  • onValueChangeoptional
    (value: string[]) => void
  • labelrequired
    string

    Accessible name of the group

  • showLabeloptional
    boolean

    Show the label above the group

  • lookoptional
    ToggleGroupLook

    attachedseparateghostroundcards

  • sizeoptional
    ToggleGroupSize

    smmdlg

  • orientationoptional
    'horizontal' | 'vertical'
  • allowEmptyoptional
    boolean

    single: the chosen item can be pressed again to clear it

  • minoptional
    number

    multiple: fewest and most chosen values

  • maxoptional
    number
  • tooltipsoptional
    boolean

    Tooltips on icon-only items, on by default

  • fullWidthoptional
    boolean

    Items share the full width

Built-in texts

Toggle group writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 4 texts
atMostEnglish Up to {n} can be chosenGerman Bis zu {n} sind wählbar
atLeastEnglish At least {n} stays chosenGerman Mindestens {n} bleibt gewählt
unavailableEnglish unavailableGerman nicht verfügbar
selectedEnglish {n} chosenGerman {n} gewählt

Accessibility

Read from the source of the component.

  • Toggle group handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
  • Sets aria-describedby, aria-disabled, aria-label, aria-labelledby, aria-live, aria-orientation and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles group, separator and toolbar.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 352 lines
components/ui/toggle-group.tsx
'use client';

import { createContext, useContext, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Tooltip } from '@/components/ui/tooltip';

/*
 * Name note: toggle.tsx also exports a ToggleGroup (and ToggleGroupProps), the simple segmented one.
 * The CLI installs each component into its own file and neither imports the other, so both live
 * side by side in one project. To use both in one file, rename on import:
 *   import { ToggleGroup as RichToggleGroup } from '@/components/ui/toggle-group';
 * This one adds looks, sizes, min and max choices, unavailable items and the toolbar pattern.
 */
const TEXTS = {
  en: { atMost: 'Up to {n} can be chosen', atLeast: 'At least {n} stays chosen', unavailable: 'unavailable', selected: '{n} chosen' },
  de: { atMost: 'Bis zu {n} sind wählbar', atLeast: 'Mindestens {n} bleibt gewählt', unavailable: 'nicht verfügbar', selected: '{n} gewählt' },
};

export interface ToggleGroupItem {
  value: string;
  label: string;
  icon?: IconName;
  /** Only the icon shows, the label becomes the name and the tooltip */
  iconOnly?: boolean;
  /** A fuller name for screen readers when the visible label is short, e.g. Monday for Mo */
  srLabel?: string;
  /** A second line, for the cards look */
  description?: string;
  /** A small count or tag after the label, e.g. 24 */
  badge?: string;
  /** Keyboard shortcut shown in the tooltip, e.g. ["⌘", "B"] */
  shortcut?: string[];
  disabled?: boolean;
  /** Crossed out and not choosable but still reachable, e.g. a sold out size */
  unavailable?: boolean;
}

/**
 * attached: joined segments with shared borders. separate: single outlined buttons (sizes).
 * ghost: quiet toolbar buttons. round: circles (weekdays). cards: larger rows with icon,
 * description and a check (layers, channels).
 */
export type ToggleGroupLook = 'attached' | 'separate' | 'ghost' | 'round' | 'cards';
export type ToggleGroupSize = 'sm' | 'md' | 'lg';

export interface ToggleGroupProps {
  items: ToggleGroupItem[];
  /** single: one value like a radio set. multiple: any number like checkboxes. */
  type?: 'single' | 'multiple';
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  /** Accessible name of the group */
  label: string;
  /** Show the label above the group */
  showLabel?: boolean;
  look?: ToggleGroupLook;
  size?: ToggleGroupSize;
  orientation?: 'horizontal' | 'vertical';
  /** single: the chosen item can be pressed again to clear it */
  allowEmpty?: boolean;
  /** multiple: fewest and most chosen values */
  min?: number;
  max?: number;
  /** Tooltips on icon-only items, on by default */
  tooltips?: boolean;
  /** Items share the full width */
  fullWidth?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

/* Inside a ToggleToolbar the toolbar owns the arrow keys across all its groups */
const ToolbarContext = createContext(false);

const fill = (s: string, n: number) => s.replace('{n}', String(n));

function popIcon(button: HTMLElement) {
  const svg = button.querySelector('svg[data-tg-icon]');
  if (!svg || prefersReducedMotion() || typeof svg.animate !== 'function') return;
  svg.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.72)', offset: 0.25 }, { transform: 'scale(1.16)', offset: 0.65 }, { transform: 'scale(1)' }], { duration: 440, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}

function nudge(button: HTMLElement) {
  if (prefersReducedMotion() || typeof button.animate !== 'function') return;
  button.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(-2px)' }, { transform: 'translateX(0)' }], { duration: 300, easing: 'ease-out' });
}

/** Moves focus between the enabled items of a container with arrows, Home and End (roving tabindex) */
function rove(e: React.KeyboardEvent<HTMLElement>, container: HTMLElement | null, orientation: 'horizontal' | 'vertical' | 'both') {
  const next = orientation === 'vertical' ? ['ArrowDown'] : orientation === 'horizontal' ? ['ArrowRight'] : ['ArrowRight', 'ArrowDown'];
  const prev = orientation === 'vertical' ? ['ArrowUp'] : orientation === 'horizontal' ? ['ArrowLeft'] : ['ArrowLeft', 'ArrowUp'];
  if (![...next, ...prev, 'Home', 'End'].includes(e.key) || !container) return;
  const list = Array.from(container.querySelectorAll<HTMLElement>('[data-tg-item]:not(:disabled)'));
  const i = list.indexOf(document.activeElement as HTMLElement);
  if (i < 0) return;
  e.preventDefault();
  const rtl = getComputedStyle(container).direction === 'rtl' && orientation !== 'vertical';
  let step = next.includes(e.key) ? 1 : -1;
  if (rtl && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) step = -step;
  const to = e.key === 'Home' ? 0 : e.key === 'End' ? list.length - 1 : (i + step + list.length) % list.length;
  list.forEach((el, j) => (el.tabIndex = j === to ? 0 : -1));
  list[to].focus();
}

const SIZE: Record<ToggleGroupSize, { h: string; px: string; sq: string; text: string; icon: number; round: string }> = {
  sm: { h: 'h-11 sm:h-9', px: 'px-3', sq: 'w-11 sm:w-9', text: 'text-[13px]', icon: 16, round: 'size-11 sm:size-9 text-[13px]' },
  md: { h: 'h-11', px: 'px-3.5', sq: 'w-11', text: 'text-sm', icon: 18, // Seven days fit one row on a phone: 38 px circles whose hit area reaches 44 px
    round: "size-[38px] text-sm after:absolute after:-inset-[3px] after:content-[''] sm:size-11 sm:after:inset-0" },
  lg: { h: 'h-12', px: 'px-4', sq: 'w-12', text: 'text-[15px]', icon: 19, round: 'size-12 text-[15px]' },
};

const FOCUS = 'outline-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';

const ITEM: Record<ToggleGroupLook, string> = {
  attached: cn(
    'text-muted-foreground hover:bg-accent hover:text-foreground',
    'aria-pressed:bg-[color-mix(in_oklab,var(--primary)_14%,var(--background))] aria-pressed:text-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_18%,var(--background))] dark:aria-pressed:bg-[color-mix(in_oklab,var(--primary)_24%,var(--background))]',
  ),
  separate: cn(
    'rounded-lg border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] hover:border-[color-mix(in_oklab,var(--foreground)_40%,var(--border-strong))]',
    'aria-pressed:border-primary aria-pressed:bg-[color-mix(in_oklab,var(--primary)_8%,var(--background))] aria-pressed:shadow-[0_0_0_1px_var(--primary),var(--shadow-sm)]',
  ),
  ghost: 'rounded-md text-muted-foreground hover:bg-accent hover:text-foreground aria-pressed:bg-accent aria-pressed:text-foreground',
  round: cn(
    'rounded-full border border-border-strong bg-background font-medium text-muted-foreground hover:border-[color-mix(in_oklab,var(--foreground)_40%,var(--border-strong))] hover:text-foreground',
    'aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--foreground))]',
  ),
  cards: cn(
    'rounded-xl border border-border bg-card text-left text-foreground shadow-[var(--shadow-sm)] hover:border-border-strong',
    'aria-pressed:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] aria-pressed:bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]',
  ),
};

/**
 * A group of pressable buttons for one or many values, each with aria-pressed and one tab stop for the
 * whole group (roving tabindex). Five looks, horizontal or vertical, tooltips on icon-only items, limits
 * for multiple choice, crossed out unavailable items. Several groups form one toolbar in ToggleToolbar.
 *   <ToggleGroup label="Days" type="multiple" look="round" min={1} items={[{ value: 'mon', label: 'Mo' }, …]} />
 */
export function ToggleGroup({
  items,
  type = 'single',
  value: valueProp,
  defaultValue,
  onValueChange,
  label,
  showLabel,
  look = 'attached',
  size = 'md',
  orientation = 'horizontal',
  allowEmpty,
  min = 0,
  max,
  tooltips = true,
  fullWidth,
  labels,
  className,
}: ToggleGroupProps) {
  const t = useTexts(TEXTS, labels);
  const inToolbar = useContext(ToolbarContext);
  const id = useId();
  const ref = useRef<HTMLDivElement>(null);
  const multiple = type === 'multiple';
  const [inner, setInner] = useState<string[]>(defaultValue ?? (multiple || allowEmpty ? [] : items[0] ? [items[0].value] : []));
  const value = valueProp ?? inner;
  const [notice, setNotice] = useState('');
  const vertical = orientation === 'vertical';
  const s = SIZE[size];

  // The tab stop: the first chosen item, else the first that can take focus
  const usable = items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0);
  const firstOn = items.findIndex((it) => !it.disabled && value.includes(it.value));
  const [stop, setStop] = useState(firstOn >= 0 ? firstOn : usable[0] ?? 0);

  const press = (it: ToggleGroupItem, el: HTMLElement) => {
    if (it.disabled || it.unavailable) return nudge(el);
    const on = value.includes(it.value);
    let next: string[];
    if (multiple) {
      if (on && value.length <= min) {
        setNotice(fill(t.atLeast, min));
        return nudge(el);
      }
      if (!on && max !== undefined && value.length >= max) {
        setNotice(fill(t.atMost, max));
        return nudge(el);
      }
      next = on ? value.filter((v) => v !== it.value) : items.filter((x) => x.value === it.value || value.includes(x.value)).map((x) => x.value);
    } else {
      if (on && !allowEmpty) return;
      next = on ? [] : [it.value];
    }
    setNotice('');
    if (!on) popIcon(el);
    if (valueProp === undefined) setInner(next);
    onValueChange?.(next);
  };

  useEffect(() => {
    if (!notice) return;
    const timer = window.setTimeout(() => setNotice(''), 2600);
    return () => window.clearTimeout(timer);
  }, [notice]);

  const attached = look === 'attached';
  const cards = look === 'cards';

  const button = (it: ToggleGroupItem, i: number) => {
    const on = value.includes(it.value);
    const iconOnly = Boolean(it.iconOnly && it.icon);
    const el = (
      <button
        key={it.value}
        type="button"
        data-tg-item=""
        aria-pressed={on}
        aria-label={iconOnly ? it.label : it.srLabel}
        aria-disabled={it.unavailable || undefined}
        aria-describedby={it.unavailable ? `${id}-na` : undefined}
        disabled={it.disabled}
        tabIndex={inToolbar ? undefined : i === stop ? 0 : -1}
        onFocus={() => !inToolbar && setStop(i)}
        onClick={(e) => press(it, e.currentTarget)}
        className={cn(
          'group/tg relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap font-medium',
          'transition-[background-color,color,border-color,box-shadow,scale] duration-200 ease-[var(--ease-kit)] active:scale-[0.96] active:duration-100 motion-reduce:active:scale-100',
          'disabled:cursor-not-allowed disabled:opacity-45 aria-disabled:cursor-not-allowed',
          FOCUS,
          ITEM[look],
          look === 'round' ? s.round : cards ? 'min-h-14 w-full justify-start gap-3 px-3.5 py-3 text-sm' : cn(s.h, s.text, iconOnly ? cn(s.sq, 'px-0') : s.px),
          fullWidth && !cards && 'flex-1',
          attached && (vertical ? 'w-full justify-start' : ''),
          it.unavailable && 'text-subtle-foreground hover:text-subtle-foreground',
        )}
      >
        {it.icon && (
          <span className={cn(cards && 'grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground transition-colors duration-200 group-aria-pressed/tg:bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] group-aria-pressed/tg:text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
            <Icon data-tg-icon="" name={it.icon} size={cards ? 18 : s.icon} strokeWidth={on ? 1.9 : 1.6} />
          </span>
        )}
        {!iconOnly &&
          (cards ? (
            <span className="min-w-0 flex-1">
              <span className="block truncate font-medium text-foreground">{it.label}</span>
              {it.description && <span className="mt-0.5 block whitespace-normal text-[13px] font-normal leading-snug text-muted-foreground">{it.description}</span>}
            </span>
          ) : (
            <span className={cn(it.unavailable && 'line-through decoration-[1.5px]')}>{it.label}</span>
          ))}
        {it.badge && !cards && (
          <span className={cn('rounded-[5px] px-1.5 py-px text-[11px] font-medium tabular-nums', look === 'round' ? 'hidden' : 'bg-muted text-muted-foreground group-aria-pressed/tg:bg-[color-mix(in_oklab,var(--primary)_16%,var(--background))] group-aria-pressed/tg:text-foreground')}>
            {it.badge}
          </span>
        )}
        {cards && (
          <span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-full border border-border-strong transition-colors duration-200 group-aria-pressed/tg:border-primary group-aria-pressed/tg:bg-primary group-aria-pressed/tg:text-primary-foreground">
            <Icon name="check" size={13} strokeWidth={2.4} className="scale-50 opacity-0 transition-[scale,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-aria-pressed/tg:scale-100 group-aria-pressed/tg:opacity-100 motion-reduce:transition-none" />
          </span>
        )}
        {it.unavailable && <span className="sr-only">, {t.unavailable}</span>}
      </button>
    );
    return iconOnly && tooltips ? (
      <Tooltip key={it.value} content={it.label} keys={it.shortcut} side={vertical ? 'right' : 'top'}>
        {el}
      </Tooltip>
    ) : (
      el
    );
  };

  return (
    <div className={cn('grid min-w-0 gap-2.5', fullWidth && 'w-full', className)}>
      {showLabel && (
        <span id={`${id}-l`} className="text-sm font-medium text-foreground">
          {label}
        </span>
      )}
      <div
        ref={ref}
        role="group"
        aria-label={showLabel ? undefined : label}
        aria-labelledby={showLabel ? `${id}-l` : undefined}
        onKeyDown={inToolbar ? undefined : (e) => rove(e, ref.current, vertical ? 'vertical' : 'horizontal')}
        className={cn(
          'relative min-w-0',
          cards ? 'grid grid-cols-[minmax(0,1fr)] gap-2' : 'flex',
          !cards && (vertical ? 'flex-col items-stretch' : 'flex-wrap items-center'),
          fullWidth || cards ? 'w-full' : 'w-fit max-w-full',
          attached &&
            cn(
              'flex-nowrap overflow-hidden rounded-lg border border-border-strong bg-background shadow-[var(--shadow-sm)]',
              // Hairlines between the segments as shadows on the items, the frame clips the first one
              vertical ? '[&_[data-tg-item]]:shadow-[0_-1px_0_var(--border)]' : '[&_[data-tg-item]]:shadow-[-1px_0_0_var(--border)]',
              '[&>*]:rounded-none',
            ),
          look === 'separate' && 'gap-2',
          look === 'ghost' && 'gap-0.5',
          look === 'round' && 'gap-0.5 sm:gap-2',
        )}
      >
        {items.map(button)}
      </div>
      <span id={`${id}-na`} hidden>
        {t.unavailable}
      </span>
      <p aria-live="polite" className={cn('min-h-0 text-[13px] text-muted-foreground transition-opacity duration-300', notice ? 'opacity-100' : 'sr-only')}>
        {notice}
      </p>
    </div>
  );
}

/**
 * A toolbar after the APG pattern that holds several groups: one tab stop for all of them,
 * arrow keys walk across every item, Home and End jump to the ends.
 *   <ToggleToolbar label="Formatting"><ToggleGroup … /><ToolbarSeparator /><ToggleGroup … /></ToggleToolbar>
 */
export function ToggleToolbar({ label, orientation = 'horizontal', className, children }: { label: string; orientation?: 'horizontal' | 'vertical'; className?: string; children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  // One tab stop: the first pressed item, else the first item. Set after mount, so without JavaScript every button stays reachable.
  useEffect(() => {
    const list = Array.from(ref.current?.querySelectorAll<HTMLElement>('[data-tg-item]:not(:disabled)') ?? []);
    const start = Math.max(0, list.findIndex((el) => el.getAttribute('aria-pressed') === 'true'));
    list.forEach((el, i) => (el.tabIndex = i === start ? 0 : -1));
  }, []);
  return (
    <ToolbarContext.Provider value>
      <div
        ref={ref}
        role="toolbar"
        aria-label={label}
        aria-orientation={orientation}
        onKeyDown={(e) => rove(e, ref.current, orientation)}
        onFocus={(e) => {
          const list = Array.from(ref.current?.querySelectorAll<HTMLElement>('[data-tg-item]') ?? []);
          if (list.includes(e.target as HTMLElement)) list.forEach((el) => (el.tabIndex = el === e.target ? 0 : -1));
        }}
        className={cn('flex min-w-0 items-center gap-1', orientation === 'vertical' ? 'flex-col' : 'flex-wrap', className)}
      >
        {children}
      </div>
    </ToolbarContext.Provider>
  );
}

/** A thin line between the groups of a toolbar */
export function ToolbarSeparator({ orientation = 'vertical', className }: { orientation?: 'horizontal' | 'vertical'; className?: string }) {
  return <span role="separator" aria-orientation={orientation} className={cn('shrink-0 bg-border', orientation === 'vertical' ? 'mx-1 h-6 w-px' : 'my-1 h-px w-6', className)} />;
}

Frequent questions

How do I install Toggle group?

With aiw add @ai-website/toggle-group. The command puts components/ui/toggle-group.tsx into your project, the code is yours from then on. It brings along icon and tooltip. It needs no further npm packages.

Which variants does Toggle group have?

5 variants, Text format toolbar, View switcher, Weekdays, Sizes and Vertical cards.

Can I translate the texts of Toggle group?

Yes. Toggle group carries 4 texts of its own in English and German, such as “unavailable”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Toggle group?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Toggle group with aiw_components and install it with aiw_add @ai-website/toggle-group.

Often searched

Jump to

Copied to the clipboard