# Toggle Group

> Toggles and segmented controls with a lit pill that slides to the selection.

Category: Forms. Docs: https://neonkit.heyaadi.com/components/toggle-group

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/toggle-group.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/toggle-group`.

npm dependencies: `motion`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

<ToggleGroup aria-label="Range" defaultValue="week">
  <ToggleGroupItem value="day">Day</ToggleGroupItem>
  <ToggleGroupItem value="week">Week</ToggleGroupItem>
  <ToggleGroupItem value="month">Month</ToggleGroupItem>
</ToggleGroup>
```

## Examples

### Range and formatting

A segmented control whose lit pill slides to the selection, and a toolbar where each pressed item powers on with a flicker.

```tsx
"use client";

import * as React from "react";
import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Italic, Strikethrough, Underline } from "@/components/ui/icons";
import { cn } from "@/lib/utils";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

const ranges = {
  day: { period: "Today", deploys: "42", change: "+8% on yesterday" },
  week: { period: "This week", deploys: "286", change: "+14% on last week" },
  month: { period: "This month", deploys: "1,204", change: "−3% on last month" },
} as const;

type Range = keyof typeof ranges;

export default function ToggleGroupDemo() {
  const [range, setRange] = React.useState<Range>("week");
  const [marks, setMarks] = React.useState<string[]>(["bold"]);
  const [align, setAlign] = React.useState("left");
  const stats = ranges[range];

  const decoration = [marks.includes("underline") && "underline", marks.includes("strike") && "line-through"]
    .filter(Boolean)
    .join(" ");

  return (
    <div className="grid w-full max-w-md gap-6">
      <div className="flex flex-wrap items-end justify-between gap-4">
        <div className="grid gap-0.5">
          <p className="text-xs text-white/50">{stats.period}</p>
          <p className="text-2xl font-semibold tabular-nums tracking-[-0.01em] text-white/90">
            {stats.deploys} <span className="text-sm font-normal text-white/50">deploys</span>
          </p>
          <p className="text-xs text-white/50">{stats.change}</p>
        </div>
        <ToggleGroup aria-label="Range" value={range} onValueChange={(value) => setRange(value as Range)}>
          <ToggleGroupItem value="day">Day</ToggleGroupItem>
          <ToggleGroupItem value="week">Week</ToggleGroupItem>
          <ToggleGroupItem value="month">Month</ToggleGroupItem>
        </ToggleGroup>
      </div>

      <div className="rounded-xl border border-white/10 bg-white/[0.02]">
        <div className="flex flex-wrap items-center gap-1.5 border-b border-white/10 p-1.5">
          <ToggleGroup
            type="multiple"
            variant="ghost"
            size="sm"
            aria-label="Text formatting"
            value={marks}
            onValueChange={setMarks}
          >
            <ToggleGroupItem value="bold" aria-label="Bold">
              <Bold />
            </ToggleGroupItem>
            <ToggleGroupItem value="italic" aria-label="Italic">
              <Italic />
            </ToggleGroupItem>
            <ToggleGroupItem value="underline" aria-label="Underline">
              <Underline />
            </ToggleGroupItem>
            <ToggleGroupItem value="strike" aria-label="Strikethrough">
              <Strikethrough />
            </ToggleGroupItem>
            <ToggleGroupItem value="code" aria-label="Code">
              <Code />
            </ToggleGroupItem>
          </ToggleGroup>
          <span aria-hidden className="mx-0.5 h-5 w-px bg-white/10" />
          <ToggleGroup variant="ghost" size="sm" aria-label="Alignment" value={align} onValueChange={setAlign}>
            <ToggleGroupItem value="left" aria-label="Align left">
              <AlignLeft />
            </ToggleGroupItem>
            <ToggleGroupItem value="center" aria-label="Align center">
              <AlignCenter />
            </ToggleGroupItem>
            <ToggleGroupItem value="right" aria-label="Align right">
              <AlignRight />
            </ToggleGroupItem>
          </ToggleGroup>
        </div>
        <p
          style={{ textAlign: align as React.CSSProperties["textAlign"], textDecorationLine: decoration || "none" }}
          className={cn(
            "px-4 py-5 text-sm leading-6 text-white/75 transition-[color] duration-150",
            marks.includes("bold") && "font-semibold text-white/90",
            marks.includes("italic") && "italic",
            marks.includes("code") && "font-mono text-[13px]",
          )}
        >
          Release notes go out with the 2.4 build on Thursday. Flag anything that should wait for 2.5.
        </p>
      </div>
    </div>
  );
}
```

