Switch
A control that allows users to toggle between two states: on and off.
Demo
Accessibility Features
WAI-ARIA Roles
-
switch- An input widget that allows users to choose one of two values: on or off
WAI-ARIA switch role (opens in new tab)
WAI-ARIA States
aria-checked
Indicates the current checked state of the switch.
| Values | true | false |
| Required | Yes (for switch role) |
| Default | initialChecked prop (default: false) |
| Change Trigger | Click, Enter, Space |
| Reference | aria-checked (opens in new tab) |
aria-disabled
Indicates the switch is perceivable but disabled.
| Values | true | undefined |
| Required | No (only when disabled) |
| Reference | aria-disabled (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Space | Toggle the switch state (on/off) |
| Enter | Toggle the switch state (on/off) |
Accessible Naming
Switches must have an accessible name. This can be provided through:
- Visible label (recommended) - The switch's child content provides the accessible name
-
aria-label- Provides an invisible label for the switch -
aria-labelledby- References an external element as the label
Visual Design
This implementation follows WCAG 1.4.1 (Use of Color) by not relying solely on color to indicate state:
- Thumb position - Left = off, Right = on
- Checkmark icon - Visible only when the switch is on
- Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode
Source Code
Switch.svelte
<script lang="ts">
import type { Snippet } from "svelte";
import { untrack } from "svelte";
interface SwitchProps {
children?: string | Snippet<[]>;
initialChecked?: boolean;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
[key: string]: unknown;
}
let {
children,
initialChecked = false,
disabled = false,
onCheckedChange = (_) => {},
...restProps
}: SwitchProps = $props();
let checked = $state(untrack(() => initialChecked));
function toggle() {
if (disabled) return;
checked = !checked;
onCheckedChange(checked);
}
function handleClick() {
toggle();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
toggle();
}
}
</script>
<button
type="button"
role="switch"
aria-checked={checked}
aria-disabled={disabled || undefined}
class="apg-switch"
{disabled}
onclick={handleClick}
onkeydown={handleKeyDown}
{...restProps}
>
<span class="apg-switch-track">
<span class="apg-switch-icon" aria-hidden="true">
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z"
fill="currentColor"
/>
</svg>
</span>
<span class="apg-switch-thumb"></span>
</span>
{#if children}
<span class="apg-switch-label">
{#if typeof children === "string"}
{children}
{:else}
{@render children?.()}
{/if}
</span>
{/if}
</button> Usage
Example
<script>
import Switch from './Switch.svelte';
function handleChange(checked) {
console.log('Checked:', checked);
}
</script>
<Switch
initialChecked={false}
onCheckedChange={handleChange}
>
Enable notifications
</Switch> API
| Prop | Type | Default | Description |
|---|---|---|---|
initialChecked | boolean | false | Initial checked state |
onCheckedChange | (checked: boolean) => void | - | Callback when state changes |
disabled | boolean | false | Whether the switch is disabled |
children | Snippet | string | - | Switch label |
All other props are passed to the underlying <button> element.
Testing
Tests cover APG compliance including keyboard interactions, ARIA attributes, and accessibility validation.
Switch.test.svelte.ts
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { describe, expect, it, vi } from "vitest";
import Switch from "./Switch.svelte";
describe("Switch (Svelte)", () => {
// 🔴 High Priority: APG 準拠の核心
describe("APG: ARIA 属性", () => {
it('role="switch" を持つ', () => {
render(Switch, {
props: { children: "Wi-Fi" },
});
expect(screen.getByRole("switch")).toBeInTheDocument();
});
it('初期状態で aria-checked="false"', () => {
render(Switch, {
props: { children: "Wi-Fi" },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveAttribute("aria-checked", "false");
});
it('クリック後に aria-checked="true" に変わる', async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: "Wi-Fi" },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveAttribute("aria-checked", "false");
await user.click(switchEl);
expect(switchEl).toHaveAttribute("aria-checked", "true");
});
it('type="button" が設定されている', () => {
render(Switch, {
props: { children: "Wi-Fi" },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveAttribute("type", "button");
});
it("disabled 時に aria-disabled が設定される", () => {
render(Switch, {
props: { children: "Wi-Fi", disabled: true },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveAttribute("aria-disabled", "true");
});
it("disabled 状態で aria-checked 変更不可", async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: "Wi-Fi", disabled: true },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveAttribute("aria-checked", "false");
await user.click(switchEl);
expect(switchEl).toHaveAttribute("aria-checked", "false");
});
});
describe("APG: キーボード操作", () => {
it("Space キーでトグルする", async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: "Wi-Fi" },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveAttribute("aria-checked", "false");
switchEl.focus();
await user.keyboard(" ");
expect(switchEl).toHaveAttribute("aria-checked", "true");
});
it("Enter キーでトグルする", async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: "Wi-Fi" },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveAttribute("aria-checked", "false");
switchEl.focus();
await user.keyboard("{Enter}");
expect(switchEl).toHaveAttribute("aria-checked", "true");
});
it("disabled 時は Tab キースキップ", async () => {
const user = userEvent.setup();
const container = document.createElement("div");
document.body.appendChild(container);
const { unmount: unmount1 } = render(Switch, {
target: container,
props: { children: "Switch 1" },
});
const { unmount: unmount2 } = render(Switch, {
target: container,
props: { children: "Switch 2", disabled: true },
});
const { unmount: unmount3 } = render(Switch, {
target: container,
props: { children: "Switch 3" },
});
await user.tab();
expect(screen.getByRole("switch", { name: "Switch 1" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("switch", { name: "Switch 3" })).toHaveFocus();
unmount1();
unmount2();
unmount3();
document.body.removeChild(container);
});
it("disabled 時はキー操作無効", async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: "Wi-Fi", disabled: true },
});
const switchEl = screen.getByRole("switch");
switchEl.focus();
await user.keyboard(" ");
expect(switchEl).toHaveAttribute("aria-checked", "false");
});
});
// 🟡 Medium Priority: アクセシビリティ検証
describe("アクセシビリティ", () => {
it("axe による WCAG 2.1 AA 違反がない", async () => {
const { container } = render(Switch, {
props: { children: "Wi-Fi" },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("ラベル(children)でアクセシブルネームを持つ", () => {
render(Switch, {
props: { children: "Wi-Fi" },
});
expect(
screen.getByRole("switch", { name: "Wi-Fi" })
).toBeInTheDocument();
});
it("aria-label でアクセシブルネームを設定できる", () => {
render(Switch, {
props: { "aria-label": "Enable notifications" },
});
expect(
screen.getByRole("switch", { name: "Enable notifications" })
).toBeInTheDocument();
});
it("aria-labelledby で外部ラベルを参照できる", () => {
const container = document.createElement("div");
container.innerHTML = '<span id="switch-label">Bluetooth</span>';
document.body.appendChild(container);
render(Switch, {
target: container,
props: { "aria-labelledby": "switch-label" },
});
expect(
screen.getByRole("switch", { name: "Bluetooth" })
).toBeInTheDocument();
document.body.removeChild(container);
});
});
describe("Props", () => {
it("initialChecked=true で ON 状態でレンダリングされる", () => {
render(Switch, {
props: { children: "Wi-Fi", initialChecked: true },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveAttribute("aria-checked", "true");
});
it("onCheckedChange が状態変化時に呼び出される", async () => {
const handleCheckedChange = vi.fn();
const user = userEvent.setup();
render(Switch, {
props: { children: "Wi-Fi", onCheckedChange: handleCheckedChange },
});
await user.click(screen.getByRole("switch"));
expect(handleCheckedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole("switch"));
expect(handleCheckedChange).toHaveBeenCalledWith(false);
});
});
// 🟢 Low Priority: 拡張性
describe("HTML 属性継承", () => {
it("デフォルトで apg-switch クラスが設定される", () => {
render(Switch, {
props: { children: "Wi-Fi" },
});
const switchEl = screen.getByRole("switch");
expect(switchEl).toHaveClass("apg-switch");
});
it("data-* 属性が継承される", () => {
render(Switch, {
props: { children: "Wi-Fi", "data-testid": "custom-switch" },
});
expect(screen.getByTestId("custom-switch")).toBeInTheDocument();
});
});
});