diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts
index 679e7350eb..6bbc858162 100644
--- a/frontend/packages/ui/src/index.ts
+++ b/frontend/packages/ui/src/index.ts
@@ -47,3 +47,5 @@ export type {
HintMessageProps,
HintMessageType,
} from "./lib/controls/utilities/HintMessage";
+export { Switch } from "./lib/controls/Switch";
+export type { SwitchProps } from "./lib/controls/Switch";
diff --git a/frontend/packages/ui/src/lib/controls/Switch.module.scss b/frontend/packages/ui/src/lib/controls/Switch.module.scss
new file mode 100644
index 0000000000..eaf6cf1e36
--- /dev/null
+++ b/frontend/packages/ui/src/lib/controls/Switch.module.scss
@@ -0,0 +1,108 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+@use "../_ds/_borders.scss" as *;
+@use "../_ds/_utils.scss" as *;
+@use "../_ds/typography.scss" as t;
+
+.switch {
+ --switch-label-foreground-color: var(--color-foreground-primary);
+ --switch-track-outline-color: none;
+ --switch-track-shadow: inset 0 1px 2px var(--color-shadow-light);
+ --switch-thumb-shadow: 0 1px 2px var(--color-shadow-light);
+
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: var(--sp-s);
+ inline-size: fit-content;
+ outline: none;
+}
+
+.switch.off {
+ --switch-track-justify-content: start;
+ --switch-track-background-color: var(--color-foreground-secondary);
+ --switch-thumb-width: #{px2rem(14)};
+ --switch-thumb-height: #{px2rem(14)};
+ --switch-thumb-background-color: var(--color-accent-off);
+ --switch-thumb-border-radius: #{$br-circle};
+}
+
+.switch.neutral {
+ --switch-track-justify-content: center;
+ --switch-track-background-color: var(--color-accent-tertiary);
+ --switch-thumb-width: #{px2rem(14)};
+ --switch-thumb-height: #{px2rem(4)};
+ --switch-thumb-background-color: var(--color-accent-off);
+ --switch-thumb-border-radius: #{$br-8};
+}
+
+.switch.on {
+ --switch-track-justify-content: end;
+ --switch-track-background-color: var(--color-accent-tertiary);
+ --switch-thumb-width: #{px2rem(14)};
+ --switch-thumb-height: #{px2rem(14)};
+ --switch-thumb-background-color: var(--color-accent-off);
+ --switch-thumb-border-radius: #{$br-circle};
+}
+
+.switch[data-disabled] {
+ pointer-events: none;
+
+ --switch-label-foreground-color: var(--color-foreground-secondary);
+ --switch-track-shadow: none;
+ --switch-thumb-shadow: none;
+}
+
+.switch.off[data-disabled] {
+ --switch-track-background-color: var(--color-background-primary);
+ --switch-track-border-color: var(--color-background-disabled);
+ --switch-thumb-background-color: var(--color-background-disabled);
+}
+
+.switch.on[data-disabled],
+.switch.neutral[data-disabled] {
+ --switch-track-background-color: var(--color-background-disabled);
+ --switch-thumb-background-color: var(--color-background-primary);
+}
+
+.switch:focus-visible {
+ --switch-track-outline-color: var(--color-accent-primary);
+}
+
+.switch:hover {
+ --switch-thumb-background-color: var(--color-static-white);
+}
+
+.switch-label {
+ @include t.use-typography("body-small");
+
+ color: var(--switch-label-foreground-color);
+ user-select: none;
+}
+
+.switch-track {
+ display: flex;
+ align-items: center;
+ justify-content: var(--switch-track-justify-content);
+ inline-size: px2rem(30);
+ block-size: px2rem(18);
+ padding: var(--sp-xxs);
+ border-radius: $br-12;
+ border: $b-1 solid var(--switch-track-border-color);
+ outline: $b-1 solid var(--switch-track-outline-color);
+ outline-offset: $b-1;
+ box-shadow: var(--switch-track-shadow);
+ background-color: var(--switch-track-background-color);
+}
+
+.switch-thumb {
+ inline-size: var(--switch-thumb-width);
+ block-size: var(--switch-thumb-height);
+ border-radius: var(--switch-thumb-border-radius);
+ box-shadow: var(--switch-thumb-shadow);
+ background-color: var(--switch-thumb-background-color);
+}
diff --git a/frontend/packages/ui/src/lib/controls/Switch.spec.tsx b/frontend/packages/ui/src/lib/controls/Switch.spec.tsx
new file mode 100644
index 0000000000..45947c011c
--- /dev/null
+++ b/frontend/packages/ui/src/lib/controls/Switch.spec.tsx
@@ -0,0 +1,138 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { fireEvent, render } from "@testing-library/react";
+import { Switch } from "./Switch";
+
+describe("Switch", () => {
+ it("should render successfully", () => {
+ const { baseElement } = render();
+ expect(baseElement).toBeTruthy();
+ });
+
+ it("should render with role=switch", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el).toBeTruthy();
+ });
+
+ it("should reflect aria-checked=false when defaultChecked is false", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("aria-checked")).toBe("false");
+ });
+
+ it("should reflect aria-checked=true when defaultChecked is true", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("aria-checked")).toBe("true");
+ });
+
+ it("should toggle from false to true on click", () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const el = container.querySelector("[role='switch']") as HTMLElement;
+ fireEvent.click(el);
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it("should toggle from true to false on click", () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const el = container.querySelector("[role='switch']") as HTMLElement;
+ fireEvent.click(el);
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it("should toggle on Space key", () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const el = container.querySelector("[role='switch']") as HTMLElement;
+ fireEvent.keyDown(el, { key: " " });
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it("should toggle on Enter key", () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const el = container.querySelector("[role='switch']") as HTMLElement;
+ fireEvent.keyDown(el, { key: "Enter" });
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it("should not toggle when disabled", () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const el = container.querySelector("[role='switch']") as HTMLElement;
+ fireEvent.click(el);
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("should set tabIndex=-1 when disabled", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("tabindex")).toBe("-1");
+ });
+
+ it("should set aria-disabled when disabled", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("aria-disabled")).toBe("true");
+ });
+
+ it("should set tabIndex=0 when not disabled", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("tabindex")).toBe("0");
+ });
+
+ it("should render label when provided", () => {
+ const { container } = render();
+ const lbl = container.querySelector("label");
+ expect(lbl?.textContent).toBe("Toggle");
+ });
+
+ it("should not render label element when label is not provided", () => {
+ const { container } = render();
+ expect(container.querySelector("label")).toBeNull();
+ });
+
+ it("should use aria-label when no visible label", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("aria-label")).toBe("Toggle feature");
+ });
+
+ it("should not set aria-label on root when label prop is present", () => {
+ const { container } = render(
+ ,
+ );
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("aria-label")).toBeNull();
+ });
+
+ it("should merge className", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("class")).toContain("extra");
+ });
+
+ it("should pass through HTML attributes", () => {
+ const { container } = render();
+ const el = container.querySelector("[role='switch']");
+ expect(el?.getAttribute("data-testid")).toBe("sw");
+ });
+});
diff --git a/frontend/packages/ui/src/lib/controls/Switch.stories.tsx b/frontend/packages/ui/src/lib/controls/Switch.stories.tsx
new file mode 100644
index 0000000000..bb87f97ac2
--- /dev/null
+++ b/frontend/packages/ui/src/lib/controls/Switch.stories.tsx
@@ -0,0 +1,45 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Switch } from "./Switch";
+
+const meta = {
+ title: "Controls/Switch",
+ component: Switch,
+ args: {
+ label: "Enable feature",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const On: Story = {
+ args: { defaultChecked: true },
+};
+
+export const Off: Story = {
+ args: { defaultChecked: false },
+};
+
+export const Neutral: Story = {
+ args: { defaultChecked: null },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true, defaultChecked: false },
+};
+
+export const DisabledOn: Story = {
+ args: { disabled: true, defaultChecked: true },
+};
+
+export const NoLabel: Story = {
+ args: { label: undefined, "aria-label": "Toggle feature" },
+};
diff --git a/frontend/packages/ui/src/lib/controls/Switch.tsx b/frontend/packages/ui/src/lib/controls/Switch.tsx
new file mode 100644
index 0000000000..75659a9067
--- /dev/null
+++ b/frontend/packages/ui/src/lib/controls/Switch.tsx
@@ -0,0 +1,110 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import {
+ type ComponentPropsWithRef,
+ type KeyboardEvent,
+ memo,
+ useCallback,
+ useId,
+ useState,
+} from "react";
+import clsx from "clsx";
+import styles from "./Switch.module.scss";
+
+export interface SwitchProps extends Omit<
+ ComponentPropsWithRef<"div">,
+ "onChange" | "defaultChecked"
+> {
+ /** Text label rendered next to the track */
+ label?: string;
+ /** Accessible label used when there is no visible label */
+ "aria-label"?: string;
+ /** Initial checked state (uncontrolled). null = neutral/indeterminate. */
+ defaultChecked?: boolean | null;
+ /** Called with the new boolean state after each toggle */
+ onChange?: (checked: boolean) => void;
+ /** When true the switch cannot be interacted with */
+ disabled?: boolean;
+}
+
+function SwitchInner(
+ {
+ label,
+ "aria-label": ariaLabel,
+ defaultChecked = null,
+ onChange,
+ disabled = false,
+ className,
+ ...rest
+ }: SwitchProps,
+ ref: React.Ref,
+) {
+ const [checked, setChecked] = useState(
+ defaultChecked ?? null,
+ );
+ const trackId = useId();
+
+ const handleToggle = useCallback(() => {
+ if (disabled) return;
+ const next = !checked;
+ setChecked(next);
+ onChange?.(next);
+ }, [checked, disabled, onChange]);
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === " " || event.key === "Enter") {
+ event.preventDefault();
+ handleToggle();
+ }
+ },
+ [handleToggle],
+ );
+
+ const hasLabel = label != null && label.trim().length > 0;
+
+ const rootClass = clsx(
+ styles.switch,
+ {
+ [styles.off]: checked === false,
+ [styles.neutral]: checked === null,
+ [styles.on]: checked === true,
+ },
+ className,
+ );
+
+ return (
+
+
+ {hasLabel && (
+
+ )}
+
+ );
+}
+
+export const Switch = memo(
+ SwitchInner as (
+ props: SwitchProps & { ref?: React.Ref },
+ ) => React.ReactElement | null,
+);