Skip to content
Kappa
Button Group
@dicehub/kappav0.4.2

Button Group

Connects related actions into one compact visual unit without changing native control behavior.

Rotor study · Run 042Review actions
Ready
<script setup>
import { ArrowLeft } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Run actions">
    <ButtonGroup.Root aria-label="Run navigation">
      <Button :icon="ArrowLeft" shape="square" variant="outline" aria-label="Back to runs" />
    </ButtonGroup.Root>
    <ButtonGroup.Root aria-label="Run preparation">
      <Button variant="outline">Save</Button>
      <Button variant="outline">Validate</Button>
    </ButtonGroup.Root>
    <ButtonGroup.Root aria-label="Run output">
      <Button variant="outline">Run</Button>
      <Button variant="outline">Export</Button>
    </ButtonGroup.Root>
  </ButtonGroup>
</template>

Installation

Barrel

import {
  ButtonGroup,
  ButtonGroupRoot,
  ButtonGroupSeparator,
  ButtonGroupText,
} from "@dicehub/kappa";

Granular

import {
  ButtonGroup,
  ButtonGroupRoot,
  ButtonGroupSeparator,
  ButtonGroupText,
} from "@dicehub/kappa/components/button-group";

Usage

Give the group an accessible name when its purpose is not clear from nearby content. Each child keeps its native action or link behavior.

<script setup>
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Case actions">
    <Button variant="outline">Save</Button>
    <Button variant="outline">Duplicate</Button>
    <Button variant="outline">Archive</Button>
  </ButtonGroup>
</template>

Composition

Typical part hierarchy
ButtonGroup.Root
├── Button / LinkButton / native input
├── ButtonGroup.Separator
└── ButtonGroup.Text

Place Button, LinkButton, ButtonGroup.Text, orButtonGroup.Separator directly inside the root. Nest roots when separate connected clusters need a small gap. Native inputs can join the group withdata-slot="input". The Menu, Select, and Popover examples use Ark UI directly because Kappa does not yet provide styled wrappers for those primitives.

  • ButtonGroup — semantic group and visual axis.
  • ButtonGroup.Text — static context for adjacent controls.
  • ButtonGroup.Separator — accessible boundary between actions.

Examples

Basic

Connected outline buttons share one border and retain separate actions.

<script setup>
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Result actions">
    <Button variant="outline">Open</Button>
    <Button variant="outline">Compare</Button>
    <Button variant="outline">Export</Button>
  </ButtonGroup>
</template>

Orientation

Horizontal is the default. Vertical changes only the visual axis; native Tab order stays unchanged.

<script setup>
import { Minus, Plus } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Horizontal zoom controls">
    <Button :icon="Minus" shape="square" variant="outline" aria-label="Zoom out" />
    <Button :icon="Plus" shape="square" variant="outline" aria-label="Zoom in" />
  </ButtonGroup>

  <ButtonGroup orientation="vertical" aria-label="Vertical zoom controls">
    <Button :icon="Plus" shape="square" variant="outline" aria-label="Increase scale" />
    <Button :icon="Minus" shape="square" variant="outline" aria-label="Decrease scale" />
  </ButtonGroup>
</template>

Sizes

Set the same size on each child. Button Group does not override child props.

xs
sm
base
lg
<script setup>
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup v-for="size in ['xs', 'sm', 'base', 'lg']" :key="size" :aria-label="size + ' actions'">
    <Button :size="size" variant="outline">Mesh</Button>
    <Button :size="size" variant="outline">Solve</Button>
    <Button :size="size" variant="outline">Review</Button>
  </ButtonGroup>
</template>

Nested

Nest roots to separate related clusters without introducing decorative containers.

<script setup>
import { Redo2, Undo2 } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Editor actions">
    <ButtonGroup.Root aria-label="History actions">
      <Button :icon="Undo2" shape="square" variant="outline" aria-label="Undo" />
      <Button :icon="Redo2" shape="square" variant="outline" aria-label="Redo" />
    </ButtonGroup.Root>
    <ButtonGroup.Root aria-label="Editor state actions">
      <Button variant="outline">Apply</Button>
      <Button variant="outline">Reset</Button>
    </ButtonGroup.Root>
  </ButtonGroup>
