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.jsonnpx shadcn@latest add https://boardcn.dev/r/segmented-control.jsonnpm 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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The children of the component. A function may be provided to alter the children based on component state. |
| variant | "solid" | "plain" | solid | — |