A control that turns a setting on or off.
View codeHide code
import { Switch, SwitchThumb } from '@syncclouds/ui'
export function SwitchDemo() {
return (
<div className="flex items-center gap-3">
<Switch id="email-alerts" defaultChecked>
<SwitchThumb />
</Switch>
<label htmlFor="email-alerts">Email me when a deploy fails</label>
</div>
)
}Installation
Available at launch
pnpm add @syncclouds/uinpm install @syncclouds/uiyarn add @syncclouds/uibun add @syncclouds/uiAvailable at launch
pnpm dlx shadcn@latest add https://ui.syncclouds.com/r/switch.jsonnpx shadcn@latest add https://ui.syncclouds.com/r/switch.jsonyarn dlx shadcn@latest add https://ui.syncclouds.com/r/switch.jsonbunx --bun shadcn@latest add https://ui.syncclouds.com/r/switch.jsonThe CLI copies 4 files into your ui folder.
1. Copy these files into one folder
switch.tsxShowHide
'use client'
import { createContext, use, type ComponentPropsWithRef } from 'react'
import { Slot } from './slot'
import { useControllableState } from './sui-use-controllable-state'
type State = 'checked' | 'unchecked'
const SwitchContext = createContext<State>('unchecked')
export interface SwitchProps
extends Omit<ComponentPropsWithRef<'button'>, 'value' | 'defaultValue'> {
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
asChild?: boolean
}
/**
* An on/off switch (APG Switch). A button with role="switch", so Space and
* Enter toggle it and a <label htmlFor> names it. Style it with
* `[data-state="checked"]` and put a <SwitchThumb /> inside.
*/
export function Switch({
checked,
defaultChecked = false,
onCheckedChange,
asChild,
disabled,
onClick,
children,
...props
}: SwitchProps) {
const [on, setOn] = useControllableState({
prop: checked,
defaultProp: defaultChecked,
onChange: onCheckedChange
})
const state: State = on ? 'checked' : 'unchecked'
const Comp = asChild ? Slot : 'button'
return (
<SwitchContext value={state}>
<Comp
type={asChild ? undefined : 'button'}
{...props}
role="switch"
aria-checked={on}
disabled={disabled}
data-scope="switch"
data-part="root"
data-state={state}
data-disabled={disabled ? '' : undefined}
onClick={event => {
onClick?.(event)
if (!event.defaultPrevented) setOn(!on)
}}
>
{children}
</Comp>
</SwitchContext>
)
}
export function SwitchThumb(props: ComponentPropsWithRef<'span'>) {
const state = use(SwitchContext)
return (
<span
aria-hidden="true"
{...props}
data-scope="switch"
data-part="thumb"
data-state={state}
/>
)
}
slot.tsxShowHide
import {
Children,
cloneElement,
isValidElement,
type CSSProperties,
type ReactElement,
type ReactNode,
type Ref
} from 'react'
import { composeRefs } from './sui-refs'
type AnyProps = Record<string, unknown>
/**
* Renders its only child with the slot's props merged in, so any component
* can take over the element (`asChild`). Event handlers run child first,
* then the slot's, unless the child's handler called preventDefault.
* Class names join, styles merge, refs compose. No hooks, so it works in
* server components too.
*/
export function Slot({
children,
...slotProps
}: AnyProps & { children?: ReactNode; ref?: Ref<unknown> }) {
const child = Children.only(children)
if (!isValidElement(child)) return null
const element = child as ReactElement<AnyProps>
const childProps = element.props
const merged: AnyProps = { ...slotProps, ...childProps }
for (const key of Object.keys(slotProps)) {
const slotValue = slotProps[key]
const childValue = childProps[key]
if (/^on[A-Z]/.test(key) && typeof slotValue === 'function') {
merged[key] =
typeof childValue === 'function'
? (...args: unknown[]) => {
childValue(...args)
const event = args[0] as { defaultPrevented?: boolean } | undefined
if (!event?.defaultPrevented) slotValue(...args)
}
: slotValue
} else if (key === 'className') {
merged.className = [slotValue, childValue].filter(Boolean).join(' ')
} else if (key === 'style') {
merged.style = {
...(slotValue as CSSProperties),
...(childValue as CSSProperties)
}
}
}
/* Only touch the ref when there is one: a server component rendering
<Slot><Link /></Slot> must not hand a function ref to a client child. */
const slotRef = slotProps.ref as Ref<unknown> | undefined
const childRef = childProps.ref as Ref<unknown> | undefined
if (slotRef && childRef) merged.ref = composeRefs(slotRef, childRef)
else if (slotRef || childRef) merged.ref = slotRef ?? childRef
else delete merged.ref
return cloneElement(element, merged)
}
sui-refs.tsShowHide
import { useCallback, type Ref, type RefCallback } from 'react'
const setRef = <T,>(ref: Ref<T> | undefined, value: T | null) => {
if (typeof ref === 'function') ref(value)
else if (ref) ref.current = value
}
/** One callback ref that feeds several refs. */
export const composeRefs =
<T,>(...refs: (Ref<T> | undefined)[]): RefCallback<T> =>
node => {
for (const ref of refs) setRef(ref, node)
}
/** A user's ref and a component's own ref, as one stable callback ref. */
export const useComposedRefs = <T,>(
outer: Ref<T> | undefined,
inner: Ref<T> | undefined
) => useCallback((node: T | null) => composeRefs(outer, inner)(node), [outer, inner])
sui-use-controllable-state.tsShowHide
import { useCallback, useState } from 'react'
/**
* Controlled when `prop` is defined, uncontrolled otherwise. `onChange`
* fires for every change either way.
*/
export function useControllableState<T>({
prop,
defaultProp,
onChange
}: {
prop: T | undefined
defaultProp: T
onChange?: (value: T) => void
}) {
const [uncontrolled, setUncontrolled] = useState(defaultProp)
const controlled = prop !== undefined
const value = controlled ? prop : uncontrolled
const setValue = useCallback(
(next: T) => {
if (!controlled) setUncontrolled(next)
if (!Object.is(next, value)) onChange?.(next)
},
[controlled, onChange, value]
)
return [value, setValue] as const
}
2. Import from that folder
The files import each other with relative paths, so keep them together, for example in components/ui.
Usage
import { Switch, SwitchThumb } from '@syncclouds/ui'<Switch id="alerts" defaultChecked>
<SwitchThumb />
</Switch>
<label htmlFor="alerts">Email alerts</label>Anatomy
Switch
└── SwitchThumbControlled
Autosave is off.
View codeHide code
'use client'
import { useState } from 'react'
import { Switch, SwitchThumb } from '@syncclouds/ui'
export function SwitchControlled() {
const [autosave, setAutosave] = useState(false)
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-3">
<Switch id="autosave" checked={autosave} onCheckedChange={setAutosave}>
<SwitchThumb />
</Switch>
<label htmlFor="autosave">Autosave</label>
</div>
<p className="text-sm">Autosave is {autosave ? 'on' : 'off'}.</p>
</div>
)
}Disabled
View codeHide code
import { Switch, SwitchThumb } from '@syncclouds/ui'
export function SwitchDisabled() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Switch id="sso" disabled defaultChecked>
<SwitchThumb />
</Switch>
<label htmlFor="sso">Single sign-on (set by your admin)</label>
</div>
<div className="flex items-center gap-3">
<Switch id="beta" disabled>
<SwitchThumb />
</Switch>
<label htmlFor="beta">Beta features (on paid plans)</label>
</div>
</div>
)
}With a description
Point aria-describedby at the hint so screen readers read it after the name.
A summary of deploys and errors, every Monday morning.
View codeHide code
import { Switch, SwitchThumb } from '@syncclouds/ui'
/* aria-describedby lets screen readers read the hint after the name. */
export function SwitchDescription() {
return (
<div className="flex max-w-sm items-start justify-between gap-6">
<div className="flex flex-col gap-1">
<label htmlFor="weekly-digest" className="font-semibold">
Weekly digest
</label>
<p id="weekly-digest-hint" className="text-sm opacity-80">
A summary of deploys and errors, every Monday morning.
</p>
</div>
<Switch id="weekly-digest" aria-describedby="weekly-digest-hint" defaultChecked>
<SwitchThumb />
</Switch>
</div>
)
}Without the theme
View codeHide code
import { Switch, SwitchThumb } from '@syncclouds/ui'
export function SwitchUnstyled() {
return (
<div className="flex items-center gap-3">
<Switch
id="dark-mode-unstyled"
className="flex h-6 w-11 cursor-pointer items-center rounded-full bg-zinc-400 p-0.5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 data-[state=checked]:bg-indigo-600 dark:bg-zinc-600 dark:data-[state=checked]:bg-indigo-400"
>
<SwitchThumb className="block size-5 rounded-full bg-white shadow transition-transform data-[state=checked]:translate-x-5" />
</Switch>
<label htmlFor="dark-mode-unstyled" className="text-sm text-zinc-900 dark:text-zinc-100">
Dark mode
</label>
</div>
)
}Accessibility
Follows the WAI-ARIA Switch pattern.
| Keys | What they do |
|---|---|
| Space, Enter | Turns the switch on or off. |
- Screen readers announce it as a switch that is on or off, not as a checkbox.
API reference
Every part also accepts the props of the element it renders, including className and ref.
Switchrenders <button>
A button with role="switch". Name it with a <label htmlFor> or aria-label.
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | none | Whether it is on, when you control it. |
defaultChecked | boolean | false | Whether it starts on. |
onCheckedChange | (checked: boolean) => void | none | Called when it is turned on or off. |
disabled | boolean | false | Stops it from changing. |
asChild | boolean | false | Use your own element. |
| Data attribute | Values |
|---|---|
data-state | "checked" | "unchecked" |
data-disabled | present when disabled |
SwitchThumbrenders <span>
The moving knob. Purely visual.
| Data attribute | Values |
|---|---|
data-state | "checked" | "unchecked" |