Skip to main content
Nexus Design System
GitHub

ToggleGroup

ToggleGroup is a set of related toggles, such as text alignment or Bold, Italic and Underline. Use type="single" for one choice or type="multiple" for several. Icon-only items need an aria-label.

'use client';

import {
  TextAlignCenterIcon,
  TextAlignLeftIcon,
  TextAlignRightIcon,
} from '@phosphor-icons/react/ssr';

import {
  ToggleGroup,
  ToggleGroupItem,
} from '@/components/nxds/components/toggle-group/toggle-group';

export default function ToggleGroupDefault() {
  return (
    <ToggleGroup type="single" defaultValue="left">
      <ToggleGroupItem value="left" aria-label="Align left">
        <TextAlignLeftIcon />
      </ToggleGroupItem>
      <ToggleGroupItem value="center" aria-label="Align center">
        <TextAlignCenterIcon />
      </ToggleGroupItem>
      <ToggleGroupItem value="right" aria-label="Align right">
        <TextAlignRightIcon />
      </ToggleGroupItem>
    </ToggleGroup>
  );
}

This example also needs:

npm install @phosphor-icons/react@^2.1.10

Installation

npx nxds-cli@latest add toggle-group

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  ToggleGroup,
  ToggleGroupItem,
} from '@/components/nxds/components/toggle-group/toggle-group';

Composition

ToggleGroup
└── ToggleGroupItem

Examples

Multiple

Set type="multiple" to let users press several items at once.

'use client';

import {
  TextBIcon,
  TextItalicIcon,
  TextUnderlineIcon,
} from '@phosphor-icons/react/ssr';

import {
  ToggleGroup,
  ToggleGroupItem,
} from '@/components/nxds/components/toggle-group/toggle-group';

export default function ToggleGroupMultiple() {
  return (
    <ToggleGroup type="multiple" defaultValue={['bold']}>
      <ToggleGroupItem value="bold" aria-label="Bold">
        <TextBIcon />
      </ToggleGroupItem>
      <ToggleGroupItem value="italic" aria-label="Italic">
        <TextItalicIcon />
      </ToggleGroupItem>
      <ToggleGroupItem value="underline" aria-label="Underline">
        <TextUnderlineIcon />
      </ToggleGroupItem>
    </ToggleGroup>
  );
}

Outline

Set variant="outline" on the group to style every item.

'use client';

import {
  TextAlignCenterIcon,
  TextAlignLeftIcon,
  TextAlignRightIcon,
} from '@phosphor-icons/react/ssr';

import {
  ToggleGroup,
  ToggleGroupItem,
} from '@/components/nxds/components/toggle-group/toggle-group';

export default function ToggleGroupOutline() {
  return (
    <ToggleGroup type="single" variant="outline" defaultValue="left">
      <ToggleGroupItem value="left" aria-label="Align left">
        <TextAlignLeftIcon />
      </ToggleGroupItem>
      <ToggleGroupItem value="center" aria-label="Align center">
        <TextAlignCenterIcon />
      </ToggleGroupItem>
      <ToggleGroupItem value="right" aria-label="Align right">
        <TextAlignRightIcon />
      </ToggleGroupItem>
    </ToggleGroup>
  );
}

Spacing

Leave spacing at 0 for a joined control, or raise it for separate pills.

'use client';

import {
  TextAlignCenterIcon,
  TextAlignLeftIcon,
  TextAlignRightIcon,
  TextBIcon,
  TextItalicIcon,
  TextUnderlineIcon,
} from '@phosphor-icons/react/ssr';

import {
  ToggleGroup,
  ToggleGroupItem,
} from '@/components/nxds/components/toggle-group/toggle-group';

