Async Switch
A switch that saves to a server: the knob shows a spinner while pending, and snaps back with a reason if the save fails.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/async-switch.json'use client'
import { useId, useState } from 'react'
import './async-switch.css'
export interface AsyncSwitchProps {
label: string
description?: string
checked?: boolean
defaultChecked?: boolean
onCheckedChange: (on: boolean) => Promise<void>
fill?: 'dark' | 'light'
className?: string
}
/** Optimistic switch with pending and revert states. */
export function AsyncSwitch({ label, description, defaultChecked = false, onCheckedChange, fill = 'dark', className }: AsyncSwitchProps) {
const [on, setOn] = useState(defaultChecked)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [shake, setShake] = useState(0)
const id = useId()
const flip = async () => {
if (busy) return
const next = !on
setOn(next); setBusy(true); setErr('')
try { await onCheckedChange(next) } catch (e) { setOn(!next); setShake((s) => s + 1); setErr(e instanceof Error ? e.message : 'Couldn’t save. Try again.') }
setBusy(false)
}
return (
<div className={['sd-asw', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-asw__row">
<div className="sd-asw__txt"><div className="sd-asw__l" id={`${id}-l`}>{label}</div>{description && <div className="sd-asw__d" id={`${id}-d`}>{description}</div>}</div>
<button key={shake} type="button" role="switch" className="sd-asw__sw" aria-checked={on} aria-busy={busy} aria-labelledby={`${id}-l`} aria-describedby={description ? `${id}-d` : undefined} data-shake={shake ? '' : undefined} onClick={flip}>
<span className="sd-asw__k">{busy && <span className="sd-asw__spin" aria-hidden />}</span>
</button>
</div>
{err && <p className="sd-asw__err" role="alert">{err}</p>}
</div>
)
}.sd-asw { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-asw[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-asw button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-asw :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-asw .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-asw { width: min(100%, 320px); display: grid; gap: 4px; }
.sd-asw__row { display: flex; align-items: center; gap: 14px; padding: 14px; border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-asw__txt { flex: 1; }
.sd-asw__l { font-size: 14px; font-weight: 600; }
.sd-asw__d { font-size: 12.5px; color: var(--mute); margin-top: 2px; }
.sd-asw__sw { position: relative; flex: none; width: 48px; height: 28px; border-radius: 99px; background: var(--soft) !important; box-shadow: inset 0 0 0 1px var(--line); transition: background-color 220ms; }
.sd-asw__sw[aria-checked='true'] { background: var(--strong) !important; box-shadow: none; }
.sd-asw__k { position: absolute; top: 3px; left: 3px; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 99px; background: var(--mute); transition: transform 300ms cubic-bezier(0.3, 1.5, 0.4, 1), background-color 200ms; }
.sd-asw__sw[aria-checked='true'] .sd-asw__k { transform: translateX(20px); background: var(--on); }
.sd-asw__sw[data-shake] { animation: sd-asw-shake 320ms; }
@keyframes sd-asw-shake { 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } }
.sd-asw__spin { width: 12px; height: 12px; border-radius: 99px; border: 2px solid var(--strong); border-right-color: transparent; animation: sd-asw-rot 650ms linear infinite; }
.sd-asw__sw[aria-checked='false'] .sd-asw__spin { border-color: var(--bg); border-right-color: transparent; }
@keyframes sd-asw-rot { to { transform: rotate(360deg); } }
.sd-asw__err { margin: 0 4px; font-size: 12px; animation: sd-asw-in 200ms ease; }
@keyframes sd-asw-in { from { opacity: 0; transform: translateY(-3px); } }
@media (prefers-reduced-motion: reduce) { .sd-asw *, .sd-asw *::before, .sd-asw *::after { animation: none !important; transition: none !important; } }Overview
Flipping moves the knob immediately (optimistic) but a small spinner sits inside it until the request settles. On failure the knob springs back and a one-line reason appears.
The switch is aria-busy while pending and ignores further clicks.
When to use
- Settings that save immediately, feature toggles, privacy controls.
When not to use
- Forms with a Save button: use a plain switch.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/async-switch.json$
npx shadcn@latest add https://sundaeui.dev/r/async-switch.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/async-switch.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/async-switch.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy async-switch.tsx and async-switch.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { AsyncSwitch } from '@/components/sundae/async-switch'
<AsyncSwitch />Props
| Prop | Type | Default |
|---|---|---|
checked / defaultCheckedState. | boolean | — |
onCheckedChange*Persist; reject to revert. | (on: boolean) => Promise<void> | — |
label*Label. | string | — |
descriptionSecondary text. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- role="switch" with aria-checked and aria-busy; failures are announced with role="alert".
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-asw; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Async Switch free?
- Yes. Async Switch is free for personal and commercial projects, with full source on this page.
- How do I install Async Switch?
- Run npx shadcn@latest add https://sundaeui.dev/r/async-switch.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Async Switch support keyboard and screen readers?
- role="switch" with aria-checked and aria-busy; failures are announced with role="alert". Reduced motion removes the movement and keeps every state change.
- Does Async Switch respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Settings List
Forms · Refined- Pro
Feature Flag
Forms · Refined Jelly Switch
Forms · PlayfulFilter Chips
Forms · RefinedNotification Matrix
Forms · Refined- Pro
Shortcut Recorder
Forms · Refined