### Toggle

A single on/off button with aria-pressed, in the default and outline variants.

```tsx
"use client";

import * as React from "react";
import { BellOff, Eye, Pin, Star } from "@/components/ui/icons";
import { Toggle } from "@/components/ui/toggle-group";

export default function ToggleExample() {
  const [watching, setWatching] = React.useState(true);

  return (
    <div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-2">
      <div className="flex items-center gap-1">
        <Toggle aria-label="Pin to sidebar">
          <Pin />
        </Toggle>
        <Toggle aria-label="Star" defaultPressed>
          <Star />
        </Toggle>
      </div>
      <span aria-hidden className="hidden h-6 w-px bg-white/10 sm:block" />
      <div className="flex items-center gap-2">
        <Toggle variant="outline" pressed={watching} onPressedChange={setWatching}>
          <Eye />
          {watching ? "Watching" : "Watch"}
        </Toggle>
        <Toggle variant="outline">
          <BellOff />
          Mute thread
        </Toggle>
      </div>
    </div>
  );
}
```

### Variants

Segmented for switching views, outline for attached settings, ghost for toolbars.

```tsx
import { Globe, LayoutGrid, List, Lock, SquareKanban, Users } from "@/components/ui/icons";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

const row = "grid gap-2";
const caption = "text-xs font-medium text-white/50";

export default function ToggleGroupVariants() {
  return (
    <div className="grid gap-6">
      <div className={row}>
        <p className={caption}>Segmented</p>
        <ToggleGroup aria-label="Layout" defaultValue="board">
          <ToggleGroupItem value="list">
            <List />
            List
          </ToggleGroupItem>
          <ToggleGroupItem value="board">
            <SquareKanban />
            Board
          </ToggleGroupItem>
          <ToggleGroupItem value="grid">
            <LayoutGrid />
            Grid
          </ToggleGroupItem>
        </ToggleGroup>
      </div>
      <div className={row}>
        <p className={caption}>Outline</p>
        <ToggleGroup variant="outline" aria-label="Visibility" defaultValue="team">
          <ToggleGroupItem value="private">
            <Lock />
            Private
          </ToggleGroupItem>
          <ToggleGroupItem value="team">
            <Users />
            Team
          </ToggleGroupItem>
          <ToggleGroupItem value="public">
            <Globe />
            Public
          </ToggleGroupItem>
        </ToggleGroup>
      </div>
      <div className={row}>
        <p className={caption}>Ghost, multiple</p>
        <ToggleGroup type="multiple" variant="ghost" aria-label="Weekdays" defaultValue={["mon", "wed", "fri"]}>
          {["mon", "tue", "wed", "thu", "fri"].map((day) => (
            <ToggleGroupItem key={day} value={day} className="capitalize">
              {day}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </div>
    </div>
  );
}
```

### Vertical

orientation="vertical" stacks the items and moves the arrow keys to Up and Down.

```tsx
import { AtSign, Bell, BellOff, CalendarDays, Tag, Users } from "@/components/ui/icons";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

export default function ToggleGroupVertical() {
  return (
    <div className="flex flex-wrap items-start gap-8">
      <div className="grid gap-2">
        <p id="notify-label" className="text-xs font-medium text-white/50">
          Notify me about
        </p>
        <ToggleGroup orientation="vertical" aria-labelledby="notify-label" defaultValue="mentions" className="w-48">
          <ToggleGroupItem value="all">
            <Bell />
            All activity
          </ToggleGroupItem>
          <ToggleGroupItem value="mentions">
            <AtSign />
            Mentions only
          </ToggleGroupItem>
          <ToggleGroupItem value="none">
            <BellOff />
            Nothing
          </ToggleGroupItem>
        </ToggleGroup>
      </div>
      <div className="grid gap-2">
        <p id="card-fields-label" className="text-xs font-medium text-white/50">
          Show on cards
        </p>
        <ToggleGroup
          type="multiple"
          variant="outline"
          orientation="vertical"
          aria-labelledby="card-fields-label"
          defaultValue={["assignee", "due"]}
          className="w-48"
        >
          <ToggleGroupItem value="assignee">
            <Users />
            Assignee
          </ToggleGroupItem>
          <ToggleGroupItem value="due">
            <CalendarDays />
            Due date
          </ToggleGroupItem>
          <ToggleGroupItem value="labels">
            <Tag />
            Labels
          </ToggleGroupItem>
        </ToggleGroup>
      </div>
    </div>
  );
}
```

