All components
Segment Group
A segmented control for mutually exclusive views with an inset track and raised active segment.
1234567891011121314import { SegmentGroup } from "#components/ui/base-ui/segment-group.tsx";
export function Example() { return ( // Base UI's ToggleGroup constrained to a single active segment; the Root // mirrors the selected value so items can derive checked/divider state <SegmentGroup.Root defaultValue="preview" size="md" aria-label="View mode"> <SegmentGroup.Item value="preview">Preview</SegmentGroup.Item> <SegmentGroup.Item value="code">Code</SegmentGroup.Item> </SegmentGroup.Root> );}
// controlled: <SegmentGroup.Root value={view} onValueChange={setView}>Sizes
xs
sm
md
lg
Install
1npx stylexblocks add segment-groupSource
components/ui/base-ui/segment-group.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297import * as stylex from "@stylexjs/stylex";import { Children, createContext, isValidElement, useContext, useLayoutEffect, useRef, useState,} from "react";import { Toggle as BaseToggle } from "@base-ui-components/react/toggle";import { ToggleGroup as BaseToggleGroup } from "@base-ui-components/react/toggle-group";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, resolveSlotStyles, type SlotRecipeVariants,} from "#lib/recipe-props.ts";import { colors } from '@stylexblocks/ui/tokens/colors.stylex.ts'import { cursor } from '@stylexblocks/ui/tokens/cursor.stylex.ts'import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { semanticShadows } from '@stylexblocks/ui/semantic-tokens/shadows.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const segmentGroupRoot = stylex.create({ base: { borderRadius: 'var(--segment-radius)', display: 'inline-flex', boxShadow: semanticShadows.inset, minWidth: 'max-content', textAlign: 'center', position: 'relative', isolation: 'isolate', backgroundColor: semanticColors.bgMuted, borderWidth: '1px', },})
const segmentGroupRootSizes = stylex.create({ xs: { '--segment-radius': semanticRadii.control, height: sizes.controlXs, }, sm: { '--segment-radius': semanticRadii.control, height: sizes.controlSm, }, md: { '--segment-radius': semanticRadii.control, height: sizes.controlMd, }, lg: { '--segment-radius': semanticRadii.control, height: sizes.controlLg, },})
const segmentGroupItem = stylex.create({ base: { display: 'flex', alignItems: 'center', userSelect: 'none', fontSize: fontSizes.sm, position: 'relative', color: { default: semanticColors.fgSubtle, ':hover': semanticColors.fg, ':is(:checked, [data-checked], [aria-checked="true"], [data-state="checked"])': semanticColors.fg, }, cursor: cursor.button, borderRadius: 'var(--segment-radius)', opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, '::before': { content: '""', position: 'absolute', insetInlineStart: 0, insetBlock: 0, backgroundColor: semanticColors.border, width: '1px', transition: 'opacity 0.2s', }, },})
const segmentGroupItemSizes = stylex.create({ xs: { fontSize: fontSizes.xs, lineHeight: lineHeights.xs, paddingInline: spacing._3, gap: spacing._1, }, sm: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, paddingInline: spacing._4, gap: spacing._2, }, md: { fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInline: spacing._4, gap: spacing._2, }, lg: { fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInline: spacing._5, gap: spacing._3, },})
const segmentGroupIndicator = stylex.create({ base: { position: 'absolute', backgroundColor: `light-dark(${semanticColors.bg}, ${semanticColors.bgEmphasized})`, width: 'var(--width)', height: 'var(--height)', top: 'var(--top)', left: 'var(--left)', zIndex: -1, borderRadius: 'var(--segment-radius)', boxShadow: '0 0 0 1px var(--sui-colors-border-emphasized)', transitionProperty: 'left, top, width, height', transitionDuration: durations.moderate, transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', },})
export type SegmentGroupSize = keyof typeof segmentGroupRootSizes
const segmentGroupRecipe = defineSlotRecipe({ slots: { root: { styles: segmentGroupRoot, sizes: segmentGroupRootSizes }, item: { styles: segmentGroupItem, sizes: segmentGroupItemSizes }, indicator: { styles: segmentGroupIndicator }, }, defaultVariants: { size: "md" },});
const chrome = stylex.create({ hideDivider: { ":is([data-checked], [data-no-divider])::before": { opacity: 0 }, }, btnReset: { backgroundColor: "transparent", borderWidth: 0, margin: 0, padding: 0, color: "inherit", fontFamily: "inherit", fontSize: "inherit", }, cursorPointer: { cursor: "pointer" },});
interface SegmentGroupContextValue { value: string | null; hiddenDividers: ReadonlySet<string>;}
const GroupContext = createContext<SegmentGroupContextValue>({ value: null, hiddenDividers: new Set(),});
function collectHiddenDividers(children: React.ReactNode, selected: string | null) { const values = Children.toArray(children) .map((child) => (isValidElement(child) ? (child.props as { value?: string }).value : undefined)) .filter((value): value is string => typeof value === "string"); const hidden = new Set<string>(); values.forEach((value, index) => { if (index === 0 || value === selected || values[index - 1] === selected) { hidden.add(value); } }); return hidden;}
const { withProvider, withContext, VariantContext } = createSlotRecipeContext(segmentGroupRecipe);
function RootNode({ value, defaultValue, onValueChange, children, ...props}: Omit<BaseToggleGroup.Props, "value" | "defaultValue" | "onValueChange"> & { value?: string | null; defaultValue?: string | null; onValueChange?: (value: string) => void;}) { const [inner, setInner] = useState<string | null>(defaultValue ?? null); const current = value !== undefined ? value : inner; const groupRef = useRef<HTMLDivElement>(null); const indicatorRef = useRef<HTMLSpanElement>(null); const v = useContext(VariantContext); useLayoutEffect(() => { const group = groupRef.current; const indicator = indicatorRef.current; if (!group || !indicator) return; const position = () => { const pressed = group.querySelector<HTMLElement>("[data-pressed]"); if (!pressed) { indicator.hidden = true; return; } indicator.hidden = false; indicator.style.setProperty("--left", `${pressed.offsetLeft}px`); indicator.style.setProperty("--top", `${pressed.offsetTop}px`); indicator.style.setProperty("--width", `${pressed.offsetWidth}px`); indicator.style.setProperty("--height", `${pressed.offsetHeight}px`); }; position(); const observer = new ResizeObserver(position); observer.observe(group); return () => observer.disconnect(); }, [current, v.size, children]); return ( <GroupContext.Provider value={{ value: current, hiddenDividers: collectHiddenDividers(children, current) }} > <BaseToggleGroup {...props} ref={groupRef} value={current ? [current] : []} onValueChange={(groupValue) => { const next = groupValue[0]; if (typeof next === "string") { setInner(next); onValueChange?.(next); } }} > <span ref={indicatorRef} hidden aria-hidden="true" {...stylex.props(...resolveSlotStyles(segmentGroupRecipe.slots.indicator, v))} /> {children} </BaseToggleGroup> </GroupContext.Provider> );}
function ItemNode({ value, ...props}: Omit<BaseToggle.Props, "value"> & { value: string }) { const { value: selected, hiddenDividers } = useContext(GroupContext); const checked = selected === value; return ( <BaseToggle {...props} value={value} data-checked={checked ? "" : undefined} data-no-divider={hiddenDividers.has(value) ? "" : undefined} /> );}
export type SegmentGroupVariantProps = Partial<SlotRecipeVariants<typeof segmentGroupRecipe>>;
/** * Segment Group — Base UI's ToggleGroup styled by the StyleX slot recipe and * constrained to a single active segment. `<SegmentGroup.Root size>` resolves * the recipe once; every item inherits it through context. Root mirrors the * selected value so items can stamp `data-checked` / `data-no-divider`, and * measures the pressed toggle to drive the indicator. */export const SegmentGroup = { Root: withProvider(RootNode, "root", "SegmentGroupRoot"), Item: withContext( ItemNode, (v) => [ chrome.btnReset, ...resolveSlotStyles(segmentGroupRecipe.slots.item, v), chrome.hideDivider, chrome.cursorPointer, ], "SegmentGroupItem", ),};
export type SegmentGroupRootProps = React.ComponentProps<typeof SegmentGroup.Root>;export type SegmentGroupItemProps = React.ComponentProps<typeof SegmentGroup.Item>;