Skip to content
edmi-ui

Toggle Group

A set of toggles; spacing 2 by default, 0 joins them.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal window
npx shadcn@latest add @edmi-ui/toggle-group
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/toggle-group
Terminal window
yarn dlx shadcn@latest add @edmi-ui/toggle-group
Terminal window
bunx shadcn@latest add @edmi-ui/toggle-group

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/toggle-group
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/toggle-group
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/toggle-group
Terminal window
bunx shadcn-vue@latest add @edmi-ui/toggle-group

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/toggle-group.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/toggle-group.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/toggle-group.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/toggle-group.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
<div className="flex flex-wrap items-start gap-6">
<ToggleGroup multiple defaultValue={["b"]}>
<ToggleGroupItem value="b" aria-label="Bold">
<TextBIcon />
</ToggleGroupItem>
<ToggleGroupItem value="i" aria-label="Italic">
<TextItalicIcon />
</ToggleGroupItem>
<ToggleGroupItem value="u" aria-label="Underline">
<TextUnderlineIcon />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup variant="outline" spacing={0} {...rangeProps}>
{["1D", "1W", "1M", "1Y", "All"].map((v) => (
<ToggleGroupItem key={v} value={v}>
{v}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* … */}
</div>
<script setup lang="ts">
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
</script>
<template>
<div class="flex flex-wrap items-start gap-6">
<ToggleGroup type="multiple" :default-value="['b']">
<ToggleGroupItem value="b" aria-label="Bold"><Bold /></ToggleGroupItem>
<ToggleGroupItem value="i" aria-label="Italic"><Italic /></ToggleGroupItem>
<ToggleGroupItem value="u" aria-label="Underline"><Underline /></ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" variant="outline" :spacing="0" @update:model-value="setRange">
<ToggleGroupItem :key="v" :value="v">{{ v }}</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" variant="segmented" @update:model-value="setRange">
<ToggleGroupItem :key="v" :value="v">{{ v }}</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="multiple" orientation="vertical" variant="outline">
<ToggleGroupItem value="b" aria-label="Bold"><Bold /></ToggleGroupItem>
<ToggleGroupItem value="i" aria-label="Italic"><Italic /></ToggleGroupItem>
</ToggleGroup>
</div>
</template>
<script lang="ts">
import * as ToggleGroup from "$lib/components/ui/toggle-group";
</script>
<div class="flex flex-wrap items-start gap-6">
<ToggleGroup.Root type="multiple">
<ToggleGroup.Item value="b" aria-label="Bold"><b>B</b></ToggleGroup.Item>
<ToggleGroup.Item value="i" aria-label="Italic"><i>I</i></ToggleGroup.Item>
<ToggleGroup.Item value="u" aria-label="Underline"><u>U</u></ToggleGroup.Item>
</ToggleGroup.Root>
<ToggleGroup.Root type="single" variant="outline" spacing={0} onValueChange={setRange}>
{#each ranges as v (v)}
<ToggleGroup.Item value={v}>{v}</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
<ToggleGroup.Root type="single" variant="segmented" onValueChange={setRange}>
{#each ranges as v (v)}
<ToggleGroup.Item value={v}>{v}</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
<ToggleGroup.Root type="multiple" orientation="vertical" variant="outline">
<ToggleGroup.Item value="b" aria-label="Bold"><b>B</b></ToggleGroup.Item>
<ToggleGroup.Item value="i" aria-label="Italic"><i>I</i></ToggleGroup.Item>
</ToggleGroup.Root>
</div>
registry/ui/toggle-group.tsx
"use client";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
import type { VariantProps } from "class-variance-authority";
import { cn } from "cn";
import * as React from "react";
import {
type Elevation,
type ElevationLevel,
useElevation,
} from "@/registry/edmi/ui/elevation";
import { toggleVariants } from "@/registry/edmi/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
elevation?: ElevationLevel;
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
elevation: undefined,
});
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
elevation,
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
/** ✦ depth, passed down to every item (segmented: only the ON item rises). */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "control");
// ✦ `variant="segmented"` renders a flat track (DESIGN §4.5); gap is fixed at 2px.
const segmented = variant === "segmented";
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={segmented ? 0.5 : spacing}
data-orientation={orientation}
style={{ "--gap": segmented ? 0.5 : spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
segmented &&
"rounded-[10px] border border-border bg-muted p-[3px] shadow-[inset_0_1px_2px_rgb(0_0_0/0.04)]",
className,
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation, elevation: level }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
);
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
elevation,
...props
}: TogglePrimitive.Props &
Omit<VariantProps<typeof toggleVariants>, "elevation"> & {
elevation?: Elevation;
}) {
const context = React.useContext(ToggleGroupContext);
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
elevation:
elevation && elevation !== "auto" ? elevation : context.elevation,
}),
className,
)}
{...props}
>
{children}
</TogglePrimitive>
);
}
export { ToggleGroup, ToggleGroupItem };
registry/ui/toggle-group/ToggleGroup.vue
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { toggleVariants } from '@/registry/edmi/ui/toggle'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, provide } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const props = withDefaults(defineProps<ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
spacing?: number
/** ✦ depth passed to every item (items may override); segmented: only the ON item rises */
elevation?: Elevation
}>(), {
spacing: 2,
elevation: undefined,
orientation: 'horizontal',
})
const emits = defineEmits<ToggleGroupRootEmits>()
const level = useElevation(() => props.elevation, 'control')
provide('toggleGroup', {
get variant() { return props.variant },
get size() { return props.size },
get spacing() { return props.spacing },
get orientation() { return props.orientation },
get elevation() { return level.value },
})
// ✦ `variant="segmented"` renders a flat track (DESIGN §4.5); gap is fixed at 2px.
const segmented = computed(() => props.variant === 'segmented')
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant', 'spacing', 'elevation')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
data-slot="toggle-group"
:data-size="size"
:data-variant="variant"
:data-spacing="segmented ? 0.5 : spacing"
:style="{
'--gap': segmented ? 0.5 : spacing,
}"
v-bind="forwarded"
:class="cn(
'group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch',
segmented && 'rounded-[10px] border border-border bg-muted p-[3px] shadow-[inset_0_1px_2px_rgb(0_0_0/0.04)]',
props.class,
)"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>
registry/ui/toggle-group/ToggleGroupItem.vue
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import { inject } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import type { Elevation, ElevationLevel } from '@/registry/edmi/ui/elevation'
import { toggleVariants } from '@/registry/edmi/ui/toggle'
type ToggleGroupVariants = VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: 'horizontal' | 'vertical'
elevation?: ElevationLevel
}
const props = withDefaults(defineProps<ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
/** ✦ overrides the group's `elevation` */
elevation?: Elevation
}>(), {
elevation: undefined,
})
const context = inject<ToggleGroupVariants>('toggleGroup')
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant', 'elevation')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
data-slot="toggle-group-item"
:data-variant="context?.variant || variant"
:data-size="context?.size || size"
:data-spacing="context?.spacing"
v-bind="forwardedProps"
:class="cn(
'shrink-0 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t',
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size,
elevation: elevation && elevation !== 'auto' ? elevation : context?.elevation,
}),
props.class)"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>
registry/ui/toggle-group/index.ts
export { default as ToggleGroup } from "./ToggleGroup.vue";
export { default as ToggleGroupItem } from "./ToggleGroupItem.vue";
src/lib/registry/ui/toggle-group/toggle-group.svelte
<script lang="ts" module>
import { getContext, setContext } from "svelte";
import { type Elevation, type ElevationLevel, useElevation } from "$lib/registry/ui/elevation/index.js";
import { toggleVariants } from "$lib/registry/ui/toggle/index.js";
import type { VariantProps } from "tailwind-variants";
type ToggleVariants = VariantProps<typeof toggleVariants>;
interface ToggleGroupContext extends ToggleVariants {
spacing?: number;
orientation?: "horizontal" | "vertical";
elevation?: ElevationLevel;
}
export function setToggleGroupCtx(props: ToggleGroupContext) {
setContext("toggleGroup", props);
}
export function getToggleGroupCtx() {
return getContext<Required<ToggleGroupContext>>("toggleGroup");
}
</script>
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size = "default",
spacing = 2,
orientation = "horizontal",
variant = "default",
elevation = "auto",
...restProps
}: ToggleGroupPrimitive.RootProps &
Omit<ToggleVariants, "elevation"> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
/** ✦ depth passed down to every item (segmented: only the ON item rises). */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "control");
setToggleGroupCtx({
get variant() {
return variant;
},
get size() {
return size;
},
get spacing() {
return spacing;
},
get orientation() {
return orientation;
},
get elevation() {
return level.current;
},
});
// ✦ `variant="segmented"` renders a flat track (DESIGN §4.5); gap is fixed at 2px.
const segmented = $derived(variant === "segmented");
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<ToggleGroupPrimitive.Root
bind:value={value as never}
bind:ref
{orientation}
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-elevation={level.current === "flat" ? undefined : level.current}
data-spacing={segmented ? 0.5 : spacing}
style={`--gap: ${segmented ? 0.5 : spacing}`}
class={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
segmented && "rounded-[10px] border border-border bg-muted p-[3px] shadow-[inset_0_1px_2px_rgb(0_0_0/0.04)]",
className
)}
{...restProps}
/>
src/lib/registry/ui/toggle-group/toggle-group-item.svelte
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { type ToggleVariants, toggleVariants } from "$lib/registry/ui/toggle/index.js";
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
import { cn } from "$lib/utils.js";
import { getToggleGroupCtx } from "./toggle-group.svelte";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size,
variant,
elevation,
...restProps
}: ToggleGroupPrimitive.ItemProps &
Omit<ToggleVariants, "elevation"> & { elevation?: Elevation } = $props();
const ctx = getToggleGroupCtx();
const itemElevation = $derived(elevation && elevation !== "auto" ? elevation : ctx.elevation);
const itemVariant = $derived(ctx.variant || variant);
</script>
<ToggleGroupPrimitive.Item
bind:ref
data-slot="toggle-group-item"
data-variant={ctx.variant || variant}
data-size={ctx.size || size}
data-spacing={ctx.spacing}
class={cn(
"shrink-0 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: itemVariant,
size: ctx.size || size,
elevation: itemElevation,
}),
className
)}
{value}
{...restProps}
/>
src/lib/registry/ui/toggle-group/index.ts
import Root from "./toggle-group.svelte";
import Item from "./toggle-group-item.svelte";
export {
Item,
Item as ToggleGroupItem,
Root,
//
Root as ToggleGroup,
};

See the Elevation guide for the levels, roles and layered mode.

elevation on the group flows to every item. For variant="segmented" only the ON item rises (the track never bevels). Raised (+1) and floating (+2) are equivalent on items.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

Raised (+1): only the ON item rises

Flat (0)

Raised (+1)

Flat (0)

Raised (+1)

Prop Type Default
variant default | outline | segmented default
size sm | default | lg default
spacing number 2
orientation horizontal | vertical horizontal
multiple boolean false
value / defaultValue string[] none
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
  • variant="segmented": flat track with a tab-active fill (for single selection such as ranges).