Skip to content

ButtonGroup# ButtonGroup

ButtonGroup component for organizing and aligning buttons with controlled spacing. Supports propagation of size and variant props to child buttons and special border handling for connected buttons.

Usage examples

Basic ButtonGroup

Simple group with default gap

vue
<template>
  <SuButtonGroup>
    <SuButton>First</SuButton>
    <SuButton>Second</SuButton>
    <SuButton>Third</SuButton>
  </SuButtonGroup>
</template>

Spacing (gap)

Different spacings

Gap Small

Gap Medium (default)

Gap Large

Gap None (connected)

vue
<template>
  <div>
    <!-- Small spacing -->
    <SuButtonGroup gap="sm">
      <SuButton>Action 1</SuButton>
      <SuButton>Action 2</SuButton>
      <SuButton>Action 3</SuButton>
    </SuButtonGroup>
    
    <!-- Medium spacing (default) -->
    <SuButtonGroup gap="md">
      <SuButton>Action 1</SuButton>
      <SuButton>Action 2</SuButton>
      <SuButton>Action 3</SuButton>
    </SuButtonGroup>
    
    <!-- Large spacing -->
    <SuButtonGroup gap="lg">
      <SuButton>Action 1</SuButton>
      <SuButton>Action 2</SuButton>
      <SuButton>Action 3</SuButton>
    </SuButtonGroup>
    
    <!-- Connected buttons -->
    <SuButtonGroup gap="none">
      <SuButton>First</SuButton>
      <SuButton>Middle</SuButton>
      <SuButton>Last</SuButton>
    </SuButtonGroup>
  </div>
</template>

Size propagation

Forced size on all buttons

Small size forced

Medium size forced

Large size forced

vue
<template>
  <div>
    <!-- All buttons will be small size -->
    <SuButtonGroup size="sm">
      <SuButton>Small 1</SuButton>
      <SuButton>Small 2</SuButton>
      <SuButton>Small 3</SuButton>
    </SuButtonGroup>
    
    <!-- All buttons will be large size -->
    <SuButtonGroup size="lg">
      <SuButton>Large 1</SuButton>
      <SuButton>Large 2</SuButton>
      <SuButton>Large 3</SuButton>
    </SuButtonGroup>
  </div>
</template>

Variant propagation

Forced variant on all buttons

Primary variant forced

Outline variant forced

Ghost variant forced

vue
<template>
  <div>
    <!-- All buttons will be primary -->
    <SuButtonGroup variant="primary">
      <SuButton>Action 1</SuButton>
      <SuButton>Action 2</SuButton>
      <SuButton>Action 3</SuButton>
    </SuButtonGroup>
    
    <!-- All buttons will be outline -->
    <SuButtonGroup variant="outline">
      <SuButton>Action 1</SuButton>
      <SuButton>Action 2</SuButton>
      <SuButton>Action 3</SuButton>
    </SuButtonGroup>
  </div>
</template>

Connected buttons with variants

Connected buttons with different variants

Connected primary

Connected outline

Icon toolbar

vue
<script setup>
import { PlusIcon, CogIcon, TrashIcon } from '@heroicons/vue/24/outline'
</script>

<template>
  <div>
    <!-- Connected primary buttons -->
    <SuButtonGroup gap="none" variant="primary">
      <SuButton>Left</SuButton>
      <SuButton>Center</SuButton>
      <SuButton>Right</SuButton>
    </SuButtonGroup>
    
    <!-- Icon toolbar -->
    <SuButtonGroup gap="none" variant="secondary" size="sm">
      <SuButton :icon="PlusIcon" iconDisplay="only" aria-label="Add" />
      <SuButton :icon="CogIcon" iconDisplay="only" aria-label="Settings" />
      <SuButton :icon="TrashIcon" iconDisplay="only" aria-label="Delete" />
    </SuButtonGroup>
  </div>
</template>

API

Props

PropTypeDefaultDescription
gap'sm' | 'md' | 'lg' | 'none''md'Spacing between buttons
size'sm' | 'md' | 'lg'undefinedForced size for all buttons
variant'primary' | 'secondary' | 'outline' | 'ghost'undefinedForced variant for all buttons

Accessibility attributes

PropTypeDefaultDescription
ariaLabelstringundefinedAccessible label for the group
ariaDescribedBystringundefinedID of the description element
rolestringundefinedCustom ARIA role (e.g. 'toolbar', 'group')

