sveltelean Primitives
Versionv0.2.0 GitHub

Example

History actions
<div role="group" aria-label="History" data-slean="button-group">
	<button type="button" data-slean="button" data-variant="outline">Undo</button>
	<button type="button" data-slean="button" data-variant="outline">Redo</button>
	<button type="button" data-slean="button" data-variant="outline">Restore</button>
</div>
Tier 0: the two sources differ only by the stylesheet imports. The corners are logical, so the RTL switch moves the rounded ends. Everything here works with page JavaScript disabled.

Why this implementation exists

Related actions often read as one control: undo and redo, zoom in and out, a main action and its menu. Joining them is layout. The buttons stay native buttons, and role="group" with a name tells a screen reader that they belong together.

button-group.css pulls each button 1px over the previous one, squares the corners where two buttons meet, raises the hovered or focused button above its neighbours and, between filled buttons, draws the shared border in their own text color. It uses logical properties throughout, so the group mirrors under right-to-left text.

The browser owns

  • every button: focus, the tab order, Enter and Space activation
  • announcing the group’s name when focus enters it
  • opening a split button’s menu popover from its trigger

Svelte Lean owns

  • button-group.css: shared borders, square inner corners, dividers between filled buttons
  • the horizontal and vertical orientations, mirrored under RTL
  • the contract: a group is not a toolbar, and where a split button’s menu goes
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.

npm install @svelte-lean/styles
stylesheets
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/button-group.css';

Put the buttons directly inside the group, in reading order, with the variant and size each one needs; button.css draws them and button-group.css joins them, so load it after button.css (and toggle.css for toggles).

Name the group with aria-label or aria-labelledby. For a split button, place the menu popover after the group and give the trigger aria-haspopup="menu" and a name of its own.

Use a Toolbar instead when the set should be one tab stop with arrow keys between the controls, and a Toggle group when the buttons have a pressed state.

Anatomy

PartElementdata-slean-partRequiredNotes
root<div role="group" aria-label="…" data-slean="button-group">–yesdata-slean-orientation="horizontal|vertical". Named with aria-label or aria-labelledby.
buttons<button data-slean="button">, <a href data-slean="button">, toggles–yesThe direct children, in reading order; each keeps its own variant, size and tab stop.
split button menu<div popover role="menu" data-slean="menu">–noAfter the group, opened by a trigger with popovertarget and aria-haspopup="menu" as the last child.

Runtime profile

Tier 0: the button group has no behavior module, the Vite plugin maps button-group to no module, and the page ships no Svelte Lean JavaScript for it. A split button's menu brings the menu behavior of its own contract.

Accessibility contract

  • role="group" with aria-label or aria-labelledby: the name is announced when focus enters the group.
  • Every button is a native button in the tab order, activated by Enter and Space.
  • A split button trigger has aria-haspopup="menu" and its own name, such as “More publish options”; the menu is labelled by the trigger.
  • The focused button is raised above its neighbours, so the whole focus ring stays visible.

Keyboard

KeyWhenResult
Tab/Shift+TabanywhereMoves from button to button (native); every button is a tab stop
Enter/Spacefocus on a buttonActivates it; on a split button trigger, opens the menu (native)

Platform features

FeatureBaselineOutside the target
Logical corner radiiBaseline 2021Not applicable
color-mix()Baseline 2023Two filled buttons meet without a divider
popover, popovertarget (split button)Baseline 2024The menu does not open; see the popover contract

Without JavaScript

Fully functional: the buttons are native, and a split button's popover opens without JavaScript. The arrow keys inside its menu need the menu behavior.

Server rendering

Static markup; nothing is computed.

Before hydration

Nothing is attached; the group works before and after hydration alike.

Styling

button-group.css sets the flex row or column, the 1px overlap, the square inner corners, the stacking of the hovered and focused button and the dividers between filled buttons. The tokens it reads (the buttons read theirs from button.css):

The stylesheet button-group.css was not found in this build.

State selectors the stylesheet targets, all from the platform or ARIA: :disabled, :first-child, :focus-visible, :hover, :last-child, [aria-disabled="true"].

Variant attributes: data-slean-orientation (vertical); data-variant (soft, danger).

Compatibility notes

Logical corner radii are Baseline 2021 and color-mix() Baseline 2023, both widely available. Without color-mix(), two filled buttons meet without a divider.

Examples

Split button

The main action and a menu trigger in one group. The trigger opens a menu popover with popovertarget; the popover sits after the group, so the trigger stays its last child. Between two filled buttons the shared border is drawn in the buttons' text color.

split button
<div role="group" aria-label="Publish" data-slean="button-group">
	<button type="button" data-slean="button">Publish</button>
	<button
		type="button"
		id="publish-more"
		data-slean="button"
		data-icon-only
		popovertarget="publish-menu"
		aria-haspopup="menu"
		aria-label="More publish options"
	>
		<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16">…</svg>
	</button>
</div>
<!-- The menu goes after the group: the trigger stays the group's last child. -->
<div id="publish-menu" popover role="menu" aria-labelledby="publish-more" data-slean="menu">
	<button type="button" role="menuitem">Schedule</button>
	<button type="button" role="menuitem">Publish as draft</button>
</div>

Vertical

data-slean-orientation="vertical" stacks the buttons in a column of one width and joins them along the block axis. The keys do not change: every button is a tab stop.

vertical
<div
	role="group"
	aria-label="Zoom"
	data-slean="button-group"
	data-slean-orientation="vertical"
>
	<button type="button" data-slean="button" data-variant="outline" data-size="sm">Zoom in</button>
	<button type="button" data-slean="button" data-variant="outline" data-size="sm">Fit</button>
	<button type="button" data-slean="button" data-variant="outline" data-size="sm">Zoom out</button>
</div>

Testing

Source