# Switch

> A switch for a setting that takes effect immediately. CSS component with two classes and no JavaScript.

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](/components/checkbox.md) is right: a switch that does nothing until you press “Save” promises more than it delivers.

## How to use it

**Plain HTML**

```html
<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 -->
```

```html
<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>
```

**TypeScript**

```ts
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:

```tsx
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:

```ts
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](#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:

```ts
import { switchControl } from "@lo-tek/interfaces"
```

## 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”.

**Plain HTML**

```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>
```

**React**

```tsx
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**

```html
<!-- 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.

**kotlinx.html**

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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox#checked). The attribute goes on the form because [HTML does not allow it on a checkbox](https://html.spec.whatwg.org/multipage/input.html#checkbox-state-\(type=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.

```kotlin
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
              }
            })
            """
        }
    }
}
```

```kotlin
// 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

| 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](/customising.md).

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

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.

1. Focus `#auto-renew`: focus on the `switch` "Auto-renew", not checked.
2. 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](/components/checkbox.md) 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.
