Skip to content

Components / UI

Switch

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.

Live demo

a checkbox underneath · Space flips it

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Switch from '@moonarc/core/Switch';
---
<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" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/switch.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

Switch.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>

Props

PropTypeDefaultDescription
namestringnoneForm field name; the value is sent when the switch is on.
valuestring'on'Submitted value.
checkedbooleanfalseInitial state.
labelstringnoneVisible 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.
colorstringnoneTrack colour when on; default the ink token.

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.

Why it is built this way

Replaces: Switch (Motion Primitives) · Switch (shadcn / Radix) · Toggle (Tailwind Plus). See the migration table.

a tick that draws · one indeterminate

A checkbox whose tick is drawn.

every browserclick

sample prices

Starter

$12/mo$115/yr

one site

Studio

$48/mo$460/yr

up to ten

A monthly / yearly toggle whose prices roll to the other amount instead of swapping.

newly · Chrome 125 · Firefox 129 · Safari 17.5click

click · the page changes from the button
ThemeScript · 473 B raw, inline in <head>

Cycles system → light → dark, and the page changes theme through a circle that grows from the click, on a same-document view transition.

newly · Chrome 111 · Firefox 144 · Safari 18click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown