jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — button-group

$npx jixoai-ui add button-group

https://ui.jixoai.com/r/button-group.json

orientation · justify 1px hairline seams one cluster shadow · flat buttons ButtonGroupDivider role=group law zero deps · Svelte 5 runes

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.

Button group usage
<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.

button-group-usage.svelte svelte
<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.

button-group-usage.svelte svelte
<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

free-floating: no seams, no group — only the default changed; the lone button outside never saw the zone.

the group — zone + join

same zone, plus the hairline join — one component when both are wanted.
button-group-zone-demo.svelte svelte
<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>
the scope — copy-paste
<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>
link is NOT a zone value — variant="link" is a compile error; the interaction exception keeps its only route through PressButton's own explicit prop.

Properties

PropertyTypeDefaultDescription
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 * SnippetrequiredThe scoped subtree — rendered as-is; the scope adds NO element, no paint, no seams.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity 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.

button-group-divider.svelte svelte
<!--
  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

each press performs; nothing stays active.
button-group-boundary-demo.svelte svelte
<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

the selection law
<ToggleGroup name="align" type="single" label="alignment">
  <ToggleGroupItem value="left">left</ToggleGroupItem>
  <ToggleGroupItem value="center">center</ToggleGroupItem>
</ToggleGroup>
one active value, submitted as a form field.

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

PropertyTypeDefaultDescription
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 zoneThe 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 booleanambient zone ?? trueThe 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 booleanghost ⇒ trueThe 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 booleanfalseThe 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 scopeDensity 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 * SnippetrequiredThe 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).

PropertyTypeDefaultDescription
size 'small' | 'medium' | 'large' | 'auto' | numberambient scopeSUPPLY-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 scopeCONSUMED 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' | numberambient scopeCONSUMED 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' | numberambient scopeCONSUMED — 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 | stringambient scopeCONSUMED 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 scopePARTIAL 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' | numberambient scopeSUPPLY-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' | numberambient scopeSUPPLY-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.

the eight axes on button-group
<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

density default — the pointer-lane row (40px hits, 13px type)
density="lg" — the touch tier steps the whole row (48px, 15px)
radius="medium" — the group anchors; the joined buttons compute R − seam
theme="dark" — the variant borders flip; the label ink stays frozen; the seams stay color-free; the cluster shadow follows the theme

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.

button-group-axes.svelte svelte
<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>
one real query() case
<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.

button-group-query-demo.svelte svelte
<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>
TokenDefaultSource
--jx-hit28 / 32 / 40 / 48px across xs / sm / default / lgdensity
--jx-text11 / 12 / 13 / 15px across xs / sm / default / lgdensity
--jx-inset8 / 8 / 12 / 16px across xs / sm / default / lgdensity
--shadow-xsthemestructural
--borderthemestructural

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

KeyAction
TabEach button keeps its own tab stop — a group is NOT a roving-tabindex collection
Space / EnterActivates the focused button (native)

ARIA

AttributeValueDescription
rolegroupThe law. Override through the rest props only with an explicit labeled toolbar contract
aria-labellabelThe group accessible name — or aria-labelledby through the rest props; a nameless group is announced as nothing
role="separator"dividerButtonGroupDivider announces the boundary between clusters; aria-orientation describes the LINE (vertical inside a horizontal flow)
no aria-pressedlawNo button carries a pressed state here — that is toggle-group’s contract
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h