Press / to search

    No results

    Search failed

    npm GitHub

    <switch-elemental>

    A <button> that flips between on and off, per the APG Switch pattern. Light DOM, no shadow root, form-associated, nothing moved or wrapped.

    Dark mode

    <span id="switch-theme-label">Dark mode</span>
    <switch-elemental id="switch-theme-demo" checked-if="[data-theme=dark]">
      <button aria-labelledby="switch-theme-label">
        <span class="switch-elemental-off" aria-hidden="true"
          ><svg><!-- ... --></svg></span
        >
        <span class="switch-elemental-on" aria-hidden="true"
          ><svg><!-- ... --></svg></span
        >
      </button>
    </switch-elemental>
    <span class="demo-state" aria-hidden="true"
      ><span class="on">On</span><span class="off">Off</span></span
    >
    const toggle = document.getElementById('switch-theme-demo');
    const root = document.documentElement;
    if (!toggle) return;
    // The starting state is `checked-if`'s. This is the other half: writing the theme
    // back out, and following the topbar's switch when that one is the one flipped.
    const sync = () => {
      toggle.checked = root.dataset.theme === 'dark';
    };
    toggle.addEventListener('switch-toggle', (e) => {
      root.dataset.theme = e.detail.checked ? 'dark' : 'light';
      try {
        localStorage.setItem('theme', root.dataset.theme);
      } catch (err) {}
      sync();
    });
    new MutationObserver(sync).observe(root, { attributeFilter: ['data-theme'] });

    Usage

    Write a <button> and wrap it. The button must be a real <button> and a direct child โ€” without one there is no switch, and nothing is enforced beyond that. Edit the sample and the preview above it follows as you type; take the <button> out and watch it stop being a switch:

    <span id="dark-label">Dark mode</span>
    <switch-elemental checked>
      <button aria-labelledby="dark-label"></button>
    </switch-elemental>
    import 'book-of-elementals/switch';
    @use "book-of-elementals/switch/style.scss"; // structure
    @use "book-of-elementals/switch/theme.scss"; // the look, optional

    Or the single-element bundle โ€” no build step, no script to write:

    <script src="https://unpkg.com/book-of-elementals/dist/elementals/switch.min.js"></script>
    <link
      rel="stylesheet"
      href="https://unpkg.com/book-of-elementals/dist/elementals/switch.min.css"
    />
    <link
      rel="stylesheet"
      href="https://unpkg.com/book-of-elementals/dist/elementals/switch-theme.min.css"
    />

    It registers itself on include and upgrades on connect. Nothing on window, nothing to instantiate, no init call to forget.

    API

    Attributes

    Attribute Type Default Description
    checked boolean false Whether it is on. Reflected โ€” markup, script and CSS read the same thing.
    checked-if string โ€” A selector. Starts on when <html> matches it, asked once at upgrade. See below.
    name string โ€” Submits under this name. No name, no form data.
    value string on What it submits while on.
    disabled boolean false Disables the button and submits nothing. A <fieldset disabled> does the same.
    required boolean false The form will not submit while it is off.
    required-message string โ€” What this one says while it is required and off.

    Properties

    Property Type Description
    checked boolean Get/set the state. Writes the attribute.
    value string Get/set the submitted value.
    disabled boolean Get/set. Reads true for a fieldset-disabled switch too.
    required boolean Get/set.
    requiredMessage string The message in force: attribute, then the static, then the browser's.
    button HTMLButtonElement Read-only. The direct-child button.
    defaultChecked boolean What a form reset goes back to. Read at upgrade.
    validity, validationMessage, willValidate โ€” Read-only, the platform's.
    checkValidity(), reportValidity(), setCustomValidity(msg) โ€” The platform's, see below.

    Events

    switch-toggle fires on the element on every state change โ€” click, script, or attribute โ€” and bubbles:

    const toggle = document.querySelector('switch-elemental');
    
    toggle.addEventListener('switch-toggle', (e) => e.detail.checked);
    
    toggle.checked = true; // also fires it
    Property Value
    detail.checked The new state, as a boolean

    There is no change event: the control is a <button>, so the platform never fires one.

    What it writes on the button

    Attribute Value
    role switch
    aria-checked true / false, written from checked
    type button, only if the markup did not set a type

    type="button" because a <button> in a form submits it unless told otherwise, and a setting that posts the page away on its first flip is not a setting.

    Keyboard

    All of it is the button's, which is the point of using one:

    Key Action
    Tab Move to it
    Space / Enter Flip it

    Styling hooks

    switch-elemental[checked] {
    } /* the host, reflected state */
    switch-elemental > button[aria-checked="true"] {
    } /* what the theme keys off */
    switch-elemental > button:disabled {
    } /* own, host's, or a fieldset's */
    switch-elemental:not(:defined) {
    } /* before upgrade */

    The label

    The name comes from outside the control, as it does in the APG's own examples. Three ways, in order of preference:

    <!-- 1. Visible label beside it, referenced by id -->
    <span id="dark-label">Dark mode</span>
    <switch-elemental
      ><button aria-labelledby="dark-label"></button
    ></switch-elemental>
    
    <!-- 2. No visible label โ€” the name is the button's own text -->
    <switch-elemental><button>Dark mode</button></switch-elemental>
    
    <!-- 3. Icon-only, where the surrounding UI makes it obvious -->
    <switch-elemental><button aria-label="Dark mode"></button></switch-elemental>

    Name the thing being switched, and keep that name still. aria-checked already announces on/off, so a label reading "On" says it twice, and a label that swaps as you flip it renames the control under the reader while focus is still on it โ€” the name is also what voice control aims at.

    <label> is not an option โ€” it binds to form controls and a <button> is not one. aria-labelledby is the equivalent.

    State text

    If the state should be readable as a word and not only as a position, put it beside the switch as its own aria-hidden text, never in the name. The reflected checked attribute does the swap, so there is no script and nothing to keep in step:

    <span id="dark-label">Dark mode</span>
    <switch-elemental
      ><button aria-labelledby="dark-label"></button
    ></switch-elemental>
    <span class="switch-state" aria-hidden="true">
      <span class="on">On</span><span class="off">Off</span>
    </span>
    switch-elemental[checked] + .switch-state .off,
    switch-elemental:not([checked]) + .switch-state .on {
      display: none;
    }

    aria-hidden because a screen reader has already heard it from aria-checked โ€” this is for the readers who see the switch and want the word. It is your own two spans, as the demo at the top of this page is; nothing here ships it.

    In a form

    Give it a name and it is a form control: it submits, resets, restores and disables like a checkbox, because it is form-associated through ElementInternals rather than through a hidden <input> that could disagree with it.

    Flip these โ€” the line underneath is the form's live FormData. Terms is required, so Save refuses until it is on; Beta features sits in a <fieldset> that Pro tier enables:

    Autoplay
    Email me replies
    Pro tier
    Beta features (Pro only)
    Accept the terms (required)

    โ€ฆ

    <form>
      <switch-elemental name="autoplay"
        ><button aria-labelledby="โ€ฆ"></button
      ></switch-elemental>
      <switch-elemental name="replies" checked
        ><button aria-labelledby="โ€ฆ"></button
      ></switch-elemental>
      <switch-elemental name="tier" value="pro"
        ><button aria-labelledby="โ€ฆ"></button
      ></switch-elemental>
    
      <fieldset id="beta" disabled>
        <switch-elemental name="beta" checked
          ><button aria-labelledby="โ€ฆ"></button
        ></switch-elemental>
      </fieldset>
    
      <switch-elemental name="terms" required
        ><button aria-labelledby="โ€ฆ"></button
      ></switch-elemental>
      <button>Save</button>
    </form>
    // One switch gating another is a line: the fieldset owns the disabling, and every
    // control under it goes with it.
    tier.addEventListener('switch-toggle', (e) => {
      beta.disabled = !e.detail.checked;
    });
    Wanted Markup / code
    Submits as autoplay=on name="autoplay" and on
    Submits nothing off, disabled, inside a disabled <fieldset>, or no name
    A value other than on value="pro"
    On when the page loads checked in the markup โ€” also what reset returns to
    Must be on to submit required
    Associate with a form by id form="settings", as any control does

    Reading it

    const form = document.querySelector('form');
    
    new FormData(form).get('autoplay'); // 'on', or null when off
    form.querySelector('[name="autoplay"]').checked; // the live boolean
    
    // There is no `change` event โ€” the control is a button โ€” but `switch-toggle`
    // bubbles, so one listener on the form hears every switch in it.
    form.addEventListener('switch-toggle', (e) => {
      console.log(e.target.name, e.detail.checked);
    });

    On the server it is the checkbox story unchanged: an off switch is absent from the body rather than empty, which is what every "was this ticked" check is already written against.

    Reset, restore, disable

    • Reset puts it back to the state its markup arrived in โ€” nothing to wire up.
    • Back-navigation restores what the reader left it at, same.
    • disabled disables the button, and the switch drops out of the form data. A <fieldset disabled> reaches it too, and switch-elemental[disabled] is a styling hook.

    Validation

    required means the form will not submit while the switch is off, and the browser's own bubble points at the button. The rest of the constraint API is the platform's, read off the internals:

    toggle.required = true;
    toggle.checkValidity(); // false while it is off
    toggle.validity.valueMissing; // true
    toggle.reportValidity(); // checks and shows the bubble
    
    // Your own constraint, for what the browser cannot know. '' clears it.
    toggle.setCustomValidity(
      data.get('beta') && !data.get('tier') ? 'Beta needs Pro.' : '',
    );

    A disabled switch is barred from validation, as every other control is โ€” a required switch inside a disabled fieldset does not block the form.

    The message

    A form-associated custom element gets no message from the platform: setValidity refuses an empty one, and there is no default to ask for. So the default here is borrowed โ€” the browser's own message for a required checkbox, read once off a throwaway <input>, which arrives already translated into the reader's language. It says box rather than switch, and a reader given the right language with the wrong noun is better served than one given English.

    Three levels, most specific first:

    <!-- 1. this one switch -->
    <switch-elemental required required-message="Accept the terms to continue."
      >โ€ฆ</switch-elemental
    >
    // 2. every switch on the page, one line at boot
    import { SwitchElemental } from 'book-of-elementals/switch';
    SwitchElemental.requiredMessage = 'Veuillez activer ceci.';
    
    // 3. nothing set โ€” the browser's translated checkbox message

    setCustomValidity() is not one of them: it makes the switch invalid until you clear it, which is a different thing from wording the required message.

    Note

    Ask whether a required switch is the right control at all. "Accept the terms" is a value you are submitting, not a setting that takes effect as you flip it, and that is a checkbox. required is here for the forms that mix the two, not as an invitation.

    Note

    attachInternals is the one part that is not everywhere โ€” Safari only got it in 16.4. Without it the switch is a switch that does not submit; it still toggles, still fires switch-toggle, and its checked is still readable. If the setting must survive scripting being off entirely, that is <input type="checkbox" role="switch">.

    Theme toggle

    The switch a switch most often is, and the code behind the demo at the top. A theme is stamped on <html> before first paint, so the document knows the answer and the markup does not โ€” which is what checked-if is for:

    <!-- in <head>, before any CSS: stamp the saved theme before first paint -->
    <script>
      (function () {
        var t = localStorage.getItem('theme');
        if (!t)
          t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
        document.documentElement.dataset.theme = t;
      })();
    </script>
    
    <switch-elemental checked-if="[data-theme=dark]">
      <button aria-labelledby="dark-label"></button>
    </switch-elemental>
    const root = document.documentElement;
    
    document.querySelector('switch-elemental').addEventListener('switch-toggle', (e) => {
      root.dataset.theme = e.detail.checked ? 'dark' : 'light';
      localStorage.setItem('theme', root.dataset.theme);
    });

    The "On" beside it is state text and needs no line here โ€” it keys off the checked attribute the condition has just written.

    checked-if is why there is no ordering rule to get right. Written in script instead, the starting state has to be set in the same task that registers the element: registering is what lifts the visibility: hidden the button wears while undefined, so from that moment it is on screen, and a seed arriving at DOMContentLoaded is late. Measured on this site's own topbar when it worked that way: the knob painted off over an already-dark page for two frames, around 90ms, then slid across. The attribute is read at upgrade, before the button can be painted at all, so nothing can arrive late.

    Note

    Asked once, and only of <html> โ€” the one element certain to be parsed whenever a switch upgrades. It is a starting state, not a binding: flip the switch, or change data-theme from elsewhere, and the condition is not consulted again. Keeping two controls for one setting in step is a MutationObserver on the root, as the demo at the top of this page does. A selector the browser cannot parse leaves the markup's own checked standing and raises the error where the console will report it, rather than taking the switch down over a typo: by upgrade there is a button on screen, and throwing leaves it there with no role.

    The look

    style.scss is structure only; theme.scss is the look and is optional โ€” a light-DOM element cannot scope a look away from a page that did not ask for one. It is a pill with a knob that slides, painted out of currentcolor, so it sits in whatever palette the page has โ€” including the theme it is switching:

    Property Default Description
    --switch-elemental-width 3.625rem Track width
    --switch-elemental-height 2rem Track height
    --switch-elemental-radius the height Track corners. The height is a pill
    --switch-elemental-border-width 2px Track border
    --switch-elemental-border-color currentcolor Track border, off
    --switch-elemental-border-color-checked the above Track border, on
    --switch-elemental-gap 2px Between the knob and the inside of the track
    --switch-elemental-track transparent Track fill, off
    --switch-elemental-track-checked currentcolor Track fill, on
    --switch-elemental-knob currentcolor Knob fill, off
    --switch-elemental-knob-checked Canvas Knob fill, on
    --switch-elemental-knob-size derived Knob width/height. Travel follows it
    --switch-elemental-knob-radius 50% Knob shape
    --switch-elemental-duration 250ms Slide and cross-fade
    --switch-elemental-easing ease-in-out Slide and cross-fade

    That is the table above, live. Turn the knobs in the Options tab until the switch looks the way you want it, then copy the rule out of the bottom of the panel โ€” that rule is the whole of what you would write, and its selector is the element rather than :root for the reason the note below gives:

    <span id="knobs-label">Turn me in the Options tab</span>
    <switch-elemental checked>
      <button aria-labelledby="knobs-label"></button>
    </switch-elemental>

    Three properties are not in the panel, and deliberately: --switch-elemental-inset, --switch-elemental-knob-size and --switch-elemental-travel are calc()-derived from the four geometry properties, and setting one by hand is how a knob ends up overshooting its own track. Size it with --switch-elemental-width and --switch-elemental-height and the rest follows.

    prefers-reduced-motion: reduce switches the motion off. Under forced-colors the track and knob are repainted in system colors. A disabled switch is the same switch at opacity: 0.5 with a not-allowed cursor โ€” restyle it through switch-elemental > button:disabled:

    <switch-elemental disabled><button aria-label="โ€ฆ"></button></switch-elemental>

    Note

    These go on the <switch-elemental> โ€” a class on it, .card switch-elemental, or the element itself. The theme sets its defaults on the element, and a property set on an element always beats one inherited from an ancestor, so .settings-panel { --switch-elemental-track-checked: โ€ฆ } silently does nothing.

    Size

    Two presets ship, and they combine. Both are only the properties printed beside them, so any other size is the same two lines:

    <switch-elemental class="switch-elemental-small">โ€ฆ</switch-elemental>
    <switch-elemental class="switch-elemental-thin">โ€ฆ</switch-elemental>
    .switch-elemental-small {
      --switch-elemental-width: 2.75rem;
      --switch-elemental-height: 1.5rem;
    }
    
    .switch-elemental-thin {
      --switch-elemental-border-width: 1px;
    }

    Icons are your own SVGs at your own size, so size them down too at the small one.

    The knob

    The knob is a pseudo-element on the button, sized and moved from the properties above โ€” set its size and the travel follows, because both are read from the same property and it can never overshoot its track. Four of them do everything worth doing to it:

    KnobOffOnWhat changed
    Default Circle, inset by the gap.
    Square --switch-elemental-knob-radius, and the track squared with it.
    Rail A knob bigger than the track it rides on, reaching both ends โ€” the gap goes negative to clear the border, and the knob keeps its colour so the overhang survives.
    Flush --switch-elemental-gap: 0px โ€” knob fills the track.
    Colour A colour of your own: track and rim on the on state, knob untouched.
    Ink The knob keeps its colour in both states, so the track can only tint. Never has to know what is behind it.
    /* the track squares with the knob: 0.25rem + the gap + the border keeps the corners
       concentric */
    .knob-square {
      --switch-elemental-knob-radius: 0.25rem;
      --switch-elemental-radius: 0.5rem;
    }
    
    .knob-rail {
      --switch-elemental-height: 1.1rem;
      --switch-elemental-border-width: 2px;
      --switch-elemental-gap: -2px;
      --switch-elemental-knob-size: 1.25rem;
      --switch-elemental-knob-checked: var(--switch-elemental-knob);
      --switch-elemental-track-checked: color-mix(
        in srgb,
        currentcolor 30%,
        transparent
      );
    }
    .knob-flush {
      --switch-elemental-gap: 0px;
    } /* 0px, not 0 โ€” see below */
    
    /* the one that leaves currentcolor: the on state, track and rim */
    .knob-color {
      --switch-elemental-track-checked: #7c5cff;
      --switch-elemental-border-color-checked: #7c5cff;
    }
    
    /* the knob keeps its colour, so the track tints instead of filling */
    .knob-ink {
      --switch-elemental-knob-checked: var(--switch-elemental-knob);
      --switch-elemental-track-checked: color-mix(
        in srgb,
        currentcolor 22%,
        transparent
      );
    }

    Warning

    Give the lengths a unit โ€” 0px, never 0. They are added together inside calc(), where a unitless zero is invalid, and the knob computes to nothing at all.

    Note

    --switch-elemental-knob-checked defaults to Canvas, the page's own background, because that is what a knob dropped out of a filled track is. It is the one value that knows about its surroundings, so re-point it on a card (--switch-elemental-knob-checked: var(--card-background)), or on a page that themes in custom properties without declaring color-scheme โ€” there Canvas stays white in dark mode and the knob thins out. Declaring color-scheme: dark fixes it, and the ink knob above sidesteps the question: a knob that keeps its own colour never asks what is behind it. It is also what an oversized knob needs โ€” one bigger than its track paints over the page rather than over the track, and a Canvas knob disappears into it.

    Icons

    Optional, and the reason the button may have children at all: one icon per state, each in the half the knob is not in. The knob covers one end, so the icon that shows is always the one at the other:

    <switch-elemental>
      <button aria-label="Dark mode">
        <span class="switch-elemental-off" aria-hidden="true"
          ><svg โ€ฆ><!-- sun --></svg></span
        >
        <span class="switch-elemental-on" aria-hidden="true"
          ><svg โ€ฆ><!-- moon --></svg></span
        >
      </button>
    </switch-elemental>

    aria-hidden on both โ€” they are the state twice over. Each icon takes the knob color of the state it belongs to, so draw them in currentColor and they follow.

    Switch or checkbox?

    The same boolean wearing different promises: a checkbox is a value you are about to submit (and has a third, indeterminate state), a switch is a setting that takes effect the moment you flip it. Being in a <form> does not settle it โ€” give this element a name and it submits like a checkbox. Two things do send you to <input type="checkbox" role="switch"> instead:

    • Scripting off. The button is removed until the element upgrades (display: none under @media (scripting: none); with scripting on its box is held invisible instead, so the page does not shift when it lands), and an element that never upgrades never sets a form value either. A switch that silently does not switch is worse than none.
    • <label>. A real form control gets one; a <button> does not.

    The theme dresses this element's <button> only, but the box is identical on a native checkbox โ€” only the state selector differs, :checked where this keys off [aria-checked="true"].

    More than two states is neither: that is one choice out of a few, which is <segmented-elemental> โ€” the same track and sliding knob, over a native radio group.

    Layout

    <switch-elemental> is display: contents: a switch is usually a flex or grid item next to its label, and an extra box in between is one the parent lays out instead of the button. Dropping the element around an existing button changes no layout at all. Give it display: block in your own CSS if you want something to style.