Switch
A checkbox with role="switch". Screen readers then say “on” and “off” instead of “checked”, while the keyboard and FormData work as for an ordinary checkbox.
Choose Switch when the setting takes effect at once. If the choice should only apply when the form is submitted, Checkbox is right: a switch that does nothing until you press “Save” promises more than it delivers.
How to use it
Section titled “How to use it”<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/switch/switch.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/label/label.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/help-text/help-text.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css --><div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="email" checked /><label class="lt-label" for="email">Notify me by email</label></div>
<div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="sms" /><label class="lt-label" for="sms">Notify me by text message</label></div>
<div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="post" disabled /><label class="lt-label" for="post">Notify me by post</label></div>import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/switch.css"import "@lo-tek/interfaces/label.css"import "@lo-tek/interfaces/help-text.css"lt is used in two ways, and which one depends on whether you create the element or find it.
If you create the markup, you spread the attributes in. That is the route in React, Astro and anything else that renders:
import { lt } from "@lo-tek/interfaces/react"
<div className={lt.switch.row}> <input {...lt.switch()} id="notifications" /> <label {...lt.label()} htmlFor="notifications">Notify me by email</label></div>The import goes to /react here, since the block is JSX. From there the keys are called className and htmlFor. In Astro and anything else without React you import @lo-tek/interfaces and get class and for.
If the element is already there, you set them on it. That is the route when you enhance HTML a server or a template has sent:
import { lt } from "@lo-tek/interfaces"
const toggle = document.querySelector<HTMLInputElement>("#notifications")
// The server sent a checkbox. Now it is a switch.lt.setAttributes(toggle, lt.switch())
// A setting the user is not allowed to change.lt.setAttributes(toggle, lt.switch({ disabled: true }))The function sets both type and role, which are the two things that must go together for the switch to be announced correctly. lt.switch.row gives the class for the row around it.
Do not disable the switch to prevent another press while a save is in progress. disabled takes focus away from a switch that has it, focus does not come back when it is enabled again, and another press of the space bar does nothing. See Saving immediately for how to block another press without it.
If you import it directly instead of through lt, it is called switchControl, since switch is a reserved word in JavaScript:
import { switchControl } from "@lo-tek/interfaces"Saving immediately
Section titled “Saving immediately”The switch promises that the change applies. So it also has to say when saving fails, and go back. fetch only rejects when the network fails, so a response with an error status has to be checked by you, otherwise a refusal from the server ends up in the branch that reports success. In “Plain HTML”, Astro and React, another press is blocked while the save is in progress without disabled, which would take focus from the switch; in Datastar another press sends another @put, and the server decides. The markup is the same in “Plain HTML” and Astro; React uses the builder functions, and Datastar its own attributes. Astro uses the same script as “Plain HTML”.
<div class="lt-switch-row"> <input class="lt-switch" type="checkbox" role="switch" id="notifications" /> <label class="lt-label" for="notifications">Notify me by email</label></div><p class="lt-help-text" id="notifications-status" role="status"></p>
<script type="module"> const toggle = document.getElementById("notifications") const status = document.getElementById("notifications-status") let saving = false
// Another press while saving should not flip the switch. `disabled` // would block it the same way, but take focus from it. toggle.addEventListener("click", (event) => { if (saving) event.preventDefault() })
toggle.addEventListener("change", async () => { const on = toggle.checked saving = true
try { const response = await fetch("/api/notifications", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: on }), }) if (!response.ok) throw new Error(response.statusText) status.textContent = on ? "Notifications are on." : "Notifications are off." } catch { toggle.checked = !on status.textContent = "Could not save. Try again." } finally { saving = false } })</script>import { useState } from "react"import { lt } from "@lo-tek/interfaces/react"
export function NotificationSwitch({ initial }: { initial: boolean }) { const [on, setOn] = useState(initial) const [saving, setSaving] = useState(false) const [status, setStatus] = useState("")
async function changed(value: boolean) { // While a save is in progress, the switch stays where the state says. // `disabled` would block it the same way, but take focus from it. if (saving) return
// Flip at once, so the switch does not lag behind the finger. setOn(value) setSaving(true) setStatus("")
try { const response = await fetch("/api/notifications", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: value }), }) if (!response.ok) throw new Error(response.statusText) setStatus(value ? "Notifications are on." : "Notifications are off.") } catch { // Back to where it was. The switch promised something it did not keep. setOn(!value) setStatus("Could not save. Try again.") } finally { setSaving(false) } }
return ( <> <div className={lt.switch.row}> <input {...lt.switch()} id="notifications" checked={on} onChange={(e) => changed(e.currentTarget.checked)} /> <label {...lt.label()} htmlFor="notifications">Notify me by email</label> </div> <p {...lt.helpText()} role="status">{status}</p> </> )}checked and onChange together make the field controlled, and that is exactly what makes the rollback possible: React draws the switch from your state, not from what the user clicked. It is also what keeps the switch in place during the save: when changed does not change the state, React draws it back.
<!-- Datastar itself. Without it the `data-` attributes below do nothing. --><script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>
<div class="lt-switch-row" data-signals="{notifications: false, status: ''}"> <input class="lt-switch" type="checkbox" role="switch" id="notifications" data-bind:notifications data-on:change="@put('/api/notifications')" /> <label class="lt-label" for="notifications">Notify me by email</label></div><p class="lt-help-text" role="status" data-text="$status"></p>The server decides the outcome here. @put sends the signals, and the response sets status and, when the save failed, notifications back to where it was. The rollback is therefore in the server and not in the page, as for all other state the server decides. Another press while the save is in progress sends another @put, and the server decides that one too; a disabled guard in the page would take focus from the switch.
role="status" makes screen readers announce the result without interrupting what the user is doing.
Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-switch | The switch itself |
.lt-switch-row | The row that holds the switch and the text side by side |
| Variable | Default |
|---|---|
--lt-switch-width | var(--lt-spacing-10) |
--lt-switch-height | var(--lt-spacing-6) |
The knob inside is a gradient and not a pseudo-element, because Firefox does not render ::before and ::after on an <input>. It therefore follows the height of the switch by itself. See Customising.
The switch slides between off and on. If the user has asked for reduced motion in their system, it changes state without a transition instead.
Accessibility
Section titled “Accessibility”- The label should say what the switch controls, not which state it is in. “Notify me by email”, not “Email notifications on”.
- Do not write “Off” and “On” as text next to it. The screen reader already says it, and the text contradicts itself when the user flips the switch.
- The switch has no invalid state. If switching it on is required, for example to accept terms, that belongs in a Checkbox with
required. - If the change needs confirmation or cannot be undone, use a button instead. A switch that opens a dialog is not a switch.