Segmented Control

Pill-style segmented control (Weekly / Monthly / Yearly switchers).

Segments

An animated pill switcher.

function SegmentedControlDemo() {
  const [value, setValue] = useState(new Set(["weekly"]));
  return (
    <SegmentedControl selectedKeys={value} onSelectionChange={(keys) => setValue(keys as Set<string>)}>
      <SegmentedControlItem id="weekly">Weekly</SegmentedControlItem>
      <SegmentedControlItem id="monthly">Monthly</SegmentedControlItem>
      <SegmentedControlItem id="yearly">Yearly</SegmentedControlItem>
    </SegmentedControl>
  );
}
function SegmentedControlDemo() {
  const [value, setValue] = useState(new Set(["weekly"]));
  return (
    <SegmentedControl selectedKeys={value} onSelectionChange={(keys) => setValue(keys as Set<string>)}>
      <SegmentedControlItem id="weekly">Weekly</SegmentedControlItem>
      <SegmentedControlItem id="monthly">Monthly</SegmentedControlItem>
      <SegmentedControlItem id="yearly">Yearly</SegmentedControlItem>
    </SegmentedControl>
  );
}

Installation

npx shadcn@latest add https://boardcn.dev/r/segmented-control.json
npx shadcn@latest add https://boardcn.dev/r/segmented-control.json

