a tick that draws · one indeterminate
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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
every browser.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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.
npx shadcn@latest add https://moonarc.dev/r/switch.jsonThe 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 — 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
| 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. |
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
- 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). See the migration table.
Related
sample prices
Starter
$12/mo$115/yr
one site
Studio
$48/mo$460/yr
up to ten
Price Switch
DataA monthly / yearly toggle whose prices roll to the other amount instead of swapping.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown