Segmented
A set of mutually exclusive options inside a single inline container.
npx shadcn@latest add @velaui/segmentedpnpm dlx velaui add @velaui/segmentedyarn dlx shadcn@latest add @velaui/segmentedbunx --bun shadcn@latest add @velaui/segmentedveni add segmentedInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/segmentedpnpm dlx velaui add @velaui/segmentedyarn dlx shadcn@latest add @velaui/segmentedbunx --bun shadcn@latest add @velaui/segmentedExamples
- Default
- Billing medium
- Status strong
- Priority strong small
- View icons
- Align icon only
- Theme toggle
- Weekdays
- Shipping disabled
Usage
Props
Segmented
| Prop | Type | Default |
|---|---|---|
emphasis | "strong" | "default" | "default" |
size | SegmentedSize | "sm" |
Takes these and every prop of SegmentedProps.
SegmentedItem
Takes every prop of SegmentedItemProps.
Segmented
| Prop | Type | Default |
|---|---|---|
defaultValue | string | (readonly string[] & string) | — |
emphasis | "strong" | "default" | "default" |
onValueChange | (value: string) => void | — |
size | SegmentedSize | "sm" |
value | string | — |
Takes these and every prop of SegmentedProps.
SegmentedItem
Takes every prop of SegmentedItemProps.
Dependencies
- npm
class-variance-authority@0.7.1radix-ui@1.6.0- Installs with it
- nothing else
- npm
@base-ui/react@1.6.0class-variance-authority@0.7.1- Installs with it
- nothing else
Source
components/ui/segmented.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
type SegmentedEmphasis = "default" | "strong";
type SegmentedSize = "sm" | "md";
type SegmentedContextType = {
emphasis: SegmentedEmphasis;
size: SegmentedSize;
};
// The control owns emphasis/size; items read them so every call site does not
// repeat the props.
const SegmentedContext = React.createContext<SegmentedContextType>({
emphasis: "default",
size: "sm",
});
// Strokes are drawn as rings: Figma strokes the container INSIDE and the
// selected item OUTSIDE, so neither may add to the 40/48px height. The track
// and its items have an 8px radius (Figma 2341:17765, 2026-09-28; they were
// pills before). An icon-only track, like the light/dark toggle (Figma
// 957:11914), spaces its squares 4px apart inside a 1px stroke.
const segmentedVariants = cva(
"group/segmented inline-flex w-fit items-center gap-0.5 rounded-lg p-1",
{
variants: {
emphasis: {
default:
"bg-bg-surface-1 inset-ring-[1.5px] inset-ring-border-default backdrop-blur-[40px] has-[>[data-slot=segmented-item]>svg:not([data-icon])]:gap-1 has-[>[data-slot=segmented-item]>svg:not([data-icon])]:inset-ring",
strong: "bg-bg-main inset-ring inset-ring-border-default",
},
},
defaultVariants: {
emphasis: "default",
},
},
);
export type SegmentedProps = Omit<
ToggleGroupPrimitive.ToggleGroupSingleProps,
"type"
> &
VariantProps<typeof segmentedVariants> & {
emphasis?: SegmentedEmphasis;
size?: SegmentedSize;
};
function Segmented({
className,
emphasis = "default",
size = "sm",
value: valueProp,
defaultValue,
onValueChange,
...props
}: SegmentedProps) {
// A segmented control always has one option selected. Radix single toggle
// groups clear the value when the active item is pressed again, so that
// empty change is swallowed here for both controlled and uncontrolled use.
const [uncontrolledValue, setUncontrolledValue] = React.useState(
defaultValue ?? "",
);
const value = valueProp ?? uncontrolledValue;
const handleValueChange = React.useCallback(
(next: string) => {
if (!next) return;
setUncontrolledValue(next);
onValueChange?.(next);
},
[onValueChange],
);
const context = React.useMemo(() => ({ emphasis, size }), [emphasis, size]);
return (
<SegmentedContext value={context}>
<ToggleGroupPrimitive.Root
type="single"
data-slot="segmented"
data-emphasis={emphasis}
data-size={size}
value={value}
onValueChange={handleValueChange}
className={cn(segmentedVariants({ emphasis }), className)}
{...props}
/>
</SegmentedContext>
);
}
// Icon + label items tighten the leading edge (`data-icon="inline-start"`);
// an item whose only content is an untagged svg is icon-only and goes square.
// The selected default item is SemiBold with a soft 24px glow, which no shadow
// token matches, so its value is written out; a selected icon-only square
// takes the small shadow and no stroke instead.
const segmentedItemVariants = cva(
"group/segmented-item inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg text-sm leading-[1.4] font-medium whitespace-nowrap transition-all outline-none hover:bg-bg-surface-1 focus-visible:bg-bg-surface-1 focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
emphasis: {
default:
"text-text-note data-[state=on]:bg-bg-main data-[state=on]:font-semibold data-[state=on]:tracking-[-0.2px] data-[state=on]:text-text-strong data-[state=on]:shadow-[0_0_24px_-4px_rgb(0_0_0/0.07)] data-[state=on]:ring-[0.5px] data-[state=on]:ring-border-default data-[state=on]:hover:bg-bg-main has-[>svg:not([data-icon])]:data-[state=on]:shadow-xs has-[>svg:not([data-icon])]:data-[state=on]:ring-0",
strong:
"text-text-strong data-[state=on]:bg-primary-default data-[state=on]:text-text-inverse data-[state=on]:hover:bg-primary-default",
},
size: {
sm: "h-8 gap-2 px-3 py-1 has-data-[icon=inline-start]:gap-1 has-data-[icon=inline-start]:pl-2 has-[>svg:not([data-icon])]:size-8 has-[>svg:not([data-icon])]:px-0 [&_svg:not([class*='size-'])]:size-5",
md: "h-10 gap-2 px-4 py-2 has-data-[icon=inline-start]:pl-3 has-[>svg:not([data-icon])]:size-10 has-[>svg:not([data-icon])]:px-0 [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
emphasis: "default",
size: "sm",
},
},
);
export type SegmentedItemProps = React.ComponentProps<typeof ToggleGroupPrimitive.Item>;
function SegmentedItem({
className,
...props
}: SegmentedItemProps) {
const { emphasis, size } = React.useContext(SegmentedContext);
return (
<ToggleGroupPrimitive.Item
data-slot="segmented-item"
className={cn(segmentedItemVariants({ emphasis, size }), className)}
{...props}
/>
);
}
export { Segmented, SegmentedItem, segmentedVariants, segmentedItemVariants };
export type { SegmentedEmphasis, SegmentedSize };components/ui/segmented.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
import { cn } from "@/lib/utils";
type SegmentedEmphasis = "default" | "strong";
type SegmentedSize = "sm" | "md";
type SegmentedContextType = {
emphasis: SegmentedEmphasis;
size: SegmentedSize;
};
// The control owns emphasis/size; items read them so every call site does not
// repeat the props.
const SegmentedContext = React.createContext<SegmentedContextType>({
emphasis: "default",
size: "sm",
});
// Strokes are drawn as rings: Figma strokes the container INSIDE and the
// selected item OUTSIDE, so neither may add to the 40/48px height. The track
// and its items have an 8px radius (Figma 2341:17765, 2026-09-28; they were
// pills before). An icon-only track, like the light/dark toggle (Figma
// 957:11914), spaces its squares 4px apart inside a 1px stroke.
const segmentedVariants = cva(
"group/segmented inline-flex w-fit items-center gap-0.5 rounded-lg p-1",
{
variants: {
emphasis: {
default:
"bg-bg-surface-1 inset-ring-[1.5px] inset-ring-border-default backdrop-blur-[40px] has-[>[data-slot=segmented-item]>svg:not([data-icon])]:gap-1 has-[>[data-slot=segmented-item]>svg:not([data-icon])]:inset-ring",
strong: "bg-bg-main inset-ring inset-ring-border-default",
},
},
defaultVariants: {
emphasis: "default",
},
},
);
// A segmented control always has one option selected, so the string API of
// the Radix flavour is preserved here: the value is carried as a one-element
// array inside the Base UI toggle group.
export type SegmentedProps = React.ComponentProps<"div"> &
VariantProps<typeof segmentedVariants> & {
emphasis?: SegmentedEmphasis;
size?: SegmentedSize;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
};
function Segmented({
className,
emphasis = "default",
size = "sm",
value: valueProp,
defaultValue,
onValueChange,
...props
}: SegmentedProps) {
// A segmented control always has one option selected. Base UI toggle groups
// clear the value when the active item is pressed again, so that empty
// change is swallowed here for both controlled and uncontrolled use.
const [uncontrolledValue, setUncontrolledValue] = React.useState(
defaultValue ?? "",
);
const value = valueProp ?? uncontrolledValue;
const handleValueChange = React.useCallback(
(next: string[]) => {
const nextValue = next[0];
if (!nextValue) return;
setUncontrolledValue(nextValue);
onValueChange?.(nextValue);
},
[onValueChange],
);
const context = React.useMemo(() => ({ emphasis, size }), [emphasis, size]);
return (
<SegmentedContext value={context}>
<ToggleGroupPrimitive
data-slot="segmented"
data-emphasis={emphasis}
data-size={size}
value={value ? [value] : []}
onValueChange={handleValueChange}
className={cn(segmentedVariants({ emphasis }), className)}
{...props}
/>
</SegmentedContext>
);
}
// Icon + label items tighten the leading edge (`data-icon="inline-start"`);
// an item whose only content is an untagged svg is icon-only and goes square.
// The selected default item is SemiBold with a soft 24px glow, which no shadow
// token matches, so its value is written out; a selected icon-only square
// takes the small shadow and no stroke instead.
const segmentedItemVariants = cva(
"group/segmented-item inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg text-sm leading-[1.4] font-medium whitespace-nowrap transition-all outline-none hover:bg-bg-surface-1 focus-visible:bg-bg-surface-1 focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
emphasis: {
default:
"text-text-note data-pressed:bg-bg-main data-pressed:font-semibold data-pressed:tracking-[-0.2px] data-pressed:text-text-strong data-pressed:shadow-[0_0_24px_-4px_rgb(0_0_0/0.07)] data-pressed:ring-[0.5px] data-pressed:ring-border-default data-pressed:hover:bg-bg-main has-[>svg:not([data-icon])]:data-pressed:shadow-xs has-[>svg:not([data-icon])]:data-pressed:ring-0",
strong:
"text-text-strong data-pressed:bg-primary-default data-pressed:text-text-inverse data-pressed:hover:bg-primary-default",
},
size: {
sm: "h-8 gap-2 px-3 py-1 has-data-[icon=inline-start]:gap-1 has-data-[icon=inline-start]:pl-2 has-[>svg:not([data-icon])]:size-8 has-[>svg:not([data-icon])]:px-0 [&_svg:not([class*='size-'])]:size-5",
md: "h-10 gap-2 px-4 py-2 has-data-[icon=inline-start]:pl-3 has-[>svg:not([data-icon])]:size-10 has-[>svg:not([data-icon])]:px-0 [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
emphasis: "default",
size: "sm",
},
},
);
export type SegmentedItemProps = React.ComponentProps<typeof TogglePrimitive>;
function SegmentedItem({
className,
...props
}: SegmentedItemProps) {
const { emphasis, size } = React.useContext(SegmentedContext);
return (
<TogglePrimitive
data-slot="segmented-item"
className={cn(segmentedItemVariants({ emphasis, size }), className)}
{...props}
/>
);
}
export { Segmented, SegmentedItem, segmentedVariants, segmentedItemVariants };
export type { SegmentedEmphasis, SegmentedSize };