Skip to content

Button Group

Group multiple button-like elements together.

Demo

This requires the following keys to be installed:

This requires the following theme to be installed:

Component

ButtonGroup.vue
vue
<script lang="ts">
import type { VariantProps } from 'tailwind-variants'
import { buttonGroupInjectionKey } from '@/ui/keys/button-group'
import theme from '@/ui/theme/button-group'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, provide } from 'vue'

const buttonGroup = tv(theme)

type ButtonGroupVariants = VariantProps<typeof buttonGroup>

export interface ButtonGroupProps {
  as?: any
  size?: ButtonGroupVariants['size']
  orientation?: ButtonGroupVariants['orientation']
  class?: any
}

export interface ButtonGroupSlots {
  default: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<ButtonGroupProps>(), {
  orientation: 'horizontal',
})
defineSlots<ButtonGroupSlots>()

provide(buttonGroupInjectionKey, computed(() => ({
  orientation: props.orientation,
  size: props.size,
})))
</script>

<template>
  <Primitive :as="as" :class="buttonGroup({ orientation, class: props.class })">
    <slot />
  </Primitive>
</template>
ButtonGroup.vue
vue
<script lang="ts">
import type { VariantProps } from 'tailwind-variants'
import { buttonGroupInjectionKey } from '@/UI/Keys/button-group'
import theme from '@/UI/Theme/button-group'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, provide } from 'vue'

const buttonGroup = tv(theme)

type ButtonGroupVariants = VariantProps<typeof buttonGroup>

export interface ButtonGroupProps {
  as?: any
  size?: ButtonGroupVariants['size']
  orientation?: ButtonGroupVariants['orientation']
  class?: any
}

export interface ButtonGroupSlots {
  default: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<ButtonGroupProps>(), {
  orientation: 'horizontal',
})
defineSlots<ButtonGroupSlots>()

provide(buttonGroupInjectionKey, computed(() => ({
  orientation: props.orientation,
  size: props.size,
})))
</script>

<template>
  <Primitive :as="as" :class="buttonGroup({ orientation, class: props.class })">
    <slot />
  </Primitive>
</template>

Theme

button-group.ts
ts
export const buttonGroupVariant = {
  buttonGroup: {
    horizontal: 'not-only:first:rounded-e-none not-only:last:rounded-s-none not-last:not-first:rounded-none',
    vertical: 'not-only:first:rounded-b-none not-only:last:rounded-t-none not-last:not-first:rounded-none',
  },
}

export const buttonGroupVariantWithRoot = {
  buttonGroup: {
    horizontal: {
      root: 'group',
      base: 'group-not-only:group-first:rounded-e-none group-not-only:group-last:rounded-s-none group-not-last:group-not-first:rounded-none',
    },
    vertical: {
      root: 'group',
      base: 'group-not-only:group-first:rounded-b-none group-not-only:group-last:rounded-t-none group-not-last:group-not-first:rounded-none',
    },
  },
}

export default {
  base: '',
  variants: {
    size: {
      xs: '',
      sm: '',
      md: '',
      lg: '',
      xl: '',
    },
    orientation: {
      horizontal: '',
      vertical: '',
    },
  },
}
View Nuxt UI theme
button-group.ts
ts
export const buttonGroupVariant = {
  buttonGroup: {
    horizontal: 'not-only:first:rounded-e-none not-only:last:rounded-s-none not-last:not-first:rounded-none',
    vertical: 'not-only:first:rounded-b-none not-only:last:rounded-t-none not-last:not-first:rounded-none',
  },
}

export const buttonGroupVariantWithRoot = {
  buttonGroup: {
    horizontal: {
      root: 'group',
      base: 'group-not-only:group-first:rounded-e-none group-not-only:group-last:rounded-s-none group-not-last:group-not-first:rounded-none',
    },
    vertical: {
      root: 'group',
      base: 'group-not-only:group-first:rounded-b-none group-not-only:group-last:rounded-t-none group-not-last:group-not-first:rounded-none',
    },
  },
}

export default {
  base: 'relative',
  variants: {
    size: {
      xs: '',
      sm: '',
      md: '',
      lg: '',
      xl: '',
    },
    orientation: {
      horizontal: 'inline-flex -space-x-px',
      vertical: 'flex flex-col -space-y-px',
    },
  },
}

Test

To test this component, you can use the following test file:

ButtonGroup.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Button from '@/ui/components/Button.vue'
import ButtonGroup from '@/ui/components/ButtonGroup.vue'
import Input from '@/ui/components/Input.vue'
import theme from '@/ui/theme/button-group'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('button-group', () => {
  const sizes = Object.keys(theme.variants.size) as any

  it.each<[string, RenderOptions<typeof ButtonGroup>]>([
    // Props
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'absolute' } }],
    // Slots
    ['with default slot', {
      slots: {
        default: {
          components: { Input, Button },
          template: `<Input /> <Button> Click me! </Button>`,
        },
      },
    }],
    ['orientation vertical with default slot', {
      props: { orientation: 'vertical' },
      slots: {
        default: {
          components: { Input, Button },
          template: `<Input /> <Button> Click me! </Button>`,
        },
      },
    }],
    ...sizes.map((size: string) =>
      [`with size ${size}`, { props: { size }, slots: {
        default: {
          components: { Input, Button },
          template: `<Input /> <Button> Click me! </Button>`,
        },
      } }],
    ),
  ])('renders %s correctly', (name, options) => {
    render(ButtonGroup, {
      attrs: {
        'data-testid': 'button-group',
      },
      ...options,
    })

    expect(screen.getByTestId('button-group')).matchSnapshot()
  })
})
ButtonGroup.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Button from '@/UI/Components/Button.vue'
import ButtonGroup from '@/UI/Components/ButtonGroup.vue'
import Input from '@/UI/Components/Input.vue'
import theme from '@/UI/Theme/button-group'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('button-group', () => {
  const sizes = Object.keys(theme.variants.size) as any

  it.each<[string, RenderOptions<typeof ButtonGroup>]>([
    // Props
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'absolute' } }],
    // Slots
    ['with default slot', {
      slots: {
        default: {
          components: { Input, Button },
          template: `<Input /> <Button> Click me! </Button>`,
        },
      },
    }],
    ['orientation vertical with default slot', {
      props: { orientation: 'vertical' },
      slots: {
        default: {
          components: { Input, Button },
          template: `<Input /> <Button> Click me! </Button>`,
        },
      },
    }],
    ...sizes.map((size: string) =>
      [`with size ${size}`, { props: { size }, slots: {
        default: {
          components: { Input, Button },
          template: `<Input /> <Button> Click me! </Button>`,
        },
      } }],
    ),
  ])('renders %s correctly', (name, options) => {
    render(ButtonGroup, {
      attrs: {
        'data-testid': 'button-group',
      },
      ...options,
    })

    expect(screen.getByTestId('button-group')).matchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
0bf32 - feat: create ButtonGroup component (#91) on 12/31/2024