Slots

SlotDescription
defaultButtons to display in the group

Prop behavior

🔄 Automatic propagation

When size or variant are defined on ButtonGroup, they override the props of child buttons automatically:

vue
<!-- All buttons will have 'lg' size and 'primary' variant -->
<SuButtonGroup size="lg" variant="primary">
  <SuButton size="sm" variant="outline">Button 1</SuButton>  <!-- Becomes lg + primary -->
  <SuButton>Button 2</SuButton>                              <!-- Becomes lg + primary -->
  <SuButton variant="ghost">Button 3</SuButton>              <!-- Becomes lg + primary -->
</SuButtonGroup>

🎯 Content validation

The component automatically checks that only Button components are passed in the slot:

  • Button components: Processed and styled normally
  • ⚠️ Other components: Warning in console and element ignored
  • Comments/text: Silently ignored (normal Vue behavior)

Spacing and borders

📏 Gap values

  • gap="sm" : 0.25rem (4px)
  • gap="md" : 0.5rem (8px) - default
  • gap="lg" : 0.75rem (12px)
  • gap="none" : 0px with connected borders

🔗 Gap "none" - Connected buttons

When gap="none", buttons are visually connected:

  • Overlapping borders: margin-left: -1px to avoid double borders
  • Adapted border-radius:
    • First button: left corners rounded only
    • Middle buttons: no rounded corners
    • Last button: right corners rounded only
    • Single button: all corners rounded
  • Smart z-index: Focus, hover and active have higher z-index

Accessibility

The ButtonGroup component follows WCAG 2.1 AA standards:

✅ Accessibility features

  • ARIA roles: Support for toolbar, group, etc. roles
  • Group labels: aria-label to describe the group
  • Keyboard navigation: Preserves Tab navigation between buttons
  • Visible focus: Z-index management for focus visibility
  • Descriptions: Support for aria-describedby for descriptions

🎯 Best practices

vue
<!-- Action toolbar -->
<SuButtonGroup 
  gap="sm" 
  role="toolbar" 
  aria-label="Formatting actions"
>
  <SuButton aria-label="Bold">B</SuButton>
  <SuButton aria-label="Italic">I</SuButton>
  <SuButton aria-label="Underline">U</SuButton>
</SuButtonGroup>

<!-- Page navigation -->
<SuButtonGroup 
  gap="none" 
  variant="outline"
  role="group" 
  aria-label="Page navigation"
>
  <SuButton>Page 1</SuButton>
  <SuButton>Page 2</SuButton>
  <SuButton>Page 3</SuButton>
</SuButtonGroup>

<!-- CRUD actions -->
<SuButtonGroup gap="sm" aria-label="Item actions">
  <SuButton variant="primary" :icon="PlusIcon">Create</SuButton>
  <SuButton variant="secondary" :icon="CogIcon">Edit</SuButton>
  <SuButton variant="outline" :icon="TrashIcon">Delete</SuButton>
</SuButtonGroup>

Advanced usage examples

Formatting toolbar

vue
<script setup>
import { ref } from 'vue'
import { 
  BoldIcon, 
  ItalicIcon, 
  UnderlineIcon,
  AlignLeftIcon,
  AlignCenterIcon,
  AlignRightIcon
} from '@heroicons/vue/24/outline'

const formatting = ref({
  bold: false,
  italic: false,
  underline: false,
  align: 'left'
})
</script>

