# Button group

> A labelled group of related buttons, spaced (wrapping, placed by align) or attached as one strip with single hairline seams and outer-only corners, in a row or a column. Your buttons go in as children.

- ID: `cmp_button_group_01`
- Slug: `button-group-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-01T07:35:15Z
- Updated: 2026-10-01
- Available versions: `1.0.0`
- Kind: control
- Primary category: `buttons`
- Detail page: https://pagesugar.com/components/button-group-01
- Preview: https://pagesugar.com/preview/button-group-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-978dfa40838975b939cf4d52c38053c4efe43833dad74dd51909394ae6796aa7` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/button-group-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Wrap related buttons and give the group a label. Spaced, it sets one gap and places the buttons with align; attached, it joins them into one strip and owns the seams between them. It does not manage selection (use a toggle group for Day, Week, Month), add arrow-key navigation or a toolbar role, or style the buttons beyond their seams, inner corners and press.

Required props: `children`

```svelte
<script lang="ts">
	import ButtonGroup from '$lib/components/button-group-01/ButtonGroup.svelte';
</script>

<ButtonGroup label="Timeline navigation" attached>
	<button type="button" class="min-h-9 rounded-lg bg-white px-4 text-sm font-medium">Previous</button>
	<button type="button" class="min-h-9 rounded-lg bg-white px-4 text-sm font-medium">Today</button>
	<button type="button" class="min-h-9 rounded-lg bg-white px-4 text-sm font-medium">Next</button>
</ButtonGroup>

<ButtonGroup label="Dialog actions" align="end" class="mt-6">
	<button type="button" class="min-h-9 rounded-lg px-4 text-sm font-medium">Cancel</button>
	<button type="submit" class="min-h-9 rounded-lg bg-zinc-900 px-4 text-sm font-medium text-white">Save changes</button>
</ButtonGroup>
```

Limitations:

- The group fills the width it is given (the rest of a flex row, or its whole line) so align can place the buttons; set a width or wrap it in your own element if it must hug its buttons.
- An attached row with narrow='stack' measures its own width with a container query, so it needs a width from its parent. In a grid track sized to content, or an inline-block, it collapses; give that track a width or use narrow='scroll'.
- Stacking happens below a fixed 16rem, not when the buttons stop fitting. A strip of many or long segments overflows wider columns too; use narrow='scroll' or a vertical group for those.
- Attached mode replaces each segment's box-shadow with the group's hairline edge, keeping a Tailwind ring (focus-visible:ring-\*) in front of it. A button that draws its edge with border instead gets both; remove the border from buttons you put in an attached group.
- Attached segments need an opaque fill (a secondary or filled button). On a transparent ghost button both overlapping edges show, so the seams read darker than the outer edge.
- Attached segments tint when pressed instead of scaling, because a shrinking segment would open a gap in the strip.
- Selected or current state is not drawn. Use a toggle group (aria-pressed) for a choice such as Day, Week, Month.
- The first and last segment are found with :nth-child(of S), which needs Chrome 111, Firefox 113 or Safari 9 and later; older browsers keep every corner round.

## Usage guide

### Button group

A wrapper for buttons that belong together. It names them as a group for assistive technology
and lays them out one of two ways:

| Mode     | Looks like                                            | Use it for                                    |
| -------- | ----------------------------------------------------- | --------------------------------------------- |
| spaced   | separate buttons, one 8 px gap, wrapping when narrow  | dialog and form footers, card actions         |
| attached | one strip, single hairline seams, round outer corners | Previous / Today / Next, toolbars, small sets |

```svelte
<ButtonGroup label="Dialog actions" align="end">
	<ActionButton variant="ghost">Cancel</ActionButton>
	<ActionButton>Save changes</ActionButton>
</ButtonGroup>

<ButtonGroup label="Timeline navigation" attached>
	<ActionButton variant="secondary">Previous</ActionButton>
	<ActionButton variant="secondary">Today</ActionButton>
	<ActionButton variant="secondary">Next</ActionButton>
</ButtonGroup>
```

#### What the group does to your buttons

Spaced, nothing: it only sets the gap and where the row sits.

