├── src
├── util.js
├── index.js
├── const.js
├── index.html
├── main.js
├── style.js
└── dom.js
├── webpack.dev.js
├── .gitignore
├── webpack.prod.js
├── package.json
├── webpack.common.js
├── README.md
└── dist
└── touchpad-scroll-carousel.min.js
/src/util.js:
--------------------------------------------------------------------------------
1 | export const roundDimension = (number) => {
2 | return +(+number).toFixed(0);
3 | };
4 |
--------------------------------------------------------------------------------
/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const { merge } = require("webpack-merge");
2 | const common = require("./webpack.common.js");
3 |
4 | module.exports = merge(common, {
5 | mode: "development",
6 |
7 | devtool: "inline-source-map",
8 |
9 | devServer: {
10 | contentBase: "./dist",
11 | hot: true,
12 | },
13 | });
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist/index.*
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 |
9 | # Log files
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 |
15 | # Editor directories and files
16 | .idea
17 | .vscode
18 | *.suo
19 | *.ntvs*
20 | *.njsproj
21 | *.sln
22 | *.sw?
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | TouchpadScrollCarousel({
2 | carouselSelector: "#carousel1",
3 | gap: 30,
4 | mouseDrag: true,
5 | responsive: [
6 | {
7 | breakpoint: 768,
8 | slidesToShow: 2.5,
9 | gap: 30,
10 | },
11 | {
12 | breakpoint: 1200,
13 | slidesToShow: 3.2,
14 | gap: 50,
15 | },
16 | ],
17 | });
18 |
19 | TouchpadScrollCarousel({
20 | carouselSelector: "#carousel2",
21 | });
22 |
--------------------------------------------------------------------------------
/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const HtmlMinimizerPlugin = require("html-minimizer-webpack-plugin");
2 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
3 | const TerserWebpackPlugin = require("terser-webpack-plugin");
4 | const { merge } = require("webpack-merge");
5 | const common = require("./webpack.common.js");
6 |
7 | module.exports = merge(common, {
8 | mode: "production",
9 |
10 | // https://webpack.js.org/configuration/optimization/
11 | optimization: {
12 | // https://webpack.js.org/guides/tree-shaking/
13 | usedExports: true,
14 |
15 | minimizer: [
16 | // https://webpack.js.org/plugins/html-minimizer-webpack-plugin/
17 | new HtmlMinimizerPlugin(),
18 |
19 | // https://webpack.js.org/plugins/css-minimizer-webpack-plugin/
20 | new CssMinimizerPlugin(),
21 |
22 | // https://webpack.js.org/plugins/terser-webpack-plugin/
23 | new TerserWebpackPlugin({
24 | extractComments: false,
25 | }),
26 | ],
27 | },
28 | });
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "touchpad-scroll-carousel",
3 | "version": "1.0.3",
4 | "description": "The simple, lightweight responsive carousel that requires no dependencies and supports scroll on touchpad.",
5 | "author": "Robin Huy",
6 | "license": "MIT",
7 | "homepage": "https://github.com/robinhuy/touchpad-scroll-carousel#readme",
8 | "bugs": {
9 | "url": "https://github.com/robinhuy/touchpad-scroll-carousel/issues"
10 | },
11 | "main": "dist/touchpad-scroll-carousel",
12 | "scripts": {
13 | "start": "webpack serve --open --config webpack.dev.js",
14 | "build": "webpack --config webpack.prod.js"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/robinhuy/touchpad-scroll-carousel.git"
19 | },
20 | "keywords": [
21 | "javascript",
22 | "scroll",
23 | "touchpad",
24 | "carousel"
25 | ],
26 | "devDependencies": {
27 | "@babel/core": "^7.19.3",
28 | "@babel/preset-env": "^7.19.3",
29 | "babel-loader": "^8.2.5",
30 | "css-loader": "^5.2.6",
31 | "css-minimizer-webpack-plugin": "^3.0.2",
32 | "html-loader": "^4.2.0",
33 | "html-minimizer-webpack-plugin": "^3.2.0",
34 | "html-webpack-plugin": "^5.5.0",
35 | "image-minimizer-webpack-plugin": "^2.2.0",
36 | "imagemin-gifsicle": "^7.0.0",
37 | "imagemin-jpegtran": "^7.0.0",
38 | "imagemin-optipng": "^8.0.0",
39 | "imagemin-svgo": "^9.0.0",
40 | "json5": "^2.2.2",
41 | "mini-css-extract-plugin": "^1.6.0",
42 | "sass": "^1.55.0",
43 | "sass-loader": "^12.1.0",
44 | "terser-webpack-plugin": "^5.3.6",
45 | "url-loader": "^4.1.1",
46 | "webpack": "^5.74.0",
47 | "webpack-cli": "^4.10.0",
48 | "webpack-dev-server": "^3.11.2",
49 | "webpack-merge": "^5.8.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/const.js:
--------------------------------------------------------------------------------
1 | export const DEFAULT_OPTIONS = {
2 | carouselSelector: null,
3 | slidesToShow: 1,
4 | slidesToScroll: 1,
5 | gap: 15,
6 | mouseDrag: true,
7 | showArrows: true,
8 | nextButtonSelector: null,
9 | prevButtonSelector: null,
10 | showScrollbar: true,
11 | scrollbarStyle: {
12 | position: "bottom",
13 | height: 8,
14 | marginTop: 8,
15 | marginBottom: 8,
16 | borderRadius: 4,
17 | backgroundColor: "#ebebeb",
18 | thumbColor: "#6d6d6d",
19 | thumbHoverColor: "#4b4b4b",
20 | },
21 | responsive: null,
22 | };
23 |
24 | export const CAROUSEL_STYLE_TEXT = `
25 | display: flex;
26 | flex-wrap: nowrap;
27 | overflow-x: scroll;
28 | -ms-overflow-style: none;
29 | scrollbar-width: none;
30 | `;
31 |
32 | export const ARROW_STYLE = {
33 | buttonBackground: "#ffffff",
34 | buttonBackgroundHover: "#111111",
35 | buttonShadow: "#b4b4b4 0px 0px 2px 1px",
36 | buttonShadowHover: "#dadada 0px 0px 2px 1px",
37 | color: "#979797",
38 | colorHover: "#ffffff",
39 | };
40 | export const arrowStyle = {
41 | arrowButtonStyleText: `
42 | width: 40px;
43 | height: 40px;
44 | position: absolute;
45 | top: calc(50% - 20px);
46 | cursor: pointer;
47 | transition: all 0.4s linear;
48 | background-color: ${ARROW_STYLE.buttonBackground};
49 | box-shadow: ${ARROW_STYLE.buttonShadow};
50 | touch-action: manipulation;
51 | appearance: none;
52 | border: none;
53 | border-radius: 50%;
54 | `,
55 | arrowButtonIconStyleText: `
56 | position: relative;
57 | width: 12px;
58 | height: 12px;
59 | border-style: solid;
60 | border-width: 0 0 2px 2px;
61 | border-color: ${ARROW_STYLE.color};
62 | `,
63 | };
64 |
65 | export const scrollIndicatorStyle = {
66 | scrollIndicatorStyleText: `
67 | display: flex;
68 | align-items: center;
69 | justify-content: center;
70 | position: relative;
71 | cursor: pointer;
72 | `,
73 | scrollIndicatorBarWrapperStyleText: `
74 | width: 100%;
75 | scrollbar-width: none;
76 | transform: translateX(0);
77 | `,
78 | scrollIndicatorBarStyleText: `
79 | will-change: transform;
80 | position: absolute;
81 | top: 0;
82 | bottom: 0;
83 | transform-origin: 0 0;
84 | cursor: grab;
85 | transition: background-color 0.3s;
86 | `,
87 | };
88 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Touchpad scroll carousel examples
8 |
26 |
27 |
28 |
29 |
Touchpad scroll carousel examples
30 |
31 |
32 |
38 |
39 |
44 |
45 |
50 |
51 |
56 |
57 |
62 |
63 |
68 |
69 |
74 |
75 |
76 |
77 |
83 |
84 |
89 |
90 |
95 |
96 |
101 |
102 |
107 |
108 |
113 |
114 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/webpack.common.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack");
2 | const HtmlWebpackPlugin = require("html-webpack-plugin");
3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
4 | const ImageMinimizerWebpackPlugin = require("image-minimizer-webpack-plugin");
5 | const {extendDefaultPlugins} = require("svgo");
6 | const json5 = require("json5");
7 |
8 | module.exports = {
9 | // https://webpack.js.org/concepts/entry-points/
10 | entry: {
11 | main: {
12 | import: "./src/main.js",
13 | filename: "touchpad-scroll-carousel.min.js",
14 | },
15 | index: {
16 | import: "./src/index.js",
17 | filename: "index.[contenthash].js",
18 | dependOn: "main",
19 | },
20 | },
21 |
22 | // https://webpack.js.org/concepts/output/
23 | output: {
24 | path: `${__dirname}/dist`,
25 | publicPath: "/",
26 | clean: true,
27 | },
28 |
29 | // https://webpack.js.org/concepts/plugins/
30 | plugins: [
31 | // https://webpack.js.org/plugins/html-webpack-plugin/
32 | new HtmlWebpackPlugin({
33 | template: "./src/index.html",
34 | inject: "body",
35 | chunks: ["main", "index"],
36 | filename: "index.html",
37 | }),
38 |
39 | // https://webpack.js.org/plugins/mini-css-extract-plugin/
40 | new MiniCssExtractPlugin({
41 | filename: "[name].min.css",
42 | }),
43 |
44 | // https://webpack.js.org/plugins/image-minimizer-webpack-plugin/
45 | new ImageMinimizerWebpackPlugin({
46 | minimizerOptions: {
47 | // Lossless optimization with custom option
48 | plugins: [
49 | ["gifsicle", {interlaced: true}],
50 | ["jpegtran", {progressive: true}],
51 | ["optipng", {optimizationLevel: 5}],
52 | // Svgo configuration here https://github.com/svg/svgo#configuration
53 | [
54 | "svgo",
55 | {
56 | plugins: extendDefaultPlugins([
57 | {
58 | name: "removeViewBox",
59 | active: false,
60 | },
61 | {
62 | name: "addAttributesToSVGElement",
63 | params: {
64 | attributes: [{xmlns: "http://www.w3.org/2000/svg"}],
65 | },
66 | },
67 | ]),
68 | },
69 | ],
70 | ],
71 | },
72 | }),
73 |
74 | // https://webpack.js.org/plugins/banner-plugin/#root
75 | new webpack.BannerPlugin({
76 | banner: "Scroll Carousel v1.0.3 | (c) 2022 Robin Huy | MIT license.\n",
77 | }),
78 | ],
79 |
80 | // https://webpack.js.org/concepts/modules/
81 | module: {
82 | rules: [
83 | {
84 | test: /\.html$/i,
85 | loader: "html-loader",
86 | },
87 | {
88 | test: /.s?css$/,
89 | use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
90 | },
91 | {
92 | test: /\.js$/,
93 | exclude: /node_modules/,
94 | loader: "babel-loader",
95 | options: {
96 | presets: ["@babel/preset-env"],
97 | },
98 | },
99 | {
100 | test: /\.(jpe?g|png|gif|svg)$/i,
101 | type: "asset/resource",
102 | generator: {
103 | filename: "img/[hash][ext]",
104 | },
105 | },
106 | {
107 | test: /\.(woff|woff2|eot|ttf|otf)$/i,
108 | generator: {
109 | filename: "fonts/[name][ext]",
110 | },
111 | use: {
112 | loader: "url-loader", // Use url-loader when change generator filename
113 | },
114 | },
115 | {
116 | test: /\.json5$/i,
117 | type: "json",
118 | parser: {
119 | parse: json5.parse,
120 | },
121 | },
122 | ],
123 | },
124 | };
125 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import {DEFAULT_OPTIONS} from "./const";
2 | import {initArrows, initMouseDrag, initScrollIndicatorBarDrag} from "./dom";
3 | import {createScrollIndicator, getResponsiveSettings, setCarouselStyles} from "./style";
4 |
5 | function TouchpadScrollCarousel(options) {
6 | // Set default values
7 | let {
8 | carouselSelector,
9 | slidesToShow,
10 | slidesToScroll,
11 | gap,
12 | mouseDrag,
13 | showArrows,
14 | prevButtonSelector,
15 | nextButtonSelector,
16 | showScrollbar,
17 | responsive,
18 | } = Object.assign({...DEFAULT_OPTIONS}, options);
19 | let scrollbarStyle = Object.assign(DEFAULT_OPTIONS.scrollbarStyle, options?.scrollbarStyle);
20 |
21 | // Global state to track data changed
22 | const state = {
23 | carousel: null,
24 | carouselStyled: false,
25 | prevButtonElement: null,
26 | nextButtonElement: null,
27 | handlePrevButtonClick: null,
28 | handleNextButtonClick: null,
29 | scrollIndicatorElement: null,
30 | scrollIndicatorBarElement: null,
31 | isScrollbarIndicatorScrolling: false,
32 | resizeDebounceTimer: null,
33 | };
34 |
35 | // Check carousel selector
36 | const carousel = document.querySelector(carouselSelector);
37 | if (!carousel) {
38 | console.error(`Cannot found carouselSelector "${carouselSelector}".`);
39 | return;
40 | }
41 | state.carousel = carousel;
42 |
43 | initCarousel(
44 | state,
45 | carouselSelector,
46 | slidesToShow,
47 | slidesToScroll,
48 | gap,
49 | mouseDrag,
50 | showArrows,
51 | prevButtonSelector,
52 | nextButtonSelector,
53 | showScrollbar,
54 | responsive,
55 | scrollbarStyle
56 | );
57 |
58 | // Update carousel responsive
59 | const debounce = (callback, time) => {
60 | window.clearTimeout(state.resizeDebounceTimer);
61 | state.resizeDebounceTimer = window.setTimeout(callback, time);
62 | };
63 | window.addEventListener("resize", () => {
64 | debounce(() => {
65 | initCarousel(
66 | state,
67 | carouselSelector,
68 | slidesToShow,
69 | slidesToScroll,
70 | gap,
71 | mouseDrag,
72 | showArrows,
73 | prevButtonSelector,
74 | nextButtonSelector,
75 | showScrollbar,
76 | responsive,
77 | scrollbarStyle
78 | );
79 | }, 500);
80 | });
81 | }
82 |
83 | function initCarousel(
84 | state,
85 | carouselSelector,
86 | slidesToShow,
87 | slidesToScroll,
88 | gap,
89 | mouseDrag,
90 | showArrows,
91 | prevButtonSelector,
92 | nextButtonSelector,
93 | showScrollbar,
94 | responsive,
95 | scrollbarStyle
96 | ) {
97 | const {carousel} = state;
98 |
99 | // Setup sizes
100 | let {_slidesToShow, _slidesToScroll, _gap} = getResponsiveSettings(
101 | responsive,
102 | slidesToShow,
103 | slidesToScroll,
104 | gap
105 | );
106 | _slidesToScroll = Math.round(_slidesToScroll);
107 | if (Number.isFinite(+_gap)) {
108 | _gap = _gap + "px";
109 | }
110 | const gapNumber = parseFloat(_gap);
111 | const totalGapNumber = (_slidesToShow - 1) * gapNumber;
112 |
113 | setCarouselStyles(state, carouselSelector, _slidesToShow, _gap, gapNumber, totalGapNumber);
114 |
115 | if (showArrows) {
116 | initArrows(
117 | state,
118 | gapNumber,
119 | totalGapNumber,
120 | _slidesToShow,
121 | _slidesToScroll,
122 | nextButtonSelector,
123 | prevButtonSelector
124 | );
125 |
126 | // Re-init to prevent layout changed (ex: document scrollbar appear/disappear)
127 | setTimeout(() => {
128 | initArrows(
129 | state,
130 | gapNumber,
131 | totalGapNumber,
132 | _slidesToShow,
133 | _slidesToScroll,
134 | nextButtonSelector,
135 | prevButtonSelector
136 | );
137 | }, 100);
138 | }
139 |
140 | if (showScrollbar) {
141 | createScrollIndicator(state, scrollbarStyle);
142 |
143 | carousel.addEventListener("scroll", () => {
144 | if (!state.isScrollbarIndicatorScrolling) {
145 | const carouselMaxScrollLeft = carousel.scrollWidth - carousel.offsetWidth;
146 | const scrollIndicatorBarMaxTranslate =
147 | carousel.offsetWidth - state.scrollIndicatorBarElement.offsetWidth;
148 | const scrollIndicatorBarTranslate =
149 | (carousel.scrollLeft * scrollIndicatorBarMaxTranslate) / carouselMaxScrollLeft;
150 | state.scrollIndicatorBarElement.style.transform = `translateX(${scrollIndicatorBarTranslate}px)`;
151 | }
152 | });
153 | initScrollIndicatorBarDrag(state);
154 | }
155 |
156 | if (mouseDrag) {
157 | initMouseDrag(carousel);
158 | }
159 | }
160 |
161 | window.TouchpadScrollCarousel = TouchpadScrollCarousel;
162 |
163 | export default TouchpadScrollCarousel;
164 |
--------------------------------------------------------------------------------
/src/style.js:
--------------------------------------------------------------------------------
1 | import { ARROW_STYLE, CAROUSEL_STYLE_TEXT, arrowStyle, scrollIndicatorStyle } from "./const";
2 |
3 | export const getResponsiveSettings = (responsive, slidesToShow, slidesToScroll, gap) => {
4 | if (!responsive) {
5 | return { _slidesToShow: slidesToShow, _slidesToScroll: slidesToScroll, _gap: gap };
6 | }
7 |
8 | responsive = responsive.sort((a, b) => a.breakpoint - b.breakpoint);
9 |
10 | let index = -1;
11 | for (let screen of responsive) {
12 | if (window.matchMedia(`(min-width: ${screen.breakpoint}px)`).matches) {
13 | index++;
14 | } else {
15 | break;
16 | }
17 | }
18 |
19 | const setting = responsive[index];
20 |
21 | return {
22 | _slidesToShow: setting?.slidesToShow || slidesToShow,
23 | _slidesToScroll: setting?.slidesToScroll || slidesToScroll,
24 | _gap: setting?.gap || gap,
25 | };
26 | };
27 |
28 | export const setCarouselStyles = (
29 | state,
30 | carouselSelector,
31 | slidesToShow,
32 | gap,
33 | gapNumber,
34 | totalGapNumber
35 | ) => {
36 | const { carousel, carouselStyled } = state;
37 | if (carouselStyled) return;
38 |
39 | const gapUnit = gap.replace(gapNumber, "");
40 | const totalGapWithUnit = totalGapNumber + gapUnit;
41 | const halfGapWithUnit = gapNumber / 2 + gapUnit;
42 | const carouselChildren = carousel.children;
43 |
44 | // Create wrapper element
45 | const wrapperElement = document.createElement("div");
46 | wrapperElement.style.position = "relative";
47 | carousel.after(wrapperElement);
48 | wrapperElement.appendChild(carousel);
49 |
50 | // Style carousel
51 | const styleElement = document.createElement("style");
52 | const styles = document.createTextNode(`
53 | ${carouselSelector}::-webkit-scrollbar {
54 | display: none;
55 | }
56 | ${carouselSelector} {
57 | ${CAROUSEL_STYLE_TEXT}
58 | }
59 | ${carouselSelector} img {
60 | max-width: 100%;
61 | }
62 | `);
63 | styleElement.appendChild(styles);
64 | carousel.before(styleElement);
65 |
66 | // Style items
67 | for (let i = 0; i < carousel.children.length; i++) {
68 | const item = carousel.children[i];
69 |
70 | item.style.cssText = `
71 | flex-shrink: 0;
72 | width: calc((100% - ${totalGapWithUnit}) / ${slidesToShow});
73 | margin-left: ${halfGapWithUnit};
74 | margin-right: ${halfGapWithUnit};
75 | `;
76 |
77 | if (i === 0) {
78 | item.style.marginLeft = "0";
79 | }
80 |
81 | if (i === carouselChildren.length - 1) {
82 | item.style.marginRight = "0";
83 | }
84 | }
85 | };
86 |
87 | export const createDefaultArrowButton = (type = "prev") => {
88 | // Create button
89 | const button = document.createElement("button");
90 | button.style.cssText = arrowStyle.arrowButtonStyleText;
91 |
92 | // Create arrow
93 | const arrow = document.createElement("div");
94 | arrow.style.cssText = arrowStyle.arrowButtonIconStyleText;
95 | if (type === "prev") {
96 | button.setAttribute("aria-label", "Previous");
97 | button.style.left = "-20px";
98 | arrow.style.left = "10px";
99 | arrow.style.transform = "rotate(45deg)";
100 | } else {
101 | button.setAttribute("aria-label", "Next");
102 | button.style.right = "-20px";
103 | arrow.style.left = "5px";
104 | arrow.style.transform = "rotate(-135deg)";
105 | }
106 | button.appendChild(arrow);
107 |
108 | // Hover effect
109 | const removeHoverEffect = () => {
110 | button.style.backgroundColor = ARROW_STYLE.buttonBackground;
111 | button.style.boxShadow = ARROW_STYLE.buttonShadow;
112 | arrow.style.borderColor = ARROW_STYLE.color;
113 | button.removeEventListener("mouseleave", removeHoverEffect);
114 | };
115 | button.addEventListener("mouseenter", () => {
116 | button.style.backgroundColor = ARROW_STYLE.buttonBackgroundHover;
117 | button.style.boxShadow = ARROW_STYLE.buttonShadowHover;
118 | arrow.style.borderColor = ARROW_STYLE.colorHover;
119 | button.addEventListener("mouseleave", removeHoverEffect);
120 | });
121 |
122 | return button;
123 | };
124 |
125 | export const createScrollIndicator = (
126 | state,
127 | {
128 | position,
129 | height,
130 | marginTop,
131 | marginBottom,
132 | borderRadius,
133 | backgroundColor,
134 | thumbColor,
135 | thumbHoverColor,
136 | }
137 | ) => {
138 | const { carousel } = state;
139 |
140 | if (!state.scrollIndicatorElement) {
141 | // Create scroll indicator element
142 | const scrollIndicator = document.createElement("div");
143 | scrollIndicator.style.cssText = `
144 | ${scrollIndicatorStyle.scrollIndicatorStyleText}
145 | margin-top: ${marginTop}px;
146 | margin-bottom: ${marginBottom}px;
147 | `;
148 | state.scrollIndicatorElement = scrollIndicator;
149 |
150 | // Create scroll indicator bar wrapper element
151 | const scrollIndicatorBarWrapper = document.createElement("div");
152 | scrollIndicatorBarWrapper.style.cssText = `
153 | ${scrollIndicatorStyle.scrollIndicatorBarWrapperStyleText}
154 | height: ${height}px;
155 | background: ${backgroundColor};
156 | border-radius: ${borderRadius}px;
157 | `;
158 |
159 | // Create scroll indicator bar element
160 | const scrollIndicatorBar = document.createElement("div");
161 | scrollIndicatorBar.style.cssText = `
162 | ${scrollIndicatorStyle.scrollIndicatorBarStyleText}
163 | height: ${height}px;
164 | background-color: ${thumbColor};
165 | border-radius: ${borderRadius}px;
166 | `;
167 | scrollIndicatorBar.addEventListener("mouseover", () => {
168 | scrollIndicatorBar.style.backgroundColor = thumbHoverColor || thumbColor;
169 | });
170 | scrollIndicatorBar.addEventListener("mouseleave", () => {
171 | scrollIndicatorBar.style.backgroundColor = thumbColor;
172 | });
173 | state.scrollIndicatorBarElement = scrollIndicatorBar;
174 |
175 | // Append scrollbar to carousel
176 | scrollIndicator.appendChild(scrollIndicatorBarWrapper);
177 | scrollIndicatorBarWrapper.appendChild(scrollIndicatorBar);
178 | if (position === "top") {
179 | carousel.before(scrollIndicator);
180 | } else {
181 | carousel.after(scrollIndicator);
182 | }
183 | }
184 |
185 | // Calculate scroll indicator bar width
186 | const scrollIndicatorBarWidthRatio =
187 | (carousel.offsetWidth * carousel.offsetWidth) / carousel.scrollWidth;
188 | state.scrollIndicatorBarElement.style.width = `${scrollIndicatorBarWidthRatio}px`;
189 | };
190 |
--------------------------------------------------------------------------------
/src/dom.js:
--------------------------------------------------------------------------------
1 | import { createDefaultArrowButton } from "./style";
2 | import { roundDimension } from "./util";
3 |
4 | const preventDragElementAnchor = (carousel) => {
5 | let isDrag = false;
6 |
7 | const anchorElements = carousel.getElementsByTagName("a");
8 |
9 | for (let element of anchorElements) {
10 | element.addEventListener("dragstart", (event) => {
11 | element.style.cursor = "grab";
12 | isDrag = true;
13 | event.preventDefault();
14 | });
15 |
16 | element.addEventListener("click", (event) => {
17 | if (isDrag) {
18 | event.preventDefault();
19 | }
20 | });
21 |
22 | element.addEventListener("mouseup", () => {
23 | element.style.removeProperty("cursor");
24 | setTimeout(() => {
25 | isDrag = false;
26 | }, 50);
27 | });
28 | }
29 | };
30 |
31 | export const initMouseDrag = (carousel) => {
32 | // Store scroll state
33 | const position = { left: 0, x: 0 };
34 |
35 | const startDrag = (e) => {
36 | // The current scroll
37 | position.left = carousel.scrollLeft;
38 |
39 | // The current mouse position
40 | position.x = e.clientX;
41 |
42 | // Add event handleDrag on element
43 | carousel.addEventListener("mousemove", handleDrag);
44 |
45 | // Add event removeDragEvent on document to prevent mouseup outside element
46 | document.addEventListener("mouseup", removeDragEvent);
47 |
48 | // Disable user select & change style of cursor when drag
49 | carousel.style.userSelect = "none";
50 | carousel.style.cursor = "grab";
51 | };
52 |
53 | const removeDragEvent = () => {
54 | document.removeEventListener("mouseup", removeDragEvent);
55 | carousel.removeEventListener("mousemove", handleDrag);
56 | carousel.style.removeProperty("user-select");
57 | carousel.style.removeProperty("cursor");
58 | };
59 |
60 | const handleDrag = (e) => {
61 | const dx = e.clientX - position.x;
62 | const distance = position.left - dx;
63 | carousel.scrollLeft = distance;
64 | };
65 |
66 | preventDragElementAnchor(carousel);
67 | carousel.addEventListener("mousedown", startDrag);
68 | };
69 |
70 | export const initScrollIndicatorBarDrag = (state) => {
71 | const { carousel, scrollIndicatorElement, scrollIndicatorBarElement } = state;
72 |
73 | // Store scroll state
74 | const position = { left: 0, x: 0 };
75 |
76 | // Reset position of scroll indicator bar
77 | scrollIndicatorBarElement.style.transform = `translateX(0)`;
78 |
79 | const startDrag = (e) => {
80 | // The current scroll indicator bar position
81 | position.left = parseFloat(scrollIndicatorBarElement.style.transform.slice(11)) || 0;
82 |
83 | // The current mouse position
84 | position.x = e.clientX;
85 |
86 | // Update scrolling status
87 | state.isScrollbarIndicatorScrolling = true;
88 |
89 | // Add event handle
90 | document.addEventListener("mousemove", handleDrag);
91 | document.addEventListener("mouseup", removeDragEvent);
92 |
93 | // Disable user select & change style of cursor when drag
94 | carousel.style.userSelect = "none";
95 | scrollIndicatorBarElement.style.cursor = "grab";
96 | };
97 |
98 | const removeDragEvent = () => {
99 | document.removeEventListener("mouseup", removeDragEvent);
100 | document.removeEventListener("mousemove", handleDrag);
101 | carousel.style.removeProperty("user-select");
102 | scrollIndicatorBarElement.style.removeProperty("cursor");
103 |
104 | // Update scrolling status
105 | state.isScrollbarIndicatorScrolling = false;
106 | };
107 |
108 | const handleDrag = (e) => {
109 | const carouselWidth = carousel.offsetWidth;
110 | const scrollIndicatorBarMaxTranslate = carouselWidth - scrollIndicatorBarElement.offsetWidth;
111 | const carouselMaxScrollLeft = carousel.scrollWidth - carouselWidth;
112 |
113 | // Move the scroll indicator bar
114 | const dx = e.clientX - position.x;
115 | let translate = position.left + dx;
116 |
117 | if (translate <= 0) translate = 0;
118 | if (translate >= scrollIndicatorBarMaxTranslate) translate = scrollIndicatorBarMaxTranslate;
119 |
120 | scrollIndicatorBarElement.style.transform = `translateX(${translate}px)`;
121 |
122 | // Move the carousel
123 | carousel.scrollLeft = (translate * carouselMaxScrollLeft) / scrollIndicatorBarMaxTranslate;
124 | };
125 |
126 | scrollIndicatorElement.addEventListener("mousedown", startDrag);
127 | };
128 |
129 | export const initArrows = (
130 | state,
131 | gapNumber,
132 | totalGapNumber,
133 | slidesToShow,
134 | slidesToScroll,
135 | nextButtonSelector,
136 | prevButtonSelector
137 | ) => {
138 | let { carousel, prevButtonElement, nextButtonElement } = state;
139 |
140 | const position = { left: 0 };
141 | const item = { itemWidth: 0, itemFullWidth: 0 };
142 | item.itemWidth = roundDimension((carousel.offsetWidth - totalGapNumber) / slidesToShow);
143 | item.itemFullWidth = roundDimension(item.itemWidth + gapNumber);
144 |
145 | // Create next button element
146 | if (!nextButtonElement) {
147 | if (nextButtonSelector) {
148 | nextButtonElement = document.querySelector(nextButtonSelector);
149 | if (!nextButtonElement)
150 | console.error(`Cannot found nextButtonSelector "${nextButtonSelector}".`);
151 | }
152 |
153 | if (!nextButtonElement) {
154 | nextButtonElement = createDefaultArrowButton("next");
155 | carousel.after(nextButtonElement);
156 | }
157 |
158 | state.nextButtonElement = nextButtonElement;
159 | }
160 |
161 | // Remove previous onclick event if exists
162 | if (state.handleNextButtonClick) {
163 | nextButtonElement.removeEventListener("click", state.handleNextButtonClick);
164 | }
165 |
166 | // Add onclick event handler to next button
167 | const handleNextButtonClick = () => {
168 | position.left = roundDimension(carousel.scrollLeft);
169 | const remainDistance = roundDimension(position.left % item.itemFullWidth);
170 | const left = position.left + item.itemFullWidth * slidesToScroll - remainDistance;
171 | carousel.scrollTo({ left, behavior: "smooth" });
172 | };
173 | nextButtonElement.addEventListener("click", handleNextButtonClick);
174 | state.handleNextButtonClick = handleNextButtonClick;
175 |
176 | // Create prev button element
177 | if (!prevButtonElement) {
178 | if (prevButtonSelector) {
179 | prevButtonElement = document.querySelector(prevButtonSelector);
180 | if (!prevButtonElement)
181 | console.error(`Cannot found prevButtonSelector "${prevButtonSelector}".`);
182 | }
183 |
184 | if (!prevButtonElement) {
185 | prevButtonElement = createDefaultArrowButton("prev");
186 | carousel.after(prevButtonElement);
187 | }
188 |
189 | state.prevButtonElement = prevButtonElement;
190 | }
191 |
192 | // Remove previous onclick event if exists
193 | if (state.handlePrevButtonClick) {
194 | prevButtonElement.removeEventListener("click", state.handlePrevButtonClick);
195 | }
196 |
197 | // Add onclick event handler to prev button
198 | const handlePrevButtonClick = () => {
199 | position.left = roundDimension(carousel.scrollLeft);
200 | let remainDistance = roundDimension(position.left % (item.itemFullWidth * slidesToScroll));
201 | if (remainDistance <= gapNumber) remainDistance = item.itemFullWidth * slidesToScroll;
202 |
203 | const left = position.left - remainDistance;
204 | carousel.scrollTo({ left, behavior: "smooth" });
205 | };
206 | prevButtonElement.addEventListener("click", handlePrevButtonClick);
207 | state.handlePrevButtonClick = handlePrevButtonClick;
208 | };
209 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Touchpad Scroll Carousel
2 |
3 | _The simple, lightweight, responsive carousel requires no dependencies and supports smooth scrolling (left/right) on touchpads._
4 |
5 | ## Demo
6 |
7 | [Touchpad Scroll Carousel Example](https://huydq.dev/static-apps/touchpad-scroll-carousel/)
8 |
9 | ## Install
10 |
11 | ### Markup
12 |
13 | HTML would look something like this:
14 |
15 | ```html
16 |
23 | ```
24 |
25 | You only need a list of item (slide), then initialize the carousel like this:
26 |
27 | ```javascript
28 | TouchpadScrollCarousel({
29 | carouselSelector: "#carousel",
30 | ... // other options
31 | });
32 | ```
33 |
34 | ### CDN
35 |
36 | jsDelivr: [https://cdn.jsdelivr.net/npm/touchpad-scroll-carousel/dist/touchpad-scroll-carousel.min.js](https://cdn.jsdelivr.net/npm/touchpad-scroll-carousel/dist/touchpad-scroll-carousel.min.js)
37 |
38 | Example use CDN:
39 |
40 | ```html
41 | ...
42 |
43 |
44 |
45 |

46 |
47 |
48 |
49 |

50 |
51 |
52 |
53 |

54 |
55 |
56 |
57 |
58 |
63 |
64 | ```
65 |
66 | ### NPM
67 |
68 | ```
69 | npm install touchpad-scroll-carousel
70 | ```
71 |
72 | or using `yarn`:
73 |
74 | ```
75 | yarn add touchpad-scroll-carousel
76 | ```
77 |
78 | Example use NPM for a React App:
79 |
80 | ```jsx
81 | import { useEffect } from "react";
82 | import "touchpad-scroll-carousel/dist/touchpad-scroll-carousel.min.js";
83 |
84 | const CarouselComponent = () => {
85 | useEffect(() => {
86 | window.TouchpadScrollCarousel({
87 | carouselSelector: "#carousel",
88 | });
89 | }, []);
90 |
91 | return (
92 |
93 |
98 |
99 |
104 |
105 |
110 |
111 | );
112 | };
113 |
114 | export default CarouselComponent;
115 | ```
116 |
117 | If you want to use TypeScript, create a file name `index.d.ts` in root folder of the project or in same folder as the component:
118 |
119 | ```js
120 | export {};
121 |
122 | interface ResponsiveOptions {
123 | breakpoint?: number;
124 | slidesToShow?: number;
125 | gap?: number;
126 | }
127 |
128 | interface TouchpadScrollCarouselOptions {
129 | carouselSelector: string;
130 | slidesToShow?: number;
131 | slidesToScroll?: number;
132 | gap?: number;
133 | mouseDrag?: boolean;
134 | showArrows?: boolean;
135 | nextButtonSelector?: string;
136 | prevButtonSelector?: string;
137 | showScrollbar?: boolean;
138 | scrollbarStyle?: {
139 | position?: string;
140 | height?: number;
141 | marginTop?: number;
142 | marginBottom?: number;
143 | borderRadius?: number;
144 | backgroundColor?: string;
145 | thumbColor?: string;
146 | thumbHoverColor?: string;
147 | };
148 | responsive?: ResponsiveOptions[];
149 | }
150 |
151 | declare global {
152 | interface Window {
153 | TouchpadScrollCarousel: (options: TouchpadScrollCarouselOptions) => void;
154 | }
155 | }
156 | ```
157 |
158 | ## Settings
159 |
160 | | Option | Type | Default | Description |
161 | | ------------------ | -------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
162 | | carouselSelector | string (CSS selector) | null | Selects a node to initialize the carousel |
163 | | gap | int (value as pixel unit) | 15 | The gap between items |
164 | | mouseDrag | boolean | true | Enables mouse dragging |
165 | | showArrows | boolean | true | Enables Next/Prev arrows |
166 | | nextButtonSelector | string (CSS selector) | `#btn-next` | Allows you to select a node to customize the "Next" arrow. Only work when `showArrows = true`. |
167 | | prevButtonSelector | string (CSS selector) | `#btn-prev` | Allows you to select a node to customize the "Previous" arrow. Only work when `showArrows = true`. |
168 | | showScrollbar | boolean | true | Enables scrollbar. |
169 | | scrollbarStyle | object | [See example](#scrollbarstyle-option) | Contains style settings for the scrollbar. Only work when `showScrollbar = true`. |
170 | | responsive | array | null | Array of objects [contains breakpoints and setting objects (see example)](#responsive-option). Enables settings at given `breakpoint`. |
171 | | slidesToScroll | int | 1 | # of slides to scroll at a time |
172 | | slidesToShow | float | 1 | # of slides to show at a time |
173 |
174 | ### ScrollbarStyle Option
175 |
176 | The scrollbar style options, for example:
177 |
178 | ```javascript
179 | TouchpadScrollCarousel({
180 | carouselSelector: "#carousel",
181 | scrollbarStyle: {
182 | position: "bottom", // "top" or "bottom"
183 | height: 8,
184 | marginTop: 8,
185 | marginBottom: 8,
186 | borderRadius: 4,
187 | backgroundColor: "#ebebeb",
188 | thumbColor: "#6d6d6d",
189 | thumbHoverColor: "#4b4b4b",
190 | },
191 | });
192 | ```
193 |
194 | Note that dimensions are measured in px and colors are in string format (color name, hex value,...).
195 |
196 | ### Responsive Option
197 |
198 | The responsive options with self-defined breakpoints, for example:
199 |
200 | ```javascript
201 | TouchpadScrollCarousel({
202 | carouselSelector: "#carousel",
203 | responsive: [
204 | {
205 | breakpoint: 768,
206 | slidesToShow: 2.5,
207 | gap: 20,
208 | },
209 | {
210 | breakpoint: 1200,
211 | slidesToShow: 4,
212 | gap: 30,
213 | },
214 | ],
215 | });
216 | ```
217 |
218 | ## Browser support
219 |
220 | Touchpad Scroll Carousel works on modern browsers such as Edge, Chrome, Firefox, and Safari.
221 |
222 | ## License
223 |
224 | Copyright (c) 2022 Robin Huy.
225 |
226 | Licensed under the MIT license.
227 |
--------------------------------------------------------------------------------
/dist/touchpad-scroll-carousel.min.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * Scroll Carousel v1.0.3 | (c) 2022 Robin Huy | MIT license.
3 | *
4 | */(()=>{"use strict";var e,t={},n={};function r(e){var o=n[e];if(void 0!==o)return o.exports;var l=n[e]={exports:{}};return t[e](l,l.exports,r),l.exports}r.m=t,e=[],r.O=(t,n,o,l)=>{if(!n){var a=1/0;for(u=0;u=l)&&Object.keys(r.O).every((e=>r.O[e](n[i])))?n.splice(i--,1):(c=!1,l0&&e[u-1][2]>l;u--)e[u]=e[u-1];e[u]=[n,o,l]},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={179:0};r.O.j=t=>0===e[t];var t=(t,n)=>{var o,l,[a,c,i]=n,s=0;if(a.some((t=>0!==e[t]))){for(o in c)r.o(c,o)&&(r.m[o]=c[o]);if(i)var u=i(r)}for(t&&t(n);s=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var l,a=!0,c=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return a=e.done,e},e:function(e){c=!0,l=e},f:function(){try{a||null==n.return||n.return()}finally{if(c)throw l}}}}function b(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&void 0!==arguments[0]?arguments[0]:"prev",t=document.createElement("button");t.style.cssText=f.arrowButtonStyleText;var n=document.createElement("div");n.style.cssText=f.arrowButtonIconStyleText,"prev"===e?(t.setAttribute("aria-label","Previous"),t.style.left="-20px",n.style.left="10px",n.style.transform="rotate(45deg)"):(t.setAttribute("aria-label","Next"),t.style.right="-20px",n.style.left="5px",n.style.transform="rotate(-135deg)"),t.appendChild(n);var r=function e(){t.style.backgroundColor=a,t.style.boxShadow=i,n.style.borderColor=u,t.removeEventListener("mouseleave",e)};return t.addEventListener("mouseenter",(function(){t.style.backgroundColor=c,t.style.boxShadow=s,n.style.borderColor=d,t.addEventListener("mouseleave",r)})),t},g=function(e){return+(+e).toFixed(0)};function x(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=function(e,t){if(!e)return;if("string"==typeof e)return w(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return w(e,t)}(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var r=0,o=function(){};return{s:o,n:function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var l,a=!0,c=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return a=e.done,e},e:function(e){c=!0,l=e},f:function(){try{a||null==n.return||n.return()}finally{if(c)throw l}}}}function w(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n=l&&(i=l),r.style.transform="translateX(".concat(i,"px)"),t.scrollLeft=i*a/l};n.addEventListener("mousedown",(function(n){o.left=parseFloat(r.style.transform.slice(11))||0,o.x=n.clientX,e.isScrollbarIndicatorScrolling=!0,document.addEventListener("mousemove",a),document.addEventListener("mouseup",l),t.style.userSelect="none",r.style.cursor="grab"}))}(e)),l&&S(f)}window.TouchpadScrollCarousel=B;o=r.O(o)})();
--------------------------------------------------------------------------------