Skip to content
Palette Neutral
Download ZIP

Neutral palette · 6.5 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_button_group_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_button_group_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Neutral
Version
1.0.0
Digest
Full digest
sha256-978dfa40838975b939cf4d52c38053c4efe43833dad74dd51909394ae6796aa7
ButtonGroup.svelte Svelte · 9.3 KB Raw
<!--
	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>

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.

Suggested location
src/lib/components/button-group-01
Required props
children

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.

Example

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>

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
childrenSnippetYesNoneThe buttons, in reading order. Each direct child is one button (or link).
labelstringNoNoneAccessible name, written to aria-label, e.g. 'Timeline navigation'. Required unless you pass aria-labelledby pointing at a visible heading.
attachedbooleanNofalseJoin 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.
wrapbooleanNotrueLet 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.
classClassValueNoNoneExtra classes on the group element, for margins or placement.

Customization#

On this page

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.

Public CSS variables

VariableToken
--button-group-hairlinehairline

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Presentational
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 1 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.