diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index 0c17ed51b2..2645ced7e1 100644
--- a/frontend/src/app/main/ui/ds.cljs
+++ b/frontend/src/app/main/ui/ds.cljs
@@ -13,6 +13,7 @@
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.controls.select :refer [select*]]
+ [app.main.ui.ds.controls.switcher.switcher :refer [switcher*]]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
[app.main.ui.ds.controls.utilities.label :refer [label*]]
@@ -60,6 +61,7 @@
:Loader loader*
:RawSvg raw-svg*
:Select select*
+ :Switcher switcher*
:Combobox combobox*
:Text text*
:TabSwitcher tab-switcher*
diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs b/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs
new file mode 100644
index 0000000000..df56138855
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.cljs
@@ -0,0 +1,99 @@
+;; 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
+
+(ns app.main.ui.ds.controls.switcher.switcher
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:switcher
+ [:map
+ [:id {:optional true} :string]
+ [:label {:optional true} :string]
+ [:checked {:optional true} :boolean]
+ [:default-checked {:optional true} :boolean]
+ [:on-change {:optional true} [:maybe fn?]]
+ [:disabled {:optional true} :boolean]
+ [:size {:optional true} [:enum "sm" "md" "lg"]]
+ [:aria-label {:optional true} [:maybe :string]]
+ [:class {:optional true} :string]
+ [:data-testid {:optional true} :string]])
+
+(mf/defc switcher*
+ {::mf/forward-ref true
+ ::mf/schema schema:switcher}
+ [{:keys [id label checked default-checked on-change disabled size aria-label class data-testid] :rest props} ref]
+ (let [id (or id (mf/use-id))
+ size (keyword (d/nilv size "md"))
+ disabled (d/nilv disabled false)
+
+ ;; Internal state for uncontrolled mode
+ internal-checked* (mf/use-state (d/nilv default-checked false))
+ internal-checked (deref internal-checked*)
+
+ ;; Determine if controlled or uncontrolled
+ controlled? (some? checked)
+ current-checked (if controlled? checked internal-checked)
+
+ ;; Handle toggle
+ handle-toggle (mf/use-fn
+ (mf/deps controlled? current-checked on-change internal-checked*)
+ (fn [event]
+ (when-not disabled
+ (let [new-checked (not current-checked)]
+ (when-not controlled?
+ (reset! internal-checked* new-checked))
+ (when on-change
+ (on-change new-checked event))))))
+
+ ;; Handle keyboard events
+ handle-keydown (mf/use-fn
+ (mf/deps handle-toggle)
+ (fn [event]
+ (when (or (= (.-key event) " ") (= (.-key event) "Enter"))
+ (.preventDefault event)
+ (handle-toggle event))))
+
+ ;; Label click handler
+ handle-label-click (mf/use-fn
+ (mf/deps handle-toggle)
+ (fn [event]
+ (.preventDefault event)
+ (handle-toggle event)))
+
+ has-label (not (str/blank? label))
+ effective-aria-label (or aria-label (when-not has-label "Toggle switch"))]
+
+ [:div {:class (dm/str class " " (stl/css-case :switcher-wrapper true))}
+ (when has-label
+ [:label {:for id
+ :class (stl/css :switcher-label)
+ :on-click handle-label-click}
+ label])
+ [:div {:id id
+ :ref ref
+ :role "switch"
+ :tabIndex (if disabled -1 0)
+ :aria-checked current-checked
+ :aria-disabled disabled
+ :aria-label effective-aria-label
+ :data-testid data-testid
+ :class (stl/css-case :switcher true
+ :is-checked current-checked
+ :is-disabled disabled
+ :switcher--sm (= size :sm)
+ :switcher--md (= size :md)
+ :switcher--lg (= size :lg))
+ :on-click handle-toggle
+ :on-key-down handle-keydown}
+ [:div {:class (stl/css :switcher__track)}
+ [:div {:class (stl/css :switcher__thumb)}]]]]))
+
+;; Export as default
+(def switcher switcher*)
diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx b/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx
new file mode 100644
index 0000000000..e95084bf7c
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.mdx
@@ -0,0 +1,159 @@
+{ /* 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 { Canvas, Meta } from '@storybook/blocks';
+import * as SwitcherStories from "./switcher.stories";
+
+
+
+# Switcher
+
+The `switcher*` component is a toggle control that allows users to switch between two states (on/off). It provides both controlled and uncontrolled modes, making it suitable for various use cases across the interface.
+
+
+
+## Anatomy
+
+The switcher component consists of three main parts:
+
+- **Label** (optional): Text that describes what the switcher controls
+- **Track**: The pill-shaped background that indicates the current state
+- **Thumb**: The circular knob that moves between positions
+
+## Props
+
+| Name | Type | Default | Description | Required |
+|------|------|---------|-------------|----------|
+| `id` | `string` | auto-generated | Unique identifier for the switcher | No |
+| `label` | `string` | - | Text label displayed next to the switcher | No |
+| `checked` | `boolean` | - | Controlled checked state | No |
+| `default-checked` | `boolean` | `false` | Initial checked state for uncontrolled mode | No |
+| `on-change` | `function` | - | Callback fired when state changes `(new-checked, event) => void` | No |
+| `disabled` | `boolean` | `false` | Whether the switcher is disabled | No |
+| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Size variant of the switcher | No |
+| `aria-label` | `string` | - | Accessible label when no visible label is provided | No |
+| `class` | `string` | - | Additional CSS classes | No |
+| `data-testid` | `string` | - | Test identifier for automated testing | No |
+
+## Usage Examples
+
+### Controlled vs Uncontrolled
+
+#### Uncontrolled (manages its own state)
+```clj
+[:> switcher* {:label "Enable notifications"
+ :default-checked false
+ :on-change (fn [checked event]
+ (js/console.log "New state:" checked))}]
+```
+
+#### Controlled (state managed externally)
+```clj
+(let [checked (r/atom false)]
+ [:> switcher* {:label "Enable notifications"
+ :checked @checked
+ :on-change (fn [new-checked event]
+ (reset! checked new-checked))}])
+```
+
+
+
+### Different Sizes
+
+The switcher supports three size variants: `"sm"`, `"md"` (default), and `"lg"`.
+
+```clj
+[:> switcher* {:size "sm" :label "Small"}]
+[:> switcher* {:size "md" :label "Medium"}]
+[:> switcher* {:size "lg" :label "Large"}]
+```
+
+
+
+### Disabled State
+
+Disabled switchers are non-interactive and visually distinct.
+
+```clj
+[:> switcher* {:label "Disabled feature"
+ :disabled true
+ :default-checked false}]
+```
+
+
+
+### Without Visible Label
+
+When no visible label is provided, use `aria-label` for accessibility.
+
+```clj
+[:> switcher* {:aria-label "Toggle dark mode"
+ :default-checked false}]
+```
+
+
+
+## Accessibility
+
+The switcher component follows accessibility best practices:
+
+- **Role**: Uses `role="switch"` for proper semantic meaning
+- **Keyboard Support**:
+ - `Tab` to focus the switcher
+ - `Space` or `Enter` to toggle the state
+- **ARIA Attributes**:
+ - `aria-checked` reflects the current state
+ - `aria-disabled` when disabled
+ - `aria-label` for screen readers when no visible label
+- **Focus Management**: Visible focus ring for keyboard navigation
+
+
+
+## Design Tokens
+
+The switcher component uses the following design system tokens:
+
+### Colors
+- **OFF state track**: `--color-background-quaternary` (muted gray)
+- **ON state track**: `--color-accent-success` (green/teal)
+- **Thumb**: `--color-background-primary` (white/light)
+- **Label**: `--color-foreground-primary`
+- **Focus ring**: `--color-accent-primary`
+
+### Sizes
+- **Small**: Track 32×16px, Thumb 12px
+- **Medium**: Track 40×24px, Thumb 16px
+- **Large**: Track 48×28px, Thumb 24px
+
+### Spacing
+- **Label gap**: `--sp-s` (8px)
+
+## Usage Guidelines
+
+### When to Use
+
+- For binary settings that take effect immediately
+- To enable/disable features or functionality
+- In preference panels and configuration screens
+- For toggling between two distinct states
+
+### When Not to Use
+
+- For actions that require confirmation (use buttons instead)
+- For multiple choice selections (use radio buttons or select)
+- For temporary states that need explicit "Apply" action
+
+### Best Practices
+
+- Always provide clear labels that describe what will happen when toggled
+- Use consistent sizing within the same interface area
+- Consider the immediate impact of the toggle action
+- Group related switchers logically
+- Provide feedback for state changes when appropriate
+
+## Interactive Example
+
+
diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss b/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss
new file mode 100644
index 0000000000..8af4ff08a1
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.scss
@@ -0,0 +1,197 @@
+// 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/colors.scss" as *;
+@use "ds/_sizes.scss" as *;
+@use "ds/_borders.scss" as *;
+@use "ds/spacing.scss" as *;
+
+$switcher-sm-track-width: $sz-32;
+$switcher-sm-track-height: $sz-16;
+$switcher-sm-thumb-size: $sz-12;
+
+$switcher-md-track-width: $sz-40;
+$switcher-md-track-height: $sz-24;
+$switcher-md-thumb-size: $sz-16;
+
+$switcher-lg-track-width: $sz-48;
+$switcher-lg-track-height: $sz-28;
+$switcher-lg-thumb-size: $sz-24;
+
+$switcher-transition-duration: 0.2s;
+
+.switcher-wrapper {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+}
+
+.switcher-label {
+ color: var(--color-foreground-primary);
+ cursor: pointer;
+ user-select: none;
+
+ &:hover {
+ color: var(--color-foreground-primary);
+ }
+}
+
+.switcher {
+ position: relative;
+ display: inline-block;
+ cursor: pointer;
+ outline: none;
+ border: none;
+ background: transparent;
+ padding: 0;
+
+ // Focus ring using DS tokens - teal border around track
+ &:focus-visible {
+ .switcher__track {
+ outline: $b-2 solid var(--color-accent-tertiary);
+ outline-offset: $b-2;
+ }
+ }
+
+ // Disabled state
+ &.is-disabled {
+ cursor: not-allowed;
+
+ .switcher-label {
+ cursor: not-allowed;
+ color: var(--color-foreground-secondary);
+ }
+
+ .switcher__track {
+ background-color: var(--color-background-tertiary);
+ opacity: 0.6;
+ }
+
+ &.is-checked .switcher__track {
+ background-color: var(--color-background-quaternary);
+ opacity: 0.6;
+ }
+
+ .switcher__thumb {
+ background-color: var(--color-foreground-secondary);
+ opacity: 0.5;
+ }
+
+ &.is-checked .switcher__thumb {
+ background-color: var(--color-foreground-secondary);
+ opacity: 0.6;
+ }
+ }
+
+ // Size variants - Medium (default)
+ &.switcher--md {
+ .switcher__track {
+ width: $switcher-md-track-width;
+ height: $switcher-md-track-height;
+ }
+
+ .switcher__thumb {
+ width: $switcher-md-thumb-size;
+ height: $switcher-md-thumb-size;
+ top: calc((#{$switcher-md-track-height} - #{$switcher-md-thumb-size}) / 2);
+ left: calc((#{$switcher-md-track-height} - #{$switcher-md-thumb-size}) / 2);
+ }
+
+ &.is-checked .switcher__thumb {
+ transform: translateX(calc(#{$switcher-md-track-width} - #{$switcher-md-track-height}));
+ }
+ }
+
+ // Size variants - Small
+ &.switcher--sm {
+ .switcher__track {
+ width: $switcher-sm-track-width;
+ height: $switcher-sm-track-height;
+ }
+
+ .switcher__thumb {
+ width: $switcher-sm-thumb-size;
+ height: $switcher-sm-thumb-size;
+ top: calc((#{$switcher-sm-track-height} - #{$switcher-sm-thumb-size}) / 2);
+ left: calc((#{$switcher-sm-track-height} - #{$switcher-sm-thumb-size}) / 2);
+ }
+
+ &.is-checked .switcher__thumb {
+ transform: translateX(calc(#{$switcher-sm-track-width} - #{$switcher-sm-track-height}));
+ }
+ }
+
+ // Size variants - Large
+ &.switcher--lg {
+ .switcher__track {
+ width: $switcher-lg-track-width;
+ height: $switcher-lg-track-height;
+ }
+
+ .switcher__thumb {
+ width: $switcher-lg-thumb-size;
+ height: $switcher-lg-thumb-size;
+ top: calc((#{$switcher-lg-track-height} - #{$switcher-lg-thumb-size}) / 2);
+ left: calc((#{$switcher-lg-track-height} - #{$switcher-lg-thumb-size}) / 2);
+ }
+
+ &.is-checked .switcher__thumb {
+ transform: translateX(calc(#{$switcher-lg-track-width} - #{$switcher-lg-track-height}));
+ }
+ }
+}
+
+.switcher__track {
+ position: relative;
+ border-radius: 9999px;
+ background-color: var(--color-background-quaternary);
+ transition: background-color $switcher-transition-duration ease-in-out;
+
+ // ON state - use success/teal color from DS
+ .switcher.is-checked & {
+ background-color: var(--color-accent-success);
+ }
+
+ // Hover states (only when not disabled)
+ .switcher:not(.is-disabled):hover:not(.is-checked) & {
+ background-color: var(--color-background-quaternary);
+ }
+
+ .switcher:not(.is-disabled):hover.is-checked & {
+ background-color: var(--color-accent-tertiary);
+ }
+}
+
+.switcher__thumb {
+ position: absolute;
+ border-radius: 50%;
+ background-color: var(--color-foreground-secondary);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
+ transition: transform $switcher-transition-duration ease-in-out, background-color $switcher-transition-duration ease-in-out;
+
+ // ON state - contrasting thumb (white in dark theme, dark in light theme)
+ .switcher.is-checked & {
+ background-color: var(--color-foreground-primary);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
+ }
+
+ // Hover states - contrasting thumb for OFF state
+ .switcher:not(.is-disabled):hover:not(.is-checked) & {
+ background-color: var(--color-foreground-primary);
+ }
+
+ // Hover states - keep contrasting thumb for ON state
+ .switcher:not(.is-disabled):hover.is-checked & {
+ background-color: var(--color-foreground-primary);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .switcher__track,
+ .switcher__thumb {
+ transition: none;
+ }
+}
diff --git a/frontend/src/app/main/ui/ds/controls/switcher/switcher.stories.jsx b/frontend/src/app/main/ui/ds/controls/switcher/switcher.stories.jsx
new file mode 100644
index 0000000000..1afda9e9b7
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/switcher/switcher.stories.jsx
@@ -0,0 +1,228 @@
+// 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 * as React from "react";
+import Components from "@target/components";
+
+const { Switcher } = Components;
+
+export default {
+ title: "DS/Controls/Switcher",
+ component: Components.Switcher,
+ argTypes: {
+ checked: {
+ control: { type: "boolean" },
+ description: "Controlled checked state",
+ },
+ defaultChecked: {
+ control: { type: "boolean" },
+ description: "Default checked state for uncontrolled mode",
+ },
+ label: {
+ control: { type: "text" },
+ description: "Label text displayed next to the switcher",
+ },
+ disabled: {
+ control: { type: "boolean" },
+ description: "Whether the switcher is disabled",
+ },
+ size: {
+ options: ["sm", "md", "lg"],
+ control: { type: "select" },
+ description: "Size variant of the switcher",
+ },
+ "aria-label": {
+ control: { type: "text" },
+ description: "Accessible label when no visible label is provided",
+ },
+ onChange: {
+ action: "changed",
+ description: "Callback fired when the switcher state changes",
+ },
+ },
+ args: {
+ label: "Enable notifications",
+ disabled: false,
+ size: "md",
+ defaultChecked: false,
+ },
+ parameters: {
+ controls: { exclude: ["id", "class", "dataTestid"] },
+ },
+ render: ({ onChange, ...args }) => (
+
+ Try using Tab to focus and Space/Enter to toggle: +
++ Screen reader accessible (no visible label): +
+