### Sizes

32, 40 and 48px tall, so a segmented control lines up with inputs and buttons.

```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

const scales = ["1x", "2x", "3x"];

export default function ToggleGroupSizes() {
  return (
    <div className="grid justify-items-start gap-3">
      {(["sm", "md", "lg"] as const).map((size) => (
        <ToggleGroup key={size} size={size} aria-label="Export scale" defaultValue="2x">
          {scales.map((scale) => (
            <ToggleGroupItem key={scale} value={scale} className="tabular-nums">
              {scale}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      ))}
    </div>
  );
}
```

## API reference

### ToggleGroup

Renders a div and accepts every native div attribute. Give it an aria-label or aria-labelledby.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `"single" \| "multiple"` | `"single"` | Single acts as a radio group; multiple is a toolbar of pressed buttons. |
| `value` | `string \| string[]` | — | Controlled value. An array when type is multiple. |
| `defaultValue` | `string \| string[]` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: string) => void \| (value: string[]) => void` | — | Called with the new value. Single never deselects, like a radio group. |
| `variant` | `"segmented" \| "outline" \| "ghost"` | `"segmented"` | Track with a sliding pill, attached cells with a lit edge, or bare items. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Overall height: 32, 40 or 48px. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Layout direction and which arrow keys move between items. |
| `disabled` | `boolean` | `false` | Disables every item. |
| `name` | `string` | — | Submits the selected value, one entry per value for multiple, with a surrounding form. A form reset puts back the starting selection. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### ToggleGroupItem

Renders a button. Icon-only items need an aria-label.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | — | Unique value within the group. |
| `disabled` | `boolean` | `false` | Skipped by the arrow keys and can't be pressed. |

### Toggle

A standalone pressable button. Accepts every native button attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pressed` | `boolean` | — | Controlled state. |
| `defaultPressed` | `boolean` | `false` | Initial state when uncontrolled. |
| `onPressedChange` | `(pressed: boolean) => void` | — | Called when it's pressed or released. |
| `variant` | `"default" \| "outline"` | `"default"` | Bare or bordered. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 32, 40 or 48px tall; icon-only toggles are square. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

## Accessibility

- Items in a row get an invisible target at least 44px tall, so the 24px small segments are still easy to tap.
- Single groups are a radiogroup of radio items with aria-checked; multiple groups are a toolbar of buttons with aria-pressed.
- The group is one Tab stop. Arrow keys move between items and wrap, Home and End jump to the ends, and disabled items are skipped.
- In a single group, moving with the arrows also selects, as in a native radio group. In a toolbar the arrows only move focus; Space or Enter presses.
- A radio group answers both arrow pairs; a toolbar only the pair along its orientation.
- The sliding pill and the power-on flicker are turned off under prefers-reduced-motion.

## Source

`components/ui/toggle-group.tsx`

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const EASE = [0.23, 1, 0.32, 1] as const;

// A struck tube: two quick drops in brightness, then it holds. The label dips with it.
const keyframes =
  "@keyframes neonkit-toggle-ignite{0%{opacity:0}12%{opacity:.85}22%{opacity:.15}40%{opacity:1}52%{opacity:.4}68%,100%{opacity:1}}" +
  "@keyframes neonkit-toggle-ignite-label{0%{opacity:.5}12%{opacity:1}22%{opacity:.55}40%{opacity:1}52%{opacity:.7}68%,100%{opacity:1}}";
const IGNITE = "[animation:neonkit-toggle-ignite_350ms_linear_both] motion-reduce:[animation:none]";
const IGNITE_LABEL =
  "[animation:neonkit-toggle-ignite-label_350ms_linear_both] motion-reduce:[animation:none]";

const FOCUS_RING =
  "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]";

// An invisible target at least 44px tall, so small items are still easy to tap. It
// overlaps neighbours by a pixel to cover the gap between segments.
const HIT_AREA =
  "before:absolute before:-inset-x-px before:top-1/2 before:h-[max(100%,2.75rem)] before:-translate-y-1/2 before:content-['']";

