Toggle Group
toggle-group ui 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
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/toggle-group.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/toggle-group.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/toggle-group.json $ bunx 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…
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
} npm dependencies
Includes
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