diff --git a/CHANGES.md b/CHANGES.md
index 1e3caf8d7d..681e81f020 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -9,7 +9,9 @@
### :heart: Community contributions (Thank you!)
### :sparkles: New features & Enhancements
+
- Select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320)
+- Toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341)
### :bug: Bugs fixed
diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc
index ea81ca238d..015246c005 100644
--- a/common/src/app/common/data.cljc
+++ b/common/src/app/common/data.cljc
@@ -1048,6 +1048,12 @@
(into [elem])
(into (subvec without-elem insert-pos)))))))
+(defn invert-map
+ "Returns a map with keys and values swapped.
+ If the input map has duplicate values, later entries overwrite earlier ones."
+ [m]
+ (into {} (map (fn [[k v]] [v k]) m)))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; String Functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/common/src/app/common/types/variant.cljc b/common/src/app/common/types/variant.cljc
index bbac03d24a..63acbd706a 100644
--- a/common/src/app/common/types/variant.cljc
+++ b/common/src/app/common/types/variant.cljc
@@ -310,3 +310,17 @@
the real name of the shape joined by the properties values separated by '/'"
[variant]
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
+
+(defn find-boolean-pair
+ "Given a vector, return the map from 'bool-values' that contains both as keys.
+ Returns nil if none match."
+ [v]
+ (let [bool-values [{"on" true "off" false}
+ {"yes" true "no" false}
+ {"true" true "false" false}]]
+ (when (= (count v) 2)
+ (some (fn [b]
+ (when (and (contains? b (first v))
+ (contains? b (last v)))
+ b))
+ bool-values))))
diff --git a/common/test/common_tests/variant_test.cljc b/common/test/common_tests/variant_test.cljc
index 05bc748703..85060cb241 100644
--- a/common/test/common_tests/variant_test.cljc
+++ b/common/test/common_tests/variant_test.cljc
@@ -159,3 +159,13 @@
(t/testing "update-number-in-repeated-prop-names"
(t/is (= (ctv/update-number-in-repeated-prop-names props) numbered-props)))))
+
+
+(t/deftest find-boolean-pair
+ (t/testing "find-boolean-pair"
+ (t/is (= (ctv/find-boolean-pair ["off" "on"]) {"on" true "off" false}))
+ (t/is (= (ctv/find-boolean-pair ["on" "off"]) {"on" true "off" false}))
+ (t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil))
+ (t/is (= (ctv/find-boolean-pair ["yes" "no"]) {"yes" true "no" false}))
+ (t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false}))
+ (t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil))))
diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index 0c17ed51b2..4144bc9d6c 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.switch :refer [switch*]]
[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*
+ :Switch switch*
:Combobox combobox*
:Text text*
:TabSwitcher tab-switcher*
diff --git a/frontend/src/app/main/ui/ds/_borders.scss b/frontend/src/app/main/ui/ds/_borders.scss
index 2c3ee5cb84..9fff3615ac 100644
--- a/frontend/src/app/main/ui/ds/_borders.scss
+++ b/frontend/src/app/main/ui/ds/_borders.scss
@@ -7,9 +7,10 @@
@use "ds/_utils.scss" as *;
// TODO: create actual tokens once we have them from design
-$br-8: px2rem(8);
$br-4: px2rem(4);
$br-6: px2rem(6);
+$br-8: px2rem(8);
+$br-12: px2rem(12);
$br-circle: 50%;
$b-1: px2rem(1);
diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss
index aa3d93bce6..d772dc41a9 100644
--- a/frontend/src/app/main/ui/ds/colors.scss
+++ b/frontend/src/app/main/ui/ds/colors.scss
@@ -79,6 +79,7 @@ $grayish-red: #bfbfbf;
--color-accent-select: #{$purple-600-10};
--color-accent-action: #{$purple-400};
--color-accent-action-hover: #{$purple-500};
+ --color-accent-off: #{$gray-50};
--color-accent-success: #{$green-500};
--color-background-success: #{$green-200};
@@ -92,6 +93,7 @@ $grayish-red: #bfbfbf;
--color-background-default: #{$white};
--color-accent-default: #{$gray-100};
--color-icon-default: #{$blue-teal-700};
+ --color-background-disabled: #{$gray-200};
--color-background-primary: #{$white};
--color-background-secondary: #{$gray-200};
@@ -105,6 +107,7 @@ $grayish-red: #bfbfbf;
--color-static-black: #{$black};
--color-shadow-dark: #{color.change($gray-200, $alpha: 0.6)};
+ --color-shadow-light: #{color.change($black, $alpha: 0.3)};
--color-overlay-default: #{$white-60};
--color-overlay-onboarding: #{$white-90};
--color-canvas: #{$grayish-red};
@@ -127,6 +130,7 @@ $grayish-red: #bfbfbf;
--color-accent-select: #{$mint-250-10};
--color-accent-action: #{$purple-400};
--color-accent-action-hover: #{$purple-500};
+ --color-accent-off: #{$gray-50};
--color-accent-success: #{$green-500};
--color-background-success: #{$green-950};
@@ -140,6 +144,7 @@ $grayish-red: #bfbfbf;
--color-background-default: #{$gray-950};
--color-accent-default: #{$gray-800};
--color-icon-default: #{$grayish-blue-500};
+ --color-background-disabled: #{$gray-800};
--color-background-primary: #{$gray-950};
--color-background-secondary: #{$black};
@@ -153,6 +158,7 @@ $grayish-red: #bfbfbf;
--color-static-black: #{$black};
--color-shadow-dark: #{color.change($black, $alpha: 0.6)};
+ --color-shadow-light: #{color.change($black, $alpha: 0.3)};
--color-overlay-default: #{$gray-950-60};
--color-overlay-onboarding: #{$gray-950-90};
--color-canvas: #{$grayish-red};
diff --git a/frontend/src/app/main/ui/ds/controls/switch.cljs b/frontend/src/app/main/ui/ds/controls/switch.cljs
new file mode 100644
index 0000000000..a9b7f96c85
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/switch.cljs
@@ -0,0 +1,78 @@
+;; 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.switch
+ (:require-macros
+ [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.util.dom :as dom]
+ [app.util.keyboard :as kbd]
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:switch
+ [:map
+ [:id {:optional true} :string]
+ [:class {:optional true} :string]
+ [:label {:optional true} [:maybe :string]]
+ [:aria-label {:optional true} [:maybe :string]]
+ [:default-checked {:optional true} [:maybe :boolean]]
+ [:on-change {:optional true} [:maybe fn?]]
+ [:disabled {:optional true} :boolean]])
+
+(mf/defc switch*
+ {::mf/schema schema:switch}
+ [{:keys [id class label aria-label default-checked on-change disabled] :rest props} ref]
+ (let [checked* (mf/use-state default-checked)
+ checked? (deref checked*)
+
+ disabled? (d/nilv disabled false)
+
+ has-label? (not (str/blank? label))
+
+ handle-toggle
+ (mf/use-fn
+ (mf/deps on-change checked? disabled?)
+ #(when-not disabled?
+ (let [updated-checked? (not checked?)]
+ (reset! checked* updated-checked?)
+ (when on-change
+ (on-change updated-checked?)))))
+
+ handle-keydown
+ (mf/use-fn
+ (mf/deps handle-toggle)
+ (fn [event]
+ (dom/prevent-default event)
+ (when-not disabled?
+ (when (or (kbd/space? event) (kbd/enter? event))
+ (handle-toggle event)))))
+
+ props
+ (mf/spread-props props {:ref ref
+ :role "switch"
+ :aria-label (when-not has-label?
+ aria-label)
+ :class [class (stl/css-case :switch true
+ :off (false? checked?)
+ :neutral (nil? checked?)
+ :on (true? checked?))]
+ :aria-checked checked?
+ :tab-index (if disabled? -1 0)
+ :on-click handle-toggle
+ :on-key-down handle-keydown
+ :disabled disabled?})]
+
+ [:> :div props
+ (when has-label?
+ [:label {:for id
+ :class (stl/css :switch-label)}
+ label])
+
+ [:div {:id id
+ :class (stl/css :switch-track)}
+ [:div {:class (stl/css :switch-thumb)}]]]))
diff --git a/frontend/src/app/main/ui/ds/controls/switch.mdx b/frontend/src/app/main/ui/ds/controls/switch.mdx
new file mode 100644
index 0000000000..e5720c9f20
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/switch.mdx
@@ -0,0 +1,52 @@
+{ /* 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/addon-docs/blocks';
+import * as Switch from "./switch.stories";
+
+
+
+# Switch
+
+The `switch*` component is a toggle control that allows users to switch between two mutually exclusive options.
+
+
+
+## Anatomy
+
+The switch component consists of three main parts:
+
+- **Label** (optional): text that describes what the switch controls
+- **Track**: the pill-shaped background that indicates the current state
+- **Thumb**: the circular knob that moves between positions
+
+## Accesibility
+
+When no visible label is provided, use `aria-label` for accessibility.
+
+## Variants
+
+### With Label
+
+```clj
+[:> switch* {:label "Toggle dark mode"
+ :default-checked false}]
+```
+
+
+
+## Usage Guidelines
+
+### When to Use
+
+- For binary settings that take effect immediately
+- In preference panels and configuration screens
+
+### 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
diff --git a/frontend/src/app/main/ui/ds/controls/switch.scss b/frontend/src/app/main/ui/ds/controls/switch.scss
new file mode 100644
index 0000000000..c5abbf3a00
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/switch.scss
@@ -0,0 +1,118 @@
+// 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/_sizes.scss" as *;
+@use "ds/_utils.scss" as *;
+@use "ds/colors.scss" as *;
+@use "ds/spacing.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;
+
+ &.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};
+ }
+
+ &.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};
+ }
+
+ &.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};
+ }
+
+ &[disabled] {
+ pointer-events: none;
+ --switch-label-foreground-color: var(--color-foreground-secondary);
+
+ --switch-track-shadow: none;
+
+ --switch-thumb-shadow: none;
+ }
+
+ &.off[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);
+ }
+
+ &.on[disabled],
+ &.neutral[disabled] {
+ --switch-track-background-color: var(--color-background-disabled);
+
+ --switch-thumb-background-color: var(--color-background-primary);
+ }
+
+ &:focus-visible {
+ --switch-track-outline-color: var(--color-accent-primary);
+ }
+
+ &: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/src/app/main/ui/ds/controls/switch.stories.jsx b/frontend/src/app/main/ui/ds/controls/switch.stories.jsx
new file mode 100644
index 0000000000..53593ca3d2
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/switch.stories.jsx
@@ -0,0 +1,65 @@
+// 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 { Switch } = Components;
+
+export default {
+ title: "Controls/Switch",
+ component: Switch,
+ argTypes: {
+ defaultChecked: {
+ control: { type: "boolean" },
+ description: "Default checked state for uncontrolled mode",
+ },
+ label: {
+ control: { type: "text" },
+ description: "Label text displayed next to the switch",
+ },
+ disabled: {
+ control: { type: "boolean" },
+ description: "Whether the switch is disabled",
+ }
+ },
+ args: {
+ defaultChecked: false,
+ disabled: false
+ },
+ parameters: {
+ controls: { exclude: ["id", "class", "aria-label", "on-change"] },
+ },
+ render: ({ ...args }) => (
+