Skip to content
edmi-ui

Switch

Instant on/off for a setting. On state uses the brand color.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal window
npx shadcn@latest add @edmi-ui/switch
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/switch
Terminal window
yarn dlx shadcn@latest add @edmi-ui/switch
Terminal window
bunx shadcn@latest add @edmi-ui/switch

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/switch
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/switch
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/switch
Terminal window
bunx shadcn-vue@latest add @edmi-ui/switch

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/switch.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/switch.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/switch.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/switch.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
<div className="flex flex-col gap-4">
<Label className="flex items-center gap-2.5 text-sm">
<Switch defaultChecked /> Keeper on
</Label>
<Label className="flex items-center gap-2.5 text-sm">
<Switch size="sm" /> Small
</Label>
<Label className="flex items-center gap-2.5 text-sm">
<Switch disabled /> Disabled
</Label>
</div>
<script setup lang="ts">
import { Switch } from "@/components/ui/switch";
</script>
<template>
<div class="flex flex-col gap-4">
<label class="flex items-center gap-2.5 text-sm"><Switch default-value /> Keeper on</label>
<label class="flex items-center gap-2.5 text-sm"><Switch size="sm" /> Small</label>
<label class="flex items-center gap-2.5 text-sm"><Switch disabled /> Disabled</label>
</div>
</template>
<script lang="ts">
import { Switch } from "$lib/components/ui/switch";
</script>
<div class="flex flex-col gap-4">
<label class="flex items-center gap-2.5 text-sm"><Switch checked /> Keeper on</label>
<label class="flex items-center gap-2.5 text-sm"><Switch size="sm" /> Small</label>
<label class="flex items-center gap-2.5 text-sm"><Switch disabled /> Disabled</label>
</div>
registry/ui/switch.tsx
"use client";
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "cn";
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
// On uses --brand so live settings read as active at a glance. Sizes: default 40x24, sm 32x18.
function Switch({
className,
size = "default",
elevation,
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default";
/** ✦ depth: raised +1 / floating +2 make the thumb rise (never the track). */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "handle");
const raised = level === "raised" || level === "floating";
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full bg-input shadow-[inset_0_1px_2px_rgb(0_0_0/0.12)] transition-colors outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:shadow-[inset_0_1px_2px_rgb(0_0_0/0.12),0_0_0_3px_var(--ring-soft)] aria-invalid:shadow-[inset_0_1px_2px_rgb(0_0_0/0.12),0_0_0_3px_var(--destructive-soft)] aria-invalid:outline aria-invalid:outline-1 aria-invalid:-outline-offset-1 aria-invalid:outline-destructive data-[invalid]:outline data-[invalid]:outline-1 data-[invalid]:-outline-offset-1 data-[invalid]:outline-destructive data-[checked]:bg-brand data-[size=default]:h-6 data-[size=default]:w-10 data-[size=sm]:h-[18px] data-[size=sm]:w-8 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none ml-[3px] block rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.1)] transition-transform group-data-[size=default]/switch:size-[18px] group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-[checked]:translate-x-4 group-data-[size=sm]/switch:data-[checked]:translate-x-3.5",
raised && "bg-linear-to-b from-white to-[#eeede9] shadow-thumb",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
registry/ui/switch/Switch.vue
<script setup lang="ts">
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/registry/edmi/lib/utils"
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation"
// On uses --brand so live settings read as active at a glance. Sizes: default 40x24, sm 32x18.
const props = withDefaults(
defineProps<SwitchRootProps & { class?: HTMLAttributes["class"], size?: "sm" | "default", elevation?: Elevation }>(),
{ size: "default", elevation: undefined },
)
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, "class", "size", "elevation")
const level = useElevation(() => props.elevation, "handle")
const raised = computed(() => level.value === "raised" || level.value === "floating")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
// A bare `default-value` attribute arrives as "" (Reka types it loosely, so Vue does not boolean-cast it);
// read it as true so `<Switch default-value />` renders on, like `:default-value="true"`.
const defaultValue = computed(() => ((props.defaultValue as unknown) === "" ? true : props.defaultValue))
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
:data-size="size"
v-bind="forwarded"
:default-value="defaultValue"
:class="
cn(
'peer group/switch relative inline-flex shrink-0 items-center rounded-full bg-input shadow-[inset_0_1px_2px_rgb(0_0_0/0.12)] transition-colors outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:shadow-[inset_0_1px_2px_rgb(0_0_0/0.12),0_0_0_3px_var(--ring-soft)] aria-invalid:shadow-[inset_0_1px_2px_rgb(0_0_0/0.12),0_0_0_3px_var(--destructive-soft)] aria-invalid:outline aria-invalid:outline-1 aria-invalid:-outline-offset-1 aria-invalid:outline-destructive data-[state=checked]:bg-brand data-[size=default]:h-6 data-[size=default]:w-10 data-[size=sm]:h-[18px] data-[size=sm]:w-8 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
props.class,
)
"
>
<SwitchThumb
data-slot="switch-thumb"
:class="cn(
'pointer-events-none ml-[3px] block rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.1)] transition-transform group-data-[size=default]/switch:size-[18px] group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-[state=checked]:translate-x-4 group-data-[size=sm]/switch:data-[state=checked]:translate-x-3.5',
raised && 'bg-linear-to-b from-white to-[#eeede9] shadow-thumb',
)"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>
registry/ui/switch/index.ts
export { default as Switch } from "./Switch.vue";
src/lib/registry/ui/switch/switch.svelte
<script lang="ts">
import { Switch as SwitchPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
// On uses --brand so live settings read as active at a glance. Sizes: default 40x24, sm 32x18.
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
size = "default",
elevation = "auto",
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> & {
size?: "sm" | "default";
/** ✦ depth: raised +1 / floating +2 make the thumb rise (never the track). */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "handle");
const raised = $derived(level.current === "raised" || level.current === "floating");
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
data-size={size}
class={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full bg-input shadow-[inset_0_1px_2px_rgb(0_0_0/0.12)] transition-colors outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:shadow-[inset_0_1px_2px_rgb(0_0_0/0.12),0_0_0_3px_var(--ring-soft)] aria-invalid:shadow-[inset_0_1px_2px_rgb(0_0_0/0.12),0_0_0_3px_var(--destructive-soft)] aria-invalid:outline aria-invalid:outline-1 aria-invalid:-outline-offset-1 aria-invalid:outline-destructive data-[state=checked]:bg-brand data-[size=default]:h-6 data-[size=default]:w-10 data-[size=sm]:h-[18px] data-[size=sm]:w-8 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
"pointer-events-none ml-[3px] block rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.1)] transition-transform group-data-[size=default]/switch:size-[18px] group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-[state=checked]:translate-x-4 group-data-[size=sm]/switch:data-[state=checked]:translate-x-3.5",
raised && "bg-linear-to-b from-white to-[#eeede9] shadow-thumb"
)}
/>
</SwitchPrimitive.Root>
src/lib/registry/ui/switch/index.ts
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};

See the Elevation guide for the levels, roles and layered mode.

Only the thumb rises (raised +1 or floating +2); the track never gets a bevel.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

Raised (+1)

Flat (0)

Raised (+1)

Flat (0)

Raised (+1)

Built on Base UI Switch.

Prop Type Default
size "sm" | "default" "default"
checked / defaultChecked boolean false
onCheckedChange (checked: boolean, event) => void -
disabled boolean false
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
  • Checked track uses --brand (not --primary) so live settings read as active.