├── 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 |
40 | 41 | 42 | 43 |
44 | 45 |
46 | 47 | 48 | 49 |
50 | 51 |
52 | 53 | 54 | 55 |
56 | 57 |
58 | 59 | 60 | 61 |
62 | 63 |
64 | 65 | 66 | 67 |
68 | 69 |
70 | 71 | 72 | 73 |
74 |
75 | 76 |
77 | 83 | 84 |
85 | 86 | 87 | 88 |
89 | 90 |
91 | 92 | 93 | 94 |
95 | 96 |
97 | 98 | 99 | 100 |
101 | 102 |
103 | 104 | 105 | 106 |
107 | 108 |
109 | 110 | 111 | 112 |
113 | 114 |
115 | 116 | 117 | 118 |
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 | 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 | 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)})(); --------------------------------------------------------------------------------