const LIT_LABEL =
  "text-[rgb(var(--neon))] [text-shadow:0_0_10px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1)))] [&_svg]:[filter:drop-shadow(0_0_5px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))))]";

type Size = "sm" | "md" | "lg";
type Orientation = "horizontal" | "vertical";
type ToggleGroupVariant = "segmented" | "outline" | "ghost";

/** Counts off-to-on changes, so the flicker plays when switched on but not when mounted on. */
function useIgnitions(on: boolean) {
  const [ignitions, setIgnitions] = React.useState(0);
  const [previous, setPrevious] = React.useState(on);
  if (previous !== on) {
    setPrevious(on);
    if (on) setIgnitions((count) => count + 1);
  }
  return ignitions;
}

/* -------------------------------------------------------------------------------------------------
 * Toggle
 * -----------------------------------------------------------------------------------------------*/

const toggleSizes = {
  sm: { root: "h-8 min-w-8 rounded-md px-2 text-xs", label: "gap-1.5 [&_svg]:size-4" },
  md: { root: "h-10 min-w-10 rounded-lg px-2.5 text-sm", label: "gap-2 [&_svg]:size-4" },
  lg: { root: "h-12 min-w-12 rounded-xl px-3 text-base", label: "gap-2.5 [&_svg]:size-[18px]" },
} as const;

const toggleVariants = {
  default: { root: "text-white/60 hover:bg-white/[0.05] hover:text-white/90", glow: "inset-0" },
  outline: {
    root: "border border-white/10 bg-white/[0.03] text-white/70 hover:border-white/20 hover:text-white/90",
    // Sits over the border so the lit edge replaces it.
    glow: "-inset-px",
  },
} as const;

export interface ToggleProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color" | "value" | "onChange"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  variant?: keyof typeof toggleVariants;
  size?: Size;
  pressed?: boolean;
  defaultPressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
}

const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
  (
    {
      className,
      style,
      color,
      variant = "default",
      size = "md",
      pressed: pressedProp,
      defaultPressed = false,
      onPressedChange,
      onClick,
      children,
      ...props
    },
    ref,
  ) => {
    const [uncontrolled, setUncontrolled] = React.useState(defaultPressed);
    const isControlled = pressedProp !== undefined;
    const pressed = isControlled ? pressedProp : uncontrolled;
    const ignitions = useIgnitions(pressed);
    const s = toggleSizes[size];
    const v = toggleVariants[variant];

    return (
      <>
        <button
          ref={ref}
          type="button"
          aria-pressed={pressed}
          data-state={pressed ? "on" : "off"}
          onClick={(event) => {
            onClick?.(event);
            if (event.defaultPrevented) return;
            if (!isControlled) setUncontrolled(!pressed);
            onPressedChange?.(!pressed);
          }}
          style={neonVars(color, style)}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap font-medium outline-none",
            "transition-[background-color,border-color,color,transform] duration-150 ease-out active:scale-[0.97]",
            HIT_AREA,
            FOCUS_RING,
            "disabled:pointer-events-none disabled:opacity-50",
            "[&_svg]:pointer-events-none [&_svg]:shrink-0",
            s.root,
            v.root,
            pressed && "hover:bg-transparent",
            className,
          )}
          {...props}
        >
          {pressed && (
            <span
              aria-hidden
              className={cn(
                "pointer-events-none absolute rounded-[inherit] bg-[rgb(var(--neon)/0.12)]",
                "shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.5),inset_0_0_12px_rgb(var(--neon)/CALC(0.14*var(--neon-glow,1))),0_0_16px_-4px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1)))]",
                v.glow,
                ignitions > 0 && IGNITE,
              )}
            />
          )}
          <span
            className={cn(
              "relative inline-flex items-center justify-center",
              s.label,
              pressed && LIT_LABEL,
              pressed && ignitions > 0 && IGNITE_LABEL,
            )}
          >
            {children}
          </span>
        </button>
        {ignitions > 0 && <style>{keyframes}</style>}
      </>
    );
  },
);
Toggle.displayName = "Toggle";

/* -------------------------------------------------------------------------------------------------
 * ToggleGroup
 * -----------------------------------------------------------------------------------------------*/

