For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Toggle Group

The Toggle Group component from this design system, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/toggle-group

Options

  • variant="segmented" puts the items on a track with a sliding thumb on the selected one.
  • variant="ghost" has no track; the pressed item gets a filled chip.

Source

"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 { toggleVariants } from "@/components/ui/toggle";

const ToggleGroupContext = React.createContext<
  Omit<VariantProps<typeof toggleVariants>, "variant"> & {
    variant?: VariantProps<typeof toggleVariants>["variant"] | "segmented";
    spacing?: number;
    orientation?: "horizontal" | "vertical";
  }
>({
  orientation: "horizontal",
  size: "default",
  spacing: 2,
  variant: "default",
});

function ToggleGroup({
  className,
  variant,
  size,
  spacing = 2,
  orientation = "horizontal",
  children,
  ref,
  ...props
}: ToggleGroupPrimitive.Props &
  Omit<VariantProps<typeof toggleVariants>, "variant"> & {
    variant?: VariantProps<typeof toggleVariants>["variant"] | "segmented";
    spacing?: number;
    orientation?: "horizontal" | "vertical";
  }) {
  const innerRef = React.useRef<HTMLDivElement>(null);
  const [thumb, setThumb] = React.useState<{
    left: number;
    top: number;
    width: number;
    height: number;
  } | null>(null);
  const segmented = variant === "segmented" || variant === "ghost";
  React.useLayoutEffect(() => {
    const el = innerRef.current;
    if (!el || variant !== "segmented" || props.multiple) {
      return;
    }
    const measure = () => {
      const selected = el.querySelector<HTMLElement>(
        '[data-slot="toggle-group-item"][data-pressed]'
      );
      setThumb(
        selected
          ? {
              left: selected.offsetLeft,
              top: selected.offsetTop,
              width: selected.offsetWidth,
              height: selected.offsetHeight,
            }
          : null
      );
    };
    const ro = new ResizeObserver(measure);
    const observe = () => {
      for (const item of el.querySelectorAll("button")) {
        ro.observe(item);
      }
      measure();
    };
    ro.observe(el);
    const mo = new MutationObserver(observe);
    mo.observe(el, {
      attributes: true,
      childList: true,
      characterData: true,
      subtree: true,
      attributeFilter: ["data-pressed"],
    });
    observe();
    return () => {
      ro.disconnect();
      mo.disconnect();
    };
  }, [variant, props.multiple]);
  return (
    <ToggleGroupPrimitive
      ref={(node) => {
        innerRef.current = node;
        if (typeof ref === "function") {
          ref(node);
        } else if (ref) {
          ref.current = node;
        }
      }}
      data-slot="toggle-group"
      data-variant={variant}
      data-size={size}
      data-multiple={props.multiple ? "" : undefined}
      data-spacing={spacing}
      data-orientation={orientation}
      style={{ "--gap": spacing } as React.CSSProperties}
      className={cn(
        "group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-[6px] data-[spacing=0]:data-[variant=outline]:shadow-xs data-vertical:flex-col data-vertical:items-stretch",
        segmented && "relative items-start gap-0.5 rounded-[10px]",
        variant === "segmented" &&
          "bg-[var(--toggle-group-track,var(--muted))] p-1",
        className
      )}
      {...props}
      onValueChange={(value, details) => {
        // Segmented single-select always keeps one item selected.
        if (segmented && !props.multiple && value.length === 0) {
          details.cancel();
          return;
        }
        props.onValueChange?.(value, details);
      }}
    >
      {variant === "segmented" && !props.multiple && thumb && (
        <span
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 rounded-[6px] bg-[var(--surface-raised)] shadow-[shadow:var(--toggle-group-shadow,0_1px_rgb(0_0_0/0.05))] transition-[transform,width,height] duration-200 ease-[ease] motion-reduce:transition-none"
          style={{
            transform: `translate(${thumb.left}px, ${thumb.top}px)`,
            width: thumb.width,
            height: thumb.height,
          }}
        />
      )}
      <ToggleGroupContext.Provider
        value={{ orientation, size, spacing, variant }}
      >
        {children}
      </ToggleGroupContext.Provider>
    </ToggleGroupPrimitive>
  );
}

function ToggleGroupItem({
  className,
  children,
  variant = "default",
  size = "default",
  ...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
  const context = React.useContext(ToggleGroupContext);

  const segmented =
    context.variant === "segmented" || context.variant === "ghost";

  return (
    <TogglePrimitive
      data-slot="toggle-group-item"
      data-variant={context.variant || variant}
      data-size={context.size || size}
      data-spacing={context.spacing}
      className={cn(
        !segmented &&
          "shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:shadow-none focus:z-10 focus-visible:z-10 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-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-[6px] group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-[6px] group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-[6px] group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-[6px] data-[state=on]:bg-muted group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
        !segmented &&
          toggleVariants({
            size: context.size || size,
            variant:
              context.variant === "outline" ||
              context.variant === "choice" ||
              context.variant === "swatch"
                ? context.variant
                : variant,
          }),
        segmented &&
          "relative z-10 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-[6px] px-2.5 py-1 text-center text-sm leading-5 font-normal whitespace-nowrap text-[color:var(--foreground-secondary,var(--muted-foreground))] outline-none hover:text-foreground hover:transition-colors hover:duration-200 hover:ease-[ease] focus-visible:ring-2 focus-visible:ring-ring data-pressed:font-medium data-pressed:text-foreground data-pressed:transition-none data-disabled:cursor-not-allowed data-disabled:opacity-50 motion-reduce:transition-none",
        segmented &&
          context.variant === "segmented" &&
          "isolate before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] group-data-[multiple]/toggle-group:data-pressed:before:bg-[var(--surface-raised)]",
        className
      )}
      {...props}
    >
      {children}
    </TogglePrimitive>
  );
}

export { ToggleGroup, ToggleGroupItem };