diff --git a/breaking-changes.md b/breaking-changes.md
index c08ffd5cf..50ca14445 100644
--- a/breaking-changes.md
+++ b/breaking-changes.md
@@ -2,4 +2,8 @@
## Popup
-- v-model 调整为 v-model:show
+- `v-model` 调整为 `v-model:show`
+
+## Switch
+
+- v-model 对应的属性名和事件名由 `value/input` 调整为 `modelValue/update:modelValue`
diff --git a/src-next/switch/README.md b/src-next/switch/README.md
new file mode 100644
index 000000000..8b75c4411
--- /dev/null
+++ b/src-next/switch/README.md
@@ -0,0 +1,110 @@
+# Switch
+
+### Install
+
+```js
+import Vue from 'vue';
+import { Switch } from 'vant';
+
+Vue.use(Switch);
+```
+
+## Usage
+
+### Basic Usage
+
+```html
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ checked: true,
+ };
+ },
+};
+```
+
+### Disabled
+
+```html
+
+```
+
+### Loading
+
+```html
+
+```
+
+### Custom Size
+
+```html
+
+```
+
+### Custom Color
+
+```html
+
+```
+
+### Async Control
+
+```html
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ checked: true,
+ };
+ },
+ methods: {
+ onUpdateValue(checked) {
+ Dialog.confirm({
+ title: 'Confirm',
+ message: 'Are you sure to toggle switch?',
+ }).then(() => {
+ this.checked = checked;
+ });
+ },
+ },
+};
+```
+
+### Inside a Cell
+
+```html
+
+
+
+
+
+```
+
+## API
+
+### Props
+
+| Attribute | Description | Type | Default |
+| --- | --- | --- | --- |
+| v-model | Check status of Switch | _ActiveValue \| InactiveValue_ | `false` |
+| loading | Whether to show loading icon | _boolean_ | `false` |
+| disabled | Whether to disable switch | _boolean_ | `false` |
+| size `v2.2.11` | Size of switch | _number \| string_ | `30px` |
+| active-color | Background color when active | _string_ | `#1989fa` |
+| inactive-color | Background color when inactive | _string_ | `white` |
+| active-value | Value when active | _any_ | `true` |
+| inactive-value | Value when inactive | _any_ | `false` |
+
+### Events
+
+| Event | Description | Parameters |
+| --------------- | ----------------------------------- | -------------- |
+| change | Triggered when check status changed | _value: any_ |
+| click `v2.2.11` | Triggered when clicked | _event: Event_ |
diff --git a/src-next/switch/README.zh-CN.md b/src-next/switch/README.zh-CN.md
new file mode 100644
index 000000000..134658066
--- /dev/null
+++ b/src-next/switch/README.zh-CN.md
@@ -0,0 +1,122 @@
+# Switch 开关
+
+### 引入
+
+```js
+import Vue from 'vue';
+import { Switch } from 'vant';
+
+Vue.use(Switch);
+```
+
+## 代码演示
+
+### 基础用法
+
+通过`v-model`绑定开关的选中状态,`true`表示开,`false`表示关
+
+```html
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ checked: true,
+ };
+ },
+};
+```
+
+### 禁用状态
+
+通过`disabled`属性来禁用开关,禁用状态下开关不可点击
+
+```html
+
+```
+
+### 加载状态
+
+通过`loading`属性设置开关为加载状态,加载状态下开关不可点击
+
+```html
+
+```
+
+### 自定义大小
+
+通过`size`属性自定义开关的大小
+
+```html
+
+```
+
+### 自定义颜色
+
+`active-color`属性表示打开时的背景色,`inactive-color`表示关闭时的背景色
+
+```html
+
+```
+
+### 异步控制
+
+需要异步控制开关时,可以使用 `modelValue` 属性和 `update:model-value` 事件代替 `v-model`,并在事件回调函数中手动处理开关状态。
+
+```html
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ checked: true,
+ };
+ },
+ methods: {
+ onUpdateValue(checked) {
+ Dialog.confirm({
+ title: '提醒',
+ message: '是否切换开关?',
+ }).then(() => {
+ this.checked = checked;
+ });
+ },
+ },
+};
+```
+
+### 搭配单元格使用
+
+```html
+
+
+
+
+
+```
+
+## API
+
+### Props
+
+| 参数 | 说明 | 类型 | 默认值 |
+| -------------- | ------------------------ | ------------------ | --------- |
+| v-model | 开关选中状态 | _any_ | `false` |
+| loading | 是否为加载状态 | _boolean_ | `false` |
+| disabled | 是否为禁用状态 | _boolean_ | `false` |
+| size `v2.2.11` | 开关尺寸,默认单位为`px` | _number \| string_ | `30px` |
+| active-color | 打开时的背景色 | _string_ | `#1989fa` |
+| inactive-color | 关闭时的背景色 | _string_ | `white` |
+| active-value | 打开时对应的值 | _any_ | `true` |
+| inactive-value | 关闭时对应的值 | _any_ | `false` |
+
+### Events
+
+| 事件名 | 说明 | 回调参数 |
+| --------------- | ------------------ | -------------- |
+| change | 开关状态切换时触发 | _value: any_ |
+| click `v2.2.11` | 点击时触发 | _event: Event_ |
diff --git a/src-next/switch/demo/index.vue b/src-next/switch/demo/index.vue
new file mode 100644
index 000000000..51d4ab39f
--- /dev/null
+++ b/src-next/switch/demo/index.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-next/switch/index.js b/src-next/switch/index.js
new file mode 100644
index 000000000..334e42fe7
--- /dev/null
+++ b/src-next/switch/index.js
@@ -0,0 +1,71 @@
+// Utils
+import { createNamespace, addUnit } from '../utils';
+import { switchProps } from './shared';
+
+// Mixins
+import { FieldMixin } from '../mixins/field';
+
+// Components
+import Loading from '../loading';
+
+const [createComponent, bem] = createNamespace('switch');
+
+export default createComponent({
+ mixins: [FieldMixin],
+
+ props: switchProps,
+
+ emits: ['click', 'change', 'update:modelValue'],
+
+ computed: {
+ checked() {
+ return this.modelValue === this.activeValue;
+ },
+
+ style() {
+ return {
+ fontSize: addUnit(this.size),
+ backgroundColor: this.checked ? this.activeColor : this.inactiveColor,
+ };
+ },
+ },
+
+ methods: {
+ onClick(event) {
+ this.$emit('click', event);
+
+ if (!this.disabled && !this.loading) {
+ const newValue = this.checked ? this.inactiveValue : this.activeValue;
+ this.$emit('update:modelValue', newValue);
+ this.$emit('change', newValue);
+ }
+ },
+
+ genLoading() {
+ if (this.loading) {
+ const color = this.checked ? this.activeColor : this.inactiveColor;
+ return ;
+ }
+ },
+ },
+
+ render() {
+ const { checked, loading, disabled } = this;
+
+ return (
+
+ );
+ },
+});
diff --git a/src-next/switch/index.less b/src-next/switch/index.less
new file mode 100644
index 000000000..8baa7a43a
--- /dev/null
+++ b/src-next/switch/index.less
@@ -0,0 +1,58 @@
+@import '../style/var';
+
+.van-switch {
+ position: relative;
+ display: inline-block;
+ box-sizing: content-box;
+ width: @switch-width;
+ height: @switch-height;
+ font-size: @switch-size;
+ background-color: @switch-background-color;
+ border: @switch-border;
+ border-radius: @switch-node-size;
+ cursor: pointer;
+ transition: background-color @switch-transition-duration;
+
+ &__node {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: @switch-node-z-index;
+ width: @switch-node-size;
+ height: @switch-node-size;
+ background-color: @switch-node-background-color;
+ border-radius: 100%;
+ box-shadow: @switch-node-box-shadow;
+ transition: transform @switch-transition-duration
+ cubic-bezier(0.3, 1.05, 0.4, 1.05);
+ }
+
+ &__loading {
+ top: 25%;
+ left: 25%;
+ width: 50%;
+ height: 50%;
+ line-height: 1;
+ }
+
+ &--on {
+ background-color: @switch-on-background-color;
+
+ .van-switch__node {
+ transform: translateX(@switch-width - @switch-node-size);
+ }
+
+ .van-switch__loading {
+ color: @switch-on-background-color;
+ }
+ }
+
+ &--disabled {
+ cursor: not-allowed;
+ opacity: @switch-disabled-opacity;
+ }
+
+ &--loading {
+ cursor: default;
+ }
+}
diff --git a/src-next/switch/shared.ts b/src-next/switch/shared.ts
new file mode 100644
index 000000000..fd77d663b
--- /dev/null
+++ b/src-next/switch/shared.ts
@@ -0,0 +1,31 @@
+/**
+ * Common Switch Props
+ */
+
+export type SharedSwitchProps = {
+ size?: string | number;
+ loading?: boolean;
+ disabled?: boolean;
+ modelValue?: any;
+ activeValue: any;
+ inactiveValue: any;
+ activeColor?: string;
+ inactiveColor?: string;
+};
+
+export const switchProps = {
+ size: [Number, String],
+ loading: Boolean,
+ disabled: Boolean,
+ modelValue: null as any,
+ activeColor: String,
+ inactiveColor: String,
+ activeValue: {
+ type: null as any,
+ default: true,
+ },
+ inactiveValue: {
+ type: null as any,
+ default: false,
+ },
+};
diff --git a/src-next/switch/test/__snapshots__/demo.spec.js.snap b/src-next/switch/test/__snapshots__/demo.spec.js.snap
new file mode 100644
index 000000000..c05490ce5
--- /dev/null
+++ b/src-next/switch/test/__snapshots__/demo.spec.js.snap
@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders demo correctly 1`] = `
+
+`;
diff --git a/src-next/switch/test/__snapshots__/index.spec.js.snap b/src-next/switch/test/__snapshots__/index.spec.js.snap
new file mode 100644
index 000000000..8a9a87679
--- /dev/null
+++ b/src-next/switch/test/__snapshots__/index.spec.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`inactive-color prop 1`] = `
+
+`;
+
+exports[`size prop 1`] = `
+
+`;
diff --git a/src-next/switch/test/demo.spec.js b/src-next/switch/test/demo.spec.js
new file mode 100644
index 000000000..5c70922b5
--- /dev/null
+++ b/src-next/switch/test/demo.spec.js
@@ -0,0 +1,4 @@
+import Demo from '../demo';
+import { snapshotDemo } from '../../../test/demo';
+
+snapshotDemo(Demo);
diff --git a/src-next/switch/test/index.spec.js b/src-next/switch/test/index.spec.js
new file mode 100644
index 000000000..07a177272
--- /dev/null
+++ b/src-next/switch/test/index.spec.js
@@ -0,0 +1,91 @@
+import Switch from '..';
+import { mount } from '../../../test';
+
+test('emit event', () => {
+ const input = jest.fn();
+ const change = jest.fn();
+ const wrapper = mount(Switch, {
+ listeners: {
+ input,
+ change,
+ },
+ });
+ wrapper.trigger('click');
+
+ expect(input).toHaveBeenCalledWith(true);
+ expect(change).toHaveBeenCalledWith(true);
+});
+
+test('disabled', () => {
+ const input = jest.fn();
+ const change = jest.fn();
+ const wrapper = mount(Switch, {
+ listeners: {
+ input,
+ change,
+ },
+ propsData: {
+ disabled: true,
+ },
+ });
+ wrapper.trigger('click');
+
+ expect(input).toHaveBeenCalledTimes(0);
+ expect(change).toHaveBeenCalledTimes(0);
+});
+
+test('active-value & inactive-value prop', () => {
+ const input = jest.fn();
+ const change = jest.fn();
+ const wrapper = mount(Switch, {
+ propsData: {
+ value: '1',
+ activeValue: '1',
+ inactiveValue: '2',
+ },
+ listeners: {
+ input,
+ change,
+ },
+ });
+
+ wrapper.trigger('click');
+
+ expect(input).toHaveBeenCalledWith('2');
+ expect(change).toHaveBeenCalledWith('2');
+});
+
+test('inactive-color prop', () => {
+ const wrapper = mount(Switch, {
+ propsData: {
+ value: '2',
+ inactiveValue: '2',
+ inactiveColor: 'black',
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('size prop', () => {
+ const wrapper = mount(Switch, {
+ propsData: {
+ size: 20,
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('click event', () => {
+ const click = jest.fn();
+ const wrapper = mount(Switch, {
+ listeners: {
+ click,
+ },
+ });
+
+ wrapper.trigger('click');
+
+ expect(click).toHaveBeenCalledTimes(1);
+});
diff --git a/vant.config.js b/vant.config.js
index 208884089..b7db042f7 100644
--- a/vant.config.js
+++ b/vant.config.js
@@ -167,10 +167,10 @@ module.exports = {
// path: 'stepper',
// title: 'Stepper 步进器',
// },
- // {
- // path: 'switch',
- // title: 'Switch 开关',
- // },
+ {
+ path: 'switch',
+ title: 'Switch 开关',
+ },
// {
// path: 'uploader',
// title: 'Uploader 文件上传',
@@ -501,10 +501,10 @@ module.exports = {
// path: 'stepper',
// title: 'Stepper',
// },
- // {
- // path: 'switch',
- // title: 'Switch',
- // },
+ {
+ path: 'switch',
+ title: 'Switch',
+ },
// {
// path: 'uploader',
// title: 'Uploader',