Switch
“Our walls have no doors.”
“Every wall I’ve met said that,” Trace said.
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”Black ICE. Check local law.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/switch/switch.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/label/label.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/help-text/help-text.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css --><div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="auto-renew" checked /><label class="lt-label" for="auto-renew">Auto-renew</label></div>
<div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="lethal" aria-describedby="lethal-help" /><label class="lt-label" for="lethal">Lethal response</label></div><p class="lt-help-text" id="lethal-help">Black ICE. Check local law.</p>
<div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="intrusions" disabled /><label class="lt-label" for="intrusions">Report every intrusion attempt</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="auto-renew" /> <label {...lt.label()} htmlFor="auto-renew">Auto-renew</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>("#auto-renew")
// 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. A redirect, as to the log-in page when the session has run out, is followed to a page that is ok, so it is checked too. In “Plain HTML”, React and kotlinx.html, 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. React uses the builder functions, Datastar its own attributes, and kotlinx.html the same script as “Plain HTML”.
<!-- autocomplete="off" stands on the form, since HTML does not allow it on a checkbox. Without it Firefox keeps the state from before a reload. --><form autocomplete="off"> <div class="lt-switch-row"> <input class="lt-switch" type="checkbox" role="switch" id="auto-renew" /> <label class="lt-label" for="auto-renew">Auto-renew</label> </div></form><p class="lt-help-text" id="auto-renew-status" role="status"></p>
<script type="module"> const toggle = document.getElementById("auto-renew") const status = document.getElementById("auto-renew-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/subscription", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ autoRenew: on }), }) // A redirect, as to the log-in page when the session has run out, is // followed to a page that is ok, but nothing was saved. if (!response.ok || response.redirected) throw new Error(response.statusText) status.textContent = on ? "Auto-renew is on." : "Auto-renew is off. Your walls expire with the subscription." } 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 AutoRenewSwitch({ 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/subscription", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ autoRenew: value }), }) // A redirect, as to the log-in page when the session has run out, is // followed to a page that is ok, but nothing was saved. if (!response.ok || response.redirected) throw new Error(response.statusText) setStatus(value ? "Auto-renew is on." : "Auto-renew is off. Your walls expire with the subscription.") } 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="auto-renew" checked={on} onChange={(e) => changed(e.currentTarget.checked)} /> <label {...lt.label()} htmlFor="auto-renew">Auto-renew</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="{renew: false, status: ''}"> <input class="lt-switch" type="checkbox" role="switch" id="auto-renew" data-bind:renew data-on:change="@put('/api/subscription')" /> <label class="lt-label" for="auto-renew">Auto-renew</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, renew 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.
On a Kotlin or Java server, with kotlinx.html and lo-tek-kotlinx-html. The server renders the saved choice with on, and autocomplete="off" on the form around it makes that choice stand after a reload: Firefox otherwise keeps the checked state from before. The attribute goes on the form because HTML does not allow it on a checkbox. A switch takes effect the moment it is pressed, with no button to submit, so saving it is the one thing that needs a script: it sends the choice to the server with fetch, as in the HTML tab.
import kotlinx.html.*import no.lotek.*import no.lotek.html.lt
fun FlowContent.autoRenew(on: Boolean = false) { // autocomplete="off" stands on the form, since HTML does not allow it on // a checkbox. Without it Firefox keeps the state from before a reload // over the one the server rendered. form { autoComplete = false div { lt(Lt.switch.row) input { lt(Lt.`switch`()); id = "auto-renew"; checked = on } label { lt(Lt.label()); htmlFor = "auto-renew"; +"Auto-renew" } } } p { lt(Lt.helpText()); id = "auto-renew-status"; attributes["role"] = "status" } script(type = "module") { unsafe { +""" const toggle = document.getElementById("auto-renew") const status = document.getElementById("auto-renew-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/subscription", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ autoRenew: on }), }) // A redirect, as to the log-in page when the session has run out, is // followed to a page that is ok, but nothing was saved. if (!response.ok || response.redirected) throw new Error(response.statusText) status.textContent = on ? "Auto-renew is on." : "Auto-renew is off. Your walls expire with the subscription." } catch { toggle.checked = !on status.textContent = "Could not save. Try again." } finally { saving = false } }) """ } }}// Once for the whole app, in the page template. The switch is CSS alone,// so there is no web component to register.head { link(rel = "stylesheet", href = LotekWebJar.CSS) }body { autoRenew() }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”
What a screen reader is given when the recipe above is used, step by step. It is checked through AT-SPI, the
interface Orca reads, in Chromium and Firefox, on every change, by check-accessibility-tree.ts, and
every change the check lists as a mutation must make it fail.
- Focus
#auto-renew: focus on theswitch"Auto-renew", not checked. - Press Space: focus on the
switch"Auto-renew", not checked; "Could not save. Try again." announced once, polite.
- The label should say what the switch controls, not which state it is in. “Lethal response”, not “Lethal response is 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.