# Switch (Moonarc)

A toggle that springs. It is a native checkbox with role="switch" and appearance: none (the input is the track, its ::before the thumb), so the value submits, required and disabled work, Space flips it, and the thumb travels on the preset curve with a bounce under the snap and lively presets. Zero JavaScript.

- Import: `import Switch from '@moonarc/core/Switch'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/switch.json`
- Tier A · category ui · trigger click
- Readout: `<Switch name="alerts" label="Email me" checked>`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 6.6 kB raw)
- Page: https://moonarc.dev/components/switch/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `name` | `string` | none | Form field name; the value is sent when the switch is on. |
| `value` | `string` | `'on'` | Submitted value. |
| `checked` | `boolean` | `false` | Initial state. |
| `label` | `string` | none | Visible label, wrapping the input in a <label>. Without it, pass aria-label. |
| `size` | `'sm' | 'md'` | `'md'` | Track height: 1.25 rem or 1.5 rem; the thumb and the travel follow. |
| `color` | `string` | none | Track colour when on; default the ink token. |

## Usage

```astro
<Switch name="alerts" label="Email me about releases" checked />
<Switch name="beta" label="Beta features" size="sm" />

<!-- no visible label: name it for assistive tech -->
<Switch name="dark" aria-label="Dark mode" />
```

## Reduced motion

The thumb jumps and the track colour changes at once.

## With ClientRouter

A native input; its state is in the DOM and goes with the page. Nothing to bind.

## Craft

- The input is the control. appearance: none removes the native drawing and leaves everything else: the value in FormData, required, disabled, the label association, Space and the accessibility tree (role="switch" on a checkbox is the pattern the ARIA spec names). No hidden input, no custom element.
- The thumb is a pseudo-element of the input translated by track width minus its own, on the preset curve: under snap or lively that curve is a sampled spring, so the thumb overshoots and settles; under ui it eases. The track colour crosses on the fast token, so it has changed before the thumb arrives.
- Right to left, the thumb starts at the right end and on moves it left: its offsets are logical, and the travel flips under a dir="rtl" ancestor. The selector reads the attribute because Chrome has :dir() only from 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never matches. An ltr island one level inside a right-to-left page is set back.
- WebKit's switch attribute is set as well, so a browser that wants to render it natively may, but the stylesheet never depends on it; appearance: none wins in every engine.
- Windows high contrast (forced-colors) gets its own rules: a ButtonText stroke for the track, a filled dot, Highlight when on. A control drawn with appearance: none is invisible there without them.
- Focus is the ring the platform draws on :focus-visible, on the ink colour, offset from the track. Disabled halves the opacity and drops the transition.

## Replaces

- Switch (Motion Primitives)
- Switch (shadcn / Radix)
- Toggle (Tailwind Plus)

## Source

```astro
---
/**
 * Switch — a toggle that springs, zero JS. It *is* a checkbox: `<input
 * type="checkbox" role="switch">` with `appearance: none`, the input itself
 * drawn as the track and its ::before as the thumb, so the value submits,
 * `required` and `disabled` work, Space flips it and a form library sees a
 * plain checkbox. The thumb travels on `translate` with the preset curve
 * (snap and lively are sampled springs, so it lands with a bounce); the
 * track colour crosses on the fast token. WebKit's native `switch`
 * attribute is set too, but nothing here depends on it. Wrapped in a
 * <label> when `label` is given; otherwise pass `aria-label`. Classes are
 * `.ma-toggle` — `.ma-switch` was already taken.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends Omit<HTMLAttributes<'input'>, 'type' | 'size'> {
  /** Form field name. */
  name?: string;
  /** Submitted value when on. */
  value?: string;
  checked?: boolean;
  /** Visible label; without it, pass aria-label. */
  label?: string;
  /** Track height: 1.25rem or 1.5rem. */
  size?: 'sm' | 'md';
  /** Track colour when on; default the ink token. */
  color?: string;
}

const { name, value = 'on', checked = false, label, size = 'md', color, class: className, style, ...rest } = Astro.props;
const vars = [color && `--ma-toggle-on:${color}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// the WebKit native switch attribute: harmless elsewhere, and appearance: none overrides its look everywhere
const native = { switch: '' };
---

<label class:list={['ma-toggle', className]} data-size={size} style={vars || undefined}>
  <input type="checkbox" role="switch" class="ma-toggle__input" name={name} value={value} checked={checked} {...native} {...rest} />
  {label && <span class="ma-toggle__label">{label}</span>}
</label>