</template>

The second composition keeps the message field and inline voice indicator in one input surface, separated from the attachment action.

<script setup>
import { AudioLines, Plus } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Message composer">
    <ButtonGroup.Root aria-label="Attachment actions">
      <Button :icon="Plus" shape="square" variant="outline" aria-label="Add attachment" />
    </ButtonGroup.Root>
    <ButtonGroup.Root aria-label="Message input">
      <div
        class="button-group-native-input-group button-group-native-input-group--nested"
        data-slot="input-group"
        role="group"
        aria-label="Message and voice controls"
      >
        <input
          class="button-group-native-input-group__field"
          data-slot="input-group-control"
          type="text"
          aria-label="Message"
          placeholder="Send a message..."
        />
        <div
          class="button-group-native-input-group__addon"
          data-slot="input-group-addon"
          data-align="inline-end"
          role="img"
          aria-label="Voice Mode"
          title="Voice Mode"
        >
          <AudioLines aria-hidden="true" />
        </div>
      </div>
    </ButtonGroup.Root>
  </ButtonGroup>
</template>

<style scoped>
.button-group-native-input-group {
  box-sizing: border-box;
  display: flex;
  inline-size: clamp(10rem, 56vw, 18rem);
  min-inline-size: 0;
  block-size: 2rem;
  align-items: center;
  border: 1px solid var(--kappa-line, #e3e6eb);
  border-radius: var(--kappa-button-group-radius, 0.5rem);
  background: var(--kappa-control, #ffffff);
  color: var(--kappa-default, #17191f);
}

.button-group-native-input-group:focus-within {
  outline: 2px solid var(--kappa-focus, #4c63ff);
  outline-offset: 2px;
}

.button-group-native-input-group[data-active="true"] {
  border-color: var(--kappa-warning, #d97706);
}

.button-group-native-input-group--nested {
  inline-size: min(14.5625rem, 60vw);
}

.button-group-native-input-group__field {
  min-inline-size: 0;
  block-size: 100%;
  flex: 1 1 auto;
  padding-inline: 0.625rem;
  border: 0;
  background: transparent;
  color: inherit;
  font-family: var(--kappa-font-sans, inherit);
  font-size: 0.8125rem;
  outline: 0;
}

.button-group-native-input-group__field::placeholder {
  color: var(--kappa-muted, #9aa2ae);
}

.button-group-native-input-group__field:disabled {
  cursor: not-allowed;
}

.button-group-native-input-group__addon {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  padding-inline-end: 0.5rem;
  color: var(--kappa-subtle, #5f6875);
  user-select: none;
}

.button-group-native-input-group__addon svg {
  inline-size: 1rem;
  block-size: 1rem;
}

.button-group-native-input-group__action[data-active="true"] {
  background: var(--kappa-warning-tint, #fff4e5);
  color: var(--kappa-warning-text, #b54708);
}

.button-group-native-input-group__action {
  flex: none;
  color: var(--kappa-subtle, #5f6875);
}

.button-group-native-input-group__action:focus-visible {
  outline: 0;
}

@media (forced-colors: active) {
  .button-group-native-input-group { border-color: ButtonText; }
  .button-group-native-input-group:focus-within { outline-color: Highlight; }
}
</style>

Separator

Use a separator with borderless variants such as ghost or primary. Outline buttons already provide a single seam and do not need another line. Setorientation="horizontal" inside a vertical group.

<script setup>
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Clipboard actions">
    <Button variant="ghost">Copy</Button>
    <ButtonGroup.Separator />
    <Button variant="ghost">Paste</Button>
  </ButtonGroup>

  <ButtonGroup orientation="vertical" aria-label="Case lifecycle">
    <Button variant="ghost">Prepare</Button>
    <ButtonGroup.Separator orientation="horizontal" />
    <Button variant="ghost">Run</Button>
  </ButtonGroup>
</template>

Split

This example uses split geometry for two independent actions. It does not provide a menu or claim popup behavior; compose a real menu trigger when one is available.

<script setup>
import { Copy } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Run case">
    <Button variant="primary">Run case</Button>
    <ButtonGroup.Separator />
    <Button
      :icon="Copy"
      shape="square"
      variant="primary"
      aria-label="Duplicate case and run"
    />
  </ButtonGroup>
</template>

Input

A native input can join a Button when it uses data-slot="input". Keep the input labelled and apply product input styles in the consumer.

<script setup>
import { Search } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Case search">
    <input
      class="button-group-native-input"
      data-slot="input"
      type="search"
      aria-label="Search cases"
      placeholder="Search..."
    />
    <Button :icon="Search" shape="square" variant="outline" aria-label="Search" />
  </ButtonGroup>
</template>

<style scoped>
.button-group-native-input {
  box-sizing: border-box;
  inline-size: clamp(8rem, 48vw, 14rem);
  min-inline-size: 0;
  block-size: 2rem;
  flex: 1 1 auto;
  appearance: none;
  padding-block: 0;
  padding-inline: 0.625rem;
  border: 1px solid var(--kappa-line, #e3e6eb);
  border-radius: var(--kappa-button-group-radius, 0.5rem);
  background: var(--kappa-control, #ffffff);
  color: var(--kappa-default, #17191f);
  font-family: var(--kappa-font-sans, inherit);
  font-size: 0.8125rem;
  line-height: 1rem;
}

.button-group-native-input::placeholder {
  color: var(--kappa-muted, #9aa2ae);
}

.button-group-native-input:focus-visible {
  outline: 2px solid var(--kappa-focus, #4c63ff);
  outline-offset: 2px;
}

.button-group-native-input:disabled {
  cursor: not-allowed;
  opacity: var(--kappa-disabled-opacity, 0.56);
}

@media (forced-colors: active) {
  .button-group-native-input { border-color: ButtonText; }
  .button-group-native-input:focus-visible { outline-color: Highlight; }
}
</style>

Input Group

Nest a local input surface when the field owns an inline action. This example keeps the voice toggle state and accessible name on a real Button.

<script setup>
import { AudioLines, Plus } from "@lucide/vue";
import { ref } from "vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";

const voiceEnabled = ref(false);
</script>

<template>
  <ButtonGroup aria-label="Message composer">
    <ButtonGroup.Root aria-label="Attachment actions">
      <Button :icon="Plus" shape="square" variant="outline" aria-label="Add attachment" />
    </ButtonGroup.Root>
    <ButtonGroup.Root aria-label="Message controls">
      <div
        class="button-group-native-input-group"
        data-slot="input-group"
        :data-active="voiceEnabled ? 'true' : undefined"
      >
        <input
          class="button-group-native-input-group__field"
          data-slot="input-group-control"
          type="text"
          aria-label="Message"
          :disabled="voiceEnabled"
          :placeholder="voiceEnabled ? 'Record and send audio...' : 'Send a message...'"
        />
        <Button
          :icon="AudioLines"
          class="button-group-native-input-group__action"
          shape="square"
          size="xs"
          variant="ghost"
          :aria-label="voiceEnabled ? 'Stop voice input' : 'Start voice input'"
          :aria-pressed="voiceEnabled"
          :data-active="voiceEnabled ? 'true' : undefined"
          :title="voiceEnabled ? 'Stop voice input' : 'Start voice input'"
          @click="voiceEnabled = !voiceEnabled"
        />
      </div>
    </ButtonGroup.Root>
  </ButtonGroup>
</template>

<style scoped>
.button-group-native-input-group {
  box-sizing: border-box;
  display: flex;
  inline-size: clamp(10rem, 56vw, 18rem);
  min-inline-size: 0;
  block-size: 2rem;
  align-items: center;
  border: 1px solid var(--kappa-line, #e3e6eb);
  border-radius: var(--kappa-button-group-radius, 0.5rem);
  background: var(--kappa-control, #ffffff);
  color: var(--kappa-default, #17191f);
}

.button-group-native-input-group:focus-within {
  outline: 2px solid var(--kappa-focus, #4c63ff);
  outline-offset: 2px;
}

.button-group-native-input-group[data-active="true"] {
  border-color: var(--kappa-warning, #d97706);
}

.button-group-native-input-group--nested {
  inline-size: min(14.5625rem, 60vw);
}

.button-group-native-input-group__field {
  min-inline-size: 0;
  block-size: 100%;
  flex: 1 1 auto;
  padding-inline: 0.625rem;
  border: 0;
  background: transparent;
  color: inherit;
  font-family: var(--kappa-font-sans, inherit);
  font-size: 0.8125rem;
  outline: 0;
}

.button-group-native-input-group__field::placeholder {
  color: var(--kappa-muted, #9aa2ae);
}

.button-group-native-input-group__field:disabled {
  cursor: not-allowed;
}

.button-group-native-input-group__addon {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  padding-inline-end: 0.5rem;
  color: var(--kappa-subtle, #5f6875);
  user-select: none;
}

.button-group-native-input-group__addon svg {
  inline-size: 1rem;
  block-size: 1rem;
}

.button-group-native-input-group__action[data-active="true"] {
  background: var(--kappa-warning-tint, #fff4e5);
  color: var(--kappa-warning-text, #b54708);
}

.button-group-native-input-group__action {
  flex: none;
  color: var(--kappa-subtle, #5f6875);
}

.button-group-native-input-group__action:focus-visible {
  outline: 0;
}

@media (forced-colors: active) {
  .button-group-native-input-group { border-color: ButtonText; }
  .button-group-native-input-group:focus-within { outline-color: Highlight; }
}
</style>

Use Ark Menu for popup behavior. Keep its positioner outside ButtonGroup so it does not become part of the joined border sequence.

<script setup>
import {
  AlertTriangle,
  Check,
  ChevronDown,
  Copy,
  Share2,
  Trash2,
  UserRoundX,
  VolumeX,
} from "@lucide/vue";
import { Menu } from "@ark-ui/vue/menu";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";

const conversationItems = [
  { value: "mute", label: "Mute conversation", icon: VolumeX },
  { value: "read", label: "Mark as read", icon: Check },
  { value: "report", label: "Report conversation", icon: AlertTriangle },
  { value: "block", label: "Block user", icon: UserRoundX },
  { value: "share", label: "Share conversation", icon: Share2 },
  { value: "copy", label: "Copy conversation", icon: Copy },
];
</script>

<template>
  <Menu.Root :positioning="{ placement: 'bottom-end', gutter: 6 }">
    <ButtonGroup aria-label="Follow actions">
      <Button variant="outline">Follow</Button>
      <Menu.Trigger as-child>
        <Button :icon="ChevronDown" shape="square" variant="outline" aria-label="More follow options" />
      </Menu.Trigger>
    </ButtonGroup>
    <Menu.Positioner class="button-group-positioner">
      <Menu.Content class="button-group-surface button-group-menu" aria-label="Conversation actions">
        <Menu.Item
          v-for="item in conversationItems"
          :key="item.value"
          :value="item.value"
          class="button-group-option"
        >
          <component :is="item.icon" aria-hidden="true" />
          {{ item.label }}
        </Menu.Item>
        <Menu.Separator class="button-group-separator" />
        <Menu.Item value="delete" class="button-group-option button-group-option--danger">
          <Trash2 aria-hidden="true" />
          Delete conversation
        </Menu.Item>
      </Menu.Content>
    </Menu.Positioner>
  </Menu.Root>
</template>

<style scoped>
.button-group-positioner {
  z-index: 30;
}

.button-group-surface {
  box-sizing: border-box;
  min-inline-size: 12rem;
  border: 1px solid var(--kappa-line, #e3e6eb);
  border-radius: 0.625rem;
  background: var(--kappa-control, #ffffff);
  color: var(--kappa-default, #17191f);
  font-family: var(--kappa-font-sans, inherit);
  font-size: 0.8125rem;
}

.button-group-surface[hidden] {
  display: none;
}
.button-group-option {
  display: flex;
  min-block-size: 2rem;
  align-items: center;
  gap: 0.5rem;
  padding-inline: 0.625rem;
  border-radius: 0.375rem;
  cursor: default;
  outline: none;
}

.button-group-option[data-highlighted] {
  background: var(--kappa-overlay, #e9edf2);
}

.button-group-option svg {
  inline-size: 1rem;
  block-size: 1rem;
  flex: none;
}

.button-group-option--danger {
  color: var(--kappa-danger-text, #b42318);
}

.button-group-option--danger[data-highlighted] {
  background: var(--kappa-danger-tint, #fee4e2);
}

.button-group-separator {
  block-size: 1px;
  margin-block: 0.375rem;
  background: var(--kappa-line, #e3e6eb);
}

.button-group-menu { inline-size: 13rem; padding: 0.375rem; }
</style>

Select

A Select trigger, amount field, and send action form one compact transfer control. Ark owns option navigation and selection.

<script setup>
import { Check, ChevronDown, ArrowRight } from "@lucide/vue";
import { Select, createListCollection } from "@ark-ui/vue/select";
import { ref } from "vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";

const currencyItems = [
  { label: "$", value: "$", name: "US Dollar" },
  { label: "€", value: "€", name: "Euro" },
  { label: "£", value: "£", name: "British Pound" },
];
const currencies = createListCollection({ items: currencyItems });
const currency = ref(["$"]);
</script>

<template>
  <Select.Root
    v-model="currency"
    :collection="currencies"
    name="currency"
    :positioning="{ placement: 'bottom-start', gutter: 6 }"
  >
    <ButtonGroup aria-label="Transfer amount">
      <ButtonGroup.Root aria-label="Amount and currency">
        <Select.Trigger as-child>
          <Button :icon="ChevronDown" icon-position="inline-end" variant="outline" aria-label="Currency">
            <Select.ValueText placeholder="$" />
          </Button>
        </Select.Trigger>
        <input
          class="button-group-native-input button-group-amount-input"
          data-slot="input"
          type="text"
          inputmode="decimal"
          aria-label="Amount"
          placeholder="10.00"
        />
      </ButtonGroup.Root>
      <ButtonGroup.Root aria-label="Transfer actions">
        <Button :icon="ArrowRight" shape="square" variant="outline" aria-label="Send amount" />
      </ButtonGroup.Root>
    </ButtonGroup>
    <Select.HiddenSelect />
    <Select.Positioner class="button-group-positioner">
      <Select.Content class="button-group-surface button-group-select" aria-label="Currencies">
        <Select.Item
          v-for="item in currencyItems"
          :key="item.value"
          :item="item"
          class="button-group-option"
        >
          <Select.ItemText>
            <span class="button-group-select__symbol">{{ item.label }}</span>
            <span>{{ item.name }}</span>
          </Select.ItemText>
          <Select.ItemIndicator class="button-group-select__indicator">
            <Check aria-hidden="true" />
          </Select.ItemIndicator>
        </Select.Item>
      </Select.Content>
    </Select.Positioner>
  </Select.Root>
</template>

<style scoped>
.button-group-native-input {
  box-sizing: border-box;
  inline-size: clamp(8rem, 48vw, 14rem);
  min-inline-size: 0;
  block-size: 2rem;
  flex: 1 1 auto;
  appearance: none;
  padding-block: 0;
  padding-inline: 0.625rem;
  border: 1px solid var(--kappa-line, #e3e6eb);
  border-radius: var(--kappa-button-group-radius, 0.5rem);
  background: var(--kappa-control, #ffffff);
  color: var(--kappa-default, #17191f);
  font-family: var(--kappa-font-sans, inherit);
  font-size: 0.8125rem;
  line-height: 1rem;
}

.button-group-native-input::placeholder {
  color: var(--kappa-muted, #9aa2ae);
}

.button-group-native-input:focus-visible {
  outline: 2px solid var(--kappa-focus, #4c63ff);
  outline-offset: 2px;
}

.button-group-native-input:disabled {
  cursor: not-allowed;
  opacity: var(--kappa-disabled-opacity, 0.56);
}

@media (forced-colors: active) {
  .button-group-native-input { border-color: ButtonText; }
  .button-group-native-input:focus-visible { outline-color: Highlight; }
}
.button-group-positioner {
  z-index: 30;
}

.button-group-surface {
  box-sizing: border-box;
  min-inline-size: 12rem;
  border: 1px solid var(--kappa-line, #e3e6eb);
  border-radius: 0.625rem;
  background: var(--kappa-control, #ffffff);
  color: var(--kappa-default, #17191f);
  font-family: var(--kappa-font-sans, inherit);
  font-size: 0.8125rem;
}

.button-group-surface[hidden] {
  display: none;
}
.button-group-option {
  display: flex;
  min-block-size: 2rem;
  align-items: center;
  gap: 0.5rem;
  padding-inline: 0.625rem;
  border-radius: 0.375rem;
  cursor: default;
  outline: none;
}

.button-group-option[data-highlighted] {
  background: var(--kappa-overlay, #e9edf2);
}

.button-group-option svg {
  inline-size: 1rem;
  block-size: 1rem;
  flex: none;
}

.button-group-option--danger {
  color: var(--kappa-danger-text, #b42318);
}

.button-group-option--danger[data-highlighted] {
  background: var(--kappa-danger-tint, #fee4e2);
}

.button-group-separator {
  block-size: 1px;
  margin-block: 0.375rem;
  background: var(--kappa-line, #e3e6eb);
}

.button-group-amount-input { inline-size: clamp(6rem, 28vw, 9rem); }
.button-group-select { min-inline-size: 11rem; padding: 0.375rem; }
.button-group-select__symbol { inline-size: 1.25rem; font-family: var(--kappa-font-mono, monospace); }
.button-group-select__indicator { margin-inline-start: auto; }
</style>

Popover

Use Ark Popover for focus, outside-interaction, and Escape handling. The positioner stays beside the ButtonGroup rather than inside it.

<script setup>
import { Bot, ChevronDown } from "@lucide/vue";
import { Popover } from "@ark-ui/vue/popover";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <Popover.Root :portalled="false" :positioning="{ placement: 'bottom-end', gutter: 6 }">
    <ButtonGroup aria-label="Copilot actions">
      <Button :icon="Bot" variant="outline">Copilot</Button>
      <Popover.Trigger as-child>
        <Button :icon="ChevronDown" shape="square" variant="outline" aria-label="Open Copilot task" />
      </Popover.Trigger>
    </ButtonGroup>
    <Popover.Positioner class="button-group-positioner">
      <Popover.Content class="button-group-surface button-group-popover">
        <Popover.Title class="button-group-popover__title">Start a new task with Copilot</Popover.Title>
        <Popover.Description class="button-group-popover__description">
          Describe your task in natural language.
        </Popover.Description>
        <label class="button-group-popover__label" for="copilot-task">Task description</label>
        <textarea
          id="copilot-task"
          class="button-group-popover__textarea"
          rows="4"
          placeholder="I need to..."
        ></textarea>
        <p class="button-group-popover__hint">Copilot will open a pull request for review.</p>
      </Popover.Content>
    </Popover.Positioner>
  </Popover.Root>
</template>

<style scoped>
.button-group-positioner {
  z-index: 30;
}

.button-group-surface {
  box-sizing: border-box;
  min-inline-size: 12rem;
  border: 1px solid var(--kappa-line, #e3e6eb);
  border-radius: 0.625rem;
  background: var(--kappa-control, #ffffff);
  color: var(--kappa-default, #17191f);
  font-family: var(--kappa-font-sans, inherit);
  font-size: 0.8125rem;
}

.button-group-surface[hidden] {
  display: none;
}

.button-group-popover {
  display: grid;
  inline-size: min(20rem, calc(100vw - 2rem));
  gap: 0.75rem;
  padding: 1rem;
}

.button-group-popover__title { font-size: 0.875rem; font-weight: 650; }
.button-group-popover__description,
.button-group-popover__hint { margin: 0; color: var(--kappa-subtle, #5f6875); line-height: 1.4; }
.button-group-popover__label { font-weight: 600; }

.button-group-popover__textarea {
  box-sizing: border-box;
  inline-size: 100%;
  min-block-size: 6rem;
  resize: vertical;
  padding: 0.625rem;
  border: 1px solid var(--kappa-line, #e3e6eb);
  border-radius: 0.5rem;
  background: var(--kappa-control, #ffffff);
  color: var(--kappa-default, #17191f);
  font: inherit;
}

.button-group-popover__textarea::placeholder { color: var(--kappa-muted, #9aa2ae); }
.button-group-popover__textarea:focus-visible {
  outline: 2px solid var(--kappa-focus, #4c63ff);
  outline-offset: 2px;
}
</style>

Text and asChild

Text supplies non-interactive context. Use asChild to keep a more suitable semantic child while Kappa applies the connected treatment. Text aligns with base-size controls; consumers remain responsible for the size of every interactive child.

Delta t
Courant
<script setup>
import { Gauge } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <ButtonGroup aria-label="Time step limit">
    <ButtonGroup.Text>Delta t</ButtonGroup.Text>
    <Button variant="outline">0.002 s</Button>
  </ButtonGroup>

  <ButtonGroup aria-label="Courant limit">
    <ButtonGroup.Text as-child>
      <strong><Gauge aria-hidden="true" /> Courant</strong>
    </ButtonGroup.Text>
    <Button variant="outline">0.8</Button>
  </ButtonGroup>
</template>

LinkButton remains a real anchor when navigation belongs beside related actions.

RTL

Kappa uses logical borders and corner radii. Set the surrounding direction and choose directional icons that match the reading direction.

<script setup>
import { ArrowRight, Plus } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { ButtonGroup } from "@dicehub/kappa/components/button-group";
</script>

<template>
  <div dir="rtl">
    <ButtonGroup aria-label="إجراءات الحالة">
      <Button :icon="ArrowRight" shape="square" variant="outline" aria-label="العودة" />
      <Button :icon="Plus" variant="outline">حالة جديدة</Button>
      <Button variant="outline">تصدير</Button>
    </ButtonGroup>
  </div>
</template>

Accessibility

  • The root has role="group". Add aria-label or aria-labelledby when context does not name it.
  • Tab and Shift+Tab move through every control. Button Group does not add arrow-key navigation or roving focus.
  • Every icon-only button needs its own concise accessible name.
  • Separator is intentionally semantic, matching the official Base Separator contract. It exposes its axis with role="separator" and aria-orientation.
  • Use toggle controls for pressed or selected states. Button Group manages layout only.
  • Focus, disabled, loading, dark-mode, and reduced-motion behavior stay owned by each child component.

API Reference

ButtonGroup

Renders a semantic group and forwards remaining HTML attributes and listeners.

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Sets the visual axis. It does not add arrow-key navigation.

ButtonGroup.Text

PropTypeDefaultDescription
asChildbooleanfalseMerges text styling and attributes onto one semantic child.

ButtonGroup.Separator

PropTypeDefaultDescription
orientation"horizontal" | "vertical""vertical"Sets the separator axis. Use horizontal inside a vertical group.

Exports

ExportDescription
ButtonGroupRoot component with Root, Text, and Separator compound parts.
ButtonGroupRootUnaugmented semantic group component.
ButtonGroupTextStatic text or an asChild semantic element inside the group.
ButtonGroupSeparatorAccessible visual separator between controls.
BUTTON_GROUP_ORIENTATIONSReadonly horizontal and vertical option list.
BUTTON_GROUP_DEFAULT_ORIENTATIONPublic horizontal root default.
BUTTON_GROUP_SEPARATOR_DEFAULT_ORIENTATIONPublic vertical separator default.
isButtonGroupOrientationRuntime orientation guard.
resolveButtonGroupOrientationRuntime orientation resolver with a safe fallback.
resolveButtonGroupSeparatorOrientationResolves an explicit separator axis with a vertical fallback.
ButtonGroup*Props / ButtonGroup*SlotsPublic Vue part contracts.
ButtonGroupOrientationPublic orientation union.