export default function ToggleGroupSpacing() {
  return (
    <div className="nx:flex nx:flex-col nx:gap-4">
      <ToggleGroup type="single" variant="outline" defaultValue="left">
        <ToggleGroupItem value="left" aria-label="Align left">
          <TextAlignLeftIcon />
        </ToggleGroupItem>
        <ToggleGroupItem value="center" aria-label="Align center">
          <TextAlignCenterIcon />
        </ToggleGroupItem>
        <ToggleGroupItem value="right" aria-label="Align right">
          <TextAlignRightIcon />
        </ToggleGroupItem>
      </ToggleGroup>
      <ToggleGroup
        type="single"
        variant="outline"
        spacing={2}
        defaultValue="left"
      >
        <ToggleGroupItem value="left" aria-label="Align left (spaced)">
          <TextAlignLeftIcon />
        </ToggleGroupItem>
        <ToggleGroupItem value="center" aria-label="Align center (spaced)">
          <TextAlignCenterIcon />
        </ToggleGroupItem>
        <ToggleGroupItem value="right" aria-label="Align right (spaced)">
          <TextAlignRightIcon />
        </ToggleGroupItem>
      </ToggleGroup>
      <ToggleGroup
        type="multiple"
        variant="outline-primary"
        defaultValue={['bold', 'italic']}
      >
        <ToggleGroupItem value="bold" aria-label="Outline primary bold">
          <TextBIcon />
        </ToggleGroupItem>
        <ToggleGroupItem value="italic" aria-label="Outline primary italic">
          <TextItalicIcon />
        </ToggleGroupItem>
        <ToggleGroupItem
          value="underline"
          aria-label="Outline primary underline"
        >
          <TextUnderlineIcon />
        </ToggleGroupItem>
      </ToggleGroup>
    </div>
  );
}

Formatting settings

Use toggle groups for text formatting and paragraph style inside a settings form.

'use client';

import { useId } from 'react';

import {
  TextBIcon,
  TextItalicIcon,
  TextUnderlineIcon,
} from '@phosphor-icons/react/ssr';

import { Button } from '@/components/nxds/components/button/button';
import { Input } from '@/components/nxds/components/input/input';
import { Label } from '@/components/nxds/components/label/label';
import {
  ToggleGroup,
  ToggleGroupItem,
} from '@/components/nxds/components/toggle-group/toggle-group';

export default function ToggleGroupFormattingSettings() {
  const titleId = useId();

  return (
    <section
      className="nx:flex nx:max-w-full nx:flex-col nx:gap-6"
      aria-label="Formatting settings"
    >
      <div className="nx:flex nx:flex-col nx:gap-2">
        <Label htmlFor={titleId}>Document title</Label>
        <Input id={titleId} defaultValue="Assessment instructions" />
      </div>
      <div className="nx:flex nx:flex-wrap nx:items-center nx:gap-4">
        <ToggleGroup
          type="multiple"
          variant="outline-primary"
          defaultValue={['bold']}
          aria-label="Text formatting"
        >
          <ToggleGroupItem value="bold" aria-label="Bold">
            <TextBIcon />
          </ToggleGroupItem>
          <ToggleGroupItem value="italic" aria-label="Italic">
            <TextItalicIcon />
          </ToggleGroupItem>
          <ToggleGroupItem value="underline" aria-label="Underline">
            <TextUnderlineIcon />
          </ToggleGroupItem>
        </ToggleGroup>
        <Button variant="outline">Reset formatting</Button>
        <Button>Save settings</Button>
      </div>
      <ToggleGroup
        type="single"
        variant="outline-primary"
        spacing={2}
        aria-label="Paragraph style"
        className="nx:flex-wrap"
      >
        <ToggleGroupItem value="body">Body text</ToggleGroupItem>
        <ToggleGroupItem value="heading">Section heading</ToggleGroupItem>
      </ToggleGroup>
    </section>
  );
}

This example also needs: Button, Input, Label.

Disabled

Disable the group to disable every item in it.

'use client';

import {
  TextAlignCenterIcon,
  TextAlignLeftIcon,
} from '@phosphor-icons/react/ssr';

