- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"use client";
import { Switch } from "@/components/ui/switch";
export function SwitchDemo() {
return (
<label className="flex items-center gap-3 text-sm">
<Switch animated={true} aria-invalid={false} aria-label={"Enable notifications"} defaultChecked={false} disabled={false} readOnly={false} size={"default"} />
<span>{"Enable notifications"}</span>
</label>
);
}Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/switch.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- @base-ui/react@^1.8.0
- cn
- motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn motion@^12.38.0Usage
"use client";
import { Switch } from "@/components/ui/switch";
export function Demo() {
return (
<label className="flex items-center gap-3 text-sm">
<Switch defaultChecked />
Enable notifications
</label>
);
}Use your configured UI alias if it differs. Requires React 19, Tailwind CSS 4 with shadcn theme tokens (background, foreground, input, primary, primary-foreground, ring, and destructive), Base UI 1.8+, Motion 12.38+, and cn. No website-specific CSS or provider is required for LTR. For manual installation, install these packages and copy the complete Source into your UI directory, preserving the upstream MIT notice.
Examples
Basic
Wrap the switch and text in a label so the whole row is clickable. defaultChecked initializes uncontrolled state. Changing it does not update a mounted switch; the playground remounts when this configuration changes. A switch represents an immediate on/off setting, not a submit action or mixed selection.
"use client";
/* eslint-disable jsx-a11y/label-has-associated-control -- Switch supplies the enclosed native input. */
import { Switch } from "@/components/ui/switch";
export const SwitchDemo = () => (
<label className="flex items-center gap-3 text-sm">
<Switch defaultChecked />
Enable notifications
</label>
);Sizes and states
Use size="sm" for a compact control. Disabled switches cannot change and are omitted from submission. Read-only switches remain focusable and retain their submitted value. Use aria-invalid for error semantics and styling, with aria-describedby connecting the error text. This does not implement validation by itself.
Account alerts are required for this workspace.
"use client";
/* eslint-disable jsx-a11y/label-has-associated-control -- Switch supplies the enclosed native input. */
import { Switch } from "@/components/ui/switch";
export const SwitchStates = () => (
<div className="flex flex-col gap-5">
<label className="flex items-center gap-3 text-sm">
<Switch size="sm" defaultChecked />
Small switch
</label>
<label className="flex items-center gap-3 text-sm text-muted-foreground">
<Switch disabled defaultChecked />
Disabled
</label>
<label className="flex items-center gap-3 text-sm">
<Switch readOnly defaultChecked />
Read only
</label>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-3 text-sm">
<Switch aria-invalid aria-describedby="switch-states-error" />
Enable account alerts
</label>
<p id="switch-states-error" className="text-xs text-destructive">
Account alerts are required for this workspace.
</p>
</div>
</div>
);Controlled state, submission and reset
Use checked with onCheckedChange to control state. The callback receives the next boolean and Base UI event details immediately, without waiting for animation; details.cancel() rejects the change. Set name and value for form submission. The checked value defaults to "on"; unchecked switches are omitted unless uncheckedValue is set. Base UI does not synchronize uncontrolled visual state with native form reset. Restore controlled state in onReset when reset is needed, as shown here.
"use client";
/* eslint-disable jsx-a11y/label-has-associated-control -- Switch supplies the enclosed native input. */
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
export const SwitchForm = () => {
const [checked, setChecked] = useState(true);
const [result, setResult] = useState("Submit to inspect the form value.");
return (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
setResult(
`updates: ${new FormData(event.currentTarget).get("updates")}`
);
}}
onReset={() => {
setChecked(true);
setResult("Submit to inspect the form value.");
}}
>
<label className="flex items-center gap-3 text-sm">
<Switch
name="updates"
value="weekly"
uncheckedValue="off"
checked={checked}
onCheckedChange={setChecked}
/>
Weekly updates
</label>
<div className="flex gap-2">
<Button type="submit" size="sm">
Submit
</Button>
<Button type="reset" variant="outline" size="sm">
Reset
</Button>
</div>
<output className="text-xs text-muted-foreground" aria-live="polite">
{result}
</output>
</form>
);
};Behavior & Accessibility
- Tab focuses the switch; Space toggles it. The default root is a span with switch semantics and a hidden native checkbox input. Use an enclosing label or Base UI Field. For a sibling
htmlForlabel, userender={<button />}andnativeButtonas explained in the Base UI API. reftargets the visible root andinputReftargets the native input. Root props, event handlers, state-awareclassName/style, and composition are forwarded; children are reserved for the thumb.requiredenables native constraint validation;formassociates an external input with a form.- The thumb slides over 200 ms using an interruptible ease-out transform, without bounce, scaling, or delayed state updates.
animated={false}and reduced-motion preferences make thumb changes immediate. Reduced motion also disables the CSS track-color transition;animated={false}alone does not. - Sizes retain the shadcn foundation: default track 32 × 18.4 px with a 16 px thumb, small track 24 × 14 px with a 12 px thumb. The invisible hit area extends beyond the track; leave adequate space between controls. Labels provide a larger interaction target.
- Light and dark styles inherit shadcn tokens. Use
dir="rtl"or Base UIDirectionProviderfor RTL thumb motion; an ancestor HTMLdiralone does not configure Base UI direction. Keep visible keyboard-focus treatment when customizing classes.
Storybook Stories (Optional)
Install Switch first, then add its portable stories to your existing React Storybook:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/switch-stories.json
This adds only switch.stories.tsx beside Switch in your configured UI directory. It does not reinstall Switch, add packages, or modify .storybook/. Review overwrite prompts for existing stories. Supply @storybook/react matching your Storybook version and React; stories are typechecked and composed against Storybook React 10.6.1. No extra example dependencies are required.
Include ../components/ui/**/*.stories.@(js|jsx|mjs|ts|tsx) in .storybook/main.ts, adjusted for your UI directory. Import your application's Tailwind/global stylesheet in .storybook/preview.ts and include the UI/stories directory in Tailwind source scanning. Stories inherit your theme and decorators. Apply the dark-theme selector inside the preview iframe. The autodocs tag needs your existing @storybook/addon-docs configuration; Canvas and Controls do not.
Props
| Prop | Type / Description | Default |
|---|---|---|
| animated | booleanSlides the thumb over 200 ms. Reduced motion makes this immediate. | true |
| aria-invalid | boolean | 'true' | 'false'Invalid semantics and styling; connect error text using aria-describedby. | Not set |
| checked | booleanControlled checked state. Pair with onCheckedChange. | Not set |
| className | string | ((state: Switch.Root.State) => string)Classes merged with defaults, optionally derived from Base UI state. | Not set |
| defaultChecked | booleanInitial uncontrolled state; changing it does not update a mounted switch. | false |
| dir | "ltr" | "rtl"Thumb direction. Use dir or Base UI DirectionProvider for RTL. | DirectionProvider (ltr by default) |
| disabled | booleanPrevents interaction and excludes the input from submission. | false |
| inputRef | Ref<HTMLInputElement>Ref to the native input. Ordinary ref targets the visible root. | Not set |
| name | stringName of the native form input. | Not set |
| nativeButton | booleanSet true when render supplies a native button. | false |
| onCheckedChange | (checked: boolean, details: Switch.Root.ChangeEventDetails) => voidCalled immediately with the next boolean and Base UI event details. Call details.cancel() to reject the change. | Not set |
| readOnly | booleanPrevents changes while preserving focus and submitted value. | false |
| render | ReactElement | ComponentRenderFnBase UI composition. For a sibling label use render={<button />} and nativeButton. | Not set (span) |
| required | booleanRequires an active switch for native form validation. | false |
| size | "default" | "sm"Track and thumb size. | "default" |
| uncheckedValue | stringOptional submitted value when off; otherwise omitted. | Not set |
| value | stringSubmitted value when checked. | "on" (form submission) |
Switch forwards Base UI Root props, handlers, state-aware className/style and React 19 refs. Children are reserved for the thumb. The default root is a span with switch semantics and a native input; use an enclosing label or Base UI Field.
Source
"use client";
// Adapted from shadcn/ui's Base Nova Switch (MIT).
// https://ui.shadcn.com/docs/components/base/switch
/*
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { useDirection } from "@base-ui/react/direction-provider";
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "cn";
import { motion, useReducedMotion } from "motion/react";
import type { ComponentProps } from "react";
export type SwitchProps = Omit<
ComponentProps<typeof SwitchPrimitive.Root>,
"children"
> & {
size?: "sm" | "default";
/** Animate thumb changes. Reduced-motion preferences take precedence. */
animated?: boolean;
};
export const Switch = ({
className,
size = "default",
animated = true,
dir,
...props
}: SwitchProps) => {
const reducedMotion = useReducedMotion();
const inheritedDirection = useDirection();
const direction = dir ?? inheritedDirection;
const checkedOffset =
direction === "rtl" ? "calc(-100% + 2px)" : "calc(100% - 2px)";
return (
<SwitchPrimitive.Root
{...props}
dir={direction}
data-slot="switch"
data-size={size}
className={(state) =>
cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent bg-input outline-none transition-colors duration-150 motion-reduce:transition-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-checked:bg-primary data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:data-unchecked:bg-input/80",
typeof className === "function" ? className(state) : className
)
}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block shrink-0 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3"
render={(thumbProps, state) => (
<span {...thumbProps} aria-hidden="true">
<motion.span
className="block size-full rounded-full bg-background ring-0 dark:group-data-checked/switch:bg-primary-foreground dark:group-data-unchecked/switch:bg-foreground"
initial={false}
animate={{
// Keep matching percentage/pixel terms so Motion can interpolate.
x: state.checked ? checkedOffset : "calc(0% - 0px)",
}}
transition={{
duration: animated && !reducedMotion ? 0.2 : 0,
ease: [0.22, 1, 0.36, 1],
}}
/>
</span>
)}
/>
</SwitchPrimitive.Root>
);
};
Credits
- shadcn/ui: Adapted Base Nova Switch foundation. Vandor UI adds Motion thumb transitions, direction support, and reduced-motion handling.