Buttons & Toggles

Labeled Mode Switch

A two-state text switch (e.g. Ask | Auto) with a glassy spring-driven thumb and labels that cross-fade with the thumb position — for operating-mode toggles.

A text switch with exactly two options: a pill track holds one label on each side, and a translucent glass thumb covers the selected one. Click, use the arrow keys or drag the thumb to switch; the thumb springs across and gives a small jelly squish when it lands. Both labels' color and opacity are interpolated continuously from the thumb position instead of flipping only on selection. The thumb is as wide as the label it covers and eases between the two widths mid-drag. Good for operating modes like Ask | Auto, Monthly | Yearly, or Preview | Edit.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add labeled-mode-switch.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

500
32
Active edited usageDefault props
<LabeledModeSwitch/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/labeled-mode-switch.en.json

Or, once registries are configured in components.json, install it as @webberui/labeled-mode-switch.

Usage

import { MessageCircleQuestion, Zap } from "lucide-react";
import { LabeledModeSwitch } from "@/components/ui/labeled-mode-switch";

const [mode, setMode] = React.useState("ask");

<LabeledModeSwitch
  aria-label="Assistant run mode"
  value={mode}
  onValueChange={setMode}
  options={[
    { value: "ask", label: "Ask", icon: <MessageCircleQuestion /> },
    { value: "auto", label: "Auto", icon: <Zap /> },
  ]}
/>

Props

PropTypeDefaultDescription
options[LabeledModeSwitchOption, LabeledModeSwitchOption]「詢問|自動」 (Ask | Auto)Exactly two options: [left, right]
valuestring—Current value in controlled mode; when provided, the parent fully controls it
defaultValuestringthe left option's valueInitial value in uncontrolled mode
onValueChange(value: string) => void—Called when the selected value changes
size"sm" | "md" | "lg""md"Size
draggablebooleantrueWhether the thumb can be dragged to switch (click and keyboard always work)
stiffnessnumber500Thumb spring stiffness — higher settles faster
dampingnumber32Thumb spring damping — higher bounces less
disabledbooleanfalseDisables the whole switch
aria-labelstring—Group name for assistive tech
classNamestring—Passed through to the outer track

LabeledModeSwitchOption

FieldTypeDefaultDescription
valuestring—Unique value of the option
labelstring—Display text
iconReactNode—Optional leading icon; its color follows the text

CSS variables

VariableDefaultDescription
--wb-mode-switch-glassrgb(255 255 255 / 0.55) (0.12 in dark mode)Base color of the glass thumb
--wb-mode-switch-blur10pxBackdrop blur radius of the thumb (plus saturate(1.6))
--wb-mode-switch-accenttransparentTint layer over the thumb — use it to give the thumb a brand color

How it works

  • Thumb width: after mount the component measures both labels' position and width (a ResizeObserver tracks font loading and text changes), and the thumb width is interpolated between the two by position, so labels of different lengths are always covered exactly. The thumb stays hidden until measured, so the server-rendered first paint never shows it misplaced.
  • Dragging: press on the side under the thumb to drag horizontally, constrained to the track (dragElastic 0.1). On release the side past the midpoint wins; otherwise the thumb springs back. Dragging is an enhancement only — click and keyboard are fully equivalent.
  • Clicking: click the other side to select it; click the selected side to toggle to the other one.
  • Motion: the offset uses a spring (default stiffness 500, damping 32), and on each switch the thumb plays a single scaleX 1 → 1.08 → 1 jelly squish.
  • Label cross-fade: each label stacks a muted layer and a foreground layer; the foreground opacity follows the thumb position continuously and the label's overall opacity is interpolated between 0.55 and 1. Icons use currentColor, so they change color along with the text.

Accessibility

  • Uses role="radiogroup" with two role="radio" items (aria-checked) rather than role="switch": both sides carry their own meaningful label, and radios let screen readers announce the full state (e.g. "Ask, selected"). Name the group with aria-label
  • Roving tabindex: Tab stops only on the selected item; Left/Right arrows select the left/right side, Up/Down and Space toggle to the other side, and focus follows
  • The whole track shows a ring on focus-visible
  • With disabled, both buttons are disabled and dragging is turned off
  • When the user's system has reduce motion enabled, the thumb jumps straight to the new position (no spring, no jelly squish) and the labels change color instantly