component — actions

Toggle Group

A set of pressable toggles that act as one control, built on react-aria-components and styled with the quebi design system. Supports single or multiple selection, horizontal or vertical layout.

  • action
  • toggle
  • segmented
  • selection
  • interactive

07 examples

Single selection

Exactly one item active at a time — items sit apart with a small gutter.

Multiple selection

Many items active at once — items butt together into a segmented bar.

Sizes

Vertical

Stack the group along the vertical axis.

Pill

isCircle rounds the shell and the items at its ends into a pill.

Controlled

View: grid

Disabled

source

toggle-group.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { createContext, use } from "react"
import {
  composeRenderProps,
  ToggleButton,
  ToggleButtonGroup,
  type ToggleButtonGroupProps,
  type ToggleButtonProps,
} from "react-aria-components"
import type { VariantProps } from "tailwind-variants"
import { cn, tv } from "@/lib/utils"

/**
 * ToggleGroup — quebi design system
 *
 * A set of two-state pressable buttons that act as one control (think a view
 * switcher or a text-alignment toolbar). The selected item is the active
 * segment — `selected` ground, `signal` label; in single-selection mode items sit with a small gutter, in multiple mode they
 * butt together into a segmented bar. Self-contained — the item
 * styles live here rather than reaching for a sibling Toggle.
 *
 * ## A group is not as tall as a button of the same name
 *
 * The shell wraps its items in `p-0.5` and a border, so it always stands 6px
 * taller than the item inside it — `size="xs"` is a 30px item in a 36px box,
 * which is the size of nothing else in the library. Beside a `Button
 * size="sm"` (38px) that is 1px of misalignment top and bottom, and swapping
 * up to `size="sm"` overshoots by the same 6px in the other direction (task
 * #209).
 *
 * `height="control"` is the way out: the *shell* takes the height a `Button`
 * of that size has, and the items give the 6px back out of their own padding.
 * So `<ToggleGroup size="sm" height="control">` is 38px overall with `text-sm`
 * items, which is what a toolbar row wants — see the note in `toolbar.tsx`.
 * It applies to a horizontal group only: a fixed height on a column would
 * squash it, and the items there are already one width.
 */

type ToggleGroupSize = "xs" | "sm" | "md" | "lg" | "sq-xs" | "sq-sm" | "sq-md" | "sq-lg"

/**
 * What decides the group's height.
 *
 * - `natural` — the items do, and the shell is 6px taller. The default, and
 *   what a group standing on its own wants.
 * - `control` — the shell does, matching the `Button` of the same size, and
 *   the items lose the difference from their padding. What a group sharing a
 *   row with buttons or inputs wants.
 */
export type ToggleGroupHeight = "natural" | "control"

/** The height a `Button` of each size is — see the note in `button.tsx`. */
const CONTROL_HEIGHTS: Record<ToggleGroupSize, string> = {
  xs: "h-7.5",
  sm: "h-9.5",
  md: "h-11.5",
  lg: "h-13.5",
  "sq-xs": "h-7.5",
  "sq-sm": "h-9.5",
  "sq-md": "h-11.5",
  "sq-lg": "h-13.5",
}

interface ToggleGroupContextValue
  extends Pick<ToggleButtonGroupProps, "selectionMode" | "orientation"> {
  size?: ToggleGroupSize
  height?: ToggleGroupHeight
  isCircle?: boolean
}

const ToggleGroupContext = createContext<ToggleGroupContextValue>({
  size: "md",
  height: "natural",
  selectionMode: "single",
  orientation: "horizontal",
})

const useToggleGroupContext = () => use(ToggleGroupContext)

export interface ToggleGroupProps extends ToggleButtonGroupProps {
  size?: ToggleGroupSize
  /** Whether the shell or the items decide the height. Default `natural`. */
  height?: ToggleGroupHeight
  isCircle?: boolean
}

export function ToggleGroup({
  size = "md",
  height = "natural",
  orientation = "horizontal",
  selectionMode = "single",
  isCircle,
  className,
  ...props
}: ToggleGroupProps) {
  const isFixedHeight = height === "control" && orientation === "horizontal"

  return (
    <ToggleGroupContext.Provider
      value={{
        size,
        height: isFixedHeight ? "control" : "natural",
        selectionMode,
        orientation,
        isCircle,
      }}
    >
      <ToggleButtonGroup
        data-slot="control"
        selectionMode={selectionMode}
        orientation={orientation}
        className={composeRenderProps(className, (resolved) =>
          cn(
            "inline-flex p-0.5",
            "border border-solid border-quebi-hairline",
            orientation === "horizontal" ? "flex-row" : "flex-col",
            selectionMode === "single" ? "gap-0.5" : "gap-0",
            isCircle ? "rounded-full" : "rounded-(--q-radius-control)",
            isFixedHeight && CONTROL_HEIGHTS[size],
            resolved,
          ),
        )}
        {...props}
      />
    </ToggleGroupContext.Provider>
  )
}