<style is:global>
  @layer components {
    :where(.ma-toggle) {
      --ma-toggle-h: 1.5rem;
      display: inline-flex;
      align-items: center;
      gap: 0.6em;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    :where(.ma-toggle[data-size='sm']) {
      --ma-toggle-h: 1.25rem;
    }
    :where(.ma-toggle__input) {
      /* the track: the input itself, so the value, the focus and the keyboard are the browser's */
      appearance: none;
      -webkit-appearance: none;
      position: relative;
      flex: none;
      inline-size: calc(var(--ma-toggle-h) * 1.75);
      block-size: var(--ma-toggle-h);
      margin: 0;
      border: 1px solid var(--ma-edge);
      border-radius: 999px;
      background: color-mix(in srgb, var(--ma-ink) 14%, transparent);
      cursor: pointer;
      font: inherit;
      transition:
        background-color var(--ma-duration-fast) var(--ma-ease-out),
        border-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-toggle__input)::before {
      /* the thumb: a pseudo-element, travelling the track width minus itself on the preset spring */
      content: '';
      position: absolute;
      inset-block-start: 50%;
      inset-inline-start: 0;
      inline-size: calc(var(--ma-toggle-h) - 6px);
      block-size: calc(var(--ma-toggle-h) - 6px);
      margin-block-start: calc((var(--ma-toggle-h) - 6px) / -2);
      margin-inline-start: 2px;
      border-radius: 50%;
      background: var(--ma-panel);
      box-shadow: 0 1px 2px var(--ma-glow);
      translate: 0 0;
      transition: translate var(--ma-duration) var(--ma-ease);
    }
    /* the target: whatever the track's size, the input answers a press within 24 × 24 px (WCAG 2.2) — a pseudo-element of the input is part of it for hit testing */
    :where(.ma-toggle__input)::after {
      content: '';
      position: absolute;
      inset: min(0px, calc((var(--ma-toggle-h) - 26px) / 2)) min(0px, calc((var(--ma-toggle-h) * 1.75 - 26px) / 2));
    }
    :where(.ma-toggle__input:checked) {
      background: var(--ma-toggle-on, var(--ma-ink));
      border-color: var(--ma-toggle-on, var(--ma-ink));
    }
    :where(.ma-toggle__input:checked)::before {
      translate: calc(var(--ma-toggle-h) * 0.75 * var(--ma-toggle-dir, 1)) 0;
    }
    /* right to left: the thumb starts at the right end (inset-inline-start), so on is a move to the left, and translate
       has no logical form. [dir] and not :dir(): Chrome has :dir() only from 120, and a build that targets an older
       Chrome rewrites it into a guess from lang, which a page that sets only dir never matches. An ltr island one level
       inside an rtl page is set back */
    :where([dir='rtl'] .ma-toggle, .ma-toggle__input[dir='rtl']) {
      --ma-toggle-dir: -1;
    }
    :where([dir='rtl'] [dir='ltr'] .ma-toggle, [dir='rtl'] .ma-toggle__input[dir='ltr']) {
      --ma-toggle-dir: 1;
    }
    :where(.ma-toggle__input:focus-visible) {
      outline: 2px solid var(--ma-toggle-on, var(--ma-ink));
      outline-offset: 3px;
    }
    :where(.ma-toggle__input:disabled) {
      cursor: not-allowed;
      opacity: 0.45;
      transition: none;
    }
    :where(.ma-toggle__input:disabled)::before {
      transition: none;
    }
    :where(.ma-toggle:has(.ma-toggle__input:disabled)) {
      cursor: not-allowed;
    }
    :where(.ma-toggle:has(.ma-toggle__input:disabled) .ma-toggle__label) {
      opacity: 0.6;
    }
    :where(.ma-toggle__label) {
      line-height: 1.2;
      user-select: none;
    }
    /* Windows high contrast: the track is a stroke, the thumb a filled dot, on = Highlight */
    @media (forced-colors: active) {
      :where(.ma-toggle__input) {
        border-color: ButtonText;
        background: Canvas;
      }
      :where(.ma-toggle__input)::before {
        background: ButtonText;
        box-shadow: none;
      }
      :where(.ma-toggle__input:checked) {
        background: Highlight;
        border-color: Highlight;
      }
      :where(.ma-toggle__input:checked)::before {
        background: HighlightText;
      }
      :where(.ma-toggle__input:disabled) {
        border-color: GrayText;
        opacity: 1;
      }
      :where(.ma-toggle__input:disabled)::before {
        background: GrayText;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-toggle__input),
      :where(.ma-toggle__input)::before {
        transition: none;
      }
    }
  }
</style>

```
