Vela UI Docsvenizia.ai

Segmented

A set of mutually exclusive options inside a single inline container.

npx shadcn@latest add @velaui/segmented
npx shadcn@latest add @velaui/segmented

Examples

  • Default
  • Billing medium
  • Status strong
  • Priority strong small
  • View icons
  • Align icon only
  • Theme toggle
  • Weekdays
  • Shipping disabled

View code

Segmented · Default

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-default.tsx
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented defaultValue="day">
      <SegmentedItem value="day">Day</SegmentedItem>
      <SegmentedItem value="week">Week</SegmentedItem>
      <SegmentedItem value="month">Month</SegmentedItem>
    </Segmented>
  );
}

View code

Segmented · Billing medium

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-billing-medium.tsx
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented size="md" defaultValue="monthly">
      <SegmentedItem value="monthly">Monthly</SegmentedItem>
      <SegmentedItem value="yearly">Yearly</SegmentedItem>
    </Segmented>
  );
}

View code

Segmented · Status strong

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-status-strong.tsx
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented emphasis="strong" size="md" defaultValue="all">
      <SegmentedItem value="all">All</SegmentedItem>
      <SegmentedItem value="active">Active</SegmentedItem>
      <SegmentedItem value="archived">Archived</SegmentedItem>
    </Segmented>
  );
}

View code

Segmented · Priority strong small

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-priority-strong-small.tsx
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented emphasis="strong" defaultValue="medium">
      <SegmentedItem value="low">Low</SegmentedItem>
      <SegmentedItem value="medium">Medium</SegmentedItem>
      <SegmentedItem value="high">High</SegmentedItem>
      <SegmentedItem value="urgent">Urgent</SegmentedItem>
    </Segmented>
  );
}

View code

Segmented · View icons

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-view-icons.tsx
import { ColumnVertical03 } from "@/components/icons/column-vertical-03";
import { Grid01 } from "@/components/icons/grid-01";
import { List } from "@/components/icons/list";
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented size="md" defaultValue="list">
      <SegmentedItem value="list">
        <List data-icon="inline-start" />
        List
      </SegmentedItem>
      <SegmentedItem value="grid">
        <Grid01 data-icon="inline-start" />
        Grid
      </SegmentedItem>
      <SegmentedItem value="board">
        <ColumnVertical03 data-icon="inline-start" />
        Board
      </SegmentedItem>
    </Segmented>
  );
}

View code

Segmented · Align icon only

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-align-icon-only.tsx
import { AlignCenter } from "@/components/icons/align-center";
import { AlignLeft } from "@/components/icons/align-left";
import { AlignRight } from "@/components/icons/align-right";
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented emphasis="strong" defaultValue="left">
      <SegmentedItem value="left" aria-label="Align left">
        <AlignLeft />
      </SegmentedItem>
      <SegmentedItem value="center" aria-label="Align center">
        <AlignCenter />
      </SegmentedItem>
      <SegmentedItem value="right" aria-label="Align right">
        <AlignRight />
      </SegmentedItem>
    </Segmented>
  );
}

View code

Segmented · Theme toggle

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-theme-toggle.tsx
import { Moon01 } from "@/components/icons/moon-01";
import { Sun02 } from "@/components/icons/sun-02";
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented defaultValue="light" aria-label="Color mode">
      <SegmentedItem value="light" aria-label="Light mode">
        <Sun02 />
      </SegmentedItem>
      <SegmentedItem value="dark" aria-label="Dark mode">
        <Moon01 />
      </SegmentedItem>
    </Segmented>
  );
}

View code

Segmented · Weekdays

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-weekdays.tsx
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

export default function Example() {
  return (
    <Segmented defaultValue="Wed">
      {DAYS.map((day) => (
        <SegmentedItem key={day} value={day}>
          {day}
        </SegmentedItem>
      ))}
    </Segmented>
  );
}

View code

Segmented · Shipping disabled

Installation

pnpm dlx velaui add @velaui/segmented
pnpm dlx velaui add @velaui/segmented

Code

segmented-shipping-disabled.tsx
import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented emphasis="strong" size="md" defaultValue="standard">
      <SegmentedItem value="standard">Standard</SegmentedItem>
      <SegmentedItem value="express">Express</SegmentedItem>
      <SegmentedItem value="same-day" disabled>
        Same-day
      </SegmentedItem>
    </Segmented>
  );
}

Usage

import { Segmented, SegmentedItem } from "@/components/ui/segmented";

export default function Example() {
  return (
    <Segmented defaultValue="day">
      <SegmentedItem value="day">Day</SegmentedItem>
      <SegmentedItem value="week">Week</SegmentedItem>
      <SegmentedItem value="month">Month</SegmentedItem>
    </Segmented>
  );
}

Props

Segmented

PropTypeDefault
emphasis"strong" | "default""default"
sizeSegmentedSize"sm"

Takes these and every prop of SegmentedProps.

SegmentedItem

Takes every prop of SegmentedItemProps.

Segmented

PropTypeDefault
defaultValuestring | (readonly string[] & string)—
emphasis"strong" | "default""default"
onValueChange(value: string) => void—
sizeSegmentedSize"sm"
valuestring—

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 };