Switch
FormsA real checkbox with role="switch" and appearance: none — so the label, the space key, form submission and the browser's own focus ring all still work.
Preview
Takes effect immediately. There is no Save button on this page.
Controls
Notes
It is an input, not a div. The usual visually-hidden-input-plus-styled-span version has to re-earn the label association, the keyboard and the focus ring by hand, and the focus ring is the one it normally forgets.
A switch takes effect immediately. If the change needs a Save button then it is a checkbox — the switch shape is a promise that it already happened.
The thumb is --on-accent when checked, never white. The clay accent inverts between the two themes and white fails AA against the light one.
No label that flips between On and Off. The position, the colour and role=switch already carry the state; a word that changes under the reader's cursor is a second state competing with the first.
Source
ShowHide
"use client";
import { useId, useState } from "react";
import { cn } from "@/lib/cn";
/**
* A real <input type="checkbox"> with role="switch" and appearance: none — not
* a div pretending.
*
* What that buys, for free and correctly: the label association, the space key,
* form submission, the disabled semantics, and the browser's own focus ring.
* The usual sr-only-input-plus-styled-span version has to re-earn all of them,
* and the focus ring is the one it normally forgets.
*/
const css = `
.switch-input {
appearance: none;
-webkit-appearance: none;
margin: 0;
flex: none;
position: relative;
cursor: pointer;
inline-size: var(--switch-w);
block-size: var(--switch-h);
border-radius: 999px;
border: 1px solid var(--control-border);
background: var(--paper-sunken);
transition: background-color 0.18s ease, border-color 0.18s ease;
}
.switch-input::before {
content: "";
position: absolute;
top: 50%;
left: 2px;
inline-size: calc(var(--switch-h) - 6px);
block-size: calc(var(--switch-h) - 6px);
border-radius: 999px;
background: var(--ink-muted);
translate: 0 -50%;
transition: translate 0.18s ease, background-color 0.18s ease;
}
.switch-input:checked {
background: var(--accent);
border-color: var(--accent);
}
.switch-input:checked::before {
translate: calc(var(--switch-w) - var(--switch-h)) -50%;
/* --on-accent, not white: the clay accent inverts between themes and white
fails AA on the light one. */
background: var(--on-accent);
}
.switch-input:disabled { cursor: not-allowed; opacity: 0.45; }
@media (prefers-reduced-motion: reduce) {
.switch-input,
.switch-input::before { transition: none; }
}
`;
const sizes = {
sm: { "--switch-w": "2rem", "--switch-h": "1.15rem" },
md: { "--switch-w": "2.5rem", "--switch-h": "1.4rem" },
lg: { "--switch-w": "3rem", "--switch-h": "1.7rem" },
} as const;
export type SwitchSize = keyof typeof sizes;
export function Switch({
label,
hint,
size = "md",
defaultChecked = false,
disabled = false,
name,
onChange,
}: {
label: string;
hint?: string;
size?: SwitchSize;
defaultChecked?: boolean;
disabled?: boolean;
name?: string;
onChange?: (checked: boolean) => void;
}) {
const id = useId();
const hintId = `${id}-hint`;
const [on, setOn] = useState(defaultChecked);
return (
<div className="flex items-start gap-3">
<style href="switch-track" precedence="default">
{css}
</style>
<input
id={id}
name={name ?? label.toLowerCase().replace(/\s+/g, "-")}
type="checkbox"
// Says "on/off" to a screen reader instead of "checked".
role="switch"
checked={on}
disabled={disabled}
aria-describedby={hint ? hintId : undefined}
onChange={(event) => {
setOn(event.target.checked);
onChange?.(event.target.checked);
}}
className={cn("switch-input", size === "sm" ? "mt-0.5" : "mt-px")}
style={sizes[size] as React.CSSProperties}
/>
<div className="min-w-0">
<label htmlFor={id} className="cursor-pointer text-[15px] text-ink">
{label}
</label>
{hint && (
<p id={hintId} className="mt-1 text-[13px] leading-relaxed text-ink-muted">
{hint}
</p>
)}
</div>
</div>
);
}
export default function Demo({
label = "Email me new enquiries",
hint = "Takes effect immediately. There is no Save button on this page.",
size = "md",
checked = true,
disabled = false,
}: {
label?: string;
hint?: string;
size?: SwitchSize;
checked?: boolean;
disabled?: boolean;
}) {
return (
<div className="w-full max-w-sm">
{/* Keyed on the knob so flipping it re-seeds the switch, while clicks in
the preview still toggle it locally. Deriving state from props in an
effect instead is the bug this avoids. */}
<Switch
key={String(checked)}
label={label}
hint={hint}
size={size}
defaultChecked={checked}
disabled={disabled}
/>
</div>
);
}
Next step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.