diff --git a/packages/switch/README.md b/packages/switch/README.md
new file mode 100644
index 000000000..522c3a160
--- /dev/null
+++ b/packages/switch/README.md
@@ -0,0 +1,29 @@
+# Switch
+
+[//]: # 'AUTO INSERT HEADER PREPUBLISH'
+
+`lion-input-switch` is a component that is used to toggle a property or feature on or off.
+
+## Features
+
+- Get or set the checked state (boolean) - `choiceChecked()`
+- Get or set the value of the choice - `choiceValue()`
+- Pre-select an option by setting the `checked` boolean attribute
+
+## How to use
+
+### Installation
+
+```sh
+npm i --save @lion/switch
+```
+
+```js
+import '@lion/swith/lion-input-switch.js';
+```
+
+### Example
+
+```html
+
+```
diff --git a/packages/switch/index.js b/packages/switch/index.js
new file mode 100644
index 000000000..5bc463170
--- /dev/null
+++ b/packages/switch/index.js
@@ -0,0 +1,2 @@
+export { LionInputSwitch } from './src/LionInputSwitch.js';
+export { LionButtonSwitch } from './src/LionButtonSwitch.js';
diff --git a/packages/switch/lion-button-switch.js b/packages/switch/lion-button-switch.js
new file mode 100644
index 000000000..6f3254fcf
--- /dev/null
+++ b/packages/switch/lion-button-switch.js
@@ -0,0 +1,3 @@
+import { LionButtonSwitch } from './src/LionButtonSwitch.js';
+
+customElements.define('lion-button-switch', LionButtonSwitch);
diff --git a/packages/switch/lion-input-switch.js b/packages/switch/lion-input-switch.js
new file mode 100644
index 000000000..93780c618
--- /dev/null
+++ b/packages/switch/lion-input-switch.js
@@ -0,0 +1,3 @@
+import { LionInputSwitch } from './src/LionInputSwitch.js';
+
+customElements.define('lion-input-switch', LionInputSwitch);
diff --git a/packages/switch/package.json b/packages/switch/package.json
new file mode 100644
index 000000000..671b620f0
--- /dev/null
+++ b/packages/switch/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@lion/switch",
+ "version": "0.0.1",
+ "description": "A Switch is used for switching a property or feature on and off",
+ "author": "ing-bank",
+ "homepage": "https://github.com/ing-bank/lion/",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ing-bank/lion.git",
+ "directory": "packages/switch"
+ },
+ "scripts": {
+ "prepublishOnly": "../../scripts/npm-prepublish.js"
+ },
+ "keywords": [
+ "lion",
+ "web-components",
+ "switch"
+ ],
+ "main": "index.js",
+ "module": "index.js",
+ "files": [
+ "docs",
+ "src",
+ "stories",
+ "test",
+ "translations",
+ "*.js"
+ ],
+ "dependencies": {
+ "@lion/button": "^0.3.29",
+ "@lion/choice-input": "^0.2.39",
+ "@lion/core": "^0.2.1",
+ "@lion/field": "^0.3.3"
+ },
+ "devDependencies": {
+ "@lion/form": "^0.1.68",
+ "@lion/localize": "^0.4.20",
+ "@lion/validate": "^0.2.37",
+ "@open-wc/demoing-storybook": "^0.2.0",
+ "@open-wc/testing": "^2.3.4",
+ "sinon": "^7.2.2"
+ }
+}
diff --git a/packages/switch/src/LionButtonSwitch.js b/packages/switch/src/LionButtonSwitch.js
new file mode 100644
index 000000000..00f6c2b45
--- /dev/null
+++ b/packages/switch/src/LionButtonSwitch.js
@@ -0,0 +1,105 @@
+import { LionButton } from '@lion/button';
+import { html, css } from '@lion/core';
+
+export class LionButtonSwitch extends LionButton {
+ static get properties() {
+ return {
+ checked: {
+ type: Boolean,
+ reflect: true,
+ },
+ };
+ }
+
+ static get styles() {
+ return [
+ ...super.styles,
+ css`
+ :host {
+ display: inline-block;
+ position: relative;
+ width: 36px;
+ height: 16px;
+ /* Override "button" styles */
+ min-height: auto;
+ padding: 0;
+ }
+
+ .btn {
+ height: 100%;
+ /* Override "button" styles */
+ min-height: auto;
+ padding: 0;
+ }
+
+ .button-switch__track {
+ background: #eee;
+ width: 100%;
+ height: 100%;
+ }
+
+ .button-switch__thumb {
+ background: #ccc;
+ width: 50%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ }
+
+ :host([checked]) .button-switch__thumb {
+ right: 0;
+ }
+ `,
+ ];
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ _renderBefore() {
+ return html`
+
+
+ `;
+ }
+
+ constructor() {
+ super();
+ this.role = 'switch';
+ this.checked = false;
+ this.addEventListener('click', this.__handleToggleStateChange);
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute('aria-checked', `${this.checked}`);
+ }
+
+ firstUpdated(changedProperties) {
+ super.firstUpdated(changedProperties);
+ this.removeAttribute('type');
+ }
+
+ __handleToggleStateChange() {
+ if (this.disabled) {
+ return;
+ }
+ this.checked = !this.checked;
+ this.dispatchEvent(
+ new Event('checked-changed', {
+ composed: true,
+ bubbles: true,
+ }),
+ );
+ }
+
+ /**
+ * We synchronously update aria-checked to support voice over on safari.
+ *
+ * @override
+ */
+ _requestUpdate(name, oldValue) {
+ super._requestUpdate(name, oldValue);
+ if (this.isConnected && name === 'checked') {
+ this.setAttribute('aria-checked', `${this.checked}`);
+ }
+ }
+}
diff --git a/packages/switch/src/LionInputSwitch.js b/packages/switch/src/LionInputSwitch.js
new file mode 100644
index 000000000..9ba3fbe22
--- /dev/null
+++ b/packages/switch/src/LionInputSwitch.js
@@ -0,0 +1,61 @@
+import { html, css } from '@lion/core';
+import { LionField } from '@lion/field';
+import { ChoiceInputMixin } from '@lion/choice-input';
+
+import '../lion-button-switch.js';
+
+export class LionInputSwitch extends ChoiceInputMixin(LionField) {
+ static get styles() {
+ return [
+ super.styles,
+ css`
+ :host([disabled]) {
+ color: #adadad;
+ }
+ `,
+ ];
+ }
+
+ get slots() {
+ return {
+ ...super.slots,
+ input: () => document.createElement('lion-button-switch'),
+ };
+ }
+
+ render() {
+ return html`
+
+
+
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.inputElement.addEventListener(
+ 'checked-changed',
+ this.__handleButtonSwitchCheckedChanged.bind(this),
+ );
+ this._syncButtonSwitch();
+ }
+
+ updated(changedProperties) {
+ super.updated(changedProperties);
+ this._syncButtonSwitch();
+ }
+
+ __handleButtonSwitchCheckedChanged() {
+ // TODO: should be replaced by "_inputNode" after the next breaking change
+ // https://github.com/ing-bank/lion/blob/master/packages/field/src/FormControlMixin.js#L78
+ this.checked = this.inputElement.checked;
+ }
+
+ _syncButtonSwitch() {
+ this.inputElement.checked = this.checked;
+ this.inputElement.disabled = this.disabled;
+ }
+}
diff --git a/packages/switch/stories/index.stories.js b/packages/switch/stories/index.stories.js
new file mode 100644
index 000000000..431aaa719
--- /dev/null
+++ b/packages/switch/stories/index.stories.js
@@ -0,0 +1,86 @@
+import { storiesOf, html } from '@open-wc/demoing-storybook';
+import { LitElement } from '@lion/core';
+
+import { LocalizeMixin } from '@lion/localize';
+
+import '../lion-input-switch.js';
+import '@lion/form/lion-form.js';
+
+storiesOf('Forms|Switch', module)
+ .add(
+ 'All text slots',
+ () => html`
+
+ `,
+ )
+ .add(
+ 'Disabled',
+ () => html`
+
+ `,
+ )
+ .add('Validation', () => {
+ const isTrue = value => value && value.checked && value.checked === true;
+ const isTrueValidator = (...factoryParams) => [
+ (...params) => ({
+ isTrue: isTrue(...params),
+ }),
+ ...factoryParams,
+ ];
+ const tagName = 'lion-switch-validation-demo';
+ if (!customElements.get(tagName)) {
+ customElements.define(
+ tagName,
+ class extends LocalizeMixin(LitElement) {
+ static get localizeNamespaces() {
+ const result = [
+ {
+ 'lion-validate+isTrue': () =>
+ Promise.resolve({
+ info: {
+ isTrue: 'You will not get the latest news!',
+ },
+ }),
+ },
+ ...super.localizeNamespaces,
+ ];
+ return result;
+ }
+
+ render() {
+ return html`
+
+
+
+ `;
+ }
+
+ submit() {
+ const form = this.shadowRoot.querySelector('#postsForm');
+ if (form.errorState === false) {
+ console.log(form.serializeGroup());
+ }
+ }
+ },
+ );
+ }
+ return html`
+
+ `;
+ });
diff --git a/packages/switch/test/lion-button-switch.test.js b/packages/switch/test/lion-button-switch.test.js
new file mode 100644
index 000000000..77a9fd02b
--- /dev/null
+++ b/packages/switch/test/lion-button-switch.test.js
@@ -0,0 +1,114 @@
+import { expect, fixture, html } from '@open-wc/testing';
+import sinon from 'sinon';
+
+import { LionButton } from '@lion/button';
+
+import '../lion-button-switch.js';
+
+describe('lion-button-switch', () => {
+ let el;
+ beforeEach(async () => {
+ el = await fixture(html`
+
+ `);
+ });
+
+ it('should behave like a button', () => {
+ expect(el instanceof LionButton).to.be.true;
+ });
+
+ it('should be focusable', () => {
+ expect(el.tabIndex).to.equal(0);
+ expect(el.getAttribute('tabindex')).to.equal('0');
+ });
+
+ it('should not have a [type]', () => {
+ expect(el.hasAttribute('type')).to.be.false;
+ });
+
+ it('should have checked=false by default', () => {
+ expect(el.checked).to.equal(false);
+ expect(el.hasAttribute('checked')).to.be.false;
+ });
+
+ it('should toggle the value of "checked" on click', async () => {
+ expect(el.checked).to.be.false;
+ expect(el.hasAttribute('checked')).to.be.false;
+ el.click();
+ await el.updateComplete;
+ expect(el.checked).to.be.true;
+ expect(el.hasAttribute('checked')).to.be.true;
+ el.click();
+ await el.updateComplete;
+ expect(el.checked).to.be.false;
+ expect(el.hasAttribute('checked')).to.be.false;
+ });
+
+ it('can be disabled', async () => {
+ el.disabled = true;
+ expect(el.checked).to.be.false;
+ el.click();
+ await el.updateComplete;
+ expect(el.checked).to.be.false;
+ expect(el.hasAttribute('checked')).to.be.false;
+ el.disabled = true;
+ el.checked = true;
+ el.click();
+ await el.updateComplete;
+ expect(el.checked).to.be.true;
+ expect(el.hasAttribute('checked')).to.be.true;
+ });
+
+ it('should dispatch "checked-changed" event when toggled', () => {
+ const handlerSpy = sinon.spy();
+ el.addEventListener('checked-changed', handlerSpy);
+ el.click();
+ el.click();
+ expect(handlerSpy.callCount).to.equal(2);
+ const checkCall = call => {
+ expect(call.args).to.have.a.lengthOf(1);
+ const e = call.args[0];
+ expect(e).to.be.an.instanceof(Event);
+ expect(e.bubbles).to.be.true;
+ expect(e.composed).to.be.true;
+ };
+ checkCall(handlerSpy.getCall(0), true);
+ checkCall(handlerSpy.getCall(1), false);
+ });
+
+ it('should not dispatch "checked-changed" event if disabled', () => {
+ const handlerSpy = sinon.spy();
+ el.disabled = true;
+ el.addEventListener('checked-changed', handlerSpy);
+ el.click();
+ expect(handlerSpy.called).to.be.false;
+ });
+
+ describe('a11y', () => {
+ it('should manage "aria-checked"', async () => {
+ expect(el.hasAttribute('aria-checked')).to.be.true;
+ expect(el.getAttribute('aria-checked')).to.equal('false');
+
+ el.click();
+ await el.updateComplete;
+ expect(el.getAttribute('aria-checked')).to.equal('true');
+ el.click();
+ await el.updateComplete;
+ expect(el.getAttribute('aria-checked')).to.equal('false');
+
+ el.checked = true;
+ await el.updateComplete;
+ expect(el.getAttribute('aria-checked')).to.equal('true');
+ el.checked = false;
+ await el.updateComplete;
+ expect(el.getAttribute('aria-checked')).to.equal('false');
+
+ el.setAttribute('checked', true);
+ await el.updateComplete;
+ expect(el.getAttribute('aria-checked')).to.equal('true');
+ el.removeAttribute('checked');
+ await el.updateComplete;
+ expect(el.getAttribute('aria-checked')).to.equal('false');
+ });
+ });
+});
diff --git a/packages/switch/test/lion-input-switch.test.js b/packages/switch/test/lion-input-switch.test.js
new file mode 100644
index 000000000..558aeca8f
--- /dev/null
+++ b/packages/switch/test/lion-input-switch.test.js
@@ -0,0 +1,70 @@
+import { expect, fixture, html } from '@open-wc/testing';
+
+import '../lion-input-switch.js';
+
+describe('lion-input-switch', () => {
+ it('should have default "input" element', async () => {
+ const el = await fixture(html`
+
+ `);
+ expect(el.querySelector('[slot="input"]')).not.to.be.null;
+ });
+
+ it('should sync its "disabled" state to child button', async () => {
+ const el = await fixture(html`
+
+ `);
+ expect(el.inputElement.disabled).to.be.true;
+ expect(el.inputElement.hasAttribute('disabled')).to.be.true;
+ el.disabled = false;
+ await el.updateComplete;
+ expect(el.inputElement.disabled).to.be.false;
+ expect(el.inputElement.hasAttribute('disabled')).to.be.false;
+ });
+
+ it('should sync its "checked" state to child button', async () => {
+ const uncheckedEl = await fixture(html`
+
+ `);
+ const checkedEl = await fixture(html`
+
+ `);
+ expect(uncheckedEl.inputElement.checked).to.be.false;
+ expect(checkedEl.inputElement.checked).to.be.true;
+ uncheckedEl.checked = true;
+ checkedEl.checked = false;
+ await uncheckedEl.updateComplete;
+ await checkedEl.updateComplete;
+ expect(uncheckedEl.inputElement.checked).to.be.true;
+ expect(checkedEl.inputElement.checked).to.be.false;
+ });
+
+ it('should sync "checked" state received from child button', async () => {
+ const el = await fixture(html`
+
+ `);
+ const button = el.inputElement;
+ expect(el.checked).to.be.false;
+ button.click();
+ expect(el.checked).to.be.true;
+ button.click();
+ expect(el.checked).to.be.false;
+ });
+
+ it('synchronizes modelValue to checked state and vice versa', async () => {
+ const el = await fixture(html`
+
+ `);
+ expect(el.checked).to.be.false;
+ expect(el.modelValue).to.deep.equal({
+ checked: false,
+ value: 'foo',
+ });
+ el.checked = true;
+ expect(el.checked).to.be.true;
+ expect(el.modelValue).to.deep.equal({
+ checked: true,
+ value: 'foo',
+ });
+ });
+});
diff --git a/stories/index.stories.js b/stories/index.stories.js
index bd8ebdfa8..486827adc 100755
--- a/stories/index.stories.js
+++ b/stories/index.stories.js
@@ -30,3 +30,4 @@ import '../packages/calendar/stories/index.stories.js';
import '../packages/option/stories/index.stories.js';
import '../packages/select-rich/stories/index.stories.js';
+import '../packages/switch/stories/index.stories.js';
diff --git a/yarn.lock b/yarn.lock
index b1b7a4fa0..0fd591894 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2012,7 +2012,7 @@
resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.14.2.tgz#4ad2a6fedc22992c6048729fbf7104e472f590d3"
integrity sha512-+ENGbkgoruTtuNGUVLi9hCC6+IJVBM/lnFDGjKBUt7T6RyAgidBTV+GPMzpQtfHhLRasVJmcjRW8D2him8HvAA==
-"@open-wc/testing-helpers@^1.2.1":
+"@open-wc/testing-helpers@^1.0.0", "@open-wc/testing-helpers@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-1.2.1.tgz#eecba5ccfe808f9667caf149e68cd80d781f28e0"
integrity sha512-FZBjqM81GQc+Q8W4YdWNwwk64+PW6frCvHyeov5ivCR2K7SJXwTPRcQIi0qU/6VVRVDlQeQ39PH8oSnjnIYpvQ==