import {
  ToggleGroup,
  ToggleGroupItem,
} from '@/components/nxds/components/toggle-group/toggle-group';

export default function ToggleGroupDisabled() {
  return (
    <ToggleGroup type="single" defaultValue="left" disabled>
      <ToggleGroupItem value="left" aria-label="Align left">
        <TextAlignLeftIcon />
      </ToggleGroupItem>
      <ToggleGroupItem value="center" aria-label="Align center">
        <TextAlignCenterIcon />
      </ToggleGroupItem>
    </ToggleGroup>
  );
}

Props

ToggleGroup

ToggleGroup props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | null"default"No description
spacing0 | 1 | 2 | 4 | 3 | 5 | 6 | 7 | 8 | 0.5 | 1.5 | 2.5 | 3.5 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 960

Gap between items, in spacing-scale units. 0 joins them into one segmented control. A larger value splits them into separate pills.

variant"default" | "outline" | "outline-primary" | null"default"No description

ToggleGroupItem

ToggleGroupItem props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | null"default"No description
variant"default" | "outline" | "outline-primary" | null"default"No description

ToggleGroup is a set of related toggles, such as text alignment or Bold, Italic and Underline. Use type="single" for one choice or type="multiple" for several. Icon-only items need an aria-label.

<script setup lang="ts">
import {
  PhTextAlignCenter,
  PhTextAlignLeft,
  PhTextAlignRight,
} from '@phosphor-icons/vue';

import { ToggleGroup, ToggleGroupItem } from '@/components/toggle-group';
</script>

<template>
  <ToggleGroup type="single" default-value="left">
    <ToggleGroupItem value="left" aria-label="Align left">
      <PhTextAlignLeft />
    </ToggleGroupItem>
    <ToggleGroupItem value="center" aria-label="Align center">
      <PhTextAlignCenter />
    </ToggleGroupItem>
    <ToggleGroupItem value="right" aria-label="Align right">
      <PhTextAlignRight />
    </ToggleGroupItem>
  </ToggleGroup>
</template>

This example also needs:

npm install @phosphor-icons/vue@^2.2.1

Installation

  1. Install these components first:

    Toggle.

  2. Copy and paste the following code into your project.

    components/toggle-group/ToggleGroup.vue
    <script setup lang="ts">
    import type { SpacingStep } from '@nxds/core';
    import { reactiveOmit } from '@vueuse/core';
    import {
      type AcceptableValue,
      ToggleGroupRoot,
      type ToggleGroupRootProps,
      useForwardProps,
    } from 'reka-ui';
    import { type HTMLAttributes, provide } from 'vue';
    
    import { cn } from '../../lib/utils';
    import type { ToggleVariants } from '../toggle';
    
    import { ToggleGroupKey } from '.';
    
    export interface ToggleGroupProps extends ToggleGroupRootProps<
      string | string[]
    > {
      variant?: ToggleVariants['variant'];
      size?: ToggleVariants['size'];
      /**
       * Gap between items, in spacing-scale units. `0` joins them into one
       * segmented control. A larger value splits them into separate pills.
       */
      spacing?: SpacingStep;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ToggleGroupProps>(), {
      variant: 'default',
      size: 'default',
      spacing: 0,
      orientation: 'horizontal',
    });
    // Clearing a single selection emits undefined.
    const emits = defineEmits<{
      'update:modelValue': [value: string | string[] | undefined];
    }>();
    
    provide(ToggleGroupKey, props);
    
    const delegated = reactiveOmit(props, 'class', 'variant', 'size', 'spacing');
    const forwarded = useForwardProps(delegated);
    
    // Every item's value is a string, so Reka reports only strings.
    function update(value: AcceptableValue | AcceptableValue[]) {
      emits('update:modelValue', value as string | string[] | undefined);
    }
    </script>
    
    <template>
      <ToggleGroupRoot
        v-bind="forwarded"
        :style="
          spacing
            ? { gap: `var(--nx-spacing-${String(spacing).replace('.', '_')})` }
            : undefined
        "
        :class="
          cn(
            'nx:flex nx:w-fit nx:items-center nx:rounded-md nx:data-[orientation=vertical]:flex-col nx:data-[orientation=vertical]:items-stretch',
            spacing === 0 && 'nx:isolate',
            props.class
          )
        "
        data-slot="toggle-group"
        :data-variant="variant"
        :data-size="size"
        :data-spacing="spacing"
        :data-orientation="orientation"
        @update:model-value="update"
      >
        <slot />
      </ToggleGroupRoot>
    </template>
    
    components/toggle-group/ToggleGroupItem.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      ToggleGroupItem,
      type ToggleGroupItemProps as RekaToggleGroupItemProps,
      useForwardProps,
    } from 'reka-ui';
    import { computed, type HTMLAttributes, inject } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { type ToggleVariants, toggleVariants } from '../toggle';
    
    import { useToggleGroupSelectionAttrs } from './toggle-group-selection';
    
    import { ToggleGroupKey } from '.';
    
    // Joined bordered items overlap by one stroke, so each pair shares a single
    // edge. Whichever item stacks higher paints that edge: focus, then selected
    // invalid, invalid, selected, hover; equal items fall back to DOM order, so
    // the later one wins. Disabled items never climb, so they never claim an
    // edge. The focus rung repeats its class to outrank the (0,4,0)
    // selected-invalid rung by specificity rather than by emit order.
    const joinedItem = {
      base: 'nx:relative nx:rounded-none nx:not-disabled:hover:z-10 nx:not-disabled:data-[state=on]:z-20 nx:not-disabled:aria-invalid:z-30 nx:not-disabled:aria-invalid:data-[state=on]:z-40 nx:[&&&&:focus-visible]:z-50',
      horizontal:
        'nx:first:rounded-s-md nx:last:rounded-e-md nx:[[data-slot=toggle-group-item]:not([data-variant=default])+&:not([data-variant=default])]:-ms-(--nx-borderwidth-default)',
      vertical:
        'nx:first:rounded-t-md nx:last:rounded-b-md nx:[[data-slot=toggle-group-item]:not([data-variant=default])+&:not([data-variant=default])]:-mt-(--nx-borderwidth-default)',
    } as const;
    
    export interface ToggleGroupItemProps extends Omit<
      RekaToggleGroupItemProps,
      'value'
    > {
      /** Value the group reports while this item is pressed. */
      value: string;
      variant?: ToggleVariants['variant'];
      size?: ToggleVariants['size'];
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ToggleGroupItemProps>();
    
    const group = inject(ToggleGroupKey)!;
    
    const variant = computed(() => props.variant ?? group.variant);
    const size = computed(() => props.size ?? group.size);
    const selectionAttrs = useToggleGroupSelectionAttrs(() => props.value);
    
    const delegated = reactiveOmit(props, 'class', 'variant', 'size');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <ToggleGroupItem
        v-bind="{ ...forwarded, ...selectionAttrs }"
        :class="
          cn(
            toggleVariants({ variant, size }),
            'nx:min-w-0 nx:shrink-0',
            group.spacing === 0 && [joinedItem.base, joinedItem[group.orientation]],
            props.class
          )
        "
        data-slot="toggle-group-item"
        :data-variant="variant"
        :data-size="size"
        :data-spacing="group.spacing"
        :data-orientation="group.orientation"
      >
        <slot />
      </ToggleGroupItem>
    </template>
    
    components/toggle-group/index.ts
    import type { InjectionKey } from 'vue';
    
    import type { ToggleGroupProps } from './ToggleGroup.vue';
    
    export {
      default as ToggleGroup,
      type ToggleGroupProps,
    } from './ToggleGroup.vue';
    export {
      default as ToggleGroupItem,
      type ToggleGroupItemProps,
    } from './ToggleGroupItem.vue';
    
    export const ToggleGroupKey: InjectionKey<
      Required<
        Pick<ToggleGroupProps, 'variant' | 'size' | 'spacing' | 'orientation'>
      >
    > = Symbol('toggle-group');
    
    components/toggle-group/toggle-group-selection.ts
    import { injectToggleGroupRootContext } from 'reka-ui';
    import { computed } from 'vue';
    
    /**
     * A single-select group is a radio group, as Radix renders it: each item
     * reports aria-checked instead of Reka's aria-pressed.
     */
    export function useToggleGroupSelectionAttrs(value: () => string) {
      const root = injectToggleGroupRootContext();
    
      return computed(() => {
        if (!root.isSingle.value) return {};
        return {
          role: 'radio',
          'aria-checked': root.modelValue.value === value(),
          'aria-pressed': undefined,
        };
      });
    }
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { ToggleGroup, ToggleGroupItem } from '@/components/toggle-group';

Examples

Multiple

Set type="multiple" to let users press several items at once.

<script setup lang="ts">
import { PhTextB, PhTextItalic, PhTextUnderline } from '@phosphor-icons/vue';

import { ToggleGroup, ToggleGroupItem } from '@/components/toggle-group';
</script>

<template>
  <ToggleGroup type="multiple" :default-value="['bold']">
    <ToggleGroupItem value="bold" aria-label="Bold">
      <PhTextB />
    </ToggleGroupItem>
    <ToggleGroupItem value="italic" aria-label="Italic">
      <PhTextItalic />
    </ToggleGroupItem>
    <ToggleGroupItem value="underline" aria-label="Underline">
      <PhTextUnderline />
    </ToggleGroupItem>
  </ToggleGroup>
</template>

Outline

Set variant="outline" on the group to style every item.

<script setup lang="ts">
import {
  PhTextAlignCenter,
  PhTextAlignLeft,
  PhTextAlignRight,
} from '@phosphor-icons/vue';

import { ToggleGroup, ToggleGroupItem } from '@/components/toggle-group';
</script>

<template>
  <ToggleGroup type="single" variant="outline" default-value="left">
    <ToggleGroupItem value="left" aria-label="Align left">
      <PhTextAlignLeft />
    </ToggleGroupItem>
    <ToggleGroupItem value="center" aria-label="Align center">
      <PhTextAlignCenter />
    </ToggleGroupItem>
    <ToggleGroupItem value="right" aria-label="Align right">
      <PhTextAlignRight />
    </ToggleGroupItem>
  </ToggleGroup>
</template>

Spacing

Leave spacing at 0 for a joined control, or raise it for separate pills.

<script setup lang="ts">
import {
  PhTextAlignCenter,
  PhTextAlignLeft,
  PhTextAlignRight,
  PhTextB,
  PhTextItalic,
  PhTextUnderline,
} from '@phosphor-icons/vue';

import { ToggleGroup, ToggleGroupItem } from '@/components/toggle-group';
</script>

<template>
  <div class="nx:flex nx:flex-col nx:gap-4">
    <ToggleGroup type="single" variant="outline" default-value="left">
      <ToggleGroupItem value="left" aria-label="Align left">
        <PhTextAlignLeft />
      </ToggleGroupItem>
      <ToggleGroupItem value="center" aria-label="Align center">
        <PhTextAlignCenter />
      </ToggleGroupItem>
      <ToggleGroupItem value="right" aria-label="Align right">
        <PhTextAlignRight />
      </ToggleGroupItem>
    </ToggleGroup>
    <ToggleGroup
      type="single"
      variant="outline"
      :spacing="2"
      default-value="left"
    >
      <ToggleGroupItem value="left" aria-label="Align left (spaced)">
        <PhTextAlignLeft />
      </ToggleGroupItem>
      <ToggleGroupItem value="center" aria-label="Align center (spaced)">
        <PhTextAlignCenter />
      </ToggleGroupItem>
      <ToggleGroupItem value="right" aria-label="Align right (spaced)">
        <PhTextAlignRight />
      </ToggleGroupItem>
    </ToggleGroup>
    <ToggleGroup
      type="multiple"
      variant="outline-primary"
      :default-value="['bold', 'italic']"
    >
      <ToggleGroupItem value="bold" aria-label="Outline primary bold">
        <PhTextB />
      </ToggleGroupItem>
      <ToggleGroupItem value="italic" aria-label="Outline primary italic">
        <PhTextItalic />
      </ToggleGroupItem>
      <ToggleGroupItem value="underline" aria-label="Outline primary underline">
        <PhTextUnderline />
      </ToggleGroupItem>
    </ToggleGroup>
  </div>
</template>

Formatting settings

Use toggle groups for text formatting and paragraph style inside a settings form.

<script setup lang="ts">
import { PhTextB, PhTextItalic, PhTextUnderline } from '@phosphor-icons/vue';
import { useId } from 'vue';

import { Button } from '@/components/button';
import { Input } from '@/components/input';
import { Label } from '@/components/label';
import { ToggleGroup, ToggleGroupItem } from '@/components/toggle-group';

const titleId = useId();
</script>

<template>
  <section
    class="nx:flex nx:max-w-full nx:flex-col nx:gap-6"
    aria-label="Formatting settings"
  >
    <div class="nx:flex nx:flex-col nx:gap-2">
      <Label :for="titleId">Document title</Label>
      <Input :id="titleId" default-value="Assessment instructions" />
    </div>
    <div class="nx:flex nx:flex-wrap nx:items-center nx:gap-4">
      <ToggleGroup
        type="multiple"
        variant="outline-primary"
        :default-value="['bold']"
        aria-label="Text formatting"
      >
        <ToggleGroupItem value="bold" aria-label="Bold">
          <PhTextB />
        </ToggleGroupItem>
        <ToggleGroupItem value="italic" aria-label="Italic">
          <PhTextItalic />
        </ToggleGroupItem>
        <ToggleGroupItem value="underline" aria-label="Underline">
          <PhTextUnderline />
        </ToggleGroupItem>
      </ToggleGroup>
      <Button variant="outline">Reset formatting</Button>
      <Button>Save settings</Button>
    </div>
    <ToggleGroup
      type="single"
      variant="outline-primary"
      :spacing="2"
      aria-label="Paragraph style"
      class="nx:flex-wrap"
    >
      <ToggleGroupItem value="body">Body text</ToggleGroupItem>
      <ToggleGroupItem value="heading">Section heading</ToggleGroupItem>
    </ToggleGroup>
  </section>
</template>

This example also needs: Button, Input, Label.

Disabled

Disable the group to disable every item in it.

<script setup lang="ts">
import { PhTextAlignCenter, PhTextAlignLeft } from '@phosphor-icons/vue';

import { ToggleGroup, ToggleGroupItem } from '@/components/toggle-group';
</script>

<template>
  <ToggleGroup type="single" default-value="left" disabled>
    <ToggleGroupItem value="left" aria-label="Align left">
      <PhTextAlignLeft />
    </ToggleGroupItem>
    <ToggleGroupItem value="center" aria-label="Align center">
      <PhTextAlignCenter />
    </ToggleGroupItem>
  </ToggleGroup>
</template>

Props

ToggleGroup

ToggleGroup props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | null"default"No description
spacing0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 0.5 | 1.5 | 2.5 | 3.5 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 960

Gap between items, in spacing-scale units. 0 joins them into one segmented control. A larger value splits them into separate pills.

variant"default" | "outline" | "outline-primary" | null"default"No description

ToggleGroupItem

ToggleGroupItem props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | null"default"No description
valueRequiredstringNo default documented

Value the group reports while this item is pressed.

variant"default" | "outline" | "outline-primary" | null"default"No description