├── .npmignore ├── LICENSE ├── README.md ├── assets └── test.png ├── carouscroll.js ├── demo.html └── package.json /.npmignore: -------------------------------------------------------------------------------- 1 | assets/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2024 Zach Leatherman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # carouscroll 2 | 3 | A web component to add next/previous buttons to a horizontal scrollable container. 4 | 5 | * [Demos](https://zachleat.github.io/carouscroll/demo.html) 6 | 7 | ## Features 8 | 9 | * Interaction compatible with scroll or touch. 10 | * No layout shift. Make sure you include the CSS snippet! 11 | * (Optional) Smooth scrolling with `scroll-behavior: smooth`. 12 | * (Optional) `loop` attribute to enable looping around from start/end. 13 | * (Optional) Next/Previous buttons can be placed anywhere in the document. 14 | * (Optional) `` element can accessibly announce the current slide number (out of total number of slides). 15 | 16 | ## Installation 17 | 18 | You can install via `npm` ([`@zachleat/carouscroll`](https://www.npmjs.com/package/@zachleat/carouscroll)) or download the `carouscroll.js` JavaScript file manually. 19 | 20 | ```shell 21 | npm install @zachleat/carouscroll --save 22 | ``` 23 | 24 | Add `carouscroll.js` to your site’s JavaScript assets. 25 | 26 | ## Usage 27 | 28 | First you need to add some critical CSS to your page. These styles are **crucial** to reduce Layout Shift (CLS), set the aspect ratio of the slides, and to avoid loading `loading="lazy"` images on off-screen slides. 29 | 30 | ```css 31 | carou-scroll { 32 | display: flex; 33 | overflow-x: scroll; 34 | overflow-y: hidden; 35 | } 36 | carou-scroll > * { 37 | min-width: 100%; 38 | aspect-ratio: 16/9; 39 | } 40 | ``` 41 | 42 | Next, add the HTML: 43 | 44 | ```html 45 | 46 |
1
47 |
2
48 | 49 |
50 | ``` 51 | 52 | That’s it! 53 | 54 | ### Add buttons (optional) 55 | 56 | For maximum flexibility, these buttons can be placed anywhere in the document and are tied by an `id` back to the parent scroller. 57 | 58 | Make sure you think about the before/after JavaScript experience here. This component will remove `disabled` for you but you can add additional styling via your own CSS: `carou-scroll:defined {}`. 59 | 60 | ```html 61 | 62 | 63 | ``` 64 | 65 | ### Add output (optional) 66 | 67 | This will update (and accessibly announce) a current status element with e.g. `Slide 1 of 10` text. 68 | 69 | For maximum flexibility, this element can be placed anywhere in the document and is tied by an `id` back to the parent scroller. 70 | 71 | ```html 72 | 73 | ``` 74 | 75 | ### Make it loop around (optional) 76 | 77 | Add the `loop` attribute. 78 | 79 | ```html 80 | 81 | ``` 82 | 83 | ### Smooth scrolling CSS (optional) 84 | 85 | ```css 86 | carou-scroll { 87 | scroll-behavior: smooth; 88 | } 89 | ``` 90 | 91 | ### Add your own scroll snap CSS (optional) 92 | 93 | ```css 94 | carou-scroll { 95 | scroll-snap-type: x mandatory; 96 | } 97 | carou-scroll > * { 98 | scroll-snap-align: center; 99 | } 100 | ``` -------------------------------------------------------------------------------- /assets/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zachleat/carouscroll/caf24d26f812fe81a856b9927606307d2e35c051/assets/test.png -------------------------------------------------------------------------------- /carouscroll.js: -------------------------------------------------------------------------------- 1 | class Carouscroll extends HTMLElement { 2 | static tagName = "carou-scroll"; 3 | 4 | static register(tagName, registry) { 5 | if(!registry && ("customElements" in globalThis)) { 6 | registry = globalThis.customElements; 7 | } 8 | 9 | registry?.define(tagName || this.tagName, this); 10 | } 11 | 12 | static attr = { 13 | orientation: "orientation", 14 | disabled: "disabled", 15 | prev: "data-carousel-previous", 16 | next: "data-carousel-next", 17 | output: "data-carousel-output", 18 | outputCurrent: "data-carousel-output-current", 19 | outputTotal: "data-carousel-output-total", 20 | }; 21 | 22 | static classes = { 23 | }; 24 | 25 | static css = ` 26 | :host { 27 | display: flex; 28 | overflow-x: scroll; 29 | overflow-y: hidden; 30 | overscroll-behavior-x: contain; 31 | scroll-snap-type: x mandatory; 32 | } 33 | ::slotted(*) { 34 | display: block; 35 | min-width: 100%; 36 | scroll-snap-align: center; 37 | } 38 | `; 39 | 40 | connectedCallback() { 41 | // https://caniuse.com/mdn-api_cssstylesheet_replacesync 42 | if(this.shadowRoot || !("replaceSync" in CSSStyleSheet.prototype) || this.hasAttribute(Carouscroll.attr.disabled)) { 43 | return; 44 | } 45 | 46 | let shadowroot = this.attachShadow({ mode: "open" }); 47 | let sheet = new CSSStyleSheet(); 48 | sheet.replaceSync(Carouscroll.css); 49 | shadowroot.adoptedStyleSheets = [sheet]; 50 | 51 | let slot = document.createElement("slot"); 52 | shadowroot.appendChild(slot); 53 | 54 | this.content = this; 55 | this.content.setAttribute("tabindex", "0"); 56 | this.slides = this.content.children; 57 | 58 | this.initializeButtons(); 59 | this.initializeOutput(); 60 | 61 | let activePage = this.findActivePage(); 62 | this.renderMetadata(activePage); 63 | 64 | /* Manual scrolling */ 65 | /* Note: `scrollend` missing on Safari */ 66 | this.content.addEventListener("scrollend", () => { 67 | this.renderMetadata(this.findActivePage()); 68 | }); 69 | // Manual touch scroll 70 | this.content.addEventListener("touchend", () => { 71 | this.renderMetadata(this.findActivePage()); 72 | }) 73 | } 74 | 75 | get id() { 76 | return this.getAttribute("id"); 77 | } 78 | 79 | initializeButtons() { 80 | this.nextButton = document.querySelector(`[${Carouscroll.attr.next}="${this.id}"]`); 81 | this.prevButton = document.querySelector(`[${Carouscroll.attr.prev}="${this.id}"]`); 82 | 83 | this.prevButton?.addEventListener("click", e => { 84 | this._buttonClick("previous"); 85 | e.preventDefault(); 86 | }, false); 87 | 88 | this.nextButton?.addEventListener("click", e => { 89 | this._buttonClick("next"); 90 | e.preventDefault(); 91 | }, false); 92 | } 93 | 94 | initializeOutput() { 95 | let output = document.querySelector(`[${Carouscroll.attr.output}="${this.id}"]`); 96 | if(!output) { 97 | return; 98 | } 99 | 100 | this.output = output; 101 | 102 | if(output.childElementCount === 0) { 103 | output.innerHTML = ` of `; 104 | } 105 | 106 | this.outputCurrent = output.querySelector(`[${Carouscroll.attr.outputCurrent}]`); 107 | this.outputTotal = output.querySelector(`[${Carouscroll.attr.outputTotal}]`); 108 | 109 | // https://www.w3.org/WAI/tutorials/carousels/functionality/ 110 | output.setAttribute("aria-live", "polite"); 111 | output.setAttribute("aria-atomic", "true"); 112 | } 113 | 114 | renderOutput(activePage) { 115 | if(!this.outputCurrent || !this.outputTotal) { 116 | return; 117 | } 118 | 119 | this.outputCurrent.innerText = this.findIndexForPage(activePage); 120 | this.outputTotal.innerText = this.content.children.length; 121 | } 122 | 123 | findIndexForPage(page) { 124 | let j = 0; 125 | for(let child of this.content.children) { 126 | j++; 127 | 128 | if(page === child) { 129 | return j; 130 | } 131 | } 132 | return -1; 133 | } 134 | 135 | findActivePage() { 136 | let activePage; 137 | let scrollPosition = this.content.scrollLeft; 138 | 139 | for(let child of this.content.children) { 140 | activePage = child; 141 | 142 | // start of active page must be before center of window 143 | // end of active page must be after center of window 144 | let start = child.offsetLeft; 145 | let end = start + child.offsetWidth; 146 | 147 | let scrollMidpoint = scrollPosition + this.content.offsetLeft + this.content.offsetWidth / 2; 148 | 149 | if(start <= scrollMidpoint && end >= scrollMidpoint) { 150 | break; 151 | } 152 | } 153 | 154 | return activePage; 155 | } 156 | 157 | isLooping() { 158 | return this.hasAttribute("loop"); 159 | } 160 | 161 | renderMetadata(page) { 162 | this.renderOutput(page); 163 | this.disablePrevNextButtons(page); 164 | } 165 | 166 | disablePrevNextButtons(moveToPage) { 167 | if(!this.nextButton || !this.prevButton) return; 168 | 169 | if(this.isLooping()) { 170 | this.prevButton.removeAttribute("disabled"); 171 | this.nextButton.removeAttribute("disabled"); 172 | return; 173 | } 174 | 175 | if(!moveToPage.nextElementSibling) { 176 | this.nextButton.setAttribute("disabled", ""); 177 | } else { 178 | this.nextButton.removeAttribute("disabled"); 179 | } 180 | 181 | if(!moveToPage.previousElementSibling) { 182 | this.prevButton.setAttribute("disabled", ""); 183 | } else { 184 | this.prevButton.removeAttribute("disabled"); 185 | } 186 | } 187 | 188 | _goToSlide(index) { 189 | const slideOffsetLeft = this.slides[index].offsetLeft; 190 | 191 | // In case the carousel is used in a grid system and isn’t full width, 192 | // we need the carousel position to not over scroll the slide. 193 | const carouselOffsetLeft = this.content.offsetLeft; 194 | 195 | this.content.scrollLeft = slideOffsetLeft - carouselOffsetLeft; 196 | this.renderMetadata(this.slides[index]); 197 | } 198 | 199 | _buttonClick(direction) { 200 | let activePage = this.findActivePage(); 201 | 202 | // TODO implement minScrollThreshold (of half the viewport width?) 203 | if(activePage) { 204 | let isLoop = this.isLooping(); 205 | let moveToPage = activePage[ `${direction}ElementSibling` ]; 206 | 207 | // Loop the carousel around 208 | if(!moveToPage && isLoop) { 209 | if(isLoop) { 210 | if(direction === "next") { 211 | // loop to the beginning 212 | this.content.scrollLeft = 0; 213 | } else { 214 | this.content.scrollLeft = this.content.lastElementChild.offsetLeft; 215 | } 216 | } 217 | } else if(moveToPage) { 218 | // Stop at the end 219 | let newScrollPosition = moveToPage.offsetLeft + moveToPage.offsetWidth / 2 - document.body.clientWidth / 2; 220 | this.content.scrollLeft = newScrollPosition; 221 | 222 | this.renderMetadata(moveToPage); 223 | } 224 | } 225 | } 226 | } 227 | 228 | Carouscroll.register(); 229 | 230 | export { Carouscroll } 231 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <carou-scroll> Web Component 8 | 40 | 41 | 52 | 53 | 54 | 55 |
56 |

<carou-scroll> Web Component

57 |
58 |
59 |

Back to the Source Code

60 |

Stock

61 |

This one includes a variety of child element types including <img> and <picture>. Note that the images are not loaded until you begin to scroll horizontally (when the browser supports loading="lazy").

62 | 77 |

Next/Previous Buttons

78 | 96 |

Looping

97 | 116 |

Show Current Slide Output

117 | 122 | 141 |

Show Current Slide Output with Custom Markup (e.g. i18n)

142 | 147 | 166 |

Full control of button styles and placement

167 | 181 | 197 |

Smooth scroll on Button Navigation

198 | 217 |

Links instead of Buttons

218 | 224 | 243 |

Manual Scroll Snap CSS

244 |

Useful if you want scroll snapping to be available before or without JavaScript.

245 | 253 | 272 |
273 | 274 | 275 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zachleat/carouscroll", 3 | "version": "1.0.6", 4 | "description": "Add next/previous buttons to a horizontal scrollable container.", 5 | "main": "carouscroll.js", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "scripts": { 10 | "start": "npx http-server ." 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/zachleat/carouscroll.git" 15 | }, 16 | "author": { 17 | "name": "Zach Leatherman", 18 | "email": "zachleatherman@gmail.com", 19 | "url": "https://zachleat.com/" 20 | }, 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/zachleat/carouscroll/issues" 24 | }, 25 | "homepage": "https://github.com/zachleat/carouscroll#readme" 26 | } 27 | --------------------------------------------------------------------------------