Inputs

Switch

An on or off toggle for a setting that takes effect at once, built on a native checkbox with role switch.

Built on
<input type="checkbox">role="switch"appearance: none:checked:has():dir()
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.7 kB
Dependencies
React
Try its options

Loading its options.

Code
import { Switch } from "@/components/equip/switch";

export default function Demo() {
  return (
    <div style={{ display: "grid", gap: 8, width: 200 }}>
      <Switch name="auto-deploy" label="Auto-deploy main" defaultChecked />
      <Switch name="preview-urls" label="Preview URLs" />
    </div>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:8px;width:200px">
  <label class="eq-switch">
    <span class="eq-switch-control"><input type="checkbox" role="switch" class="eq-switch-input" name="auto-deploy" checked><span class="eq-switch-thumb" aria-hidden="true"></span></span>
    <span class="eq-switch-text">
      <span class="eq-switch-label">Auto-deploy main</span>
    </span>
  </label>
  <label class="eq-switch">
    <span class="eq-switch-control"><input type="checkbox" role="switch" class="eq-switch-input" name="preview-urls"><span class="eq-switch-thumb" aria-hidden="true"></span></span>
    <span class="eq-switch-text">
      <span class="eq-switch-label">Preview URLs</span>
    </span>
  </label>
</div>

Examples

Settings list

Each switch has an id, so its description is announced after the setting name instead of inside it.

Code
import { Switch } from "@/components/equip/switch";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 12, maxWidth: 340 }}>
      <Switch
        id="switch-settings-digest"
        name="digest"
        label="Weekly digest"
        description="A summary of deploys and incidents every Monday at 09:00."
        defaultChecked
      />
      <Switch
        id="switch-settings-mentions"
        name="mentions"
        label="Mentions only"
        description="Mute every notification except direct mentions."
      />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;max-width:340px">
  <label class="eq-switch">
    <span class="eq-switch-control"><input type="checkbox" role="switch" id="switch-settings-digest" class="eq-switch-input" aria-labelledby="switch-settings-digest-label" aria-describedby="switch-settings-digest-description" name="digest" checked><span class="eq-switch-thumb" aria-hidden="true"></span></span>
    <span class="eq-switch-text">
      <span class="eq-switch-label" id="switch-settings-digest-label">Weekly digest</span>
      <span class="eq-switch-description" id="switch-settings-digest-description">A summary of deploys and incidents every Monday at 09:00.</span>
    </span>
  </label>
  <label class="eq-switch">
    <span class="eq-switch-control"><input type="checkbox" role="switch" id="switch-settings-mentions" class="eq-switch-input" aria-labelledby="switch-settings-mentions-label" aria-describedby="switch-settings-mentions-description" name="mentions"><span class="eq-switch-thumb" aria-hidden="true"></span></span>
    <span class="eq-switch-text">
      <span class="eq-switch-label" id="switch-settings-mentions-label">Mentions only</span>
      <span class="eq-switch-description" id="switch-settings-mentions-description">Mute every notification except direct mentions.</span>
    </span>
  </label>
</div>

Disabled

A disabled switch is not submitted with its form, on or off.

Code
import { Switch } from "@/components/equip/switch";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 8 }}>
      <Switch name="sso" label="Enforce SSO (Enterprise plan)" disabled />
      <Switch name="encryption" label="Encryption at rest" defaultChecked disabled />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:8px">
  <label class="eq-switch">
    <span class="eq-switch-control"><input disabled type="checkbox" role="switch" class="eq-switch-input" name="sso"><span class="eq-switch-thumb" aria-hidden="true"></span></span>
    <span class="eq-switch-text">
      <span class="eq-switch-label">Enforce SSO (Enterprise plan)</span>
    </span>
  </label>
  <label class="eq-switch">
    <span class="eq-switch-control"><input disabled type="checkbox" role="switch" class="eq-switch-input" name="encryption" checked><span class="eq-switch-thumb" aria-hidden="true"></span></span>
    <span class="eq-switch-text">
      <span class="eq-switch-label">Encryption at rest</span>
    </span>
  </label>
</div>

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label naming the setting. Clicking it flips the switch.
descriptionReactNodenoneSecondary line under the label. Pass id as well so it is read as a description.
idstringnoneOptional. When set with description, the label and description get their own ids.
classNamestringnoneAdded to the root label, not the input.
...restOmit<ComponentProps<"input">, "type" | "role">noneEvery native input attribute and ref, applied to the input. Always a checkbox with role switch.

Anatomy

root.eq-switchThe wrapping label, so the whole row is the click target.
control.eq-switch-controlPositions the thumb over the track.
track.eq-switch-inputThe native checkbox, drawn as the track.
thumb.eq-switch-thumb
text.eq-switch-text
label.eq-switch-label
description.eq-switch-description

Keyboard

SpaceTurns the switch on or off. Provided by the browser.

