registry:ui · General
button-group — joined actions, one hairline
The shadcn Button Group counterpart, native to this registry's laws: a layout container that joins press-buttons edge-to-edge. The group paints NO bezel of its own — adjacent children collapse their 1px borders into ONE hairline seam (a joined row of outline buttons reads as one control, never a 2px double border), and the ButtonGroupDivider replaces the seam wherever clusters need an explicit boundary. The buttons keep their paint ladder and density tier; PHYSICS is the one takeover (2026-09-04): the joined row is ONE control, so it casts ONE convex shadow from the ROOT — per-button convex shadows overlap at the seams, so the subtree rides raised=false by default through the texture context (an explicit raised on any child still wins; icon-buttons follow the same context for free). ROLE LAW: the root is role=group — a named grouping of related actions, NOT a toolbar; and when the children express SELECTION (a pressed state, an active value), the component is wrong: segmented selection is toggle-group's law.
$npx jixoai-ui add button-group
https://ui.jixoai.com/r/button-group.json
overview
Overview
A layout container that paints almost nothing of its own: the join, the seams, and one cluster shadow — its eight axes land in the COMPOSED press-buttons through the ambient chain.
The group owns the join: adjacent children collapse their borders into one hairline
seam (a −1px margin over a grid of content-sized tracks — the seam is geometry, never
a second border), ButtonGroupDivider is the announced boundary between
clusters, and the variant zone re-points the joined buttons' default paint
(ButtonVariantScope is the zone alone, layout-free). The cluster shadow is
the one takeover: the joined row is ONE control, so the ROOT casts the single convex
shadow while the subtree rides flat.
The overflow policy handles the row outgrowing its inline space — wrap (measured rows), collapse (the tail folds into a DropdownMenu behind a ⋯ trigger — the composed consumer: menu entries click the hidden real buttons, which keep their listeners), or the W7 scroll run (the root becomes the scroller — hidden scrollbar, smooth, proximity snap — and the scrollEffect chrome rides the stamped scroll verdict).
Because the group paints almost nothing itself, its eight axes land in the COMPOSED press-buttons: density re-tiers the whole row through the provided scope, radius makes the group the concentric anchor the buttons compute against, shape and color re-skin the buttons' corners and hue through the ambient chain — while size, elevation and motion stamp carriers nothing here reads (per-axis below, with receipts). The axis grammar lives on the universal props page.
usage
Usage
Author the buttons in your tree — the group owns the join, the divider owns explicit boundaries. Name the group; the platform owns the rest.
<script lang="ts">
import ButtonGroup, { ButtonGroupDivider } from '@ui/button-group/index';
import PressButton from '@ui/press-button/press-button.svelte';
</script>
<ButtonGroup label="export actions">
<PressButton variant="outline">copy</PressButton>
<ButtonGroupDivider />
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>button-group
Joined press-buttons with a divider between the clusters — the playground flips orientation and justify; the usage file in the drawer tracks both.
<ButtonGroup label="export actions" orientation="horizontal" justify="start">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<ButtonGroupDivider />
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>with scroll overflow
The third overflow mode (2026-09-04, the tabs contract): the joined line rides a scroll run — hidden scrollbar, smooth travel, proximity snap — and the scrollEffect edge treatments follow the scroll. The chevrons and the shadow veil key on the run's scroll-state verdict: nothing paints when the line fits.
<ButtonGroup label="export actions" orientation="horizontal" justify="start">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<ButtonGroupDivider />
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>the zone half
ButtonVariantScope — change the default, not the layout
The family's second face: a zero-DOM context boundary. ButtonGroup is layout + zone (the join, the seams); ButtonVariantScope is the zone alone — what Dialog wraps around its head and foot content so every PressButton and IconButton inside, joined or free-floating, defaults to the scope's variant while keeping its own placement. A button's explicit variant still wins (explicit ?? ambient ?? own), and a ButtonGroup inside inherits the scope's variant when it sets none of its own (inherit-then-provide). Renders its children and nothing else — no element, no paint, no seams.
button-group · variant scope
the scope — zone only
the group — zone + join
<script lang="ts">
import ButtonGroup, { ButtonVariantScope } from '@ui/button-group/index';
import PressButton from '@ui/press-button/press-button.svelte';
</script>
<!-- the zone, layout-free — nothing renders but the buttons -->
<PressButton>lone — outline</PressButton>
<ButtonVariantScope variant="ghost">
<PressButton>adopts ghost</PressButton>
<PressButton>adopts ghost</PressButton>
<PressButton variant="fill">keeps fill</PressButton>
</ButtonVariantScope>
<!-- the group — zone + join -->
<ButtonGroup variant="ghost" label="row actions">
<PressButton>adopts ghost</PressButton>
<PressButton variant="fill">keeps fill</PressButton>
</ButtonGroup><script lang="ts">
import PressButton from '@ui/press-button/press-button.svelte';
import { ButtonVariantScope } from '@ui/button-group/index';
${close}
<!-- the zone, layout-free — nothing renders but the buttons. Every
PressButton / IconButton inside that passes no variant adopts
ghost; an explicit variant always wins. Dialog's head and foot
zones use exactly this scope (dialog.svelte, real source). -->
<ButtonVariantScope variant="ghost">
<PressButton>adopts ghost</PressButton>
<PressButton variant="fill">keeps fill</PressButton>
</ButtonVariantScope>variant="link" is a compile error; the interaction exception keeps its only route through PressButton's own explicit prop.Properties
| Property | Type | Default | Description |
|---|---|---|---|
| ButtonVariantScope · variant | 'fill' | 'tonal' | 'outline' | 'ghost' | — | The variant buttons in this subtree adopt when they set none (ZonePaintVariant — link excluded by the union itself). |
| ButtonVariantScope · children * | Snippet | required | The scoped subtree — rendered as-is; the scope adds NO element, no paint, no seams. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
examples
Examples
Ability-named demos — one phrase, one capability — plus the recorded boundary against the selection family.
Nested clusters first, then the boundary note — when the children express SELECTION, the law leaves this page for toggle-group.
with nested clusters
A nested ButtonGroup is ONE child for the outer seam — the inner seams never leak outward; fill and outline rungs join on the same hairline. The divider is a COMPOSED Separator (2026-09-04): the boundary line rides the same contrast-ghost ink engine as the internal seams — its extra weight is GEOMETRY (flush border·line·border junction against the collapsed 1px intra-cluster seam), never a heavier paint.
<!--
jixoai button group — the DIVIDER part (registry/files/ui/
button-group/button-group-divider.svelte, OpenSpec
2026-08-30-expand-form-family F2; composed over Separator, Owner
2026-09-04).
The explicit boundary between joined clusters — a Separator in the
group's own geometry. The COMPOSITION (Owner 2026-09-04, retiring
the borrow-the-ink-law-only era): the ink is Separator's contrast
ghost (backdrop contrast 0.5 — the 减色墨律, no color channel, theme-
agnostic over any ground), the element form is Separator's
W3C-first pair (a horizontal group's divider is a VERTICAL line →
the div[role=separator] branch; a vertical group's → the native
<hr>), and the family keeps ONLY what is its law (button-group.css):
the junction exemption (flush border·line·border — the boundary
reads heavier than the intra-cluster collapsed seam) and the line
LENGTH (align-self: stretch; the cross-axis 1px is separator.css's,
length has always been the consumer's job).
ARIA: role=separator with the line's orientation — a boundary
landmark BETWEEN the clusters, announced without stealing focus
(it is not focusable; the separator role needs no interaction).
The vertical branch stamps role/aria itself after its spread
(Separator's non-overridable contract); the hr branch carries the
orientation through the rest props onto the native separator.
-->
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getContext } from 'svelte';
import { cn } from '$lib/utils';
import { BUTTON_GROUP_KEY, type ButtonGroupApi } from './button-group.svelte';
import Separator from '../separator/separator.svelte';
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'color'> {
class?: string;
}
let { class: className = '', ...rest }: Props = $props();
// aria-orientation describes the LINE, not the flow: a horizontal
// group's divider is a vertical hairline (and vice versa); outside
// any group it defaults to the horizontal flow. The family-state
// read (the family layout key — never an axis default) rides the
// gate's provider-file boundary: kind:provider files confine
// context reads to reactive declaration subtrees, so the
// getContext lives inside the $derived initializer, whose api
// getters keep it reactive under rerenders
const lineOrientation = $derived.by(() => {
const group = getContext<ButtonGroupApi | undefined>(BUTTON_GROUP_KEY);
return (group?.orientation ?? 'horizontal') === 'horizontal' ? 'vertical' : 'horizontal';
});
</script>
<Separator
orientation={lineOrientation}
data-jx-btngroup-divider
aria-orientation={lineOrientation}
class={cn(className)}
{...(rest as Omit<HTMLAttributes<HTMLHRElement>, 'color'>)}
></Separator>
boundary
the toggle-group boundary — selection is not this component
A button group is ACTION-ONLY: press, effect, navigate — no pressed state, no active value, no form payload. The moment the children express SELECTION, the segmented-selection law applies and the component is toggle-group (native radios/checkboxes under one name — native exclusivity, arrow-walk, FormData). The two may look similar when joined; the difference is semantic, not paint: aria-pressed (or a pressed style) on these buttons is the recorded divergence trap.
button-group · boundary
actions → button-group
<script lang="ts">
import ButtonGroup from '@ui/button-group/index';
import PressButton from '@ui/press-button/press-button.svelte';
</script>
<!-- actions → button-group: each press performs, nothing stays active -->
<ButtonGroup label="export actions">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>selection → toggle-group
<ToggleGroup name="align" type="single" label="alignment">
<ToggleGroupItem value="left">left</ToggleGroupItem>
<ToggleGroupItem value="center">center</ToggleGroupItem>
</ToggleGroup>api
API
Two parts: the container owns orientation/justify, the overflow policy and the seam; the divider owns explicit boundaries. The eight universal axis props resolve on the group root (the provider) — their per-family story is the axes table below.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| orientation | 'horizontal' | 'vertical' | 'horizontal' | The join axis; carries the valued data-jx-btngroup hook. |
| justify | 'start' | 'center' | 'end' | 'start' | Cluster placement on the main axis. |
| label | string | — | Accessible group name (aria-label); an explicit rest aria-label wins. |
| variant | 'fill' | 'tonal' | 'outline' | 'ghost' | ambient zone | The GROUP rung adopted by every child button that passes none of its own (explicit always wins; no rung is minted — context selects). Omitted → the enclosing scope’s variant (inherit-then-provide, r14-10); link is not a zone value — it stays reachable only through PressButton’s own explicit prop. |
| raised | boolean | ambient zone ?? true | The CLUSTER shadow (2026-09-04): the root carries the joined row’s ONE convex shadow — --shadow-xs, the press law’s rest pose alone (no hover growth, no active engrave; the root never presses). Explicit ?? the enclosing texture zone (a flat card/dialog foot carries through) ?? the top-level convex default; a NESTED group defaults OFF (one member of the outer cluster — one control, one shadow). false removes the root shadow and nothing else; the inner buttons’ flat default stands regardless (an explicit raised on a child still wins). |
| separator | boolean | ghost ⇒ true | The seam policy: a 1px contrast-ghost separator in every collapsed seam slot. DEFAULT on when the group’s EFFECTIVE variant (own prop, else the inherited scope) is ghost — the borderless row has no other seam. |
| leadingSeam | boolean | false | The cluster’s opening bracket (r14-13): paint the seam in the leading slot too — the first button’s own flush ::before, never a sibling element a parent gap could detach. Only paints under an active seam policy (the dialog footer’s actions region is the canonical consumer). |
| overflow | 'wrap' | 'collapse' | 'scroll' | 'wrap' | The overflow policy when the joined row outgrows its inline space: wrap breaks measured rows (per-item grid cells, row leads reset the seam); collapse folds the tail into a DropdownMenu behind the ⋯ trigger; scroll (2026-09-04) rides a scroll run — the root becomes the scroller (hidden scrollbar, smooth, proximity snap), the line never breaks, and NO measurement runs (the chevrons/veil key on the JS-stamped scroll-state instead). Scroll’s effect chrome is the horizontal contract — a vertical group declaring scroll gets the bare block-axis scroller. |
| scrollEffect | ramp() | shadow() | progressBlur() | ramp() | The edge treatment while overflow=scroll scrolls (the tabs scrollEffect convention; inert elsewhere), axis-aware: a vertical scroll group rides the same chrome against its block edges. ramp({ opacity, blur, translate, distance, radius }) is the ONE member-ramp builder — every toggle defaults ON, a toggle off never pays its property; shadow/progressBlur veil the edges from the non-scrolling host — shadow is the separator ink law’s contrast ghost (no color channel), progressBlur mounts the ProgressiveBlur ladder (inline axis; vertical substitutes shadow). width overrides the band. |
| density | DensityLane | QueryResult<DensityLane> | ambient scope | Density tier, provided to the subtree so joined buttons adopt it: explicit ?? the ambient scope (no opinion stamps nothing). |
| role | string | 'group' | The group role — a labeled toolbar is the consumer’s explicit override. |
| class | string | '' | Merged into the root (cn()). |
| children * | Snippet | required | The joined controls — authored in your tree. |
| ...rest | HTMLAttributes | — | aria-labelledby, data-*, handlers — land on the root verbatim. |
axes
The eight axes on button-group
The group is a PROVIDER: the root stamps every carrier and the COMPOSED press-buttons are the consumers (their own anatomy rides the ambient chain — press-button.css reads shape/radius/color; the hit/text lanes ride density). The group's own chrome is one shadow plus color-free seams. Measured: density re-tiers the whole row through one prop; the theme split runs four voice classes (slot borders flip, alias ink frozen, seam ghost color-free, raw shadow follows the theme); size, elevation and motion stamp carriers nothing here reads. Census: batch A (migration-census.md).
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | ambient scope | SUPPLY-ONLY for the button anatomy — the root stamps --jx-size-effective and an inline font-size, and the joined buttons' type is density-channel-anchored (--jx-text), so the stamp reaches only unstyled flow under the group (measured: a 20px root font leaves the 13px button type unmoved). The §1 collision applies: the native size attribute is never received. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | ambient scope | CONSUMED BY THE COMPOSED BUTTONS — the root stamps --jx-shape-effective and the joined press-buttons' corner-shape reads it through the ambient chain (press-button.css). The group's own chrome has no corners to re-shape: a joined row is a rectangular strip. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | ambient scope | CONSUMED BY THE COMPOSED BUTTONS — the §3 concentric anchor with the group as the supplying root: the carrier lands on the group and every joined button that resolves auto computes R − seam against it (measured: a stamped 16px carrier moves the button corner). The group's own strip has no corner of its own. Number unit: px. |
| density | 'small' | 'medium' | 'large' | 'xs' | '2xs' | 'sm' | 'default' | 'lg' | 'auto' | number | ambient scope | CONSUMED — the PROVIDER axis (the reactive bridged provider, the provider-snapshot kernel law). The named rung stamps data-density on the group root and the scope block re-bases the lanes the joined buttons read (--jx-hit, --jx-text, --jx-inset): measured 28/11/8px at xs, 32/12/8 at sm, 40/13/12 at default, 48/15/16 at lg (hit/type/inline-padding) — the whole joined row re-tiers through one prop. The NUMBER lane is inert (the declaring-element law: the coefficient composes at :root, the wrapper never re-declares). Number unit: coefficient. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | ambient scope | CONSUMED BY THE COMPOSED BUTTONS — the root stamps --jx-color-effective and the joined press-buttons' --jx-fill/--jx-tonal re-derive from it through the [data-jx-press-button] keying; the group's own chrome (seams, cluster shadow) reads none of it (grep receipt: zero carrier reads in ui/button-group/). Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | ambient scope | PARTIAL re-theme — measured, FOUR voice classes. FLIPS: the joined buttons' variant borders (the theme-scoped --jx-outline slot re-declares in scope — measured black→white under the island). STAYS FROZEN: the buttons' label ink (the --jx-foreground stylex alias, substituted at :root). THEME-NEUTRAL: the seams (the contrast-ghost ink paints no color — the backdrop's own contrast is the paint). FOLLOWS THE THEME: the cluster shadow (raw --shadow-xs — the docs theme re-declares the offset per scope, black↔white measured). light and system stamp nothing — tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | ambient scope | SUPPLY-ONLY — the cluster shadow is `raised`'s own physics (--shadow-xs on the root, the press law's rest pose), deliberately OUTSIDE this lane: the carrier stamps --jx-elevation-effective and nothing in the family css consumes it (grep receipt: zero readers; the shadow stays --shadow-xs regardless of the stamped level). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | ambient scope | SUPPLY-ONLY — stamps --jx-motion-effective; no family css reads it (grep receipt: zero readers). The scroll run's smooth travel and proximity snap are behavior, the scrollEffect ramps are builders — the axis has no family-local kernel to step. Number unit: coefficient. |
Deviations, cited: the adoption is the census batch A row (explicit-props W3-D5 — the first-time no-own surface, the provider duties riding the reshaped reads; openspec/changes/explicit-props/research/migration-census.md). The §1 collision rule: the root extends HTMLAttributes Omit<'color'>, the native elements never receive an axis name. The elevation carve-out is the cluster-shadow law: `raised` owns the root's shadow, the elevation lane supplies and nothing consumes.
<script lang="ts">
import ButtonGroup from '@ui/button-group/index';
import PressButton from '@ui/press-button/press-button.svelte';
</script>
<div class={cx(rt.gridSm2, rt.wFull)}>
<div class={cx(rt.panel)}>
<span class={cx(rt.note11)}>density default — the pointer-lane row (40px hits, 13px type)</span>
<ButtonGroup label="density default row">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>
</div>
<div class={cx(rt.panel)}>
<span class={cx(rt.note11)}>density="lg" — the touch tier steps the whole row (48px, 15px)</span>
<ButtonGroup label="density touch row" density="lg">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>
</div>
<div class={cx(rt.panel)}>
<span class={cx(rt.note11)}>radius="medium" — the group anchors; the joined buttons compute R − seam</span>
<ButtonGroup label="radius row" radius="medium">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>
</div>
<div class={cx(rt.panel)}>
<span class={cx(rt.note11)}>theme="dark" — the variant borders flip; the label ink stays frozen; the seams stay color-free; the cluster shadow follows the theme</span>
<ButtonGroup label="dark row" theme="dark">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>
</div>
</div>
<p class={cx(rt.mt8, rt.note12, rt.inkMuted70)}>
The number lane (a coefficient) stamps and re-bases nothing here — the declaring
element for every channel this family reads is outside the wrapper. Measure the hit
ladder, not the coefficient: 28 / 32 / 40 / 48px across xs / sm / default / lg.
</p>button-group · the provider lanes
The number lane (a coefficient) stamps and re-bases nothing here — the declaring element for every channel this family reads is outside the wrapper. Measure the hit ladder, not the coefficient: 28 / 32 / 40 / 48px across xs / sm / default / lg.
<script lang="ts">
import ButtonGroup from '@ui/button-group/index';
import PressButton from '@ui/press-button/press-button.svelte';
</script>
<div class={cx(rt.gridSm2, rt.wFull)}>
<div class={cx(rt.panel)}>
<span class={cx(rt.note11)}>density default — the pointer-lane row (40px hits, 13px type)</span>
<ButtonGroup label="density default row">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>
</div>
<div class={cx(rt.panel)}>
<span class={cx(rt.note11)}>density="lg" — the touch tier steps the whole row (48px, 15px)</span>
<ButtonGroup label="density touch row" density="lg">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>
</div>
<div class={cx(rt.panel)}>
<span class={cx(rt.note11)}>radius="medium" — the group anchors; the joined buttons compute R − seam</span>
<ButtonGroup label="radius row" radius="medium">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>
</div>
<div class={cx(rt.panel)}>
<span class={cx(rt.note11)}>theme="dark" — the variant borders flip; the label ink stays frozen; the seams stay color-free; the cluster shadow follows the theme</span>
<ButtonGroup label="dark row" theme="dark">
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>
</div>
</div>
<p class={cx(rt.mt8, rt.note12, rt.inkMuted70)}>
The number lane (a coefficient) stamps and re-bases nothing here — the declaring
element for every channel this family reads is outside the wrapper. Measure the hit
ladder, not the coefficient: 28 / 32 / 40 / 48px across xs / sm / default / lg.
</p><script lang="ts">
import ButtonGroup from '@ui/button-group/index';
import PressButton from '@ui/press-button/press-button.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<ButtonGroup label="responsive row" density={query<{ lg: DensityLane }, DensityLane>({ lg: 'large' }, 'small')}>
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>button-group · query()
Media keys are min-width: below 40rem the base applies — the small rung, the pointer-lane row; at 40rem and wider the lg case wins and the row steps to the touch tier (32px hits below, 48px above — measured). Resize across 40rem.
<script lang="ts">
import ButtonGroup from '@ui/button-group/index';
import PressButton from '@ui/press-button/press-button.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<ButtonGroup label="responsive row" density={query<{ lg: DensityLane }, DensityLane>({ lg: 'large' }, 'small')}>
<PressButton variant="outline">copy</PressButton>
<PressButton variant="outline">move</PressButton>
<PressButton variant="outline">delete</PressButton>
</ButtonGroup>| Token | Default | Source |
|---|---|---|
| --jx-hit | 28 / 32 / 40 / 48px across xs / sm / default / lg | density |
| --jx-text | 11 / 12 / 13 / 15px across xs / sm / default / lg | density |
| --jx-inset | 8 / 8 / 12 / 16px across xs / sm / default / lg | density |
| --shadow-xs | theme | structural |
| --border | theme | structural |
a11y
Accessibility
role=group named by label — a grouping of related actions over plain buttons; never a toolbar unless you explicitly relabel it (and then you own the roving-tabindex contract).
Keyboard
| Key | Action |
|---|---|
| Tab | Each button keeps its own tab stop — a group is NOT a roving-tabindex collection |
| Space / Enter | Activates the focused button (native) |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | group | The law. Override through the rest props only with an explicit labeled toolbar contract |
| aria-label | label | The group accessible name — or aria-labelledby through the rest props; a nameless group is announced as nothing |
| role="separator" | divider | ButtonGroupDivider announces the boundary between clusters; aria-orientation describes the LINE (vertical inside a horizontal flow) |
| no aria-pressed | law | No button carries a pressed state here — that is toggle-group’s contract |