@nordwerk/scroll-carousel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ # Replacing a library carousel
2
+
3
+ Option and API names on the left are those of Swiper, the most widely used carousel library;
4
+ other libraries name the same things similarly. The main change of mindset: layout moves from
5
+ JavaScript options into CSS, so breakpoints become media or container queries instead of
6
+ options that the script re-applies after load.
7
+
8
+ ## Options
9
+
10
+ | Library | Here | Notes |
11
+ | --- | --- | --- |
12
+ | `slidesPerView: 3` | `--sc-per-view: 3` | Fractions work the same way |
13
+ | `slidesPerView: 'auto'` | `--sc-slide-size: auto` | Or any length, e.g. `18rem` |
14
+ | `spaceBetween: 16` | `--sc-gap: 16px` | |
15
+ | `slidesOffsetBefore` / `After` | `--sc-offset-before` / `--sc-offset-after` | Snapped slides line up after the offset |
16
+ | `breakpoints: { … }` | `@media` or `@container sc (min-width: …)` | Container queries follow the carousel's own width |
17
+ | `slidesPerGroup: 3` | `--sc-group: 3` | Only group starts are snap points |
18
+ | `slidesPerGroupAuto` | `--sc-group: page` | As many whole slides as fit |
19
+ | `centeredSlides: true` | `--sc-align: center; --sc-centered: 1` | |
20
+ | `centeredSlidesBounds` | `--sc-align: center` without `--sc-centered` | |
21
+ | `centerInsufficientSlides` | class `sc--center-few` | |
22
+ | clamping slides per view to the slide count | `--sc-count: <n>` | |
23
+ | `initialSlide: 4` | `data-sc-initial` on the slide, or `initial: 4` | Plus the inline `PRE_POSITION` snippet for server rendering |
24
+ | `freeMode: true` | `--sc-snap: none` | |
25
+ | `freeMode: { sticky: true }` | `--sc-snap: mandatory` | Native momentum still carries across several slides |
26
+ | `loop: true` | `rewind: true` | Fades back to the start instead of cloning slides |
27
+ | `rewind: true` | `rewind: 'scroll'` | Scrolls back across all slides |
28
+ | `autoplay: { delay }` | `autoplay({ delay })` plugin | Adds a required pause button; focus stops it |
29
+ | `simulateTouch` (mouse drag) | `drag()` plugin | Opt-in |
30
+ | `navigation` | `[data-sc-prev]`, `[data-sc-next]` on any element | Or call `next()` / `prev()` from your own components |
31
+ | `pagination` (bullets) | `[data-sc-dots]` | Or render your own from `state.page` / `state.pageCount` |
32
+ | `watchOverflow` | automatic | `data-sc-overflow` on the root; controls hide without it |
33
+ | `a11y` module | built in | Live region, `aria-disabled`, keyboard |
34
+ | `lazy` | native `loading="lazy"` on images | |
35
+ | `speed` | not configurable | Native smooth scrolling; instant with reduced motion |
36
+ | `allowTouchMove: false` | `overflow-x: hidden` on the track | Arrows and the API still work |
37
+
38
+ ## API
39
+
40
+ | Library | Here |
41
+ | --- | --- |
42
+ | `new Swiper(el, options)` | `attach(root, options)` |
43
+ | `swiper.slideTo(i)` | `carousel.slideTo(i)` (shows the page that contains slide i) |
44
+ | `swiper.slideToLoop(i)` | `carousel.slideTo(i)` |
45
+ | `swiper.slideNext()` / `slidePrev()` | `carousel.next()` / `prev()` |
46
+ | `swiper.activeIndex`, `realIndex` | `carousel.index` |
47
+ | `swiper.snapIndex` | `carousel.page` |
48
+ | `swiper.isBeginning` / `isEnd` | the same names |
49
+ | `swiper.slides` | `carousel.slides` |
50
+ | `swiper.el`, `wrapperEl` | `carousel.root`, `carousel.track` |
51
+ | `swiper.update()` | `carousel.update()`, rarely needed: it measures on resize and content changes |
52
+ | `swiper.destroy()` | `carousel.destroy()` |
53
+ | `on('slideChange')` | `sc:change` event on the root, or `onChange`, once per settled move |
54
+ | `onSwiper(swiper)` | `carousel` returned by `attach`, or `carouselRef` in React and Preact |
55
+
56
+ Behaviour that differs:
57
+
58
+ - No change event on attach. A wrapper that promised one should call its handler once with
59
+ `carousel.state` after attaching.
60
+ - `index` is the slide at the snap position. At the end of a row it is the first slide of the
61
+ last full view, not the last slide.
62
+ - Arrows at the ends are disabled only without `rewind`; with `rewind` they stay enabled.
63
+ - There are no duplicate slides, so code that skips `.swiper-slide-duplicate` or reads
64
+ `data-swiper-slide-index` goes away, as do workarounds that reset a container's scroll
65
+ position after focus moved into a slide.
66
+
67
+ ## Deliberately not supported
68
+
69
+ - A true infinite loop (cloned slides and a teleported scroll position). It is fragile on native
70
+ scrolling and confusing for assistive technology; use `rewind`.
71
+ - Vertical carousels, zoom, a draggable scrollbar, slide effects (fade, cube, flip), virtual
72
+ slides, parallax, and syncing thumbnails with a second carousel. Thumbnails can call
73
+ `slideTo()` and listen to `sc:change`.
74
+ - Changing the transition speed or easing of moves.
75
+
76
+ ## Migrating step by step
77
+
78
+ 1. Keep your component's public props and imperative handle; swap its internals.
79
+ 2. Move per-device options into CSS: one media or container query per former breakpoint.
80
+ 3. Replace loop with rewind; check with product owners where an endless loop was visible.
81
+ 4. Keep your own arrows and dots, bound to `next()`, `prev()`, `goToPage()` and `sc:change`.
82
+ 5. Remove layout workarounds made for script-computed layout, such as first-image fixes for
83
+ server rendering.
84
+ 6. Behind a flag, migrate one call site at a time and compare with visual tests.
package/package.json ADDED
@@ -0,0 +1,106 @@
1
+ {
2
+ "name": "@nordwerk/scroll-carousel",
3
+ "version": "0.1.0",
4
+ "description": "A dependency-free carousel on native scrolling: CSS scroll snap first, arrows, dots, paging and an API as progressive enhancement.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/studio-nordwerk/scroll-carousel.git"
10
+ },
11
+ "homepage": "https://studio-nordwerk.github.io/scroll-carousel/",
12
+ "keywords": [
13
+ "carousel",
14
+ "slider",
15
+ "scroll-snap",
16
+ "progressive-enhancement",
17
+ "accessibility",
18
+ "react",
19
+ "preact",
20
+ "astro"
21
+ ],
22
+ "sideEffects": [
23
+ "*.css"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./drag": {
31
+ "types": "./dist/drag.d.ts",
32
+ "default": "./dist/drag.js"
33
+ },
34
+ "./autoplay": {
35
+ "types": "./dist/autoplay.d.ts",
36
+ "default": "./dist/autoplay.js"
37
+ },
38
+ "./markup": {
39
+ "types": "./dist/markup.d.ts",
40
+ "default": "./dist/markup.js"
41
+ },
42
+ "./react": {
43
+ "types": "./dist/react.d.ts",
44
+ "default": "./dist/react.js"
45
+ },
46
+ "./preact": {
47
+ "types": "./dist/preact.d.ts",
48
+ "default": "./dist/preact.js"
49
+ },
50
+ "./astro": "./src/astro/Carousel.astro",
51
+ "./carousel.css": "./dist/carousel.css",
52
+ "./package.json": "./package.json"
53
+ },
54
+ "files": [
55
+ "dist",
56
+ "src/astro",
57
+ "AGENTS.md",
58
+ "CHANGELOG.md",
59
+ "docs/migration.md"
60
+ ],
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
64
+ "scripts": {
65
+ "build": "node scripts/build.mjs",
66
+ "typecheck": "tsc -p tsconfig.json --noEmit",
67
+ "test": "node --test \"test/unit/*.test.ts\"",
68
+ "size": "node scripts/size.mjs",
69
+ "site": "node scripts/build.mjs && node site/generate.mjs",
70
+ "fixtures": "node scripts/fixtures.mjs",
71
+ "test:e2e": "playwright test",
72
+ "serve": "node scripts/serve.mjs",
73
+ "check": "pnpm typecheck && pnpm test && pnpm site && pnpm size && pnpm fixtures && pnpm test:e2e",
74
+ "prepublishOnly": "node scripts/build.mjs"
75
+ },
76
+ "peerDependencies": {
77
+ "preact": ">=10",
78
+ "react": ">=18"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "preact": {
82
+ "optional": true
83
+ },
84
+ "react": {
85
+ "optional": true
86
+ }
87
+ },
88
+ "devDependencies": {
89
+ "@axe-core/playwright": "4.13.0",
90
+ "@playwright/test": "1.61.1",
91
+ "@types/node": "^24.0.0",
92
+ "@types/react": "^19.3.0",
93
+ "@types/react-dom": "^19.3.0",
94
+ "astro": "^7.3.3",
95
+ "esbuild": "0.28.2",
96
+ "preact": "^10.29.8",
97
+ "preact-render-to-string": "6.7.0",
98
+ "react": "^19.3.0",
99
+ "react-dom": "^19.3.0",
100
+ "typescript": "^7.0.2"
101
+ },
102
+ "engines": {
103
+ "node": ">=22"
104
+ },
105
+ "packageManager": "pnpm@12.5.1"
106
+ }
@@ -0,0 +1,88 @@
1
+ ---
2
+ /*
3
+ * Astro adapter. Renders the carousel markup around the slides you pass in the default slot
4
+ * (one element per slide: <li> when as="ul", otherwise any element) and attaches the core in the
5
+ * browser. Drag and autoplay are loaded only when a carousel on the page asks for them.
6
+ */
7
+ import '@nordwerk/scroll-carousel/carousel.css';
8
+ import { PRE_POSITION, baseVars, responsiveCss, type LayoutProps } from '@nordwerk/scroll-carousel/markup';
9
+
10
+ interface Props extends LayoutProps {
11
+ /** Accessible name of the scrolling list. */
12
+ label: string;
13
+ as?: 'ul' | 'div';
14
+ class?: string;
15
+ style?: string;
16
+ centerFew?: boolean;
17
+ arrows?: boolean;
18
+ dots?: boolean;
19
+ initial?: number;
20
+ rewind?: boolean | 'fade' | 'scroll';
21
+ drag?: boolean;
22
+ /** Milliseconds per page; renders the play button. */
23
+ autoplay?: number;
24
+ /** Label templates: page 'Slide {n} of {count}', status 'Items {first} to {last} of {count}', prev, next. */
25
+ labels?: { page?: string; status?: string; prev?: string; next?: string; play?: string; pause?: string };
26
+ }
27
+
28
+ const props = Astro.props;
29
+ const { as: Track = 'div', label, class: className, style = '', centerFew, arrows = true, dots = true } = props;
30
+ const { initial = 0, rewind, drag, autoplay, labels = {} } = props;
31
+ const id = `sc${Math.random().toString(36).slice(2, 10)}`;
32
+ const css = responsiveCss(id, props);
33
+ const vars = Object.entries(baseVars(props)).map(([name, value]) => `${name}:${value}`).join(';');
34
+ const options = JSON.stringify({ initial, rewind, drag, autoplay, labels, group: typeof props.group == 'object' ? undefined : props.group });
35
+ ---
36
+
37
+ <div class:list={['sc', { 'sc--center-few': centerFew }, className]} data-sc-id={id} data-sc-astro={options} style={[vars, style].filter(Boolean).join(';')}>
38
+ {css && <style set:html={css} />}
39
+ <Track class="sc-track" data-sc-track tabindex="0" aria-label={label}><slot /></Track>
40
+ {arrows && (
41
+ <>
42
+ <button type="button" class="sc-nav sc-prev" data-sc-prev aria-label={labels.prev ?? 'Previous'}>
43
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m15 18-6-6 6-6" /></svg>
44
+ </button>
45
+ <button type="button" class="sc-nav sc-next" data-sc-next aria-label={labels.next ?? 'Next'}>
46
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 6 6 6-6 6" /></svg>
47
+ </button>
48
+ </>
49
+ )}
50
+ {autoplay && (
51
+ <button type="button" class="sc-play" data-sc-play aria-label={labels.pause ?? 'Stop automatic scrolling'}>
52
+ <svg class="sc-icon-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8 5v14l11-7z" /></svg>
53
+ <svg class="sc-icon-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /></svg>
54
+ </button>
55
+ )}
56
+ {dots && <div class="sc-dots" data-sc-dots></div>}
57
+ <p class="sc-status" data-sc-status aria-live="polite"></p>
58
+ </div>
59
+ {initial > 0 && <script is:inline set:html={PRE_POSITION} />}
60
+
61
+ <script>
62
+ import { attach, getCarousel, type Plugin } from '@nordwerk/scroll-carousel';
63
+ import { templateLabels } from '@nordwerk/scroll-carousel/markup';
64
+
65
+ async function attachAll() {
66
+ for (const root of document.querySelectorAll<HTMLElement>('[data-sc-astro]')) {
67
+ if (getCarousel(root)) continue;
68
+ const options = JSON.parse(root.dataset.scAstro || '{}');
69
+ const plugins: Plugin[] = [];
70
+ if (options.drag) plugins.push((await import('@nordwerk/scroll-carousel/drag')).drag());
71
+ if (options.autoplay) {
72
+ const { autoplay } = await import('@nordwerk/scroll-carousel/autoplay');
73
+ plugins.push(autoplay({ delay: options.autoplay, labels: options.labels }));
74
+ }
75
+ // The page may have changed while the plugins loaded.
76
+ if (root.isConnected) attach(root, { ...options, labels: templateLabels(options.labels), plugins });
77
+ }
78
+ }
79
+
80
+ attachAll();
81
+ document.addEventListener('astro:page-load', attachAll);
82
+ // With view transitions, carousels that leave the page must let go of their listeners.
83
+ document.addEventListener('astro:before-swap', () => {
84
+ for (const root of document.querySelectorAll<HTMLElement>('[data-sc-astro]')) {
85
+ if (!root.closest('[data-astro-transition-persist]')) getCarousel(root)?.destroy();
86
+ }
87
+ });
88
+ </script>