Attached, it styles each direct child (a visually hidden `aria-live` sibling, such as the one
ActionButton renders, is skipped):

- the edge becomes a one-pixel inset hairline in `--button-group-hairline`, and each segment
  overlaps the previous one by a pixel, so a seam is one line, not two;
- inner corners go square, outer corners keep the button's own radius (logical properties, so
  this holds in a right-to-left page);
- a hovered or pressed segment is raised one level, a focused one two, so its focus ring is drawn
  over its neighbours;
- a press tints the segment instead of scaling it.

These rules sit outside any cascade layer on purpose: they have to beat the radius, shadow and
scale utilities on your buttons.

#### Width and placement

The group fills the width it is given, and `align` places the buttons in it: `end` for a footer,
`between` to spread a pair to both edges. With `between`, an attached strip or a vertical group
spans the full width and its segments share it.

#### Narrow columns

A spaced row wraps (turn `wrap` off to let the labels wrap inside the buttons instead). An
attached row cannot wrap without breaking its seams, so below a 16rem column it becomes a column
of full-width segments. For a long toolbar, set `narrow="scroll"`: the row keeps its shape and
scrolls sideways, with four pixels of room for focus rings.

Stacking uses a container query on the group, so the group needs a width from its parent. Inside a
grid track sized to its content, give the track a width or use `narrow="scroll"`.

#### Not included

No selection (use a toggle group with `aria-pressed` for Day, Week, Month), no arrow-key navigation
and no `role="toolbar"`, no overflow menu.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `children` | `Snippet` | yes |  | The buttons, in reading order. Each direct child is one button (or link). |
| `label` | `string` | no |  | Accessible name, written to aria-label, e.g. 'Timeline navigation'. Required unless you pass aria-labelledby pointing at a visible heading. |
| `attached` | `boolean` | no | `false` | Join the buttons into one strip: single hairline seams, round outer corners only, the hovered or focused segment raised. |
| `orientation` | 'horizontal' \| 'vertical' | no | `'horizontal'` | A row or a column. Buttons in a column share the width of the widest one. |
| `wrap` | `boolean` | no | `true` | Let a spaced row wrap onto further lines. Off, buttons shrink and wrap their labels instead. Ignored when attached. |
| `align` | 'start' \| 'center' \| 'end' \| 'between' | no | `'start'` | Where the buttons sit in the width the group is given. 'end' for a dialog footer; 'between' spreads a spaced row to both edges and makes an attached strip or a column span the full width. |
| `narrow` | 'stack' \| 'scroll' | no | `'stack'` | What an attached row does in a column narrower than 16rem: become a column of full-width segments, or keep its row and scroll sideways. |
| `class` | `ClassValue` | no |  | Extra classes on the group element, for margins or placement. |

## Customization

Pick spaced or attached, row or column, and where the buttons sit through props. The buttons keep their own colours; the group's one token is the hairline it draws for attached seams and edges.

- Footers: align='end' puts a dialog's or form's actions at the end edge with the primary last, which is the end edge in a right-to-left page too. align='between' splits a Back and Continue pair.
- Attached strips: put buttons of one priority in them, usually secondary. A strip is one object, so a filled primary inside one reads as selected rather than as the main action.
- Seams: --button-group-hairline colours every attached edge and seam. The default is rgb(0 0 0 / 0.12), the same as a secondary button's edge, so a strip matches the buttons beside it.
- Worked retone for a #09090b band: --button-group-hairline: rgb(255 255 255 / 0.12), with your buttons on a #18181b fill. For filled segments on a light page, rgb(255 255 255 / 0.2) keeps the seams visible.
- Corners: segments keep their own radius on the outside, so the strip takes the radius of the buttons you give it.
- Narrow columns: an attached row stacks below 16rem. For a long toolbar of icon buttons, narrow='scroll' keeps the row and scrolls it, with room left for focus rings.
- Spacing: the spaced gap is 8 px across and down. Change gap-2 on the inner element to change it.
- Width: the group fills its line, and align places the buttons in it. To make it hug its buttons, wrap it in an element sized to fit.