<template>
  <div>
    <div class="editor-toolbar">
      <!-- Text formatting group -->
      <SuButtonGroup 
        gap="none" 
        variant="ghost" 
        size="sm"
        role="toolbar"
        aria-label="Text formatting"
      >
        <SuButton 
          :icon="BoldIcon" 
          iconDisplay="only"
          :aria-pressed="formatting.bold"
          aria-label="Bold"
          @click="formatting.bold = !formatting.bold"
        />
        <SuButton 
          :icon="ItalicIcon" 
          iconDisplay="only"
          :aria-pressed="formatting.italic"
          aria-label="Italic"
          @click="formatting.italic = !formatting.italic"
        />
        <SuButton 
          :icon="UnderlineIcon" 
          iconDisplay="only"
          :aria-pressed="formatting.underline"
          aria-label="Underline"
          @click="formatting.underline = !formatting.underline"
        />
      </SuButtonGroup>
      
      <!-- Alignment group -->
      <SuButtonGroup 
        gap="none" 
        variant="outline" 
        size="sm"
        role="toolbar"
        aria-label="Text alignment"
      >
        <SuButton 
          :icon="AlignLeftIcon" 
          iconDisplay="only"
          :aria-pressed="formatting.align === 'left'"
          aria-label="Align left"
          @click="formatting.align = 'left'"
        />
        <SuButton 
          :icon="AlignCenterIcon" 
          iconDisplay="only"
          :aria-pressed="formatting.align === 'center'"
          aria-label="Center"
          @click="formatting.align = 'center'"
        />
        <SuButton 
          :icon="AlignRightIcon" 
          iconDisplay="only"
          :aria-pressed="formatting.align === 'right'"
          aria-label="Align right"
          @click="formatting.align = 'right'"
        />
      </SuButtonGroup>
    </div>
  </div>
</template>

<style scoped>
.editor-toolbar {
  display: flex;
  gap: 1rem;
  padding: 0.75rem;
  background-color: #f9fafb;
  border-radius: 0.5rem;
  border: 1px solid #e5e7eb;
}
</style>

CRUD actions

vue
<script setup>
import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/vue/24/outline'

const handleCreate = () => console.log('Create')
const handleEdit = () => console.log('Edit')
const handleDelete = () => console.log('Delete')
</script>

<template>
  <div>
    <div class="crud-actions">
      <h3>Available actions</h3>
      
      <!-- Main actions -->
      <SuButtonGroup gap="sm" aria-label="Main actions">
        <SuButton 
          variant="primary" 
          :icon="PlusIcon" 
          iconDisplay="left"
          @click="handleCreate"
        >
          Create
        </SuButton>
        <SuButton 
          variant="secondary" 
          :icon="PencilIcon" 
          iconDisplay="left"
          @click="handleEdit"
        >
          Edit
        </SuButton>
        <SuButton 
          variant="outline" 
          :icon="TrashIcon" 
          iconDisplay="left"
          @click="handleDelete"
        >
          Delete
        </SuButton>
      </SuButtonGroup>
    </div>
  </div>
</template>
vue
<script setup>
import { ref } from 'vue'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'

const currentPage = ref(2)
const totalPages = 5

const goToPage = (page) => {
  currentPage.value = page
}

const previousPage = () => {
  if (currentPage.value > 1) currentPage.value--
}

const nextPage = () => {
  if (currentPage.value < totalPages) currentPage.value++
}
</script>

<template>
  <div>
    <nav aria-label="Page navigation">
      <SuButtonGroup 
        gap="none" 
        variant="outline"
        role="group"
        aria-label="Pages"
      >
        <SuButton 
          :icon="ChevronLeftIcon" 
          iconDisplay="only"
          :disabled="currentPage === 1"
          aria-label="Previous page"
          @click="previousPage"
        />
        
        <SuButton 
          v-for="page in totalPages"
          :key="page"
          :aria-pressed="currentPage === page"
          :variant="currentPage === page ? 'primary' : undefined"
          @click="goToPage(page)"
        >
          {{ page }}
        </SuButton>
        
        <SuButton 
          :icon="ChevronRightIcon" 
          iconDisplay="only"
          :disabled="currentPage === totalPages"
          aria-label="Next page"
          @click="nextPage"
        />
      </SuButtonGroup>
    </nav>
  </div>
</template>

Slot content control

The ButtonGroup component automatically checks the content of its slot:

✅ Accepted elements

  • Design system Button components
  • Vue comments (ignored)
  • Empty text nodes (ignored)

⚠️ Rejected elements

  • Other components or HTML elements
  • Warning in development console
  • Element ignored in render

🔍 Validation example

vue
<!-- ✅ Correct -->
<SuButtonGroup>
  <SuButton>Button 1</SuButton>
  <SuButton>Button 2</SuButton>
  <!-- Comment ignored -->
</SuButtonGroup>

<!-- ⚠️ Warning in console -->
<SuButtonGroup>
  <SuButton>Valid button</SuButton>
  <div>Non-button element</div> <!-- Ignored with warning -->
  <SuButton>Another valid button</SuButton>
</SuButtonGroup>

This approach ensures visual consistency while informing developers of potential issues.

Publié sous licence MIT.