diff --git a/src-next/slider/README.md b/src-next/slider/README.md
new file mode 100644
index 000000000..0a342c50d
--- /dev/null
+++ b/src-next/slider/README.md
@@ -0,0 +1,123 @@
+# Slider
+
+### Install
+
+```js
+import Vue from 'vue';
+import { Slider } from 'vant';
+
+Vue.use(Slider);
+```
+
+## Usage
+
+### Basic Usage
+
+```html
+
+```
+
+```js
+import { Toast } from 'vant';
+
+export default {
+ data() {
+ return {
+ value: 50,
+ };
+ },
+ methods: {
+ onChange(value) {
+ Toast('Current value:' + value);
+ },
+ },
+};
+```
+
+### Range
+
+```html
+
+```
+
+### Disabled
+
+```html
+
+```
+
+### Step size
+
+```html
+
+```
+
+### Custom style
+
+```html
+
+```
+
+### Custom button
+
+```html
+
+
+
+ {{ value }}
+
+
+
+
+
+```
+
+### Vertical
+
+```html
+
+
+
+```
+
+## API
+
+### Props
+
+| Attribute | Description | Type | Default |
+| --- | --- | --- | --- |
+| value | Current value | _number_ | `0` |
+| max | Max value | _number \| string_ | `100` |
+| min | Min value | _number \| string_ | `0` |
+| step | Step size | _number \| string_ | `1` |
+| bar-height | Height of bar | _number \| string_ | `2px` |
+| button-size `v2.4.5` | Button size | _number \| string_ | `24px` |
+| active-color | Active color of bar | _string_ | `#1989fa` |
+| inactive-color | Inactive color of bar | _string_ | `#e5e5e5` |
+| disabled | Whether to disable slider | _boolean_ | `false` |
+| vertical | Whether to display slider vertically | _boolean_ | `false` |
+
+### Events
+
+| Event | Description | Arguments |
+| ---------- | ------------------------------------ | ------------------- |
+| input | Instant triggered when value changed | value: current rate |
+| change | Triggered after value changed | value: current rate |
+| drag-start | Triggered when start drag | - |
+| drag-end | Triggered when end drag | - |
+
+### Slots
+
+| Name | Description |
+| ------ | ------------- |
+| button | Custom button |
diff --git a/src-next/slider/README.zh-CN.md b/src-next/slider/README.zh-CN.md
new file mode 100644
index 000000000..99c43e4eb
--- /dev/null
+++ b/src-next/slider/README.zh-CN.md
@@ -0,0 +1,125 @@
+# Slider 滑块
+
+### 引入
+
+```js
+import Vue from 'vue';
+import { Slider } from 'vant';
+
+Vue.use(Slider);
+```
+
+## 代码演示
+
+### 基础用法
+
+```html
+
+```
+
+```js
+import { Toast } from 'vant';
+
+export default {
+ data() {
+ return {
+ value: 50,
+ };
+ },
+ methods: {
+ onChange(value) {
+ Toast('当前值:' + value);
+ },
+ },
+};
+```
+
+### 指定选择范围
+
+```html
+
+```
+
+### 禁用
+
+```html
+
+```
+
+### 指定步长
+
+```html
+
+```
+
+### 自定义样式
+
+```html
+
+```
+
+### 自定义按钮
+
+```html
+
+
+
+ {{ value }}
+
+
+
+
+
+```
+
+### 垂直方向
+
+Slider 垂直展示时,高度为 100% 父元素高度
+
+```html
+
+
+
+```
+
+## API
+
+### Props
+
+| 参数 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| value | 当前进度百分比 | _number_ | `0` |
+| max | 最大值 | _number \| string_ | `100` |
+| min | 最小值 | _number \| string_ | `0` |
+| step | 步长 | _number \| string_ | `1` |
+| bar-height | 进度条高度,默认单位为`px` | _number \| string_ | `2px` |
+| button-size `v2.4.5` | 滑块按钮大小,默认单位为`px` | _number \| string_ | `24px` |
+| active-color | 进度条激活态颜色 | _string_ | `#1989fa` |
+| inactive-color | 进度条非激活态颜色 | _string_ | `#e5e5e5` |
+| disabled | 是否禁用滑块 | _boolean_ | `false` |
+| vertical | 是否垂直展示 | _boolean_ | `false` |
+
+### Events
+
+| 事件名 | 说明 | 回调参数 |
+| ---------- | ------------------------ | --------------- |
+| input | 进度变化时实时触发 | value: 当前进度 |
+| change | 进度变化且结束拖动后触发 | value: 当前进度 |
+| drag-start | 开始拖动时触发 | - |
+| drag-end | 结束拖动时触发 | - |
+
+### Slots
+
+| 名称 | 说明 |
+| ------ | -------------- |
+| button | 自定义滑动按钮 |
diff --git a/src-next/slider/demo/index.vue b/src-next/slider/demo/index.vue
new file mode 100644
index 000000000..a510eefc6
--- /dev/null
+++ b/src-next/slider/demo/index.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ value6 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-next/slider/index.js b/src-next/slider/index.js
new file mode 100644
index 000000000..d0e3e0b6a
--- /dev/null
+++ b/src-next/slider/index.js
@@ -0,0 +1,195 @@
+import { createNamespace, addUnit } from '../utils';
+import { preventDefault } from '../utils/dom/event';
+import { TouchMixin } from '../mixins/touch';
+import { FieldMixin } from '../mixins/field';
+
+const [createComponent, bem] = createNamespace('slider');
+
+export default createComponent({
+ mixins: [TouchMixin, FieldMixin],
+
+ props: {
+ disabled: Boolean,
+ vertical: Boolean,
+ barHeight: [Number, String],
+ buttonSize: [Number, String],
+ activeColor: String,
+ inactiveColor: String,
+ min: {
+ type: [Number, String],
+ default: 0,
+ },
+ max: {
+ type: [Number, String],
+ default: 100,
+ },
+ step: {
+ type: [Number, String],
+ default: 1,
+ },
+ modelValue: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ data() {
+ return {
+ dragStatus: '',
+ };
+ },
+
+ computed: {
+ range() {
+ return this.max - this.min;
+ },
+
+ buttonStyle() {
+ if (this.buttonSize) {
+ const size = addUnit(this.buttonSize);
+ return {
+ width: size,
+ height: size,
+ };
+ }
+ },
+ },
+
+ created() {
+ // format initial value
+ this.updateValue(this.modelValue);
+ },
+
+ mounted() {
+ this.bindTouchEvent(this.$refs.wrapper);
+ },
+
+ methods: {
+ onTouchStart(event) {
+ if (this.disabled) {
+ return;
+ }
+
+ this.touchStart(event);
+ this.startValue = this.format(this.modelValue);
+ this.dragStatus = 'start';
+ },
+
+ onTouchMove(event) {
+ if (this.disabled) {
+ return;
+ }
+
+ if (this.dragStatus === 'start') {
+ this.$emit('drag-start');
+ }
+
+ preventDefault(event, true);
+ this.touchMove(event);
+ this.dragStatus = 'draging';
+
+ const rect = this.$el.getBoundingClientRect();
+ const delta = this.vertical ? this.deltaY : this.deltaX;
+ const total = this.vertical ? rect.height : rect.width;
+ const diff = (delta / total) * this.range;
+
+ this.newValue = this.startValue + diff;
+ this.updateValue(this.newValue);
+ },
+
+ onTouchEnd() {
+ if (this.disabled) {
+ return;
+ }
+
+ if (this.dragStatus === 'draging') {
+ this.updateValue(this.newValue, true);
+ this.$emit('drag-end');
+ }
+
+ this.dragStatus = '';
+ },
+
+ onClick(event) {
+ event.stopPropagation();
+
+ if (this.disabled) return;
+
+ const rect = this.$el.getBoundingClientRect();
+ const delta = this.vertical
+ ? event.clientY - rect.top
+ : event.clientX - rect.left;
+ const total = this.vertical ? rect.height : rect.width;
+ const value = +this.min + (delta / total) * this.range;
+
+ this.startValue = this.modelValue;
+ this.updateValue(value, true);
+ },
+
+ updateValue(value, end) {
+ value = this.format(value);
+
+ if (value !== this.modelValue) {
+ this.$emit('update:modelValue', value);
+ }
+
+ if (end && value !== this.startValue) {
+ this.$emit('change', value);
+ }
+ },
+
+ format(value) {
+ return (
+ Math.round(Math.max(this.min, Math.min(value, this.max)) / this.step) *
+ this.step
+ );
+ },
+ },
+
+ render() {
+ const { vertical } = this;
+ const mainAxis = vertical ? 'height' : 'width';
+ const crossAxis = vertical ? 'width' : 'height';
+
+ const wrapperStyle = {
+ background: this.inactiveColor,
+ [crossAxis]: addUnit(this.barHeight),
+ };
+
+ const barStyle = {
+ [mainAxis]: `${((this.modelValue - this.min) * 100) / this.range}%`,
+ background: this.activeColor,
+ };
+
+ if (this.dragStatus) {
+ barStyle.transition = 'none';
+ }
+
+ return (
+
+ );
+ },
+});
diff --git a/src-next/slider/index.less b/src-next/slider/index.less
new file mode 100644
index 000000000..6919ffe0c
--- /dev/null
+++ b/src-next/slider/index.less
@@ -0,0 +1,75 @@
+@import '../style/var';
+
+.van-slider {
+ position: relative;
+ width: 100%;
+ height: @slider-bar-height;
+ background-color: @slider-inactive-background-color;
+ border-radius: @border-radius-max;
+ cursor: pointer;
+
+ // use pseudo element to expand click area
+ &::before {
+ position: absolute;
+ top: -@padding-xs;
+ right: 0;
+ bottom: -@padding-xs;
+ left: 0;
+ content: '';
+ }
+
+ &__bar {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ background-color: @slider-active-background-color;
+ border-radius: inherit;
+ transition: width @animation-duration-fast;
+ }
+
+ &__button {
+ width: @slider-button-width;
+ height: @slider-button-height;
+ background-color: @slider-button-background-color;
+ border-radius: @slider-button-border-radius;
+ box-shadow: @slider-button-box-shadow;
+
+ &-wrapper {
+ position: absolute;
+ top: 50%;
+ right: 0;
+ transform: translate3d(50%, -50%, 0);
+ cursor: grab;
+ }
+ }
+
+ &--disabled {
+ cursor: not-allowed;
+ opacity: @slider-disabled-opacity;
+
+ .van-slider__button-wrapper {
+ cursor: not-allowed;
+ }
+ }
+
+ &--vertical {
+ display: inline-block;
+ width: @slider-bar-height;
+ height: 100%;
+
+ .van-slider__button-wrapper {
+ top: auto;
+ right: 50%;
+ bottom: 0;
+ transform: translate3d(50%, 50%, 0);
+ }
+
+ // use pseudo element to expand click area
+ &::before {
+ top: 0;
+ right: -@padding-xs;
+ bottom: 0;
+ left: -@padding-xs;
+ }
+ }
+}
diff --git a/src-next/slider/test/__snapshots__/demo.spec.js.snap b/src-next/slider/test/__snapshots__/demo.spec.js.snap
new file mode 100644
index 000000000..e1a65f6e5
--- /dev/null
+++ b/src-next/slider/test/__snapshots__/demo.spec.js.snap
@@ -0,0 +1,71 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders demo correctly 1`] = `
+
+`;
diff --git a/src-next/slider/test/__snapshots__/index.spec.js.snap b/src-next/slider/test/__snapshots__/index.spec.js.snap
new file mode 100644
index 000000000..e63f4f6d0
--- /dev/null
+++ b/src-next/slider/test/__snapshots__/index.spec.js.snap
@@ -0,0 +1,81 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`bar-height prop 1`] = `
+
+`;
+
+exports[`button-size prop 1`] = `
+
+`;
+
+exports[`click bar 1`] = `
+
+`;
+
+exports[`click bar 2`] = `
+
+`;
+
+exports[`click vertical 1`] = `
+
+`;
+
+exports[`drag button 1`] = `
+
+`;
+
+exports[`drag button 2`] = `
+
+`;
+
+exports[`drag button vertical 1`] = `
+
+`;
diff --git a/src-next/slider/test/demo.spec.js b/src-next/slider/test/demo.spec.js
new file mode 100644
index 000000000..5c70922b5
--- /dev/null
+++ b/src-next/slider/test/demo.spec.js
@@ -0,0 +1,4 @@
+import Demo from '../demo';
+import { snapshotDemo } from '../../../test/demo';
+
+snapshotDemo(Demo);
diff --git a/src-next/slider/test/index.spec.js b/src-next/slider/test/index.spec.js
new file mode 100644
index 000000000..53333b201
--- /dev/null
+++ b/src-next/slider/test/index.spec.js
@@ -0,0 +1,166 @@
+import Slider from '..';
+import {
+ mount,
+ trigger,
+ triggerDrag,
+ mockGetBoundingClientRect,
+} from '../../../test';
+
+function mockRect(vertical) {
+ return mockGetBoundingClientRect({
+ width: vertical ? 0 : 100,
+ height: vertical ? 100 : 0,
+ top: vertical ? 0 : 100,
+ left: vertical ? 100 : 0,
+ });
+}
+
+test('drag button', () => {
+ const restoreMock = mockRect();
+
+ const wrapper = mount(Slider, {
+ propsData: {
+ value: 50,
+ disabled: true,
+ },
+ });
+
+ wrapper.vm.$on('input', (value) => {
+ wrapper.setProps({ value });
+ });
+
+ const button = wrapper.find('.van-slider__button');
+ triggerDrag(button, 50, 0);
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.emitted('drag-start')).toBeFalsy();
+ expect(wrapper.emitted('drag-end')).toBeFalsy();
+
+ wrapper.setData({ disabled: false });
+ trigger(button, 'touchstart', 0, 0);
+ trigger(button, 'touchend', 0, 0);
+ triggerDrag(button, 50, 0);
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.emitted('drag-start')).toBeTruthy();
+ expect(wrapper.emitted('drag-end')).toBeTruthy();
+
+ restoreMock();
+});
+
+test('click bar', () => {
+ const restoreMock = mockRect();
+
+ const wrapper = mount(Slider, {
+ propsData: {
+ value: 50,
+ disabled: true,
+ },
+ });
+
+ wrapper.vm.$on('input', (value) => {
+ wrapper.setProps({ value });
+ });
+
+ trigger(wrapper, 'click', 100, 0);
+ expect(wrapper).toMatchSnapshot();
+
+ wrapper.setData({ disabled: false });
+ trigger(wrapper, 'click', 100, 0);
+ expect(wrapper).toMatchSnapshot();
+
+ restoreMock();
+});
+
+test('drag button vertical', () => {
+ const restoreMock = mockRect(true);
+
+ const wrapper = mount(Slider, {
+ propsData: {
+ value: 50,
+ vertical: true,
+ },
+ });
+
+ wrapper.vm.$on('input', (value) => {
+ wrapper.setProps({ value });
+ });
+
+ const button = wrapper.find('.van-slider__button');
+ triggerDrag(button, 0, 50);
+ expect(wrapper).toMatchSnapshot();
+
+ restoreMock();
+});
+
+test('click vertical', () => {
+ const restoreMock = mockRect(true);
+
+ const wrapper = mount(Slider, {
+ propsData: {
+ value: 50,
+ vertical: true,
+ },
+ });
+
+ wrapper.vm.$on('input', (value) => {
+ wrapper.setProps({ value });
+ });
+
+ trigger(wrapper, 'click', 0, 100);
+ expect(wrapper).toMatchSnapshot();
+
+ restoreMock();
+});
+
+test('bar-height prop', () => {
+ const wrapper = mount(Slider, {
+ propsData: {
+ value: 50,
+ barHeight: 10,
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('button-size prop', () => {
+ const wrapper = mount(Slider, {
+ propsData: {
+ value: 50,
+ buttonSize: 10,
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('should not emit change event when value not changed', () => {
+ const wrapper = mount(Slider, {
+ propsData: {
+ value: 50,
+ },
+ listeners: {
+ input(value) {
+ wrapper.setProps({ value });
+ },
+ },
+ });
+
+ trigger(wrapper, 'click', 100, 0);
+ trigger(wrapper, 'click', 100, 0);
+
+ expect(wrapper.emitted('change').length).toEqual(1);
+});
+
+test('should format initial value', (done) => {
+ mount(Slider, {
+ propsData: {
+ value: null,
+ },
+ listeners: {
+ input(value) {
+ expect(value).toEqual(0);
+ done();
+ },
+ },
+ });
+});
diff --git a/vant.config.js b/vant.config.js
index bd5cc7bfe..f0e8c944e 100644
--- a/vant.config.js
+++ b/vant.config.js
@@ -159,10 +159,10 @@ module.exports = {
// path: 'search',
// title: 'Search 搜索',
// },
- // {
- // path: 'slider',
- // title: 'Slider 滑块',
- // },
+ {
+ path: 'slider',
+ title: 'Slider 滑块',
+ },
// {
// path: 'stepper',
// title: 'Stepper 步进器',
@@ -493,10 +493,10 @@ module.exports = {
// path: 'search',
// title: 'Search',
// },
- // {
- // path: 'slider',
- // title: 'Slider',
- // },
+ {
+ path: 'slider',
+ title: 'Slider',
+ },
// {
// path: 'stepper',
// title: 'Stepper',