| Token | Public CSS variable |
| --- | --- |
| `hairline` | `--button-group-hairline` |

## Accessibility

- Renders role="group" named by label (aria-label) or by an aria-labelledby you pass. Name every group that has no visible heading; screen readers announce an unnamed group as just "group".
- No toolbar role and no arrow-key handling: every button keeps its own tab stop, in DOM order, which is also the visual order in every layout. If you add roving focus yourself, change the role to toolbar.
- A focused segment is raised above its neighbours, so its two-pixel focus ring is drawn in full, never under the next button. A scrolling strip keeps four pixels of room for the ring, and once hydrated it scrolls a focused segment clear of the edge (browsers leave an element that is already in view where it is, even when its ring hangs past the edge).
- The group draws no focus ring of its own and sets no colours on your buttons; their labels, contrast and focus rings are yours.
- Selection is not conveyed. Use aria-pressed in a toggle group for a choice between options.
- Icon-only buttons in a strip need aria-label each. On touch screens, give your buttons a 44 px minimum height.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `ButtonGroup.svelte`
- Suggested directory: `src/lib/components/button-group-01`
- Files: 1
- Artifact digest: `sha256-978dfa40838975b939cf4d52c38053c4efe43833dad74dd51909394ae6796aa7`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `ButtonGroup.svelte`

Role: entry · 9574 bytes · SHA-256 `00a7eba78b302620f1ec08838e94b9d117917bfb8839a04e74d2d77e65cd91ef`