npm packages

  • react-aria-components

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/base/segmented-control/segmented-control.tsx
"use client";

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import {
  ToggleButton as AriaToggleButton,
  ToggleButtonGroup as AriaToggleButtonGroup,
} from "react-aria-components";
import type {
  ToggleButtonGroupProps as AriaToggleButtonGroupProps,
  ToggleButtonProps as AriaToggleButtonProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";

const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;

export type SegmentedControlVariant = "solid" | "plain";

export interface SegmentedControlProps
  extends Omit<AriaToggleButtonGroupProps, "selectionMode" | "disallowEmptySelection"> {
  children?: ReactNode;
  variant?: SegmentedControlVariant;
  ref?: Ref<HTMLDivElement>;
}

type Thumb = { left: number; top: number; width: number; height: number };

export function SegmentedControl({ className, children, variant = "solid", ref, ...props }: SegmentedControlProps) {
  const innerRef = useRef<HTMLDivElement>(null);
  const [thumb, setThumb] = useState<Thumb | null>(null);

  useIsomorphicLayoutEffect(() => {
    const el = innerRef.current;
    if (!el) return;
    const measure = () => {
      const selected = el.querySelector<HTMLElement>("[data-selected]");
      if (selected) {
        setThumb({
          left: selected.offsetLeft,
          top: selected.offsetTop,
          width: selected.offsetWidth,
          height: selected.offsetHeight,
        });
      }
    };
    measure();
    // Re-measure when selection flips (data-selected toggles) or size changes.
    const mo = new MutationObserver(measure);
    mo.observe(el, { attributes: true, subtree: true, attributeFilter: ["data-selected"] });
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => {
      mo.disconnect();
      ro.disconnect();
    };
  }, []);

  const setRefs = (node: HTMLDivElement | null) => {
    innerRef.current = node;
    if (typeof ref === "function") ref(node);
    else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
  };

  return (
    <AriaToggleButtonGroup
      ref={setRefs}
      selectionMode="single"
      disallowEmptySelection
      {...props}
      className={(state) =>
        cx(
          "relative inline-flex items-start gap-0.5 rounded-2lg",
          variant === "solid" && "bg-segmented-control-background p-1",
          typeof className === "function" ? className(state) : className,
        )
      }
    >
      {variant === "solid" && thumb && (
        <span
          aria-hidden
          className="pointer-events-none absolute left-0 top-0 rounded-md bg-segmented-control-selected-background shadow-2xs transition-[transform,width,height] duration-200 ease"
          style={{
            transform: `translate(${thumb.left}px, ${thumb.top}px)`,
            width: thumb.width,
            height: thumb.height,
          }}
        />
      )}
      {children}
    </AriaToggleButtonGroup>
  );
}

export interface SegmentedControlItemProps extends Omit<AriaToggleButtonProps, "children"> {
  children?: ReactNode;
  ref?: Ref<HTMLButtonElement>;
}

export function SegmentedControlItem({
  className,
  children,
  ref,
  ...props
}: SegmentedControlItemProps) {
  return (
    <AriaToggleButton
      ref={ref}
      {...props}
      className={(state) =>
        cx(
          "relative z-10 inline-flex cursor-pointer items-center justify-center rounded-md px-2.5 py-1 text-center whitespace-nowrap",
          "transition-colors duration-200 ease",
          "outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
          state.isSelected
            ? "text-body-medium text-text-primary"
            : "text-body-regular text-text-secondary hover:text-text-primary",
          state.isDisabled && "cursor-not-allowed opacity-50",
          typeof className === "function" ? className(state) : className,
        )
      }
    >
      {children}
    </AriaToggleButton>
  );
}
"use client";

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import {
  ToggleButton as AriaToggleButton,
  ToggleButtonGroup as AriaToggleButtonGroup,
} from "react-aria-components";
import type {
  ToggleButtonGroupProps as AriaToggleButtonGroupProps,
  ToggleButtonProps as AriaToggleButtonProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";

const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;

export type SegmentedControlVariant = "solid" | "plain";

export interface SegmentedControlProps
  extends Omit<AriaToggleButtonGroupProps, "selectionMode" | "disallowEmptySelection"> {
  children?: ReactNode;
  variant?: SegmentedControlVariant;
  ref?: Ref<HTMLDivElement>;
}

type Thumb = { left: number; top: number; width: number; height: number };

export function SegmentedControl({ className, children, variant = "solid", ref, ...props }: SegmentedControlProps) {
  const innerRef = useRef<HTMLDivElement>(null);
  const [thumb, setThumb] = useState<Thumb | null>(null);

  useIsomorphicLayoutEffect(() => {
    const el = innerRef.current;
    if (!el) return;
    const measure = () => {
      const selected = el.querySelector<HTMLElement>("[data-selected]");
      if (selected) {
        setThumb({
          left: selected.offsetLeft,
          top: selected.offsetTop,
          width: selected.offsetWidth,
          height: selected.offsetHeight,
        });
      }
    };
    measure();
    // Re-measure when selection flips (data-selected toggles) or size changes.
    const mo = new MutationObserver(measure);
    mo.observe(el, { attributes: true, subtree: true, attributeFilter: ["data-selected"] });
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => {
      mo.disconnect();
      ro.disconnect();
    };
  }, []);

  const setRefs = (node: HTMLDivElement | null) => {
    innerRef.current = node;
    if (typeof ref === "function") ref(node);
    else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
  };

  return (
    <AriaToggleButtonGroup
      ref={setRefs}
      selectionMode="single"
      disallowEmptySelection
      {...props}
      className={(state) =>
        cx(
          "relative inline-flex items-start gap-0.5 rounded-2lg",
          variant === "solid" && "bg-segmented-control-background p-1",
          typeof className === "function" ? className(state) : className,
        )
      }
    >
      {variant === "solid" && thumb && (
        <span
          aria-hidden
          className="pointer-events-none absolute left-0 top-0 rounded-md bg-segmented-control-selected-background shadow-2xs transition-[transform,width,height] duration-200 ease"
          style={{
            transform: `translate(${thumb.left}px, ${thumb.top}px)`,
            width: thumb.width,
            height: thumb.height,
          }}
        />
      )}
      {children}
    </AriaToggleButtonGroup>
  );
}

export interface SegmentedControlItemProps extends Omit<AriaToggleButtonProps, "children"> {
  children?: ReactNode;
  ref?: Ref<HTMLButtonElement>;
}

export function SegmentedControlItem({
  className,
  children,
  ref,
  ...props
}: SegmentedControlItemProps) {
  return (
    <AriaToggleButton
      ref={ref}
      {...props}
      className={(state) =>
        cx(
          "relative z-10 inline-flex cursor-pointer items-center justify-center rounded-md px-2.5 py-1 text-center whitespace-nowrap",
          "transition-colors duration-200 ease",
          "outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
          state.isSelected
            ? "text-body-medium text-text-primary"
            : "text-body-regular text-text-secondary hover:text-text-primary",
          state.isDisabled && "cursor-not-allowed opacity-50",
          typeof className === "function" ? className(state) : className,
        )
      }
    >
      {children}
    </AriaToggleButton>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

SegmentedControl

PropTypeDefaultDescription
childrenReactNodeThe children of the component. A function may be provided to alter the children based on component state.
variant"solid" | "plain"solid