const groupVariants = {
  segmented: {
    root: "gap-0.5 border border-white/10 bg-white/[0.03] p-[3px]",
    rootSize: { sm: "rounded-lg", md: "rounded-[10px]", lg: "rounded-xl" },
    item: {
      sm: "h-6 px-2.5 text-xs",
      md: "h-8 px-3 text-sm",
      lg: "h-10 px-4 text-sm",
    },
    // Root radius minus its 3px padding, set inline so the pill keeps its corners while it slides.
    radius: { sm: 5, md: 7, lg: 9 },
    idle: "text-white/55 hover:text-white/85",
    on: "text-white [text-shadow:0_0_12px_rgb(var(--neon)/CALC(0.35*var(--neon-glow,1)))] [&_svg]:text-[rgb(var(--neon))] [&_svg]:[filter:drop-shadow(0_0_5px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1))))]",
    focus: FOCUS_RING,
    indicator:
      "absolute inset-0 bg-[rgb(var(--neon)/0.12)] shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.5),inset_0_0_12px_rgb(var(--neon)/CALC(0.1*var(--neon-glow,1))),0_0_18px_-4px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1)))]",
  },
  outline: {
    root: "overflow-hidden border border-white/10 bg-white/[0.02]",
    rootSize: { sm: "rounded-md", md: "rounded-lg", lg: "rounded-xl" },
    item: {
      sm: "h-[30px] min-w-[30px] px-2.5 text-xs",
      md: "h-[38px] min-w-[38px] px-3 text-sm",
      lg: "h-[46px] min-w-[46px] px-4 text-base",
    },
    radius: { sm: 0, md: 0, lg: 0 },
    idle: "text-white/60 hover:bg-white/[0.04] hover:text-white/90",
    on: LIT_LABEL,
    // Attached cells clip their ring, so it's drawn inside.
    focus: "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[rgb(var(--neon)/0.6)]",
    indicator: "absolute inset-0 bg-[rgb(var(--neon)/0.1)]",
  },
  ghost: {
    root: "gap-1",
    rootSize: { sm: "", md: "", lg: "" },
    item: {
      sm: "h-8 min-w-8 rounded-md px-2 text-xs",
      md: "h-10 min-w-10 rounded-lg px-2.5 text-sm",
      lg: "h-12 min-w-12 rounded-xl px-3 text-base",
    },
    radius: { sm: 6, md: 8, lg: 12 },
    idle: "text-white/60 hover:bg-white/[0.05] hover:text-white/90",
    on: LIT_LABEL,
    focus: FOCUS_RING,
    indicator:
      "absolute inset-0 bg-[rgb(var(--neon)/0.12)] shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.35),0_0_14px_-4px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1)))]",
  },
} as const;

const labelSizes = {
  sm: "gap-1.5 [&_svg]:size-[15px]",
  md: "gap-2 [&_svg]:size-4",
  lg: "gap-2 [&_svg]:size-[18px]",
} as const;

type ToggleGroupContextValue = {
  baseId: string;
  type: "single" | "multiple";
  values: string[];
  toggle: (value: string) => void;
  variant: ToggleGroupVariant;
  size: Size;
  orientation: Orientation;
  disabled: boolean;
  reduceMotion: boolean;
  /** The one item in the Tab order, or null when the group itself takes the stop. */
  tabStop: string | null;
  setFocused: (value: string) => void;
};

const ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(null);

function useToggleGroup() {
  const context = React.useContext(ToggleGroupContext);
  if (!context) throw new Error("<ToggleGroupItem> must be used inside <ToggleGroup>.");
  return context;
}

interface ToggleGroupBaseProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "defaultValue" | "dir" | "onChange"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  variant?: ToggleGroupVariant;
  size?: Size;
  orientation?: Orientation;
  /** Disables every item. */
  disabled?: boolean;
  /** Submits the selected value (one entry per value for `multiple`) under this name. */
  name?: string;
}

export interface ToggleGroupSingleProps extends ToggleGroupBaseProps {
  /** One value at a time, with radio semantics. */
  type?: "single";
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
}

export interface ToggleGroupMultipleProps extends ToggleGroupBaseProps {
  /** Any number of values, as a toolbar of pressed buttons. */
  type: "multiple";
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
}

export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;

const toList = (value: string | string[] | undefined) =>
  value === undefined ? undefined : Array.isArray(value) ? value : value ? [value] : [];

