uix-image-magnify 0.2.0 → 0.2.2

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.
package/README.md ADDED
@@ -0,0 +1,518 @@
1
+ # uix-image-magnify
2
+
3
+ **Fast, accessible, SSR-safe image magnifier and product gallery for React & Next.js.**
4
+
5
+ A lightweight, fully-typed React component set for zooming images with a lens, a side-by-side pane, inner pan-and-zoom, or a follow-style magnifier. Ships with a product-style gallery (thumbnails + arrows + counter), a headless slider hook, and SSR-safe variants that prevent hydration mismatches in Next.js App Router, Remix, etc.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - 🔍 **4 zoom modes** — `lens`, `side-by-side`, `inner`, `follow`
12
+ - 🖱️ **6 zoom triggers** — `hover`, `click`, `double-click`, `hold`, `wheel`, `none`
13
+ - 🖼️ **Product gallery** with thumbnails, arrows, counter, and loop
14
+ - ♿ **Accessible** — keyboard nav, ARIA roles, screen-reader live region
15
+ - 🧠 **SSR-safe** — dedicated `/ssr` entry point eliminates hydration mismatches
16
+ - 📱 **Responsive** — zoom pane automatically moves below the image on mobile
17
+ - 🎨 **Customizable** — every part is styleable; arrows accept custom React nodes
18
+ - 🪝 **Headless hooks** — `useImageMagnify`, `useImageSlider`
19
+ - 🧩 **TypeScript-first** with strict prop types
20
+ - 🌲 **Tree-shakeable** — `sideEffects: false`, ESM + CJS builds
21
+
22
+ ---
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install uix-image-magnify
28
+ # or
29
+ yarn add uix-image-magnify
30
+ # or
31
+ pnpm add uix-image-magnify
32
+ ```
33
+
34
+ `react` and `react-dom` (`>=17`) are peer dependencies. `swiper` is installed automatically as a dependency for the gallery.
35
+
36
+ ---
37
+
38
+ ## Quick Start
39
+
40
+ ```tsx
41
+ import { ImageMagnify } from "uix-image-magnify";
42
+
43
+ export default function ProductImage() {
44
+ return (
45
+ <ImageMagnify
46
+ src="/images/shoe.jpg"
47
+ zoomSrc="/images/shoe-large.jpg"
48
+ alt="Red running shoe"
49
+ width={500}
50
+ zoomScale={2.5}
51
+ />
52
+ );
53
+ }
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Single Image Magnifier
59
+
60
+ `ImageMagnify` renders an image and zooms it on interaction. The high-res `zoomSrc` is used for the zoomed view while the regular `src` is shown normally — perfect for product photos.
61
+
62
+ ```tsx
63
+ import { ImageMagnify } from "uix-image-magnify";
64
+
65
+ <ImageMagnify
66
+ src="/thumb.jpg"
67
+ zoomSrc="/large.jpg"
68
+ alt="Product"
69
+ width="100%"
70
+ mode="side-by-side"
71
+ zoomType="hover"
72
+ zoomScale={3}
73
+ panePosition="right"
74
+ paneWidth={420}
75
+ paneHeight={420}
76
+ lensShape="circle"
77
+ lensSize={160}
78
+ lazy
79
+ />
80
+ ```
81
+
82
+ ### Zoom modes
83
+
84
+ | Mode | Description |
85
+ | -------------- | ------------------------------------------------------------- |
86
+ | `lens` | A circular/square lens follows the cursor over the image. |
87
+ | `side-by-side` | A fixed pane appears beside the image (classic Amazon style). |
88
+ | `inner` | The image itself scales and pans inside its own container. |
89
+ | `follow` | A lens follows the cursor with an optional offset. |
90
+
91
+ > **Mobile:** on `side-by-side` + `panePosition="bottom"`, the pane flows below the image instead of being absolutely positioned — no overlap, no clipping.
92
+
93
+ ### Zoom triggers
94
+
95
+ | Trigger | Behavior |
96
+ | -------------- | ------------------------------------------------------ |
97
+ | `hover` | Zooms while the pointer is over the image. |
98
+ | `click` | Toggles zoom on click. |
99
+ | `double-click` | Toggles zoom on double-click. |
100
+ | `hold` | Zooms only while the pointer is pressed down. |
101
+ | `wheel` | Scroll to zoom; supports `minZoomScale`/`maxZoomScale`.|
102
+ | `none` | Disables interaction (renders a plain image). |
103
+
104
+ > On touch devices, interaction behaves as tap-to-toggle regardless of `zoomType` (except `none`).
105
+
106
+ ### `inner` mode panning
107
+
108
+ When `mode="inner"`, set `dragMode`:
109
+
110
+ - `dragMode="follow"` (default) — the pixel under the cursor stays under the cursor.
111
+ - `dragMode="pan"` — drag to pan around the zoomed image.
112
+
113
+ ```tsx
114
+ <ImageMagnify
115
+ src="/product.jpg"
116
+ alt="Product"
117
+ mode="inner"
118
+ dragMode="pan"
119
+ zoomType="click"
120
+ zoomScale={3}
121
+ />
122
+ ```
123
+
124
+ ### Wheel zoom
125
+
126
+ ```tsx
127
+ <ImageMagnify
128
+ src="/product.jpg"
129
+ alt="Product"
130
+ zoomType="wheel"
131
+ zoomScale={2}
132
+ minZoomScale={1.5}
133
+ maxZoomScale={6}
134
+ onZoomChange={(s) => console.log("scale:", s)}
135
+ />
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Product Gallery
141
+
142
+ `ImageMagnifyGallery` combines a Swiper-powered slider with the magnifier. It supports thumbnails, arrows, a counter, loop, keyboard navigation, and adjacent-image preloading.
143
+
144
+ ```tsx
145
+ import { ImageMagnifyGallery } from "uix-image-magnify";
146
+
147
+ const images = [
148
+ "/images/shoe-1.jpg",
149
+ "/images/shoe-2.jpg",
150
+ "/images/shoe-3.jpg",
151
+ ];
152
+
153
+ <ImageMagnifyGallery
154
+ images={images}
155
+ alt="Running shoe"
156
+ mode="side-by-side"
157
+ zoomType="hover"
158
+ zoomScale={2.5}
159
+ showArrows
160
+ showThumbnails
161
+ showCounter
162
+ thumbnailSize={72}
163
+ thumbnailGap={10}
164
+ onIndexChange={(i) => console.log("active:", i)}
165
+ />
166
+ ```
167
+
168
+ ### Controlled gallery
169
+
170
+ ```tsx
171
+ const [active, setActive] = useState(0);
172
+
173
+ <ImageMagnifyGallery
174
+ images={images}
175
+ alt="Product"
176
+ activeIndex={active}
177
+ onIndexChange={setActive}
178
+ />
179
+ ```
180
+
181
+ ### Custom arrows
182
+
183
+ ```tsx
184
+ <ImageMagnifyGallery
185
+ images={images}
186
+ alt="Product"
187
+ prevArrow={<span>←</span>}
188
+ nextArrow={<span>→</span>}
189
+ arrowClassName="my-arrow"
190
+ arrowStyle={{ background: "#000", color: "#fff" }}
191
+ />
192
+ ```
193
+
194
+ ### Disable features
195
+
196
+ ```tsx
197
+ <ImageMagnifyGallery
198
+ images={images}
199
+ alt="Product"
200
+ showArrows={false}
201
+ showThumbnails={false}
202
+ showCounter={false}
203
+ enableKeyboard={false}
204
+ preloadAdjacent={false}
205
+ loop={false}
206
+ />
207
+ ```
208
+
209
+ ---
210
+
211
+ ## SSR & Next.js
212
+
213
+ The magnifier touches `window`, `matchMedia`, and pointer events. To avoid hydration mismatches, this package ships **SSR-safe wrappers** under the `uix-image-magnify/ssr` entry point:
214
+
215
+ - `ImageMagnifySSR` — plain `<img>` on server + first client render, swaps to `<ImageMagnify>` after hydration.
216
+ - `ImageMagnifyGallerySSR` — same approach for the gallery.
217
+
218
+ ### Option 1 — Use the SSR wrappers (recommended)
219
+
220
+ ```tsx
221
+ // app/products/[id]/page.tsx (Server Component-safe import)
222
+ import { ImageMagnifySSR } from "uix-image-magnify/ssr";
223
+
224
+ export default function ProductImage({ src, alt }: { src: string; alt: string }) {
225
+ return (
226
+ <ImageMagnifySSR
227
+ src={src}
228
+ alt={alt}
229
+ width={500}
230
+ mode="side-by-side"
231
+ zoomType="hover"
232
+ />
233
+ );
234
+ }
235
+ ```
236
+
237
+ ```tsx
238
+ import { ImageMagnifyGallerySSR } from "uix-image-magnify/ssr";
239
+
240
+ <ImageMagnifyGallerySSR images={images} alt="Product" />;
241
+ ```
242
+
243
+ ### Option 2 — `next/dynamic` with `ssr: false`
244
+
245
+ ```tsx
246
+ import dynamic from "next/dynamic";
247
+
248
+ const ImageMagnify = dynamic(
249
+ () => import("uix-image-magnify").then((m) => m.ImageMagnify),
250
+ { ssr: false }
251
+ );
252
+ ```
253
+
254
+ Both approaches produce zero hydration warnings. Option 1 keeps the SSR-rendered `<img>` visible before hydration so users see the image immediately.
255
+
256
+ ### Remix / React Router
257
+
258
+ `uix-image-magnify/ssr` works anywhere `useEffect` runs. No additional config required.
259
+
260
+ ---
261
+
262
+ ## Hooks
263
+
264
+ ### `useImageSlider`
265
+
266
+ A headless, SSR-safe slider state hook. No DOM, no browser globals.
267
+
268
+ ```tsx
269
+ import { useImageSlider } from "uix-image-magnify";
270
+
271
+ const { index, setIndex, prev, next, canPrev, canNext } = useImageSlider({
272
+ length: images.length,
273
+ defaultIndex: 0,
274
+ loop: true,
275
+ onIndexChange: (i) => console.log(i),
276
+ });
277
+ ```
278
+
279
+ **Options**
280
+
281
+ | Option | Type | Default | Description |
282
+ | --------------- | ------------------------- | ------- | ------------------------------ |
283
+ | `length` | `number` | — | Total items. |
284
+ | `defaultIndex` | `number` | `0` | Initial index (uncontrolled). |
285
+ | `index` | `number` | — | Controlled index. |
286
+ | `onIndexChange` | `(index: number) => void` | — | Fired when the index changes. |
287
+ | `loop` | `boolean` | `true` | Wrap around at the ends. |
288
+
289
+ **Returns:** `{ index, setIndex, prev, next, canPrev, canNext }`.
290
+
291
+ ### `useImageMagnify`
292
+
293
+ Use the same engine behind `<ImageMagnify>` directly if you need a custom renderer. Returns refs + event handlers.
294
+
295
+ ```tsx
296
+ import { useImageMagnify } from "uix-image-magnify";
297
+
298
+ const {
299
+ isZoomed,
300
+ zoomScale,
301
+ bgImage,
302
+ containerRef,
303
+ imageRef,
304
+ lensRef,
305
+ paneRef,
306
+ cursorRef,
307
+ handlers,
308
+ } = useImageMagnify({
309
+ src: "/thumb.jpg",
310
+ zoomSrc: "/large.jpg",
311
+ mode: "side-by-side",
312
+ zoomType: "hover",
313
+ zoomScale: 2.5,
314
+ });
315
+ ```
316
+
317
+ **Option highlights**
318
+
319
+ | Option | Type | Default |
320
+ | -------------- | --------------------------------------------------------------------- | ---------------- |
321
+ | `src` | `string` | — |
322
+ | `zoomSrc` | `string` | `src` |
323
+ | `zoomScale` | `number` | `2` |
324
+ | `minZoomScale` | `number` | `1.5` |
325
+ | `maxZoomScale` | `number` | `5` |
326
+ | `zoomType` | `"hover" \| "click" \| "double-click" \| "hold" \| "wheel" \| "none"` | `"hover"` |
327
+ | `mode` | `"lens" \| "side-by-side" \| "inner" \| "follow"` | `"side-by-side"` |
328
+ | `dragMode` | `"follow" \| "pan"` | `"follow"` |
329
+ | `panePosition` | `"left" \| "right" \| "top" \| "bottom"` | `"right"` |
330
+ | `lensSize` | `number` | `150` |
331
+ | `lensShape` | `"circle" \| "square"` | `"circle"` |
332
+ | `followOffset` | `number` | `0` |
333
+ | `zoomPreload` | `boolean` | `true` |
334
+ | `disabled` | `boolean` | `false` |
335
+ | `resetOnLeave` | `boolean` | `false` |
336
+ | `onZoomStart` | `() => void` | — |
337
+ | `onZoomEnd` | `() => void` | — |
338
+ | `onZoomChange` | `(scale: number) => void` | — |
339
+
340
+ ---
341
+
342
+ ## Props Reference
343
+
344
+ ### `ImageMagnifyProps`
345
+
346
+ Extends `UseImageMagnifyOptions` **plus**:
347
+
348
+ | Prop | Type | Default | Description |
349
+ | ---------------- | ------------------------------ | ------- | ------------------------------------- |
350
+ | `src` | `string` | — | Regular image URL. |
351
+ | `alt` | `string` | — | Accessible label. |
352
+ | `width` | `number \| string` | — | Outer width. On mobile, forced to `100%`. |
353
+ | `className` | `string` | — | Outer wrapper class. |
354
+ | `style` | `CSSProperties` | — | Outer wrapper style. |
355
+ | `imageClassName` | `string` | — | `<img>` class. |
356
+ | `imageStyle` | `CSSProperties` | — | `<img>` style. |
357
+ | `lensClassName` | `string` | — | Lens element class. |
358
+ | `lensStyle` | `CSSProperties` | — | Lens element style. |
359
+ | `paneClassName` | `string` | — | Side-by-side pane class. |
360
+ | `paneStyle` | `CSSProperties` | — | Side-by-side pane style. |
361
+ | `paneWidth` | `number \| string` | `400` | Pane width. |
362
+ | `paneHeight` | `number \| string` | `400` | Pane height. |
363
+ | `lazy` | `boolean` | — | Use `loading="lazy"`. |
364
+ | `imageProps` | `ImgHTMLAttributes` | — | Extra props forwarded to the `<img>`. |
365
+ | `onLoad` | `ImgHTMLAttributes["onLoad"]` | — | Fired on image load. |
366
+ | `onError` | `ImgHTMLAttributes["onError"]` | — | Fired on image error. |
367
+
368
+ ### `ImageMagnifyGalleryProps`
369
+
370
+ Extends `ImageMagnifyProps` **minus** `src` / `onLoad` / `onError`, plus:
371
+
372
+ | Prop | Type | Default | Description |
373
+ | ----------------------------- | ------------------------- | ------- | ------------------------------------ |
374
+ | `images` | `string[]` | — | Image URLs. |
375
+ | `alt` | `string` | — | Alt text (shared). |
376
+ | `activeIndex` | `number` | — | Controlled index. |
377
+ | `defaultIndex` | `number` | `0` | Initial index (uncontrolled). |
378
+ | `onIndexChange` | `(index: number) => void` | — | Fired when the active slide changes. |
379
+ | `showArrows` | `boolean` | `true` | Prev/next arrows. |
380
+ | `showThumbnails` | `boolean` | `true` | Thumbnail strip. |
381
+ | `showCounter` | `boolean` | `false` | "1 / N" counter overlay. |
382
+ | `loop` | `boolean` | `true` | Infinite loop. |
383
+ | `enableKeyboard` | `boolean` | `true` | Arrow-key navigation. |
384
+ | `preloadAdjacent` | `boolean` | `true` | Preload next/prev images. |
385
+ | `viewportClassName` | `string` | — | Main carousel viewport class. |
386
+ | `viewportStyle` | `CSSProperties` | — | Main carousel viewport style. |
387
+ | `thumbnailContainerClassName` | `string` | — | Thumbnail container class. |
388
+ | `thumbnailContainerStyle` | `CSSProperties` | — | Thumbnail container style. |
389
+ | `thumbnailClassName` | `string` | — | Thumbnail button class. |
390
+ | `thumbnailActiveClassName` | `string` | — | Active thumbnail class. |
391
+ | `thumbnailStyle` | `CSSProperties` | — | Thumbnail button style. |
392
+ | `thumbnailActiveStyle` | `CSSProperties` | — | Active thumbnail style. |
393
+ | `arrowClassName` | `string` | — | Arrow button class. |
394
+ | `arrowStyle` | `CSSProperties` | — | Arrow button style. |
395
+ | `thumbnailSize` | `number` | `80` | Thumbnail size (px). |
396
+ | `thumbnailGap` | `number` | `8` | Thumbnail gap (px). |
397
+ | `prevArrow` | `ReactNode` | — | Custom previous arrow. |
398
+ | `nextArrow` | `ReactNode` | — | Custom next arrow. |
399
+
400
+ ---
401
+
402
+ ## Styling
403
+
404
+ Every part of the component is styleable via `className` / `style` props. The magnifier uses minimal inline styles and does not require any CSS import.
405
+
406
+ ```tsx
407
+ <ImageMagnify
408
+ src="/a.jpg"
409
+ alt="Product"
410
+ width={480}
411
+ className="magnify-root"
412
+ imageClassName="magnify-image"
413
+ lensClassName="magnify-lens"
414
+ lensStyle={{ borderColor: "#111", boxShadow: "0 6px 20px rgba(0,0,0,.2)" }}
415
+ paneClassName="magnify-pane"
416
+ paneStyle={{ borderRadius: 12, border: "1px solid #eee" }}
417
+ />
418
+ ```
419
+
420
+ ```css
421
+ .magnify-root {
422
+ border-radius: 12px;
423
+ overflow: hidden;
424
+ background: #f7f7f7;
425
+ }
426
+
427
+ .magnify-lens {
428
+ border-color: #111;
429
+ }
430
+
431
+ .magnify-pane {
432
+ background-color: #fafafa;
433
+ }
434
+ ```
435
+
436
+ The gallery ships with inline defaults for arrows and thumbnails that you can override fully:
437
+
438
+ ```tsx
439
+ <ImageMagnifyGallery
440
+ images={images}
441
+ alt="Product"
442
+ thumbnailSize={64}
443
+ thumbnailGap={12}
444
+ thumbnailStyle={{ borderRadius: 8 }}
445
+ thumbnailActiveStyle={{ borderColor: "#0af" }}
446
+ arrowStyle={{ background: "#111", color: "#fff" }}
447
+ />
448
+ ```
449
+
450
+ ---
451
+
452
+ ## Accessibility
453
+
454
+ - Root gallery uses `role="region"`, `aria-roledescription="carousel"`, `aria-label`.
455
+ - Each image container is `role="img"`, `tabIndex={0}`, `aria-label={alt}`.
456
+ - Hidden `aria-live="polite"` region announces **"Image X of Y"** on slide change.
457
+ - Keyboard support on the container:
458
+ - **← / → / ↑ / ↓** — move the zoom pointer.
459
+ - **+ / = / -** — zoom in/out (when `zoomType="wheel"`).
460
+ - **Enter / Space** — toggle zoom.
461
+ - **Esc** — reset zoom.
462
+ - Disabled state exposes `aria-disabled`.
463
+ - Custom arrows accept any `ReactNode`; default icons are `<svg aria-hidden="true">` with `aria-label` on the buttons.
464
+
465
+ ---
466
+
467
+ ## TypeScript
468
+
469
+ The package is written in strict TypeScript and exports all prop and option types:
470
+
471
+ ```ts
472
+ import type {
473
+ ImageMagnifyProps,
474
+ ImageMagnifyGalleryProps,
475
+ UseImageMagnifyOptions,
476
+ UseImageSliderOptions,
477
+ UseImageSliderReturn,
478
+ ZoomType,
479
+ PanePosition,
480
+ LensShape,
481
+ MagnifyMode,
482
+ DragMode,
483
+ } from "uix-image-magnify";
484
+ ```
485
+
486
+ ---
487
+
488
+ ## Package Exports
489
+
490
+ | Import path | Contents |
491
+ | ----------------------- | ---------------------------------------------------------------------------------------- |
492
+ | `uix-image-magnify` | `ImageMagnify`, `ImageMagnifyGallery`, `useImageMagnify`, `useImageSlider` + all types |
493
+ | `uix-image-magnify/ssr` | `ImageMagnifySSR`, `ImageMagnifyGallerySSR` (and their non-SSR re-exports) |
494
+
495
+ ---
496
+
497
+ ## Browser Support
498
+
499
+ Modern evergreen browsers (Chrome, Edge, Firefox, Safari). Requires:
500
+
501
+ - `ResizeObserver`
502
+ - `matchMedia`
503
+ - Pointer Events
504
+ - CSS `transform` / `background-position`
505
+
506
+ SSR wrappers render plain `<img>` on the server for maximum compatibility.
507
+
508
+ ---
509
+
510
+ ## License
511
+
512
+ MIT © uix-image-magnify contributors
513
+
514
+ ---
515
+
516
+ ### Contributing
517
+
518
+ Issues and PRs welcome. Please run `npm run typecheck` and `npm run build` before submitting.
@@ -67,7 +67,6 @@ declare function useImageMagnify(options?: UseImageMagnifyOptions): {
67
67
  interface ImageMagnifyProps extends UseImageMagnifyOptions {
68
68
  src: string;
69
69
  alt: string;
70
- /** Rendered width of the image (height follows aspect ratio). */
71
70
  width?: number | string;
72
71
  className?: string;
73
72
  style?: CSSProperties;
@@ -77,10 +76,9 @@ interface ImageMagnifyProps extends UseImageMagnifyOptions {
77
76
  lensStyle?: CSSProperties;
78
77
  paneClassName?: string;
79
78
  paneStyle?: CSSProperties;
80
- paneWidth?: number;
81
- paneHeight?: number;
79
+ paneWidth?: number | string;
80
+ paneHeight?: number | string;
82
81
  lazy?: boolean;
83
- /** Extra attributes forwarded to the underlying <img>. */
84
82
  imageProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt" | "style">;
85
83
  onLoad?: ImgHTMLAttributes<HTMLImageElement>["onLoad"];
86
84
  onError?: ImgHTMLAttributes<HTMLImageElement>["onError"];
@@ -67,7 +67,6 @@ declare function useImageMagnify(options?: UseImageMagnifyOptions): {
67
67
  interface ImageMagnifyProps extends UseImageMagnifyOptions {
68
68
  src: string;
69
69
  alt: string;
70
- /** Rendered width of the image (height follows aspect ratio). */
71
70
  width?: number | string;
72
71
  className?: string;
73
72
  style?: CSSProperties;
@@ -77,10 +76,9 @@ interface ImageMagnifyProps extends UseImageMagnifyOptions {
77
76
  lensStyle?: CSSProperties;
78
77
  paneClassName?: string;
79
78
  paneStyle?: CSSProperties;
80
- paneWidth?: number;
81
- paneHeight?: number;
79
+ paneWidth?: number | string;
80
+ paneHeight?: number | string;
82
81
  lazy?: boolean;
83
- /** Extra attributes forwarded to the underlying <img>. */
84
82
  imageProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt" | "style">;
85
83
  onLoad?: ImgHTMLAttributes<HTMLImageElement>["onLoad"];
86
84
  onError?: ImgHTMLAttributes<HTMLImageElement>["onError"];