ButtonGroup

The n-way sibling of Toggle: a row of buttons over a fixed set of options, either as a segmented control (pick one) or a filter row (pick any).

Pass an array of strings or objects with a required value and optional label, tooltip, icon, disabled, and loading fields.

Single select

mode defaults to "single", so the group is a radiogroup of role="radio" buttons: one tab stop for the whole group, arrow keys walk it (wrapping at both ends, skipping disabled options) and carry the selection with focus, Home and End jump to either end.

sort_order is opt-in — leave it null (the default) and no arrow renders. Style the arrow with sort_button_props (it sits outside the radiogroup, so host style does not reach it).

sorting by commits in descending order

svelte<script lang="ts">
  import { GitHub } from 'svelte-widgets/icons'
  import { ButtonGroup, type ButtonGroupOption } from 'svelte-widgets'

  const options: ButtonGroupOption[] = [
    {
      value: `commits`,
      label: `commits`,
      tooltip: `Total commits to the default branch`,
    },
    { value: `stars`, label: `stars`, icon: GitHub },
    { value: `title`, label: `title` },
    { value: `size`, label: `size`, disabled: true },
  ]
  let sort_by = $state(`commits`)
  let sort_order = $state<`asc` | `desc`>(`desc`)
</script>

<ButtonGroup
  {options}
  bind:value={sort_by}
  bind:sort_order
  label="Sort projects by"
  style="--btn-group-gap: 6pt"
/>

<p>
  sorting by <code>{sort_by}</code> in <code>{sort_order}</code>ending order
</p>

Multi-select

mode="multiple" swaps the semantics rather than just the bookkeeping: the container becomes a plain group of independent toggle buttons carrying aria-pressed, each its own tab stop, since there is nothing mutually exclusive left for a radio group to announce. value is an array here.

The option snippet replaces a button’s contents, on_change fires with the new selection, and every color is a --btn-group-* custom property.

12 8 17 5

filters: svelte

svelte<script lang="ts">
  import ButtonGroup from 'svelte-widgets/ButtonGroup.svelte'

  const tags = [
    { value: `svelte`, label: `Svelte` },
    { value: `kit`, label: `SvelteKit` },
    { value: `ts`, label: `TypeScript` },
    { value: `css`, label: `CSS` },
  ]
  const tag_counts: Record<string, number> = { svelte: 12, kit: 8, ts: 17, css: 5 }
  let active = $state([`svelte`])
</script>

<ButtonGroup
  options={tags}
  mode="multiple"
  bind:value={active}
  label="Filter by tag"
  style="--btn-group-btn-radius: 1em; --btn-group-btn-active-bg: cornflowerblue; --btn-group-btn-active-color: white"
>
  {#snippet option({ option: opt, selected })}
    {opt.label}
    <span style="opacity: 0.6">{selected ? `×` : `+`}</span>
  {/snippet}
  {#snippet option_suffix({ option: opt })}
    <span style="padding-right: 6pt; opacity: 0.6">{tag_counts[opt.value]}</span>
  {/snippet}
</ButtonGroup>

<p>filters: {active.length ? active.join(`, `) : `none`}</p>

option_suffix renders as a sibling instead, wrapped with the button in a .option span, as shown by the counts above. Prefer non-interactive suffixes in single-select radiogroup mode; a focusable suffix adds a non-radio tab stop. Without option_suffix, buttons remain direct .options children, preserving .options > button selectors.

Styling

Everything themable hangs off --btn-group-*: display, gap, padding, bg, border, radius and justify-content on the container, and btn-padding, btn-radius, btn-bg, btn-color, btn-border, btn-gap, btn-cursor, btn-transition, btn-hover-bg, btn-hover-color, btn-hover-transform, btn-active-bg, btn-active-color, btn-active-border-color, btn-disabled-opacity, btn-font-family, btn-font-size on the buttons. When option_suffix wraps an option, --btn-group-option-btn-padding-right (default 0.5ex) tightens the button’s right padding so the suffix sits in that gap. The component lays its buttons out in a wrapping flex row and takes no position of its own, so placing it is the call site’s job. Pass style (or any other host attribute) through ...rest on the root.

Two only pay off together: a hover lift needs btn-hover-transform and btn-transition, since neither animates alone. btn-hover-color falls back to btn-color, so setting only the resting color keeps it through hover.

Font size needs no property of its own — the buttons resolve theirs to inherit, so style="font-size: 1.2em" on the group reaches them. Weight and style are the exception: they are declared nowhere, so your own button {} rule still wins, which also puts them out of reach of inheritance, since the user-agent button rule sets them directly. Style the buttons from your own stylesheet to change either.

The root is a <div>. Pass as="span" where a div would be invalid, such as inside a heading or a paragraph.

Per-option tooltips come from each option’s tooltip field. tooltip_options forwards everything else to the tooltip attachment. Tooltips render plain text. Use Popover for formatted content or interactive controls.