const ToggleGroup = React.forwardRef<HTMLDivElement, ToggleGroupProps>((props, ref) => {
  const {
    type = "single",
    value: valueProp,
    defaultValue,
    onValueChange,
    variant = "segmented",
    size = "md",
    orientation = "horizontal",
    disabled = false,
    name,
    color,
    style,
    className,
    onKeyDown,
    onFocus,
    onBlur,
    children,
    ...rest
  } = props;

  const baseId = React.useId();
  const reduceMotion = useReducedMotion() ?? false;
  const [uncontrolled, setUncontrolled] = React.useState<string[]>(() => toList(defaultValue) ?? []);
  const values = toList(valueProp) ?? uncontrolled;
  const [focused, setFocused] = React.useState<string | null>(null);
  const single = type === "single";

  const rootRef = React.useRef<HTMLDivElement>(null);
  React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

  // A form reset puts the selection back where it started.
  const initialValues = React.useRef(values);
  const resetRef = React.useRef<() => void>(() => {});
  React.useEffect(() => {
    resetRef.current = () => {
      const initial = initialValues.current;
      if (initial.length === values.length && initial.every((value, i) => value === values[i])) return;
      if (valueProp === undefined) setUncontrolled(initial);
      (onValueChange as ((value: string | string[]) => void) | undefined)?.(single ? (initial[0] ?? "") : initial);
    };
  });
  React.useEffect(() => {
    const form = rootRef.current?.closest("form");
    if (!form) return;
    const onReset = () => resetRef.current();
    form.addEventListener("reset", onReset);
    return () => form.removeEventListener("reset", onReset);
  }, []);

  const tabStop = focused ?? (single ? (values[0] ?? null) : null);

  const toggle = React.useCallback(
    (item: string) => {
      let next: string[];
      if (single) {
        if (values[0] === item) return;
        next = [item];
      } else {
        next = values.includes(item) ? values.filter((v) => v !== item) : [...values, item];
      }
      if (valueProp === undefined) setUncontrolled(next);
      (onValueChange as ((value: string | string[]) => void) | undefined)?.(single ? next[0] : next);
    },
    [single, values, valueProp, onValueChange],
  );

  const context = React.useMemo<ToggleGroupContextValue>(
    () => ({
      baseId,
      type,
      values,
      toggle,
      variant,
      size,
      orientation,
      disabled,
      reduceMotion,
      tabStop,
      setFocused,
    }),
    [baseId, type, values, toggle, variant, size, orientation, disabled, reduceMotion, tabStop],
  );

  function items(root: HTMLElement) {
    return Array.from(
      root.querySelectorAll<HTMLButtonElement>("[data-toggle-group-item]:not(:disabled)"),
    ).filter((item) => item.closest("[data-toggle-group]") === root);
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;

    const list = items(event.currentTarget);
    const index = list.indexOf(event.target as HTMLButtonElement);
    if (index === -1) return;

    const horizontal = orientation === "horizontal";
    // Radio groups answer both arrow pairs; a toolbar only the pair along its axis.
    const forward = single ? ["ArrowRight", "ArrowDown"] : [horizontal ? "ArrowRight" : "ArrowDown"];
    const backward = single ? ["ArrowLeft", "ArrowUp"] : [horizontal ? "ArrowLeft" : "ArrowUp"];
    const last = list.length - 1;

    let next: number;
    if (forward.includes(event.key)) next = index === last ? 0 : index + 1;
    else if (backward.includes(event.key)) next = index === 0 ? last : index - 1;
    else if (event.key === "Home") next = 0;
    else if (event.key === "End") next = last;
    else return;

    event.preventDefault();
    const target = list[next];
    target.focus();
    // Radio semantics: moving the focus moves the selection.
    if (single && target.dataset.value) toggle(target.dataset.value);
  }

  const v = groupVariants[variant];

  return (
    <ToggleGroupContext.Provider value={context}>
      <div
        ref={rootRef}
        role={single ? "radiogroup" : "toolbar"}
        aria-orientation={orientation}
        aria-disabled={disabled || undefined}
        data-toggle-group=""
        data-orientation={orientation}
        // With nothing selected or focused yet, the group holds the Tab stop and hands it to the first item.
        tabIndex={tabStop === null && !disabled ? 0 : undefined}
        onKeyDown={handleKeyDown}
        onFocus={(event) => {
          onFocus?.(event);
          if (event.target === event.currentTarget) items(event.currentTarget)[0]?.focus();
        }}
        onBlur={(event) => {
          onBlur?.(event);
          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(null);
        }}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative inline-flex w-fit outline-none",
          orientation === "vertical" ? "flex-col items-stretch" : "items-center",
          disabled && "opacity-50",
          v.root,
          v.rootSize[size],
          className,
        )}
        {...rest}
      >
        {children}
        {!single && <style>{keyframes}</style>}
        {name &&
          values.map((value) => (
            <input key={value} type="hidden" name={name} value={value} disabled={disabled} />
          ))}
      </div>
    </ToggleGroupContext.Provider>
  );
});
ToggleGroup.displayName = "ToggleGroup";