export const toggleGroupItemStyles = tv({
  base: [
    "inline-flex items-center justify-center gap-2",
    "font-sans font-medium whitespace-nowrap select-none cursor-pointer",
    "border border-solid border-transparent text-quebi-fg-muted",
    "transition-[background-color,border-color,color,opacity] duration-150 ease-out",
    "outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg focus-visible:z-10",
    "hover:not-selected:bg-quebi-raised hover:not-selected:text-quebi-fg",
    "selected:bg-quebi-selected selected:text-quebi-signal",
    "disabled:opacity-45 disabled:cursor-not-allowed disabled:hover:bg-transparent",
    "*:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center",
  ],
  variants: {
    orientation: {
      horizontal: "justify-center",
      vertical: "justify-start",
    },
    selectionMode: {
      single: "",
      multiple: "",
    },
    size: {
      xs: ["text-xs px-2.5 py-1.5", "*:data-[slot=icon]:size-3.5"],
      sm: ["text-sm px-3.5 py-2", "*:data-[slot=icon]:size-4"],
      md: ["text-sm px-5 py-3", "*:data-[slot=icon]:size-4"],
      lg: ["text-base px-6 py-3.5", "*:data-[slot=icon]:size-5"],
      // Square / icon-only. `size-*` is border-box, so a square matches its
      // text-sized sibling only if the number includes the 1px border on each
      // side: xs is line-height 16 + py-1.5 12 + 2 = 30px, and so on — the
      // same scale as button.tsx.
      "sq-xs": "size-7.5 p-0 *:data-[slot=icon]:size-3.5",
      "sq-sm": "size-9.5 p-0 *:data-[slot=icon]:size-4",
      "sq-md": "size-11.5 p-0 *:data-[slot=icon]:size-5",
      "sq-lg": "size-13.5 p-0 *:data-[slot=icon]:size-6",
    },
  },
  defaultVariants: {
    size: "md",
    selectionMode: "single",
    orientation: "horizontal",
  },
  compoundVariants: [
    {
      selectionMode: "multiple",
      orientation: "horizontal",
      className: "not-first:-ms-px",
    },
    {
      selectionMode: "multiple",
      orientation: "vertical",
      className: "not-first:-mt-px",
    },
  ],
})

export interface ToggleGroupItemProps
  extends ToggleButtonProps,
    Pick<VariantProps<typeof toggleGroupItemStyles>, "size"> {}

export function ToggleGroupItem({ className, size: sizeProp, ...props }: ToggleGroupItemProps) {
  const { size, height, selectionMode, orientation, isCircle } = useToggleGroupContext()
  const resolvedSize = sizeProp ?? size

  // The shell is stretching this item to a height it chose, so the item's own
  // is in the way: a text size keeps its padding but stops adding to it, and a
  // square one drops the fixed `size-*` and takes its width from the height it
  // was stretched to, which is what keeps it square at any shell height.
  const fitsShell =
    height === "control" &&
    (resolvedSize?.startsWith("sq-") ? "h-auto w-auto p-0 aspect-square" : "h-auto py-0")

  return (
    <ToggleButton
      data-slot="toggle-group-item"
      className={composeRenderProps(className, (className) =>
        cn(
          toggleGroupItemStyles({
            size: resolvedSize,
            orientation,
            selectionMode,
          }),
          fitsShell,
          // The shell's corners carry into what touches its ends: every item
          // when they stand apart, only the outer two when they butt into one
          // bar. A control-radius shell holds mark-radius items — 6px less the
          // 2px padding is 4px, so the corners stay concentric.
          isCircle
            ? selectionMode !== "multiple"
              ? "rounded-full"
              : orientation === "vertical"
                ? "first:rounded-t-full last:rounded-b-full"
                : "first:rounded-s-full last:rounded-e-full"
            : selectionMode !== "multiple"
              ? "rounded-(--q-radius-mark)"
              : orientation === "vertical"
                ? "first:rounded-t-(--q-radius-mark) last:rounded-b-(--q-radius-mark)"
                : "first:rounded-s-(--q-radius-mark) last:rounded-e-(--q-radius-mark)",
          className,
        ),
      )}
      {...props}
    />
  )
}