Notes

  • Renders on the server with no client JavaScript; it posts like a checkbox, sending its value only when on.
  • role="switch" makes assistive tech announce on and off instead of checked and not checked.
  • Use a switch for a setting that applies immediately; inside a form that is saved with a button, a checkbox is clearer.
  • In right-to-left text the thumb starts on the right and moves left when on.
  • Without an id, a description stays part of the accessible name; with one, it is linked by aria-describedby instead. Not yet checked with a screen reader.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/switch.tsx57 lines
import type { ComponentProps, ReactNode } from "react";
import "./switch.css";

/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

export type SwitchProps = Omit<ComponentProps<"input">, "type" | "role"> & {
  /** Visible label naming the setting. Clicking it flips the switch. */
  label: ReactNode;
  /** Secondary line under the label. Pass an id too, so it is read as a description, not as part of the name. */
  description?: ReactNode;
};

export function Switch({
  label,
  description,
  id,
  className,
  "aria-labelledby": labelledBy,
  "aria-describedby": describedBy,
  ...rest
}: SwitchProps) {
  // With an id, the description is split out of the name; without one it stays inside the label.
  const split = id != null && present(description);
  const labelId = split ? `${id}-label` : undefined;
  const descriptionId = split ? `${id}-description` : undefined;
  const described = [describedBy, descriptionId].filter(Boolean).join(" ") || undefined;

  return (
    <label className={["eq-switch", className].filter(Boolean).join(" ")}>
      <span className="eq-switch-control">
        <input
          {...rest}
          type="checkbox"
          role="switch"
          id={id}
          className="eq-switch-input"
          aria-labelledby={labelId ?? labelledBy}
          aria-describedby={described}
        />
        <span className="eq-switch-thumb" aria-hidden="true" />
      </span>
      <span className="eq-switch-text">
        <span className="eq-switch-label" id={labelId}>
          {label}
        </span>
        {present(description) && (
          <span className="eq-switch-description" id={descriptionId}>
            {description}
          </span>
        )}
      </span>
    </label>
  );
}
components/equip/switch.css114 lines
@layer equip {
  .eq-switch {
    --_track-w: 32px;
    --_track-h: 20px;
    --_thumb: 14px;
    --_inset: 3px;
    /* How far the thumb stretches while held, toward the side it will travel to. */
    --_squeeze: 0px;

    display: inline-flex;
    align-items: flex-start;
    gap: var(--eq-space-2);
    /* 2px above and below a 20px line keeps the whole row a 24px target. */
    min-height: 24px;
    box-sizing: border-box;
    padding-block: 2px;
    font: var(--eq-weight) var(--eq-text-m) / 20px var(--eq-font);
    color: var(--eq-ink);
    cursor: pointer;
  }

  .eq-switch-control {
    position: relative;
    flex: none;
    display: block;
    width: var(--_track-w);
    height: var(--_track-h);
  }

  /* The native checkbox is the track. */
  .eq-switch-input {
    appearance: none;
    box-sizing: border-box;
    display: block;
    width: var(--_track-w);
    height: var(--_track-h);
    margin: 0;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-full);
    background: var(--eq-tint);
    cursor: inherit;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-switch-input:checked {
    border-color: var(--eq-solid);
    background: var(--eq-solid);
  }

  .eq-switch-thumb {
    position: absolute;
    top: var(--_inset);
    left: var(--_inset);
    width: calc(var(--_thumb) + var(--_squeeze));
    height: var(--_thumb);
    border-radius: var(--eq-radius-full);
    background: var(--eq-ink-2);
    pointer-events: none;
    transition:
      translate var(--eq-dur-move) var(--eq-spring),
      width var(--eq-dur-move) var(--eq-spring),
      background-color var(--eq-dur) var(--eq-ease);
  }

  /* Held down, the thumb squeezes wider; it keeps its far edge, so when on it grows inward. */
  .eq-switch:active:not(:has(.eq-switch-input:disabled)) {
    --_squeeze: 4px;
  }

  .eq-switch-input:checked + .eq-switch-thumb {
    translate: calc(var(--_track-w) - var(--_thumb) - var(--_squeeze) - 2 * var(--_inset)) 0;
    background: var(--eq-solid-ink);
  }

  /* Right-to-left: on is to the left. */
  .eq-switch:dir(rtl) .eq-switch-thumb {
    left: auto;
    right: var(--_inset);
  }

  .eq-switch:dir(rtl) .eq-switch-input:checked + .eq-switch-thumb {
    translate: calc(-1 * (var(--_track-w) - var(--_thumb) - var(--_squeeze) - 2 * var(--_inset))) 0;
  }

  @media (hover: hover) {
    .eq-switch:hover .eq-switch-input:where(:not(:checked, :disabled)) {
      background: var(--eq-tint-strong);
    }
  }

  .eq-switch-input:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-switch:has(.eq-switch-input:disabled) {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-switch-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
  }

  .eq-switch-description {
    font-size: var(--eq-text-s);
    line-height: 18px;
    color: var(--eq-ink-2);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index