export interface ToggleGroupItemProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value"> {
  value: string;
}

const ToggleGroupItem = React.forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
  ({ value, disabled: disabledProp, className, onClick, onFocus, children, ...props }, ref) => {
    const group = useToggleGroup();
    const pressed = group.values.includes(value);
    const disabled = group.disabled || Boolean(disabledProp);
    const single = group.type === "single";
    const ignitions = useIgnitions(pressed);
    const v = groupVariants[group.variant];
    const vertical = group.orientation === "vertical";
    const radius = v.radius[group.size];

    const indicatorDetail =
      group.variant === "segmented" ? (
        // A short filament along the pill's lower edge: the part that reads as lit glass.
        <span className="absolute inset-x-[22%] bottom-0 h-px rounded-full bg-[rgb(var(--neon)/0.9)] shadow-[0_0_6px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1)))]" />
      ) : group.variant === "outline" ? (
        <span
          className={cn(
            "absolute bg-[rgb(var(--neon))] shadow-[0_0_10px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))),0_0_2px_rgb(var(--neon)/var(--neon-glow,1))]",
            vertical ? "inset-y-0 left-0 w-0.5" : "inset-x-0 bottom-0 h-0.5",
          )}
        />
      ) : null;

    return (
      <button
        ref={ref}
        type="button"
        role={single ? "radio" : undefined}
        aria-checked={single ? pressed : undefined}
        aria-pressed={single ? undefined : pressed}
        data-toggle-group-item=""
        data-value={value}
        data-state={pressed ? "on" : "off"}
        tabIndex={group.tabStop === value ? 0 : -1}
        disabled={disabled}
        onClick={(event) => {
          onClick?.(event);
          if (!event.defaultPrevented) group.toggle(value);
        }}
        onFocus={(event) => {
          onFocus?.(event);
          group.setFocused(value);
        }}
        className={cn(
          "relative inline-flex shrink-0 cursor-pointer select-none items-center whitespace-nowrap font-medium outline-none",
          "transition-[background-color,color] duration-150 ease-out",
          // Stacked items would steal each other's taps, so only rows get the taller target.
          !vertical && HIT_AREA,
          "disabled:pointer-events-none disabled:opacity-40",
          "[&_svg]:pointer-events-none [&_svg]:shrink-0",
          vertical ? "justify-start" : "justify-center",
          group.variant === "outline" &&
            cn("border-white/10", vertical ? "[&:not(:first-child)]:border-t" : "[&:not(:first-child)]:border-l"),
          v.item[group.size],
          v.focus,
          pressed ? v.on : v.idle,
          className,
        )}
        {...props}
      >
        {pressed &&
          (single ? (
            <motion.span
              aria-hidden
              // Always set, so the server and client agree; reduced motion only drops the slide.
              layoutId={`${group.baseId}-indicator`}
              initial={false}
              transition={group.reduceMotion ? { duration: 0 } : { duration: 0.3, ease: EASE }}
              style={{ borderRadius: radius }}
              className={cn("pointer-events-none", v.indicator)}
            >
              {indicatorDetail}
            </motion.span>
          ) : (
            <span
              aria-hidden
              style={{ borderRadius: radius }}
              className={cn("pointer-events-none", v.indicator, ignitions > 0 && IGNITE)}
            >
              {indicatorDetail}
            </span>
          ))}
        <span
          className={cn(
            "relative inline-flex items-center",
            labelSizes[group.size],
            !single && pressed && ignitions > 0 && IGNITE_LABEL,
          )}
        >
          {children}
        </span>
      </button>
    );
  },
);
ToggleGroupItem.displayName = "ToggleGroupItem";

export { Toggle, ToggleGroup, ToggleGroupItem };
```
