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.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<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.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
options | [LabeledModeSwitchOption, LabeledModeSwitchOption] | 「詢問|自動」 (Ask | Auto) | Exactly two options: [left, right] |
value | string | — | Current value in controlled mode; when provided, the parent fully controls it |
defaultValue | string | the left option's value | Initial value in uncontrolled mode |
onValueChange | (value: string) => void | — | Called when the selected value changes |
size | "sm" | "md" | "lg" | "md" | Size |
draggable | boolean | true | Whether the thumb can be dragged to switch (click and keyboard always work) |
stiffness | number | 500 | Thumb spring stiffness — higher settles faster |
damping | number | 32 | Thumb spring damping — higher bounces less |
disabled | boolean | false | Disables the whole switch |
aria-label | string | — | Group name for assistive tech |
className | string | — | Passed through to the outer track |
LabeledModeSwitchOption
| Field | Type | Default | Description |
|---|---|---|---|
value | string | — | Unique value of the option |
label | string | — | Display text |
icon | ReactNode | — | Optional leading icon; its color follows the text |
CSS variables
| Variable | Default | Description |
|---|---|---|
--wb-mode-switch-glass | rgb(255 255 255 / 0.55) (0.12 in dark mode) | Base color of the glass thumb |
--wb-mode-switch-blur | 10px | Backdrop blur radius of the thumb (plus saturate(1.6)) |
--wb-mode-switch-accent | transparent | Tint 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
ResizeObservertracks 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 (
dragElastic0.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
stiffness500,damping32), and on each switch the thumb plays a singlescaleX1 → 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 tworole="radio"items (aria-checked) rather thanrole="switch": both sides carry their own meaningful label, and radios let screen readers announce the full state (e.g. "Ask, selected"). Name the group witharia-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
Switch
A spring-driven React switch whose thumb can be dragged to toggle, with optional crossfading icons inside the thumb, a pressed-state toggle-button variant and native form submission.
Dock
A macOS-style magnifying dock — icons scale smoothly based on their horizontal distance from the cursor, with labels floating up on hover.