diff --git a/breaking-changes.md b/breaking-changes.md
index 2f1be1acb..daf6cfc31 100644
--- a/breaking-changes.md
+++ b/breaking-changes.md
@@ -18,7 +18,8 @@
- Circle: `v-model` 调整为 `v-model:currentRate`
- Popup: `v-model` 调整为 `v-model:show`
-- Switch: v-model 对应的属性名和事件名由 `value/input` 调整为 `modelValue/update:modelValue`
+- Switch: v-model 对应的属性 `value` 调整为 `modelValue`,事件由 `input` 调整为 `update:modelValue`
+- Sidebar: v-model 对应的属性 `activeKey` 调整为 `modelValue`,事件由 `input` 调整为 `update:modelValue`
## 废弃个别组件
diff --git a/src-next/sidebar-item/index.js b/src-next/sidebar-item/index.js
new file mode 100644
index 000000000..f2645b08c
--- /dev/null
+++ b/src-next/sidebar-item/index.js
@@ -0,0 +1,51 @@
+import { createNamespace } from '../utils';
+import { ChildrenMixin } from '../mixins/relation';
+import { route, routeProps } from '../utils/router';
+import Info from '../info';
+
+const [createComponent, bem] = createNamespace('sidebar-item');
+
+export default createComponent({
+ mixins: [ChildrenMixin('vanSidebar')],
+
+ props: {
+ ...routeProps,
+ dot: Boolean,
+ badge: [Number, String],
+ title: String,
+ disabled: Boolean,
+ },
+
+ computed: {
+ select() {
+ return this.index === +this.parent.modelValue;
+ },
+ },
+
+ methods: {
+ onClick() {
+ if (this.disabled) {
+ return;
+ }
+
+ this.$emit('click', this.index);
+ this.parent.$emit('update:modelValue', this.index);
+ this.parent.setIndex(this.index);
+ route(this.$router, this);
+ },
+ },
+
+ render() {
+ return (
+
+
+ {this.title}
+
+
+
+ );
+ },
+});
diff --git a/src-next/sidebar-item/index.less b/src-next/sidebar-item/index.less
new file mode 100644
index 000000000..49b6cd2d9
--- /dev/null
+++ b/src-next/sidebar-item/index.less
@@ -0,0 +1,59 @@
+@import '../style/var';
+
+.van-sidebar-item {
+ position: relative;
+ display: block;
+ box-sizing: border-box;
+ padding: @sidebar-padding;
+ overflow: hidden;
+ color: @sidebar-text-color;
+ font-size: @sidebar-font-size;
+ line-height: @sidebar-line-height;
+ word-wrap: break-word;
+ background-color: @sidebar-background-color;
+ cursor: pointer;
+ user-select: none;
+
+ &:active {
+ background-color: @sidebar-active-color;
+ }
+
+ &__text {
+ position: relative;
+ display: inline-block;
+ }
+
+ &:not(:last-child)::after {
+ border-bottom-width: 1px;
+ }
+
+ &--select {
+ color: @sidebar-selected-text-color;
+ font-weight: @sidebar-selected-font-weight;
+
+ &,
+ &:active {
+ background-color: @sidebar-selected-background-color;
+ }
+
+ &::before {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ width: @sidebar-selected-border-width;
+ height: @sidebar-selected-border-height;
+ background-color: @sidebar-selected-border-color;
+ transform: translateY(-50%);
+ content: '';
+ }
+ }
+
+ &--disabled {
+ color: @sidebar-disabled-text-color;
+ cursor: not-allowed;
+
+ &:active {
+ background-color: @sidebar-background-color;
+ }
+ }
+}
diff --git a/src-next/sidebar/README.md b/src-next/sidebar/README.md
new file mode 100644
index 000000000..296140dc7
--- /dev/null
+++ b/src-next/sidebar/README.md
@@ -0,0 +1,112 @@
+# Sidebar
+
+### Install
+
+```js
+import Vue from 'vue';
+import { Sidebar, SidebarItem } from 'vant';
+
+Vue.use(Sidebar);
+Vue.use(SidebarItem);
+```
+
+## Usage
+
+### Basic Usage
+
+```html
+
+
+
+
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ activeKey: 0,
+ };
+ },
+};
+```
+
+### Show Badge
+
+```html
+
+
+
+
+
+```
+
+### Disabled
+
+```html
+
+
+
+
+
+```
+
+### Change Event
+
+```html
+
+
+
+
+
+```
+
+```js
+import { Notify } from 'vant';
+
+export default {
+ data() {
+ return {
+ activeKey: 0,
+ };
+ },
+ methods: {
+ onChange(index) {
+ Notify({ type: 'primary', message: index });
+ },
+ },
+};
+```
+
+## API
+
+### Sidebar Props
+
+| Attribute | Description | Type | Default |
+| --------- | -------------------- | ------------------ | ------- |
+| v-model | Index of chosen item | _number \| string_ | `0` |
+
+### Sidebar Events
+
+| Event | Description | Arguments |
+| ------ | --------------------------- | ---------------------------- |
+| change | Triggered when item changed | index: index of current item |
+
+### SidebarItem Props
+
+| Attribute | Description | Type | Default |
+| --- | --- | --- | --- |
+| title | Content | _string_ | `''` |
+| dot `v2.2.1` | Whether to show red dot | _boolean_ | `false` |
+| badge `v2.5.6` | Content of the badge | _number \| string_ | `''` |
+| disabled `v2.2.0` | Whether to be disabled | _boolean_ | `false` |
+| url | Link | _string_ | - |
+| to `v2.0.4` | Target route of the link, same as to of vue-router | _string \| object_ | - |
+| replace `v2.0.4` | If true, the navigation will not leave a history record | _boolean_ | `false` |
+
+### SidebarItem Events
+
+| Event | Description | Arguments |
+| ----- | ------------------------- | ---------------------------- |
+| click | Triggered when click item | index: index of current item |
diff --git a/src-next/sidebar/README.zh-CN.md b/src-next/sidebar/README.zh-CN.md
new file mode 100644
index 000000000..32a58d61d
--- /dev/null
+++ b/src-next/sidebar/README.zh-CN.md
@@ -0,0 +1,121 @@
+# Sidebar 侧边导航
+
+### 引入
+
+```js
+import Vue from 'vue';
+import { Sidebar, SidebarItem } from 'vant';
+
+Vue.use(Sidebar);
+Vue.use(SidebarItem);
+```
+
+## 代码演示
+
+### 基础用法
+
+通过`v-model`绑定当前选中项的索引
+
+```html
+
+
+
+
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ activeKey: 0,
+ };
+ },
+};
+```
+
+### 徽标提示
+
+设置`dot`属性后,会在右上角展示一个小红点。设置`badge`属性后,会在右上角展示相应的徽标
+
+```html
+
+
+
+
+
+```
+
+### 禁用选项
+
+通过`disabled`属性禁用选项
+
+```html
+
+
+
+
+
+```
+
+### 监听切换事件
+
+设置`change`方法来监听切换导航项时的事件
+
+```html
+
+
+
+
+
+```
+
+```js
+import { Notify } from 'vant';
+
+export default {
+ data() {
+ return {
+ activeKey: 0,
+ };
+ },
+ methods: {
+ onChange(index) {
+ Notify({ type: 'primary', message: index });
+ },
+ },
+};
+```
+
+## API
+
+### Sidebar Props
+
+| 参数 | 说明 | 类型 | 默认值 |
+| ---------------- | ---------------- | ------------------ | ------ |
+| v-model `v2.0.4` | 当前导航项的索引 | _number \| string_ | `0` |
+
+### Sidebar Events
+
+| 事件名 | 说明 | 回调参数 |
+| ------ | ---------------- | ----------------------- |
+| change | 切换导航项时触发 | index: 当前导航项的索引 |
+
+### SidebarItem Props
+
+| 参数 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| title | 内容 | _string_ | `''` |
+| dot `v2.2.1` | 是否显示右上角小红点 | _boolean_ | `false` |
+| badge `v2.5.6` | 图标右上角徽标的内容 | _number \| string_ | - |
+| info | 图标右上角徽标的内容(已废弃,请使用 badge 属性) | _number \| string_ | - |
+| disabled `v2.2.0` | 是否禁用该项 | _boolean_ | `false` |
+| url | 点击后跳转的链接地址 | _string_ | - |
+| to `v2.0.4` | 点击后跳转的目标路由对象,同 vue-router 的 [to 属性](https://router.vuejs.org/zh/api/#to) | _string \| object_ | - |
+| replace `v2.0.4` | 是否在跳转时替换当前页面历史 | _boolean_ | `false` |
+
+### SidebarItem Events
+
+| 事件名 | 说明 | 回调参数 |
+| ------ | ---------- | ----------------------- |
+| click | 点击时触发 | index: 当前导航项的索引 |
diff --git a/src-next/sidebar/demo/index.vue b/src-next/sidebar/demo/index.vue
new file mode 100644
index 000000000..5ab0d55f8
--- /dev/null
+++ b/src-next/sidebar/demo/index.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-next/sidebar/index.js b/src-next/sidebar/index.js
new file mode 100644
index 000000000..6db07f361
--- /dev/null
+++ b/src-next/sidebar/index.js
@@ -0,0 +1,40 @@
+import { createNamespace } from '../utils';
+import { ParentMixin } from '../mixins/relation';
+
+const [createComponent, bem] = createNamespace('sidebar');
+
+export default createComponent({
+ mixins: [ParentMixin('vanSidebar')],
+
+ props: {
+ modelValue: {
+ type: [Number, String],
+ default: 0,
+ },
+ },
+
+ data() {
+ return {
+ index: +this.modelValue,
+ };
+ },
+
+ watch: {
+ modelValue() {
+ this.setIndex(+this.modelValue);
+ },
+ },
+
+ methods: {
+ setIndex(index) {
+ if (index !== this.index) {
+ this.index = index;
+ this.$emit('change', index);
+ }
+ },
+ },
+
+ render() {
+ return
{this.$slots.default?.()}
;
+ },
+});
diff --git a/src-next/sidebar/index.less b/src-next/sidebar/index.less
new file mode 100644
index 000000000..ff09323c9
--- /dev/null
+++ b/src-next/sidebar/index.less
@@ -0,0 +1,7 @@
+@import '../style/var';
+
+.van-sidebar {
+ width: @sidebar-width;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
diff --git a/src-next/sidebar/test/__snapshots__/demo.spec.js.snap b/src-next/sidebar/test/__snapshots__/demo.spec.js.snap
new file mode 100644
index 000000000..1a7caf7b3
--- /dev/null
+++ b/src-next/sidebar/test/__snapshots__/demo.spec.js.snap
@@ -0,0 +1,77 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders demo correctly 1`] = `
+
+`;
diff --git a/src-next/sidebar/test/demo.spec.js b/src-next/sidebar/test/demo.spec.js
new file mode 100644
index 000000000..5c70922b5
--- /dev/null
+++ b/src-next/sidebar/test/demo.spec.js
@@ -0,0 +1,4 @@
+import Demo from '../demo';
+import { snapshotDemo } from '../../../test/demo';
+
+snapshotDemo(Demo);
diff --git a/src-next/sidebar/test/index.spec.js b/src-next/sidebar/test/index.spec.js
new file mode 100644
index 000000000..a3e533410
--- /dev/null
+++ b/src-next/sidebar/test/index.spec.js
@@ -0,0 +1,78 @@
+import { mount } from '../../../test';
+import Sidebar from '..';
+
+test('click event & change event', () => {
+ const onClick = jest.fn();
+ const onChange = jest.fn();
+ const wrapper = mount({
+ template: `
+
+ Text
+ Text
+
+ `,
+ methods: {
+ onClick,
+ onChange,
+ },
+ });
+
+ wrapper.findAll('.van-sidebar-item').at(1).trigger('click');
+ expect(onClick).toHaveBeenCalledWith(1);
+ expect(onChange).toHaveBeenCalledWith(1);
+ wrapper.vm.$destroy();
+});
+
+test('v-model', () => {
+ const onChange = jest.fn();
+ const wrapper = mount({
+ template: `
+
+ Text
+ Text
+
+ `,
+ data() {
+ return {
+ active: 0,
+ };
+ },
+ methods: {
+ onChange,
+ },
+ });
+
+ wrapper.findAll('.van-sidebar-item').at(1).trigger('click');
+ expect(wrapper.vm.active).toEqual(1);
+ expect(onChange).toHaveBeenCalledWith(1);
+});
+
+test('disabled prop', () => {
+ const wrapper = mount({
+ template: `
+
+ Text
+ Text
+
+ `,
+ data() {
+ return {
+ active: 0,
+ };
+ },
+ });
+
+ wrapper.findAll('.van-sidebar-item').at(1).trigger('click');
+ expect(wrapper.vm.active).toEqual(0);
+});
+
+test('without parent', () => {
+ const consoleError = console.error;
+ try {
+ console.error = jest.fn();
+ mount(Sidebar);
+ } catch (err) {
+ console.error = consoleError;
+ expect(err).toBeTruthy();
+ }
+});