ControlsToggle Group

Toggles in a row, sharing the toggle's CVA through context — single chooses, multiple switches.

Preview

The thinking

A row of toggles is really two controls wearing the same skin, and I didn't want the caller to think about which one they're holding. The one load-bearing decision is to read type as a question about meaning, not a config flag: choosing has no off-state to undo, switching does, and everything downstream — who owns the sound, what a press means to a screen reader — falls out of that single answer. For the same reason the component refuses to invent its own look: it imports the toggle's CVA and spends its own code entirely on the group semantics. Two behaviors, one skin, one prop. (Most toggle groups ship one sound for every press and call it done.)

Interface design

There is almost no interface here on purpose. The root is flex items-center gap-1 and everything else is the toggle's CVA, imported rather than copied, so a grouped item and a lone toggle can never drift apart. The gap-1 is the decision I'd defend: separate keys with air between them instead of a fused segmented bar, because these are individual controls that happen to travel together. Variant and size are set once on the group and flow down through context — each item resolves variant ?? context.variant — so the row styles as one control without repeating props, while any single item can still override. The pressed state comes from the shared scale: data-[state=on]:bg-secondary-hover with full text-foreground ink, a rung above the hover:bg-secondary wash, so the selected key survives a glance and hover never impersonates it.

Interaction

type is a semantic fork, not a config flag: single is choosing — radix renders the items as radios and rolls one tab stop across the row — and multiple is switching, each item a real toggle, independently on or off. The press itself is inherited from the toggle: active:scale-[0.98] on the shared duration-150 ease-swift transition, so the key gives under the finger and nothing else in the row moves. The intent-reading lives in the click handler: it runs your onClick first and checks event.defaultPrevented before making a sound, because a press the caller cancelled is a press that never happened — radix won't flip the state, so the feedback shouldn't claim it did.

Sound design

Who speaks depends on what the press means, and type decides it. In single mode the items ship no audio at all — the global SoundLayer's capturing click listener taps them because they carry an interactive role, and a selection has no off-note to earn. In multiple mode each item is a true toggle, so it sets data-silent to wave the layer off and plays the pair itself — toggleOn rising, toggleOff falling — choosing by reading data-state off the DOM before radix flips it, so under spam clicks the note describes the side you're actually leaving rather than what stale React state remembers. The type reaches the items through the same context as the styling, so the caller never picks a sound. The meaning already did.

Empathy

Disabled inherits the toggle's answer — disabled:opacity-50 plus disabled:pointer-events-none — and the SoundLayer's own :disabled check keeps a disabled key quiet even in single mode, where the layer would otherwise tap it. Keyboard travel is radix's roving focus: the whole row is one tab stop, arrows move between keys, and focus-visible:ring-1 on the ring token marks the spot only when the keyboard put it there. The fork is audible to a screen reader too — single-mode items announce as radios, one choice among siblings, while multiple-mode items report a pressed state — and the demo names each row's job with an aria-label on the group. Reduced motion gets motion-reduce:transition-none; the pressed fill still lands, it just stops easing. The honest gap is single mode's empty state: radix lets you click the selected key off and I don't force a value, so if your group must always have an answer, control it. Long labels never wrap — whitespace-nowrap widens the key instead — and the row itself doesn't scroll; my fix for an overflowing group is fewer, shorter keys, not a scrollbar.
toggle-group.tsx
"use client";

import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";
import { toggleVariants } from "../toggle/toggle";
import { useSound } from "@/hooks/useSound";
import { SOUNDS } from "@/lib/sounds";


// items inherit variant/size (and the group's type, which decides who
// owns the sound) through context, so a group reads as one control
// without repeating props on every item
interface ToggleGroupContextValue extends VariantProps<typeof toggleVariants> {
	type?: "single" | "multiple";
}

const ToggleGroupContext = React.createContext<ToggleGroupContextValue>({});

export type ToggleGroupProps = React.ComponentPropsWithoutRef<
	typeof ToggleGroupPrimitive.Root
> &
	VariantProps<typeof toggleVariants> & {
		ref?: React.Ref<React.ComponentRef<typeof ToggleGroupPrimitive.Root>>;
	};

const ToggleGroup = ({
	ref,
	className,
	variant,
	size,
	children,
	...props
}: ToggleGroupProps) => (
	<ToggleGroupPrimitive.Root
		ref={ref}
		className={cn("flex items-center gap-1", className)}
		{...props}
	>
		<ToggleGroupContext.Provider value={{ variant, size, type: props.type }}>
			{children}
		</ToggleGroupContext.Provider>
	</ToggleGroupPrimitive.Root>
);
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;

export type ToggleGroupItemProps = React.ComponentPropsWithoutRef<
	typeof ToggleGroupPrimitive.Item
> &
	VariantProps<typeof toggleVariants> & {
		ref?: React.Ref<React.ComponentRef<typeof ToggleGroupPrimitive.Item>>;
	};

const ToggleGroupItem = ({
	ref,
	className,
	variant,
	size,
	onClick,
	...props
}: ToggleGroupItemProps) => {
	const context = React.useContext(ToggleGroupContext);

	// the semantic fork: multiple-mode items are true toggles and own the
	// pair; single-mode items are radios — no data-silent, the layer taps
	const isMultiple = context.type === "multiple";
	const [playOn] = useSound(SOUNDS.toggleOn);
	const [playOff] = useSound(SOUNDS.toggleOff);

	return (
		<ToggleGroupPrimitive.Item
			ref={ref}
			data-silent={isMultiple || undefined}
			onClick={(event) => {
				onClick?.(event);
				if (!isMultiple || event.defaultPrevented) return;
				// our handler runs before radix flips the state, so the DOM
				// still holds the side being left — the dock's spam-click rule
				const wasOn = event.currentTarget.dataset.state === "on";
				(wasOn ? playOff : playOn)();
			}}
			className={cn(
				toggleVariants({
					variant: variant ?? context.variant,
					size: size ?? context.size,
				}),
				className
			)}
			{...props}
		/>
	);
};
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;

export { ToggleGroup, ToggleGroupItem };