```svelte
<!--
	A labelled group of related buttons. Spaced, it lays them out with one 8 px gap, wrapping on a
	narrow screen and placed by align (end for a dialog footer). Attached, it joins them into one
	strip: the seams collapse to a single hairline, only the outer corners stay round, and a
	hovered, pressed or focused segment rises above its neighbours so its edge and focus ring are
	never covered. The buttons are yours; the group only arranges them and owns the seams.
-->
<script lang="ts" module>
	export type ButtonGroupOrientation = 'horizontal' | 'vertical';
	export type ButtonGroupAlign = 'start' | 'center' | 'end' | 'between';
	export type ButtonGroupNarrow = 'stack' | 'scroll';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { ClassValue, HTMLAttributes } from 'svelte/elements';

	interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'children' | 'role'> {
		/** Accessible name, e.g. 'Timeline navigation'. Required unless aria-labelledby names a visible heading. */
		label?: string;
		/** Join the buttons into one strip with shared seams and outer-only corners. */
		attached?: boolean;
		/** Lay the buttons out in a row or a column. */
		orientation?: ButtonGroupOrientation;
		/** Let a spaced row wrap onto further lines when it runs out of room. Ignored when attached. */
		wrap?: boolean;
		/** Where the buttons sit in the width the group is given. `between` spans it edge to edge. */
		align?: ButtonGroupAlign;
		/** What an attached row does in a column narrower than 16rem: stack into a column, or scroll sideways. */
		narrow?: ButtonGroupNarrow;
		/** Extra classes on the group element, for margins or placement. */
		class?: ClassValue;
		/** The buttons, in reading order. */
		children: Snippet;
	}

	let {
		label,
		attached = false,
		orientation = 'horizontal',
		wrap = true,
		align = 'start',
		narrow = 'stack',
		class: className,
		children,
		...rest
	}: Props = $props();

	const vertical = $derived(orientation === 'vertical');
	/** Only an attached row can stack, so only it needs its column measured. */
	const stacks = $derived(attached && !vertical && narrow === 'stack');
	const scrolls = $derived(attached && !vertical && narrow === 'scroll');

	/*
	 * A browser does not scroll a focused element that is already in view, even when its focus
	 * ring hangs past the strip's edge. Scrolling it to the nearest edge honours scroll-padding,
	 * which leaves the ring its four pixels. An enhancement: without script the strip still
	 * scrolls and every segment is still reachable.
	 */
	function keepRingInView(event: FocusEvent) {
		const target = event.target;
		if (target instanceof HTMLElement)
			target.scrollIntoView({ block: 'nearest', inline: 'nearest' });
	}

	/* Logical by nature: start is the right edge in a right-to-left page. */
	const PLACE = {
		start: 'justify-start',
		center: 'justify-center',
		end: 'justify-end',
		between: 'justify-between'
	} as const;
</script>

<!--
	The outer element is the group and fills the width it is given (the rest of a flex row), so
	align can place the buttons in it; an attached row also measures it to know when to stack.
	The inner element lays the buttons out. DOM order is visual order in every layout: no
	row-reverse, no order utilities, so the tab sequence reads the way the row does.
-->
<div
	{...rest}
	role="group"
	aria-label={label}
	class={[
		'button-group flex min-w-0 flex-auto',
		stacks && '@container basis-64',
		PLACE[align],
		className
	]}
>
	<div
		class={[
			'button-group__strip flex max-w-full *:max-w-full',
			vertical && 'flex-col',
			attached ? 'isolate items-stretch' : 'gap-2',
			!attached && !vertical && 'w-full items-center *:min-w-0',
			!attached && !vertical && wrap && 'flex-wrap',
			!attached && !vertical && PLACE[align],
			(vertical || attached) && align === 'between' && 'w-full',
			attached && !vertical && align === 'between' && '*:flex-1',
			scrolls && '-m-1 scroll-px-1 [scrollbar-width:thin] overflow-x-auto p-1'
		]}
		data-attached={attached ? '' : undefined}
		data-orientation={orientation}
		data-stacks={stacks ? '' : undefined}
		data-align={align}
		onfocusin={scrolls ? keepRingInView : undefined}
	>
		{@render children()}
	</div>
</div>

<style>
	/*
	 * Public token: set --button-group-hairline on the group or any ancestor to retone the seams.
	 * The private names carry the component's name because the segments read them on themselves,
	 * and a button such as ActionButton declares a --_hairline of its own.
	 */
	.button-group {
		--_group-hairline: var(--button-group-hairline, rgb(0 0 0 / 0.12));
		/* Pressed tint, painted inside the edge so it works on any fill. */
		--_group-press-tint: rgb(0 0 0 / 0.08);
	}

	/*
	 * A spaced row (never a column, where the auto margin would stop the last button stretching)
	 * set to between keeps its last button on the end edge when the row wraps and
	 * that button lands on a line of its own; in one line, justify-between already puts it there.
	 * A sibling live region is skipped, as below.
	 */
	.button-group__strip[data-align='between'][data-orientation='horizontal']:not([data-attached])
		> :global(:nth-last-child(1 of :not([aria-live]))) {
		margin-inline-start: auto;
	}

	/*
	 * Attached segments. These style the consumer's buttons, which this component does not render,
	 * and they have to beat those buttons' own radius, shadow and scale utilities, so they stay
	 * unlayered rather than sitting in @layer components. A visually hidden live region beside a
	 * button (ActionButton renders one) is not a segment: `of :not([aria-live])` skips it when
	 * finding the first and last.
	 *
	 * Each segment draws its whole edge as an inset hairline and overlaps the previous one by a
	 * pixel, so two edges collapse into one seam. The group owns that edge, which is what lets a
	 * filled segment show its seams too; it replaces the button's decorative shadow but keeps a
	 * Tailwind focus ring, since ring utilities are box-shadows (read here, on the segment itself).
	 * Segments need an opaque fill: on a transparent one both overlapping edges show and the seam
	 * reads darker than the outer edge. Pressing tints instead of scaling: a shrinking segment
	 * would open a gap in the strip.
	 */
	.button-group__strip[data-attached] > :global(:not([aria-live])) {
		box-shadow:
			var(--tw-ring-offset-shadow, 0 0 #0000),
			var(--tw-ring-shadow, 0 0 #0000),
			inset 0 0 0 1px var(--_group-hairline);
		scale: none;
	}

	.button-group__strip[data-attached] > :global(:not([aria-live]):active) {
		z-index: 1;
		box-shadow:
			var(--tw-ring-offset-shadow, 0 0 #0000),
			var(--tw-ring-shadow, 0 0 #0000),
			inset 0 0 0 1px var(--_group-hairline),
			inset 0 0 0 100vmax var(--_group-press-tint);
	}

	/* Raised so a neighbour painted later never covers its edge or ring. */
	@media (hover: hover) {
		.button-group__strip[data-attached] > :global(:not([aria-live]):hover) {
			z-index: 1;
		}
	}

	/* Same specificity as the hover and press rules, and later, so focus always wins. */
	.button-group__strip[data-attached] > :global(:not([aria-live]):focus-visible) {
		z-index: 2;
	}

	/* Row: seams run down, so inner start and end corners go square. */
	.button-group__strip[data-attached][data-orientation='horizontal']:not([data-stacks])
		> :global(:not([aria-live]):not(:nth-child(1 of :not([aria-live])))) {
		margin-inline-start: -1px;
		border-start-start-radius: 0;
		border-end-start-radius: 0;
	}

	.button-group__strip[data-attached][data-orientation='horizontal']:not([data-stacks])
		> :global(:not([aria-live]):not(:nth-last-child(1 of :not([aria-live])))) {
		border-start-end-radius: 0;
		border-end-end-radius: 0;
	}

	/* Column: seams run across, so inner top and bottom corners go square. */
	.button-group__strip[data-attached][data-orientation='vertical']
		> :global(:not([aria-live]):not(:nth-child(1 of :not([aria-live])))) {
		margin-block-start: -1px;
		border-start-start-radius: 0;
		border-start-end-radius: 0;
	}

	.button-group__strip[data-attached][data-orientation='vertical']
		> :global(:not([aria-live]):not(:nth-last-child(1 of :not([aria-live])))) {
		border-end-start-radius: 0;
		border-end-end-radius: 0;
	}

	/*
	 * A row that can stack measures the group (a 16rem flex basis, so it moves to its own line in
	 * a flex row before it gets that narrow). At 16rem or wider it is a row; narrower, it becomes a
	 * column of full-width segments instead of pushing past the page. The query rearranges the
	 * strip and every seam at once, which is why it is CSS rather than @container utilities.
	 */
	@container (width >= 16rem) {
		.button-group__strip[data-stacks]
			> :global(:not([aria-live]):not(:nth-child(1 of :not([aria-live])))) {
			margin-inline-start: -1px;
			border-start-start-radius: 0;
			border-end-start-radius: 0;
		}

		.button-group__strip[data-stacks]
			> :global(:not([aria-live]):not(:nth-last-child(1 of :not([aria-live])))) {
			border-start-end-radius: 0;
			border-end-end-radius: 0;
		}
	}

	@container (width < 16rem) {
		.button-group__strip[data-stacks] {
			flex-direction: column;
			width: 100%;
		}

		.button-group__strip[data-stacks]
			> :global(:not([aria-live]):not(:nth-child(1 of :not([aria-live])))) {
			margin-block-start: -1px;
			border-start-start-radius: 0;
			border-start-end-radius: 0;
		}

		.button-group__strip[data-stacks]
			> :global(:not([aria-live]):not(:nth-last-child(1 of :not([aria-live])))) {
			border-end-start-radius: 0;
			border-end-end-radius: 0;
		}
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-978dfa40838975b939cf4d52c38053c4efe43833dad74dd51909394ae6796aa7`
- Entry: `ButtonGroup.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_button_group_01/1.0.0/neutral/sha256-978dfa40838975b939cf4d52c38053c4efe43833dad74dd51909394ae6796aa7/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_button_group_01/1.0.0/neutral/sha256-978dfa40838975b939cf4d52c38053c4efe43833dad74dd51909394ae6796aa7/bundle.zip (6621 bytes, sha256 `df51ff70d3292523acd9fdd6688baafab5ac541cf572c27c935303c9b15ccdf6`)

Files:

- `ButtonGroup.svelte` (entry, 9574 bytes): https://pagesugar.com/artifacts/cmp_button_group_01/1.0.0/neutral/sha256-978dfa40838975b939cf4d52c38053c4efe43833dad74dd51909394ae6796aa7/source/ButtonGroup.svelte
