Toggle Group
toggle-groupuiGroup 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.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/toggle-group.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/toggle-group.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/toggle-group.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/toggle-group.jsonManifest:
https://uipkge.dev/r/svelte/toggle-group.jsonInstalls to:src/lib/components/ui/toggle-group/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'default' | 'outline' | — | optional |
size | 'default' | 'sm' | 'lg' | — | optional |
spacing | number | 0 | optional |
type | 'single' | 'multiple' | — | optional |
valueControlled value: a string for single-select, an array for multi-select. | string | string[] | — | optional |
disabled | boolean | false | optional |
loop | boolean | true | optional |
orientation | 'horizontal' | 'vertical' | horizontal | optional |
rovingFocus | boolean | true | optional |
animatedSliding selection indicator for single-select (default true). Multi-select keeps item chrome. | boolean | true | optional |
onValueChangeCalled with the new value after every selection change. | (value: string | string[]) => void | — | optional |
ref | HTMLDivElement | null | — | optional |
npm dependencies
Includes
Used by
Files installed (4)
src/lib/components/ui/toggle-group/ToggleGroup.svelte6.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' type ToggleGroupKeydownEvent = Parameters<NonNullable<HTMLAttributes<HTMLDivElement>['onkeydown']>>[0] export interface ToggleGroupProps extends HTMLAttributes<HTMLDivElement> { variant?: 'default' | 'outline' size?: 'default' | 'sm' | 'lg' spacing?: number type?: 'single' | 'multiple' /** Controlled value: a string for single-select, an array for multi-select. */ value?: string | string[] disabled?: boolean loop?: boolean orientation?: 'horizontal' | 'vertical' rovingFocus?: boolean /** Sliding selection indicator for single-select (default true). Multi-select keeps item chrome. */ animated?: boolean /** Called with the new value after every selection change. */ onValueChange?: (value: string | string[]) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { tick } from 'svelte' import { cn } from '$lib/utils' import { ToggleGroupContextState, setToggleGroupContext } from './context.svelte' let { class: className, variant, size, spacing = 0, type, value = $bindable(), disabled = false, loop = true, orientation = 'horizontal', rovingFocus = true, dir, animated = true, onValueChange, children, ref = $bindable(null), onkeydown, ...restProps }: ToggleGroupProps = $props() const ctx = new ToggleGroupContextState() ctx.select = (itemValue: string) => { if (disabled) return if (type === 'multiple') { const current = Array.isArray(value) ? value : [] const next = current.includes(itemValue) ? current.filter((v) => v !== itemValue) : [...current, itemValue] value = next onValueChange?.(next) } else { const next = value === itemValue ? '' : itemValue value = next onValueChange?.(next) } } setToggleGroupContext(ctx) $effect(() => { ctx.variant = variant ctx.size = size ctx.spacing = spacing ctx.type = type ctx.value = value }) // Sliding pill only for single-select. Multi-select paints per-item surfaces. const indicatorActive = $derived(animated !== false && type !== 'multiple') let rootEl: HTMLDivElement | null = $state(null) let indicatorStyle = $state<Record<string, string>>({ opacity: '0' }) function motionSafeTransition(firstPosition: boolean) { 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(firstPosition: boolean) { const root = rootEl if (!root) return false const active = root.querySelector<HTMLElement>('[data-slot="toggle-group-item"][data-state="on"]') if (!active) { indicatorStyle = { opacity: '0' } return false } const listRect = root.getBoundingClientRect() const activeRect = active.getBoundingClientRect() const left = activeRect.left - listRect.left + root.scrollLeft const top = activeRect.top - listRect.top + root.scrollTop indicatorStyle = { width: `${activeRect.width}px`, height: `${activeRect.height}px`, transform: `translate3d(${left}px, ${top}px, 0)`, borderRadius: getComputedStyle(active).borderRadius, opacity: '1', transition: motionSafeTransition(firstPosition), } return true } $effect(() => { const active = indicatorActive // Re-seat the pill when layout inputs change. void spacing void size void variant void orientation void rootEl if (!active || !rootEl) { indicatorStyle = { opacity: '0' } return } const root = rootEl let firstPosition = true const ro = new ResizeObserver(() => { firstPosition = !updateIndicator(firstPosition) ? firstPosition : false }) ro.observe(root) root.querySelectorAll('[data-slot="toggle-group-item"]').forEach((el) => ro.observe(el)) const 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)) } } tick().then(() => { firstPosition = !updateIndicator(firstPosition) ? firstPosition : false }) }) mo.observe(root, { attributes: true, attributeFilter: ['data-state'], subtree: true, childList: true, }) firstPosition = !updateIndicator(firstPosition) ? firstPosition : false return () => { ro.disconnect() mo.disconnect() } }) /** Arrow-key navigation between items (focus moves; selection stays on click/space). */ function handleKeydown(event: ToggleGroupKeydownEvent) { onkeydown?.(event) if (!rovingFocus || !rootEl || event.defaultPrevented) return const items = Array.from( rootEl.querySelectorAll<HTMLElement>('[data-slot="toggle-group-item"]:not(:disabled)'), ) if (items.length < 2) return const current = items.indexOf(document.activeElement as HTMLElement) if (current === -1) return const horizontal = orientation !== 'vertical' let next = -1 if ((horizontal && event.key === 'ArrowRight') || (!horizontal && event.key === 'ArrowDown')) next = current + 1 else if ((horizontal && event.key === 'ArrowLeft') || (!horizontal && event.key === 'ArrowUp')) next = current - 1 else if (event.key === 'Home') next = 0 else if (event.key === 'End') next = items.length - 1 else return event.preventDefault() if (next < 0) next = loop ? items.length - 1 : 0 if (next >= items.length) next = loop ? 0 : items.length - 1 items[next]?.focus() } </script> <div bind:this={rootEl} role="group" data-uipkge="" data-slot="toggle-group" data-size={size} data-variant={variant} data-spacing={spacing} data-animated={indicatorActive ? 'true' : 'false'} data-orientation={orientation} data-disabled={disabled ? '' : undefined} style:--gap={spacing} dir={dir} 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', className, )} onkeydown={handleKeydown} {...restProps} > {#if indicatorActive} <span data-slot="toggle-group-indicator" aria-hidden="true" class="bg-accent pointer-events-none absolute top-0 left-0 z-0 shadow-xs will-change-transform" style={Object.entries(indicatorStyle) .map(([k, v]) => `${k}: ${v}`) .join('; ')} ></span> {/if} {@render children?.()} </div>src/lib/components/ui/toggle-group/ToggleGroupItem.svelte2.5 kB
<script lang="ts" module> import type { HTMLButtonAttributes } from 'svelte/elements' type ToggleGroupItemClickEvent = Parameters<NonNullable<HTMLButtonAttributes['onclick']>>[0] export interface ToggleGroupItemProps extends HTMLButtonAttributes { variant?: 'default' | 'outline' size?: 'default' | 'sm' | 'lg' value: string ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { toggleVariants } from '$lib/components/ui/toggle/toggle.variants' import { getToggleGroupContext } from './context.svelte' let { class: className, variant, size, value: itemValue, disabled = false, children, ref = $bindable(null), onclick, ...restProps }: ToggleGroupItemProps = $props() const ctx = getToggleGroupContext() const effectiveVariant = $derived(ctx?.variant ?? variant) const effectiveSize = $derived(ctx?.size ?? size) const effectiveSpacing = $derived(ctx?.spacing) const selected = $derived(ctx?.isSelected(itemValue) ?? false) function handleClick(event: ToggleGroupItemClickEvent) { onclick?.(event) if (disabled || event.defaultPrevented) return ctx?.select(itemValue) } </script> <button bind:this={ref} type="button" data-uipkge="" data-slot="toggle-group-item" data-variant={effectiveVariant} data-size={effectiveSize} data-spacing={effectiveSpacing} data-state={selected ? 'on' : 'off'} aria-pressed={selected} {disabled} 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', className, )} onclick={handleClick} {...restProps} > {@render children?.()} </button>src/lib/components/ui/toggle-group/context.svelte.ts1.1 kB
import { getContext, setContext } from 'svelte' export type ToggleGroupType = 'single' | 'multiple' export class ToggleGroupContextState { variant = $state<'default' | 'outline' | undefined>(undefined) size = $state<'default' | 'sm' | 'lg' | undefined>(undefined) spacing = $state(0) /** Mirrored selection for item chrome. Synced from the group's `value`. */ value = $state<string | string[] | undefined>(undefined) type = $state<ToggleGroupType | undefined>(undefined) /** Assigned by the group during init; items call it on click. */ select: (itemValue: string) => void = () => {} isSelected(itemValue: string): boolean { if (this.type === 'multiple') { return Array.isArray(this.value) ? this.value.includes(itemValue) : false } return this.value === itemValue } } const TOGGLE_GROUP_KEY = Symbol('ToggleGroupContext') export function setToggleGroupContext(ctx: ToggleGroupContextState) { setContext(TOGGLE_GROUP_KEY, ctx) } export function getToggleGroupContext(): ToggleGroupContextState | undefined { return getContext<ToggleGroupContextState | undefined>(TOGGLE_GROUP_KEY) }src/lib/components/ui/toggle-group/index.ts0.2 kB
export { default as ToggleGroup, type ToggleGroupProps } from './ToggleGroup.svelte' export { default as ToggleGroupItem, type ToggleGroupItemProps } from './ToggleGroupItem.svelte'
Raw manifest:https://uipkge.dev/r/svelte/toggle-group.json