# Switch

> On/off toggle on a native checkbox, in two sizes and two tones.

- **Slug:** `switch`
- **Kind:** atom · **Category:** Inputs
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/atoms/Switch.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/switch
- **All files in one bundle:** https://viewership.ai/ui/components/switch.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/switch.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/switch/`:

   - `Switch.astro` (raw: https://viewership.ai/ui/raw/switch/Switch.astro.txt)

npm packages: none.

## Usage

```astro
---
import Switch from "../components/viewership/switch/Switch.astro";
---

<Switch />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import Switch from "./Switch.astro";

interface Props {
  variant?: string;
}
const { variant = "Settings" } = Astro.props;
---

{variant === "Settings" && (
  <div class="card">
    <p class="title">Marketing automations</p>
    <div class="row">
      <div class="copy">
        <span class="name">Auto-send weekly report</span>
        <span class="hint">Email the GA4 and Ads summary every Monday.</span>
      </div>
      <Switch checked label="Auto-send weekly report" />
    </div>
    <div class="row">
      <div class="copy">
        <span class="name">Alert on new AI citations</span>
        <span class="hint">Post in Slack when ChatGPT cites a page.</span>
      </div>
      <Switch label="Alert on new AI citations" />
    </div>
    <div class="row">
      <div class="copy">
        <span class="name">Brand voice check</span>
        <span class="hint">Managed by the content lead.</span>
      </div>
      <Switch checked disabled label="Brand voice check" />
    </div>
  </div>
)}

{variant === "Compact" && (
  <div class="card compact">
    <p class="title">Citations table</p>
    <Switch size="sm" tone="accent" checked class="line" labelPosition="start">Show counts</Switch>
    <Switch size="sm" tone="accent" class="line" labelPosition="start">Hide competitors</Switch>
    <Switch size="sm" tone="accent" checked class="line" labelPosition="start">Group by engine</Switch>
  </div>
)}

<style>
  .card {
    max-width: 400px;
    margin: 0 auto;
    padding: 6px 18px;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }
  .compact {
    max-width: 260px;
    padding-bottom: 10px;
  }
  .title {
    margin: 10px 0 4px;
    font-size: 13px;
    font-weight: 600;
    color: var(--vs-ink);
  }
  .row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    padding: 12px 0;
  }
  .row + .row {
    border-top: 1px solid var(--vs-line);
  }
  .copy {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .name {
    font-size: 14px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .hint {
    font-size: 12.5px;
    color: var(--vs-ink-3);
  }
  .compact .line {
    display: flex;
    justify-content: space-between;
    padding: 6px 0;
    color: var(--vs-ink-2);
  }
</style>
```

## Variants

Demo variants: `Settings`, `Compact`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `checked` | `boolean` | `false` | Initial state. |
| `disabled` | `boolean` | `false` | Disable the switch. |
| `name / value / id` | `string` |  | Passed to the native checkbox. |
| `label` | `string` |  | Accessible name if no visible label. |
| `size` | `"md" \| "sm"` | `"md"` | md 40×24; sm 30×18 (24px hit area). |
| `tone` | `"ink" \| "accent"` | `"ink"` | Track color when on. |
| `labelPosition` | `"start" \| "end"` | `"end"` | Slotted label position. |
| `change` | `Event` |  | Native change event. Read event.target.checked. |

## Used by

[Records Table](https://viewership.ai/ui/components/records-table.md)

## Source

### `src/components/viewership/switch/Switch.astro`

```astro
---
/**
 * Switch: an on/off toggle on a native checkbox.
 *
 * Ported from Beautiful UI `components/atoms/Switch.tsx`
 * (MIT, © 2026 Shane Levine), plus the compact accent switch used by the
 * upstream RecordsTable (`size="sm" tone="accent"`).
 *
 * Built on `<input type="checkbox" role="switch">`: no script, Space toggles
 * it, it submits with forms, and it fires the native `change` event. The
 * default slot is the visible label; without one, pass `label`.
 *
 * @example
 * <Switch checked name="autoDraft">Auto-draft weekly report</Switch>
 */
import type { HTMLAttributes } from "astro/types";

type Props = Omit<HTMLAttributes<"label">, "for"> & {
  checked?: boolean;
  disabled?: boolean;
  name?: string;
  value?: string;
  /** id for the input (e.g. for aria-describedby targets elsewhere). */
  id?: string;
  /** Accessible name when there is no visible label in the slot. */
  label?: string;
  size?: "md" | "sm";
  /** Track color when on. */
  tone?: "ink" | "accent";
  /** Where the slotted label sits. */
  labelPosition?: "start" | "end";
};

const {
  checked = false,
  disabled = false,
  name,
  value,
  id,
  label,
  size = "md",
  tone = "ink",
  labelPosition = "end",
  class: className,
  ...rest
} = Astro.props;
const hasText = Astro.slots.has("default");
---

<label
  class:list={["vs-switch", `vs-switch--${size}`, `vs-switch--${tone}`, { "vs-switch--disabled": disabled }, className]}
  {...rest}
>
  {hasText && labelPosition === "start" && <span class="vs-switch__label"><slot /></span>}
  <span class="vs-switch__control">
    <input
      class="vs-switch__input"
      type="checkbox"
      role="switch"
      id={id}
      name={name}
      value={value}
      checked={checked}
      disabled={disabled}
      aria-label={hasText ? undefined : label}
    />
    <span class="vs-switch__track" aria-hidden="true"><span class="vs-switch__thumb"></span></span>
  </span>
  {hasText && labelPosition === "end" && <span class="vs-switch__label"><slot /></span>}
</label>

<style>
  .vs-switch {
    display: inline-flex;
    align-items: center;
    gap: 10px;
    font-size: 14px;
    color: var(--vs-ink);
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
  }
  .vs-switch--disabled {
    cursor: not-allowed;
    opacity: 0.5;
  }
  .vs-switch__control {
    position: relative;
    display: inline-flex;
    flex-shrink: 0;
  }
  /* The real checkbox sits invisibly over the track (and pads small sizes to a 24px target). */
  .vs-switch__input {
    position: absolute;
    inset: -3px;
    z-index: 1;
    width: auto;
    height: auto;
    margin: 0;
    opacity: 0;
    cursor: inherit;
  }
  .vs-switch__track {
    position: relative;
    display: block;
    width: var(--vs-switch-w);
    height: var(--vs-switch-h);
    border-radius: var(--vs-radius-pill);
    background: var(--vs-line-strong);
    transition: background-color var(--vs-switch-duration) ease-out;
  }
  .vs-switch__thumb {
    position: absolute;
    top: 2px;
    left: 2px;
    width: var(--vs-switch-thumb);
    height: var(--vs-switch-thumb);
    border-radius: var(--vs-radius-pill);
    background: white;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
    transition:
      transform var(--vs-switch-duration) var(--vs-ease-out),
      background-color var(--vs-switch-duration) ease-out;
  }
  .vs-switch__input:checked + .vs-switch__track .vs-switch__thumb {
    transform: translateX(calc(var(--vs-switch-w) - var(--vs-switch-thumb) - 4px));
  }
  .vs-switch__input:focus-visible + .vs-switch__track {
    box-shadow: var(--vs-focus-ring);
  }

  /* Sizes */
  .vs-switch--md {
    --vs-switch-w: 40px;
    --vs-switch-h: 24px;
    --vs-switch-thumb: 20px;
    --vs-switch-duration: 200ms;
  }
  .vs-switch--sm {
    --vs-switch-w: 30px;
    --vs-switch-h: 18px;
    --vs-switch-thumb: 14px;
    --vs-switch-duration: 150ms;
    gap: 8px;
    font-size: 13px;
  }
  .vs-switch--sm .vs-switch__thumb {
    box-shadow: var(--vs-shadow-btn);
  }

  /* Tones */
  .vs-switch--ink .vs-switch__input:checked + .vs-switch__track {
    background: var(--vs-ink);
  }
  /* Keeps the thumb readable on the light ink track in dark mode. */
  .vs-switch--ink .vs-switch__input:checked + .vs-switch__track .vs-switch__thumb {
    background: var(--vs-ink-inverse);
  }
  .vs-switch--accent .vs-switch__input:checked + .vs-switch__track {
    background: var(--vs-accent);
  }

  @media (prefers-reduced-motion: reduce) {
    .vs-switch__track,
    .vs-switch__thumb {
      transition: none;
    }
  }
  @media (forced-colors: active) {
    .vs-switch__track,
    .vs-switch__thumb {
      forced-color-adjust: none;
    }
    .vs-switch .vs-switch__track {
      background: Canvas;
      outline: 1px solid CanvasText;
    }
    .vs-switch .vs-switch__thumb,
    .vs-switch .vs-switch__input:checked + .vs-switch__track .vs-switch__thumb {
      background: CanvasText;
    }
    .vs-switch .vs-switch__input:checked + .vs-switch__track {
      background: Highlight;
    }
  }
</style>
```
