UIPackage
Menu

Framework

Change language

Boilerplate repo

Toggle Group

toggle-group ui
Boilerplate repo

Group of `Toggle` buttons that act as a single-select or multi-select control. Use for view-mode pickers (grid/list), text-format toolbars, and any "pick one of N" button bar.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/toggle-group.json
Named registry: npx shadcn-vue@latest add @uipkge/toggle-group Installs to: app/components/ui/toggle-group/

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
class HTMLAttributes['class'] optional
variant
'default''outline'
optional
size
'default''sm''lg'
optional
spacing number 0 optional
asChild boolean optional
as string | object optional
type
'single''multiple'
optional
modelValue string | string[] optional
defaultValue string | string[] optional
disabled boolean optional
loop boolean optional
orientation
'horizontal''vertical'
optional
rovingFocus boolean optional
dir
'ltr''rtl'
optional
animated

Sliding selection indicator for single-select (default true). Multi-select keeps item chrome.

boolean true optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

ToggleGroupContext
type ToggleGroupContext {
  variant?: Ref<'default' | 'outline' | undefined> | 'default' | 'outline'
  size?: Ref<'default' | 'sm' | 'lg' | undefined> | 'default' | 'sm' | 'lg'
  spacing?: Ref<number | undefined> | number
}

Used by

Files installed (3)

  • app/components/ui/toggle-group/ToggleGroup.vue 5.6 kB
    <script setup lang="ts">
    import type { ToggleGroupRootEmits } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
    import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue'
    import { cn } from '@/lib/utils'
    
    // Inlined unions: SFC compiler can't extract runtime props from
    // `VariantProps<typeof toggleVariants>['...']`. Same for the
    // reka-ui `ToggleGroupRootProps` (no exports.types). Inline the
    // surface we expose.
    const props = withDefaults(
      defineProps<{
        class?: HTMLAttributes['class']
        variant?: 'default' | 'outline'
        size?: 'default' | 'sm' | 'lg'
        spacing?: number
        asChild?: boolean
        as?: string | object
        type?: 'single' | 'multiple'
        modelValue?: string | string[]
        defaultValue?: string | string[]
        disabled?: boolean
        loop?: boolean
        orientation?: 'horizontal' | 'vertical'
        rovingFocus?: boolean
        dir?: 'ltr' | 'rtl'
        /** Sliding selection indicator for single-select (default true). Multi-select keeps item chrome. */
        animated?: boolean
      }>(),
      {
        spacing: 0,
        animated: true,
      },
    )
    
    const emits = defineEmits<ToggleGroupRootEmits>()
    
    // Provide reactive refs so items pick up live variant/size/spacing changes.
    provide('toggleGroup', {
      variant: toRef(props, 'variant'),
      size: toRef(props, 'size'),
      spacing: toRef(props, 'spacing'),
    })
    
    const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant', 'animated')
    const forwarded = useForwardPropsEmits(delegatedProps, emits)
    
    // Sliding pill only for single-select. Multi-select paints per-item surfaces.
    const indicatorActive = computed(() => props.animated !== false && props.type !== 'multiple')
    
    const listEl = ref<HTMLElement | null>(null)
    const indicatorStyle = ref<Record<string, string>>({
      opacity: '0',
    })
    let ro: ResizeObserver | null = null
    let mo: MutationObserver | null = null
    let firstPosition = true
    
    function resolveListEl(node: unknown): HTMLElement | null {
      if (!node) return null
      if (node instanceof HTMLElement) return node
      const el = (node as { $el?: unknown }).$el
      return el instanceof HTMLElement ? el : null
    }
    
    function setListRef(node: unknown) {
      listEl.value = resolveListEl(node)
    }
    
    function motionSafeTransition() {
      if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        return 'none'
      }
      return firstPosition
        ? 'none'
        : 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), height 220ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 220ms cubic-bezier(0.22, 1, 0.36, 1)'
    }
    
    function updateIndicator() {
      if (!indicatorActive.value) return
      const root = listEl.value
      if (!root) return
      const active = root.querySelector<HTMLElement>('[data-slot="toggle-group-item"][data-state="on"]')
      if (!active) {
        indicatorStyle.value = { opacity: '0' }
        return
      }
    
      const listRect = root.getBoundingClientRect()
      const activeRect = active.getBoundingClientRect()
      const left = activeRect.left - listRect.left + root.scrollLeft
      const top = activeRect.top - listRect.top + root.scrollTop
      const transition = motionSafeTransition()
    
      indicatorStyle.value = {
        width: `${activeRect.width}px`,
        height: `${activeRect.height}px`,
        transform: `translate3d(${left}px, ${top}px, 0)`,
        borderRadius: getComputedStyle(active).borderRadius,
        opacity: '1',
        transition,
      }
      firstPosition = false
    }
    
    function unbindObservers() {
      ro?.disconnect()
      mo?.disconnect()
      ro = null
      mo = null
    }
    
    function bindObservers() {
      const root = listEl.value
      if (!root || !indicatorActive.value) return
    
      unbindObservers()
    
      ro = new ResizeObserver(() => updateIndicator())
      ro.observe(root)
      root.querySelectorAll('[data-slot="toggle-group-item"]').forEach((el) => ro!.observe(el))
    
      mo = new MutationObserver((mutations) => {
        for (const m of mutations) {
          if (m.type === 'childList') {
            root.querySelectorAll('[data-slot="toggle-group-item"]').forEach((el) => ro?.observe(el))
          }
        }
        nextTick(updateIndicator)
      })
      mo.observe(root, {
        attributes: true,
        attributeFilter: ['data-state'],
        subtree: true,
        childList: true,
      })
    
      updateIndicator()
    }
    
    onMounted(() => {
      nextTick(() => {
        if (!listEl.value) {
          requestAnimationFrame(() => bindObservers())
        } else {
          bindObservers()
        }
      })
    })
    
    onBeforeUnmount(() => {
      unbindObservers()
    })
    
    watch(indicatorActive, (on) => {
      firstPosition = true
      if (on) nextTick(() => bindObservers())
      else {
        unbindObservers()
        indicatorStyle.value = { opacity: '0' }
      }
    })
    
    watch(
      () => [props.spacing, props.size, props.variant, props.orientation] as const,
      () => {
        firstPosition = true
        nextTick(updateIndicator)
      },
    )
    </script>
    
    <template>
      <ToggleGroupRoot
        :ref="setListRef"
        v-slot="slotProps"
        data-uipkge
        data-slot="toggle-group"
        :data-size="size"
        :data-variant="variant"
        :data-spacing="spacing"
        :data-animated="indicatorActive ? 'true' : 'false'"
        :style="{
          '--gap': spacing,
        }"
        v-bind="forwarded"
        :class="
          cn(
            'group/toggle-group relative flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
            props.class,
          )
        "
      >
        <span
          v-if="indicatorActive"
          data-slot="toggle-group-indicator"
          aria-hidden="true"
          class="pointer-events-none absolute top-0 left-0 z-0 bg-accent shadow-xs will-change-transform"
          :style="indicatorStyle"
        />
        <slot v-bind="slotProps" />
      </ToggleGroupRoot>
    </template>
  • app/components/ui/toggle-group/ToggleGroupItem.vue 2.8 kB
    <script setup lang="ts">
    import type { HTMLAttributes, Ref } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { ToggleGroupItem, useForwardProps } from 'reka-ui'
    import { computed, inject } from 'vue'
    import { cn } from '@/lib/utils'
    import { toggleVariants } from '@/components/ui/toggle'
    
    type ToggleGroupContext = {
      variant?: Ref<'default' | 'outline' | undefined> | 'default' | 'outline'
      size?: Ref<'default' | 'sm' | 'lg' | undefined> | 'default' | 'sm' | 'lg'
      spacing?: Ref<number | undefined> | number
    }
    
    // Inlined unions: SFC compiler can't extract runtime props from
    // indexed-access types. Same for reka-ui's ToggleGroupItemProps.
    const props = defineProps<{
      class?: HTMLAttributes['class']
      variant?: 'default' | 'outline'
      size?: 'default' | 'sm' | 'lg'
      asChild?: boolean
      as?: string | object
      value: string
      disabled?: boolean
    }>()
    
    const context = inject<ToggleGroupContext>('toggleGroup')
    
    function unwrap<T>(v: Ref<T> | T | undefined): T | undefined {
      if (v && typeof v === 'object' && 'value' in (v as object)) return (v as Ref<T>).value
      return v as T | undefined
    }
    
    const effectiveVariant = computed(() => unwrap(context?.variant) || props.variant)
    const effectiveSize = computed(() => unwrap(context?.size) || props.size)
    const effectiveSpacing = computed(() => unwrap(context?.spacing))
    
    const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
    const forwardedProps = useForwardProps(delegatedProps)
    </script>
    
    <template>
      <ToggleGroupItem
        v-slot="slotProps"
        data-uipkge
        data-slot="toggle-group-item"
        :data-variant="effectiveVariant"
        :data-size="effectiveSize"
        :data-spacing="effectiveSpacing"
        v-bind="forwardedProps"
        :class="
          cn(
            toggleVariants({
              variant: effectiveVariant,
              size: effectiveSize,
            }),
            // z-10 keeps label/icons above the sliding indicator. When the parent
            // has data-animated=true (single-select), on-state surface lives on the
            // indicator — suppress item bg so the pill can slide cleanly.
            'relative z-10 w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
            'group-data-[animated=true]/toggle-group:data-[state=on]:bg-transparent group-data-[animated=true]/toggle-group:data-[state=on]:hover:bg-transparent',
            // first/last-of-type (not first/last-child): sliding indicator is a sibling span
            // and must not steal end-cap rounding or the outline left border.
            'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first-of-type:rounded-l-md data-[spacing=0]:last-of-type:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first-of-type:border-l',
            props.class,
          )
        "
      >
        <slot v-bind="slotProps" />
      </ToggleGroupItem>
    </template>
  • app/components/ui/toggle-group/index.ts 0.1 kB
    export { default as ToggleGroup } from './ToggleGroup.vue'
    export { default as ToggleGroupItem } from './ToggleGroupItem.vue'

Raw manifest: https://uipkge.dev/r/vue/toggle-group.json