react-motion-gallery 2.0.18 → 2.0.20

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.
Files changed (208) hide show
  1. package/LICENSE.md +13 -3
  2. package/README.md +747 -195
  3. package/THIRD_PARTY_NOTICES.md +31 -0
  4. package/dist/FullscreenRuntime-GNH22QHC.mjs +4 -0
  5. package/dist/FullscreenRuntime-QX6YELBN.css +1 -0
  6. package/dist/GridSkeleton-Dn7N-TEh.d.mts +23 -0
  7. package/dist/MasonrySkeleton-ftJdtDLs.d.mts +29 -0
  8. package/dist/chunk-3YENO5LQ.mjs +1 -0
  9. package/dist/chunk-4NT4UVB5.mjs +1 -0
  10. package/dist/chunk-4VHNCVVB.mjs +0 -0
  11. package/dist/chunk-6CJ7JFOA.mjs +1 -0
  12. package/dist/chunk-6FFRWH2C.mjs +1 -0
  13. package/dist/chunk-6ZNRPJC2.mjs +6 -0
  14. package/dist/chunk-ADIHG7AT.mjs +1 -0
  15. package/dist/chunk-AKY343WN.mjs +1 -0
  16. package/dist/chunk-ASH5AOA4.mjs +1 -0
  17. package/dist/chunk-AX2FSVFD.mjs +2 -0
  18. package/dist/chunk-B4CC5AGE.mjs +1 -0
  19. package/dist/chunk-BJBHSWMF.mjs +1 -0
  20. package/dist/chunk-C6PKH3FH.mjs +1 -0
  21. package/dist/chunk-CR2MZG3Q.mjs +1 -0
  22. package/dist/chunk-D3T6HIS2.mjs +1 -0
  23. package/dist/chunk-DCUCXQHE.mjs +3 -0
  24. package/dist/chunk-DCY4ZVYI.mjs +1 -0
  25. package/dist/chunk-EFXHC36P.mjs +0 -0
  26. package/dist/chunk-GUNIA4DZ.mjs +1 -0
  27. package/dist/chunk-H6XFG3CJ.mjs +1 -0
  28. package/dist/chunk-HGY3QLCE.mjs +1 -0
  29. package/dist/chunk-HK2DPKES.mjs +1 -0
  30. package/dist/chunk-IT7HWE4G.mjs +1 -0
  31. package/dist/chunk-J2ZX5I7E.mjs +5 -0
  32. package/dist/chunk-JJMFOLJZ.mjs +1 -0
  33. package/dist/chunk-L2HRIINV.mjs +1 -0
  34. package/dist/chunk-L4TRPKGX.mjs +4 -0
  35. package/dist/chunk-NHIKOJLU.mjs +1 -0
  36. package/dist/chunk-NQI246HG.mjs +1 -0
  37. package/dist/chunk-OHD2HQP7.mjs +4 -0
  38. package/dist/chunk-OWKZOHPK.mjs +3 -0
  39. package/dist/chunk-PFEGIWQJ.mjs +1 -0
  40. package/dist/chunk-Q5WUKZ2J.mjs +4 -0
  41. package/dist/chunk-R6EGYRTJ.mjs +6 -0
  42. package/dist/chunk-RLT5FULN.mjs +0 -0
  43. package/dist/chunk-RNLUNA5L.mjs +2 -0
  44. package/dist/chunk-STRS7UNJ.mjs +1 -0
  45. package/dist/chunk-TIQVSK5S.mjs +1 -0
  46. package/dist/chunk-UAEPMZQY.mjs +1 -0
  47. package/dist/chunk-UML6FCOQ.mjs +1 -0
  48. package/dist/chunk-UP6P6CQS.mjs +2 -0
  49. package/dist/chunk-UUAWLGWO.mjs +1 -0
  50. package/dist/chunk-V25YIPLC.mjs +1 -0
  51. package/dist/chunk-V7DPXRZF.mjs +1 -0
  52. package/dist/chunk-VEXMXZJM.mjs +4 -0
  53. package/dist/chunk-VGXO2IAF.mjs +1 -0
  54. package/dist/chunk-VWEQRZ24.mjs +1 -0
  55. package/dist/chunk-VXMW2JT5.mjs +1 -0
  56. package/dist/chunk-WGVWASZM.mjs +1 -0
  57. package/dist/chunk-WMG2LTLR.mjs +1 -0
  58. package/dist/chunk-XUQO5F2F.mjs +1 -0
  59. package/dist/chunk-Y7NUGXTR.mjs +1 -0
  60. package/dist/chunk-Z34PSRMG.mjs +1 -0
  61. package/dist/chunk-ZCCYTID7.mjs +1 -0
  62. package/dist/chunk-ZOFTC6YV.mjs +1 -0
  63. package/dist/core.d.mts +32 -21
  64. package/dist/core.mjs +1 -1
  65. package/dist/entries.css +1 -1
  66. package/dist/entries.d.mts +74 -11
  67. package/dist/entries.mjs +1 -1
  68. package/dist/force-C5m1QpdF.d.mts +7 -0
  69. package/dist/fullscreen-captions.css +1 -0
  70. package/dist/fullscreen-captions.d.mts +19 -0
  71. package/dist/fullscreen-captions.mjs +1 -0
  72. package/dist/fullscreen-controls.d.mts +19 -0
  73. package/dist/fullscreen-controls.mjs +1 -0
  74. package/dist/fullscreen-crossfade.d.mts +19 -0
  75. package/dist/fullscreen-crossfade.mjs +1 -0
  76. package/dist/fullscreen-lazy-load.css +1 -0
  77. package/dist/fullscreen-lazy-load.d.mts +19 -0
  78. package/dist/fullscreen-lazy-load.mjs +1 -0
  79. package/dist/fullscreen-slider.css +1 -0
  80. package/dist/fullscreen-slider.d.mts +19 -0
  81. package/dist/fullscreen-slider.mjs +1 -0
  82. package/dist/fullscreen-thumbnails.d.mts +19 -0
  83. package/dist/fullscreen-thumbnails.mjs +1 -0
  84. package/dist/fullscreen-video.css +1 -0
  85. package/dist/fullscreen-video.d.mts +19 -0
  86. package/dist/fullscreen-video.mjs +1 -0
  87. package/dist/fullscreen-zoom-pan.d.mts +19 -0
  88. package/dist/fullscreen-zoom-pan.mjs +1 -0
  89. package/dist/fullscreen.css +1 -1
  90. package/dist/fullscreen.d.mts +39 -178
  91. package/dist/fullscreen.mjs +1 -1
  92. package/dist/fullscreenThumbnails.css +1 -1
  93. package/dist/fullscreenThumbnails.d.mts +9 -6
  94. package/dist/fullscreenThumbnails.mjs +1 -1
  95. package/dist/grid-ready.d.mts +13 -0
  96. package/dist/grid-ready.mjs +1 -0
  97. package/dist/grid.css +1 -1
  98. package/dist/grid.d.mts +9 -126
  99. package/dist/grid.mjs +1 -1
  100. package/dist/index-Cp40fdvU.d.mts +21 -0
  101. package/dist/{index-DUP4I_sT.d.mts → index-YphJztdR.d.mts} +5 -6
  102. package/dist/index.css +1 -1
  103. package/dist/index.d.mts +28 -26
  104. package/dist/index.mjs +1 -1
  105. package/dist/layout-DoYnPD0I.d.mts +137 -0
  106. package/dist/masonry-ready.d.mts +13 -0
  107. package/dist/masonry-ready.mjs +1 -0
  108. package/dist/masonry.css +1 -1
  109. package/dist/masonry.d.mts +15 -66
  110. package/dist/masonry.mjs +1 -1
  111. package/dist/{media-moIXOhT1.d.mts → media.d.mts} +1 -1
  112. package/dist/media.mjs +1 -0
  113. package/dist/metafile-esm.json +1 -1
  114. package/dist/{plyrTypes-CmP9NWvX.d.mts → plyrTypes-DhzgHNfX.d.mts} +2 -1
  115. package/dist/responsive-Ch5b4LC-.d.mts +526 -0
  116. package/dist/responsive.d.mts +15 -0
  117. package/dist/responsive.mjs +1 -0
  118. package/dist/{responsive-CvE5dTnP.d.mts → responsiveNumber-CouEMJ9O.d.mts} +1 -1
  119. package/dist/skeleton-base.css +1 -0
  120. package/dist/skeleton-base.d.mts +52 -0
  121. package/dist/skeleton-base.mjs +1 -0
  122. package/dist/skeleton-grid.css +1 -0
  123. package/dist/skeleton-grid.d.mts +64 -0
  124. package/dist/skeleton-grid.mjs +1 -0
  125. package/dist/skeleton-masonry.css +1 -0
  126. package/dist/skeleton-masonry.d.mts +63 -0
  127. package/dist/skeleton-masonry.mjs +1 -0
  128. package/dist/skeleton-slider.css +1 -0
  129. package/dist/skeleton-slider.d.mts +208 -0
  130. package/dist/skeleton-slider.mjs +40 -0
  131. package/dist/slider-arrows.d.mts +9 -0
  132. package/dist/slider-arrows.mjs +1 -0
  133. package/dist/slider-auto-height.d.mts +9 -0
  134. package/dist/slider-auto-height.mjs +1 -0
  135. package/dist/slider-auto-play.d.mts +9 -0
  136. package/dist/slider-auto-play.mjs +1 -0
  137. package/dist/slider-auto-scroll.d.mts +9 -0
  138. package/dist/slider-auto-scroll.mjs +1 -0
  139. package/dist/slider-crossfade.d.mts +9 -0
  140. package/dist/slider-crossfade.mjs +1 -0
  141. package/dist/slider-dots.css +1 -0
  142. package/dist/slider-dots.d.mts +9 -0
  143. package/dist/slider-dots.mjs +1 -0
  144. package/dist/slider-fade.d.mts +9 -0
  145. package/dist/slider-fade.mjs +1 -0
  146. package/dist/slider-fullscreen.d.mts +9 -0
  147. package/dist/slider-fullscreen.mjs +1 -0
  148. package/dist/slider-lazy-load.css +1 -0
  149. package/dist/slider-lazy-load.d.mts +9 -0
  150. package/dist/slider-lazy-load.mjs +1 -0
  151. package/dist/slider-loading.css +1 -0
  152. package/dist/slider-loading.d.mts +9 -0
  153. package/dist/slider-loading.mjs +1 -0
  154. package/dist/slider-parallax.d.mts +9 -0
  155. package/dist/slider-parallax.mjs +1 -0
  156. package/dist/slider-progress.d.mts +9 -0
  157. package/dist/slider-progress.mjs +1 -0
  158. package/dist/slider-ready.d.mts +14 -0
  159. package/dist/slider-ready.mjs +1 -0
  160. package/dist/slider-ripple.d.mts +9 -0
  161. package/dist/slider-ripple.mjs +1 -0
  162. package/dist/slider-scale.d.mts +9 -0
  163. package/dist/slider-scale.mjs +1 -0
  164. package/dist/slider-scrollbar.css +1 -0
  165. package/dist/slider-scrollbar.d.mts +9 -0
  166. package/dist/slider-scrollbar.mjs +1 -0
  167. package/dist/slider.css +1 -1
  168. package/dist/slider.d.mts +7 -77
  169. package/dist/slider.mjs +1 -1
  170. package/dist/text-BBcRGVzn.d.mts +10 -0
  171. package/dist/thumbnails.css +1 -1
  172. package/dist/thumbnails.d.mts +22 -11
  173. package/dist/thumbnails.mjs +1 -1
  174. package/dist/transitions-DU3ftmIq.d.mts +6 -0
  175. package/dist/{types-CvTlITct.d.mts → types-B7AiQJkM.d.mts} +25 -12
  176. package/dist/types-BlFwyRVQ.d.mts +43 -0
  177. package/dist/types-CfvTYIyd.d.mts +450 -0
  178. package/dist/{types-9g3BgMxk.d.mts → types-DP7ogmr4.d.mts} +17 -5
  179. package/dist/types-DWzjXjYR.d.mts +48 -0
  180. package/dist/types-Dhh8xfHo.d.mts +18 -0
  181. package/dist/video.css +1 -1
  182. package/dist/video.d.mts +3 -2
  183. package/dist/video.mjs +1 -1
  184. package/dist/zoomPan.d.mts +14 -0
  185. package/dist/zoomPan.mjs +1 -0
  186. package/package.json +142 -24
  187. package/dist/chunk-2AHLR3V4.mjs +0 -1
  188. package/dist/chunk-5BVPDHQ4.mjs +0 -1
  189. package/dist/chunk-ESF6XBYF.mjs +0 -1
  190. package/dist/chunk-EV6ZK4QI.mjs +0 -1
  191. package/dist/chunk-IVNQO5UT.mjs +0 -2
  192. package/dist/chunk-KZUAS63N.mjs +0 -1
  193. package/dist/chunk-Q2WWO4HE.mjs +0 -10
  194. package/dist/chunk-QKPYVOTI.mjs +0 -1
  195. package/dist/chunk-RQXWOXAH.mjs +0 -6
  196. package/dist/chunk-TZGAHWM7.mjs +0 -1
  197. package/dist/chunk-U6H3YVAK.mjs +0 -2
  198. package/dist/chunk-UHXXMYPP.mjs +0 -6
  199. package/dist/chunk-VQ742FWN.mjs +0 -4
  200. package/dist/chunk-XBRTUB3S.mjs +0 -2
  201. package/dist/chunk-XVU6PJ7B.mjs +0 -59
  202. package/dist/chunk-YRQVG3MM.mjs +0 -1
  203. package/dist/chunk-ZZZZXO5U.mjs +0 -1
  204. package/dist/elements-24CTbRWj.d.mts +0 -49
  205. package/dist/sliderSub-DDPjywVp.d.mts +0 -33
  206. package/dist/types-D_6Ksp_r.d.mts +0 -158
  207. package/dist/types-fFyCx1KQ.d.mts +0 -278
  208. package/dist/types-tb9Qf2Mj.d.mts +0 -46
package/README.md CHANGED
@@ -1,23 +1,54 @@
1
1
  # React Motion Gallery
2
2
 
3
- Simple, motion-first React gallery primitives for sliders, grids, masonry layouts, fullscreen media, structured entries, and video. The package stays composable: `Slider`, `Grid`, and `Masonry` render children directly, `Entries` renders structured data, `GalleryCore` coordinates fullscreen state, and `Video` handles Plyr-backed video media.
3
+ Composable React media gallery primitives for production interfaces: sliders, grids, masonry, structured entries, fullscreen, thumbnails, video, zoom/pan, and loading states that are designed around the layout they protect.
4
4
 
5
- ## Export Gzip Sizes
5
+ The package stays close to React composition. `Slider`, `Grid`, and `Masonry` render children directly; `Entries` renders structured data; `GalleryCore` coordinates fullscreen state; `Video` handles Plyr-backed media; `ZoomPanImage` gives you a standalone zoom surface; and `Skeleton` can be used inside or outside gallery layouts. For loading-state precision, the repo also includes a development-time browser measurement workflow that turns real rendered text into stable skeleton text authoring data, including reflow-sensitive layouts such as masonry.
6
6
 
7
- This table reports local gzip measurements for each exported runtime surface. The script rebundles one runtime export at a time from the published root entry, excludes peer and runtime externals, and gzips the resulting JS bundle. Run `npm run build && npm run size:readme` in `packages/react-motion-gallery` to refresh it.
7
+ ## Runtime Gzip Sizes
8
+
9
+ This table reports local gzip measurements for selected runtime surfaces. Type-only imports are erased and add no JS; the responsive row measures `BREAKPOINT_MAP`, and feature subpath rows measure only that feature entry point. The script rebundles one export at a time from its published ESM entry point, excludes peer and runtime externals, and gzips the resulting JS bundle. If a surface creates async chunks, the row reports initial plus async JS. Run `npm run build && npm run size:readme` in `packages/react-motion-gallery` to refresh it.
8
10
 
9
11
  <!-- bundle-size:start -->
10
- | Export | JS gzip |
12
+ | Surface | JS gzip |
11
13
  | --- | --- |
12
- | `Entries` | 6.8kB |
13
- | `FullscreenThumbnailSlider` | 17.7kB |
14
- | `GalleryCore` | 1.8kB |
15
- | `Grid` | 7.4kB |
16
- | `Masonry` | 7.2kB |
17
- | `Slider` | 31.3kB |
18
- | `ThumbnailSlider` | 16.4kB |
19
- | `useFullscreenController` | 44.5kB |
20
- | `Video` | 10.7kB |
14
+ | `Entries` | 13.0kB |
15
+ | `FullscreenThumbnailSlider` | 20.3kB |
16
+ | `GalleryCore` | 2.6kB |
17
+ | `Grid` | 8.8kB |
18
+ | `grid/ready` | 323.0B |
19
+ | `Masonry` | 8.9kB |
20
+ | `masonry/ready` | 323.0B |
21
+ | `Skeleton base` | 8.1kB |
22
+ | `skeleton/slider` | 16.5kB |
23
+ | `skeleton/grid` | 10.4kB |
24
+ | `skeleton/masonry` | 17.8kB |
25
+ | `Slider core` | 18.4kB |
26
+ | `slider/ready` | 894.0B |
27
+ | `slider/arrows` | 1.2kB |
28
+ | `slider/dots` | 932.0B |
29
+ | `slider/progress` | 892.0B |
30
+ | `slider/scrollbar` | 1.2kB |
31
+ | `slider/auto-height` | 1.3kB |
32
+ | `slider/lazy-load` | 3.9kB |
33
+ | `slider/parallax` | 1.4kB |
34
+ | `slider/scale` | 1.2kB |
35
+ | `slider/fade` | 1.2kB |
36
+ | `slider/crossfade` | 2.8kB |
37
+ | `slider/fullscreen` | 959.0B |
38
+ | `ThumbnailSlider` | 18.9kB |
39
+ | `useFullscreenController` | 4.9kB |
40
+ | `fullscreen/slider` | 35.3kB |
41
+ | `fullscreen/controls` | 173.0B |
42
+ | `fullscreen/captions` | 13.1kB |
43
+ | `fullscreen/zoom-pan` | 9.9kB |
44
+ | `fullscreen/video` | 16.3kB |
45
+ | `fullscreen/lazy-load` | 13.1kB |
46
+ | `fullscreen/crossfade` | 181.0B |
47
+ | `fullscreen/thumbnails` | 160.0B |
48
+ | `Video` | 12.7kB |
49
+ | `ZoomPanImage` | 8.7kB |
50
+ | `media / toMediaItems` | 260.0B |
51
+ | `responsive / BREAKPOINT_MAP` | 85.0B |
21
52
  <!-- bundle-size:end -->
22
53
 
23
54
  ## Overview
@@ -41,6 +72,8 @@ Mental model:
41
72
  - `Entries` renders structured entry data with a custom media container.
42
73
  - `GalleryCore` and `useFullscreenController` power fullscreen behavior.
43
74
  - `Video` is the gallery-ready video primitive.
75
+ - `ZoomPanImage` attaches click-to-zoom, drag pan, ctrl-wheel pinch, and touch pinch to one clipped image surface.
76
+ - `Skeleton` renders standalone placeholders or wraps real content with shared loading-layer timing.
44
77
 
45
78
  `MediaItem` accepts three shapes:
46
79
 
@@ -52,7 +85,8 @@ Mental model:
52
85
 
53
86
  ```typescript
54
87
  import "react-motion-gallery/styles.css";
55
- import { Slider, toMediaItems, type MediaItem } from "react-motion-gallery";
88
+ import { toMediaItems, type MediaItem } from "react-motion-gallery/media";
89
+ import { Slider } from "react-motion-gallery/slider";
56
90
 
57
91
  const items: MediaItem[] = toMediaItems([
58
92
  "https://picsum.photos/id/1015/1600/900",
@@ -82,12 +116,178 @@ export function QuickStart() {
82
116
 
83
117
  Responsive numeric props in this package accept either a plain number or a breakpoint map like `{ 0: 1, md: 2, 1200: 3 }`. Named breakpoints resolve from the internal map: `xs: 0`, `sm: 600`, `md: 900`, `lg: 1200`, `xl: 1536`.
84
118
 
85
- The package root now exports the primary public components, helper functions, and companion prop types. Subpath entrypoints are also available when you want narrower imports: `react-motion-gallery/core`, `react-motion-gallery/slider`, `react-motion-gallery/grid`, `react-motion-gallery/masonry`, `react-motion-gallery/entries`, `react-motion-gallery/fullscreen`, `react-motion-gallery/thumbnails`, `react-motion-gallery/fullscreenThumbnails`, and `react-motion-gallery/video`.
119
+ The package root exports the primary public components, helper functions, and companion prop types. Use it when one module needs several gallery surfaces. Prefer subpaths for routes or components that only need one surface, such as `react-motion-gallery/media` or `react-motion-gallery/slider`.
120
+
121
+ Subpaths give bundlers a smaller graph than the root. Less JS to transfer, parse, evaluate, and hydrate can improve first loads, cache misses, slower devices, and perceived speed.
122
+
123
+ | Entry point | Main surface |
124
+ | --- | --- |
125
+ | `react-motion-gallery/media` | `toMediaItems`, `MediaItem`, `MediaInput` |
126
+ | `react-motion-gallery/responsive` | `BREAKPOINT_MAP` and responsive value types |
127
+ | `react-motion-gallery/core` | `GalleryCore`, `GalleryCoreProvider`, `useGalleryCore` |
128
+ | `react-motion-gallery/slider` | `Slider`, `createSliderIndexChannel`, slider types |
129
+ | `react-motion-gallery/slider/ready` | `useSliderReady` |
130
+ | `react-motion-gallery/slider/arrows` | `sliderArrows` |
131
+ | `react-motion-gallery/slider/dots` | `sliderDots` |
132
+ | `react-motion-gallery/slider/progress` | `sliderProgress` |
133
+ | `react-motion-gallery/slider/scrollbar` | `sliderScrollbar` |
134
+ | `react-motion-gallery/slider/ripple` | `sliderRipple` |
135
+ | `react-motion-gallery/slider/auto-play` | `sliderAutoPlay` |
136
+ | `react-motion-gallery/slider/auto-scroll` | `sliderAutoScroll` |
137
+ | `react-motion-gallery/slider/auto-height` | `sliderAutoHeight` |
138
+ | `react-motion-gallery/slider/lazy-load` | `sliderLazyLoad` |
139
+ | `react-motion-gallery/slider/parallax` | `sliderParallax` |
140
+ | `react-motion-gallery/slider/scale` | `sliderScale` |
141
+ | `react-motion-gallery/slider/fade` | `sliderFade` |
142
+ | `react-motion-gallery/slider/crossfade` | `sliderCrossfade` |
143
+ | `react-motion-gallery/slider/fullscreen` | `sliderFullscreen` |
144
+ | `react-motion-gallery/slider/loading` | `sliderLoading` |
145
+ | `react-motion-gallery/grid` | `Grid`, `Grid.Item`, grid types |
146
+ | `react-motion-gallery/grid/ready` | `useGridReady` |
147
+ | `react-motion-gallery/masonry` | `Masonry`, `Masonry.Item`, masonry types |
148
+ | `react-motion-gallery/masonry/ready` | `useMasonryReady` |
149
+ | `react-motion-gallery/entries` | `Entries`, `flattenEntries`, entry media container helpers |
150
+ | `react-motion-gallery/skeleton/base` | Standalone `Skeleton` and generic skeleton authoring types |
151
+ | `react-motion-gallery/skeleton/slider` | `SliderSkeleton` and slider skeleton authoring types |
152
+ | `react-motion-gallery/skeleton/grid` | `GridSkeleton` and grid skeleton authoring types |
153
+ | `react-motion-gallery/skeleton/masonry` | `MasonrySkeleton` and masonry skeleton authoring types |
154
+ | `react-motion-gallery/fullscreen` | `useFullscreenController` and fullscreen types |
155
+ | `react-motion-gallery/fullscreen/slider` | `fullscreenSlider` |
156
+ | `react-motion-gallery/fullscreen/controls` | `fullscreenControls` |
157
+ | `react-motion-gallery/fullscreen/captions` | `fullscreenCaptions` |
158
+ | `react-motion-gallery/fullscreen/zoom-pan` | `fullscreenZoomPan` |
159
+ | `react-motion-gallery/fullscreen/video` | `fullscreenVideo` |
160
+ | `react-motion-gallery/fullscreen/lazy-load` | `fullscreenLazyLoad` |
161
+ | `react-motion-gallery/fullscreen/crossfade` | `fullscreenCrossfade` |
162
+ | `react-motion-gallery/fullscreen/thumbnails` | `fullscreenThumbnails` |
163
+ | `react-motion-gallery/thumbnails` | `ThumbnailSlider`, thumbnail sync helpers |
164
+ | `react-motion-gallery/fullscreenThumbnails` | `FullscreenThumbnailSlider` |
165
+ | `react-motion-gallery/video` | `Video` and optional Plyr-backed video types |
166
+ | `react-motion-gallery/zoomPan` | `ZoomPanImage` and zoom/pan types |
167
+
168
+ ## Acknowledgements
169
+
170
+ React Motion Gallery's slider engine includes portions of code derived from [Embla Carousel](https://github.com/davidjerleke/embla-carousel), which is MIT licensed. Those portions have been substantially adapted for React Motion Gallery's React architecture, public API, transition system, fullscreen integration, loading layers, and media workflows.
171
+
172
+ See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md) for the preserved Embla Carousel copyright and MIT license notice.
173
+
174
+ ## Core
175
+
176
+ `GalleryCore` is the shared state boundary for fullscreen-aware galleries. Wrap a layout in it when you need shared breakpoints, a normalized fullscreen media list, fullscreen-open state, or programmatic fullscreen opening. `useGalleryCore()` is the public hook for reading that core state from descendants.
177
+
178
+ ### `GalleryCore` props
179
+
180
+ | Option | Type | Default | Notes |
181
+ | --- | --- | --- | --- |
182
+ | `children` | `React.ReactNode` | `—` | The gallery tree using the shared core. |
183
+ | `layout` | `"slider" \| "grid" \| "masonry" \| "entries"` | `—` | Declares the owning base layout. Omit it for standalone fullscreen/core usage. |
184
+ | `breakpoints` | `Record<string, number>` | `xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536` | Breakpoint map shared with descendants. |
185
+ | `fullscreenItems` | `MediaItem[] \| string[]` | `[]` | Normalized fullscreen media list. |
186
+ | `nodes` | `ReactNode \| ReactNode[]` | `—` | Advanced initial node list used by the slider-backed imperative state. |
187
+
188
+ ### `useGalleryCore` API
189
+
190
+ `GalleryApi` is the public alias for `GalleryCoreApi`. It covers core fullscreen state and programmatic fullscreen opening. Slider item mutation lives on `SliderHandle` and `SliderApi`.
191
+
192
+ | Field / Method | Type | Notes |
193
+ | --- | --- | --- |
194
+ | `layout` | `"slider" \| "grid" \| "masonry" \| "entries" \| null` | Current owning layout, or `null` for standalone fullscreen/core usage. |
195
+ | `effectiveBreakpoints` | `Record<string, number>` | Breakpoint map after merging custom `GalleryCore.breakpoints` with defaults. |
196
+ | `normalizedItems` | `MediaItem[]` | Fullscreen item list normalized from `fullscreenItems`. |
197
+ | `fsEnabled` | `boolean` | `true` when a mounted fullscreen controller has enabled fullscreen behavior. |
198
+ | `setFsEnabled` | `(enabled: boolean) => void` | Enables or disables fullscreen behavior. Usually handled by `useFullscreenController`. |
199
+ | `isFullscreenOpen` | `boolean` | `true` while fullscreen is open. |
200
+ | `isFullscreenOpenRef` | `React.RefObject<boolean>` | Ref mirror for handlers that need the current fullscreen-open state. |
201
+ | `setFullscreenOpen` | `(open: boolean) => void` | Updates fullscreen-open state. Usually handled by the fullscreen runtime. |
202
+ | `openFullscreenAt` | `({ index, method?, event? }) => void` | Opens fullscreen at a normalized fullscreen item index. Pass the source event for scale-origin detection. |
203
+ | `notifyBaseVisibleIndex` | `(index: number) => void` | Emits the visible base media index for fullscreen lazy-load/prewarm coordination. |
204
+ | `notifyFsVisibleIndex` | `(index: number) => void` | Emits the active fullscreen index back to base media. |
205
+ | `registerExpandableImage` | `(index: number, node: HTMLElement \| null) => void` | Registers an origin surface for layoutless scale transitions. |
206
+
207
+ ## ZoomPanImage
208
+
209
+ ```typescript
210
+ import { ZoomPanImage } from "react-motion-gallery/zoomPan";
211
+
212
+ export function ZoomPanCard() {
213
+ return (
214
+ <ZoomPanImage
215
+ src="https://picsum.photos/id/1035/1600/1200"
216
+ alt="A hiker looking over a canyon at dusk"
217
+ className="zoomCard"
218
+ zoom={{
219
+ clickZoomLevel: 2.35,
220
+ maxZoomLevel: 3.5,
221
+ }}
222
+ />
223
+ );
224
+ }
225
+ ```
226
+
227
+ `ZoomPanImage` is the lightweight standalone zoom surface. The component root is the clipping container, so border radius, aspect ratio, and overflow all live on the same element.
228
+
229
+ ## Skeleton
230
+
231
+ ```typescript
232
+ import { Skeleton, type SkeletonNode } from "react-motion-gallery/skeleton/base";
233
+
234
+ const shellSkeleton: SkeletonNode = {
235
+ kind: "rect",
236
+ style: { width: "100%", height: 320 },
237
+ };
238
+
239
+ export function LoadingShell({ ready, children }: { ready: boolean; children: React.ReactNode }) {
240
+ return (
241
+ <Skeleton
242
+ layout={shellSkeleton}
243
+ ready={ready}
244
+ timing={{ exitMs: 520, minVisibleMs: 220 }}
245
+ force={false}
246
+ ariaLabel={ready ? undefined : "Loading content"}
247
+ >
248
+ {children}
249
+ </Skeleton>
250
+ );
251
+ }
252
+ ```
253
+
254
+ `Skeleton` can render a standalone placeholder by itself, or it can wrap real content and own the loading transition. Wrapper mode is enabled when `children` are provided.
255
+
256
+ | Option | Type | Default | Notes |
257
+ | --- | --- | --- | --- |
258
+ | `layout` | `SkeletonNode` | `—` | Structured placeholder layout tree. |
259
+ | `children` | `React.ReactNode` | `—` | Real content. When present, `Skeleton` renders content and loading layers. |
260
+ | `ready` | `boolean` | `false` | Reveals content and exits the skeleton once true. |
261
+ | `enabled` | `boolean` | `true` | Set false to render content immediately with no skeleton layer. |
262
+ | `force` | `boolean \| { enabled?: boolean; showContent?: boolean; skeletonOpacity?: number }` | `false` | Keeps the skeleton visible. Set `showContent: true` to preview ready content under the skeleton, and tune the overlay with `skeletonOpacity`. |
263
+ | `timing.exitMs` | `number` | `600` | Keeps the skeleton layer mounted for this long after exit starts and controls the opacity transition. |
264
+ | `timing.minVisibleMs` | `number` | `220` | Minimum time the skeleton stays visible before exit can begin. |
265
+ | `shellClassName` / `shellStyle` | `string` / `CSSProperties` | `—` | Wrapper-layer class and style for content+skeleton mode. |
266
+ | `contentClassName` / `contentStyle` | `string` / `CSSProperties` | `—` | Content-layer class and style for wrapper mode. |
267
+
268
+ The wrapper timing model matches the gallery loading layers: content begins fading in as soon as the skeleton exit starts; it does not wait for the skeleton to unmount.
269
+
270
+ ### Browser-measured skeleton text authoring
271
+
272
+ Responsive text is one of the easiest places for a polished loading state to drift away from the real UI. React Motion Gallery's skeleton text workflow measures real DOM text in a live page with headless Chrome, then emits `lines`, `barWidth`, `lastBarWidth`, and optional `barHeight`/`lineHeight` values for the skeleton `text` nodes used by `Slider`, `Grid`, `Masonry`, `Entries`, and standalone `Skeleton` layouts.
273
+
274
+ This is development-time authoring support, not production client code. It is especially useful for multiline cards, responsive grids, equal-height sliders, and reflow-sensitive masonry surfaces where a generic text placeholder can otherwise change row height, item height, or column packing when real content appears.
275
+
276
+ ```bash
277
+ npm run --silent generate:skeleton-text-module -- \
278
+ --input ./path/to/example.skeleton-text.browser.manifest.json \
279
+ --analysis-output ./path/to/example.skeleton-text.measurements.json
280
+ ```
281
+
282
+ Use `responsiveBy: "container"` when text wrapping follows the card or cell width more closely than the viewport. For equal-height card sliders, the browser analyzer can also measure all canonical slider items and emit `rowHeightCompensation` so unseen cards cannot surprise the skeleton row height. See [`docs/skeleton-text-authoring.md`](./docs/skeleton-text-authoring.md) for manifest fields, command options, and the Codex-friendly workflow.
86
283
 
87
284
  ## Slider
88
285
 
286
+ The default `Slider` is the small synchronous core: children, drag, wheel navigation, snapping, grouping, looping, index channels, intro, and the imperative ref API. Heavier behavior is opt-in through first-party plugins, so importing one feature, such as arrows or parallax, does not pull in the rest of the slider feature set. Structured slider skeletons and restore behavior are owned by `SliderSkeleton`, composed with `useSliderReady()`.
287
+
89
288
  ```typescript
90
- import { Slider } from "react-motion-gallery";
289
+ import { Slider } from "react-motion-gallery/slider";
290
+ import { sliderArrows } from "react-motion-gallery/slider/arrows";
91
291
 
92
292
  const slides = [
93
293
  "https://picsum.photos/id/1015/1600/900",
@@ -97,7 +297,7 @@ const slides = [
97
297
 
98
298
  export function BasicSlider() {
99
299
  return (
100
- <Slider>
300
+ <Slider plugins={[sliderArrows()]}>
101
301
  {slides.map((src, index) => (
102
302
  <img key={src} src={src} alt={`Slide ${index + 1}`} style={{ width: "100%" }} />
103
303
  ))}
@@ -111,90 +311,89 @@ export function BasicSlider() {
111
311
  | Option | Type | Default | Notes |
112
312
  | --- | --- | --- | --- |
113
313
  | `children` | `React.ReactNode` | `—` | Slide content rendered in order. |
314
+ | `initialIndex` | `number` | `0` | Selects the slide index used for the first layout and intro fade-in. |
114
315
  | `breakpoints` | `Record<string, number>` | `xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536` | Merged with the internal breakpoint map for responsive values. |
115
- | `expandableImageRefs` | `React.RefObject<(HTMLImageElement | null)[]>` | internal ref | Supplies origin images for fullscreen scale transitions. |
116
316
  | `indexChannel` | `SliderIndexChannel` | internal channel | Share index state with thumbnails or sibling sliders. |
317
+ | `plugins` | `SliderPlugin[]` | `[]` | Explicit first-party slider features such as arrows, dots, auto-height, effects, fullscreen, or lazy-load. |
117
318
 
118
319
  ### Slider layout and scroll options
119
320
 
120
321
  | Option | Type | Default | Notes |
121
322
  | --- | --- | --- | --- |
122
- | `layout.gap` | `number` | `20` | Gap between cells. |
323
+ | `layout.gap` | `number \| Record<string, number>` | `20` | Responsive gap between cells. |
123
324
  | `layout.cellsPerSlide` | `number \| Record<string, number>` | `—` | Groups multiple cells into a slide page. |
124
325
  | `direction.dir` | `"ltr" \| "rtl"` | `"ltr"` | Text direction and arrow direction. |
125
326
  | `direction.axis` | `"x" \| "y"` | `"x"` | Horizontal or vertical slider axis. |
126
327
  | `align` | `"start" \| "center"` | `"start"` | Slide alignment inside the viewport. |
127
328
  | `scroll.groupCells` | `boolean` | `false` | Scrolls by grouped cells instead of every cell. |
128
- | `scroll.skipSnaps` | `boolean` | `false` | Allows momentum to skip snap points. |
329
+ | `scroll.skipSnaps` | `boolean \| { enabled?: boolean; threshold?: number }` | `false` | Allows momentum to skip snap points. Object form enables skip snaps by default and `threshold` requires release force to reach a multiple of the adjacent snap distance before multi-snap momentum is used. |
330
+ | `scroll.strictSnaps` | `boolean` | `false` | Prevents one drag release from settling more than one snap away from where the drag started. Overrides `scroll.skipSnaps`. |
129
331
  | `scroll.freeScroll` | `boolean` | `false` | Enables free dragging instead of strict snapping. |
130
332
  | `scroll.loop` | `boolean` | `false` | Wraps around at the ends. |
131
333
 
132
- ### Slider element and lazy-load options
334
+ ### Slider element and plugin options
335
+
336
+ `elements`, `motion`, and `transitions.intro` stay in the core slider. Controls, autoplay, lazy media, effects, auto-height, fullscreen, and loading overlays are explicit plugin imports.
133
337
 
134
338
  | Option | Type | Default | Notes |
135
339
  | --- | --- | --- | --- |
136
340
  | `elements.viewport` | `ElementStyle` | `—` | Class and inline style for the viewport element. |
137
341
  | `elements.container` | `ElementStyle` | `—` | Class and inline style for the moving slider container. |
138
- | `lazyLoad.enabled` | `boolean` | `false` | Enables slide-level lazy image and video loading. |
139
- | `lazyLoad.spinner` | `boolean \| ReactNode \| ((args) => ReactNode)` | `true` | `false` disables the built-in spinner. |
140
- | `lazyLoad.spinnerClassName` | `string` | `""` | Applied to the spinner wrapper. |
141
- | `lazyLoad.spinnerStyle` | `React.CSSProperties` | `{}` | Inline styles for the spinner wrapper. |
342
+ | `transitions.intro.renderIntro` | `({ active, containerProps }, content) => ReactNode` | `—` | Custom intro wrapper. |
343
+ | `transitions.intro.staggerMs` | `number` | `—` | Delay between item fade-ins. |
344
+ | `transitions.intro.durationMs` | `number` | `—` | Intro fade duration. |
345
+ | `transitions.intro.easing` | `string` | `—` | Intro fade easing. |
142
346
 
143
- ### Slider control options
347
+ ### Slider plugins
144
348
 
145
- | Option | Type | Default | Notes |
146
- | --- | --- | --- | --- |
147
- | `controls.arrows.enabled` | `boolean` | `true` | Toggles previous and next arrows. |
148
- | `controls.arrows.arrow` | `ElementStyle` | `{}` | Shared arrow class and style. |
149
- | `controls.arrows.prev` | `ElementStyle` | `{}` | Previous-arrow override. |
150
- | `controls.arrows.next` | `ElementStyle` | `{}` | Next-arrow override. |
151
- | `controls.arrows.render` | `(args) => ReactNode` | `—` | Custom renderer for both arrows. |
152
- | `controls.arrows.renderPrev` | `(args) => ReactNode` | `—` | Custom previous arrow. |
153
- | `controls.arrows.renderNext` | `(args) => ReactNode` | `—` | Custom next arrow. |
154
- | `controls.dots.enabled` | `boolean` | `true` | Toggles pagination dots. |
155
- | `controls.dots.root` | `ElementStyle` | `{}` | Dot container class and style. |
156
- | `controls.dots.dot` | `ElementStyle` | `{}` | Individual dot class and style. |
157
- | `controls.dots.render` | `(args) => ReactNode` | `—` | Full custom dots UI. |
158
- | `controls.progress.enabled` | `boolean` | `false` | Toggles the progress bar. |
159
- | `controls.progress.root` | `ElementStyle` | `{}` | Progress track class and style. |
160
- | `controls.progress.bar` | `ElementStyle` | `{}` | Progress fill class and style. |
161
- | `controls.progress.render` | `(args) => ReactNode` | `—` | Full custom progress UI. |
162
- | `controls.ripple.enabled` | `boolean` | `true` | Toggles control ripple feedback. |
163
- | `controls.ripple.className` | `string` | `""` | Custom ripple class. |
164
-
165
- ### Slider auto and transition options
349
+ Each plugin is imported from its own subpath and passed to `plugins`. There is no aggregate controls or effects helper; this keeps one-feature imports as small as possible.
166
350
 
167
- | Option | Type | Default | Notes |
168
- | --- | --- | --- | --- |
169
- | `auto.play.enabled` | `boolean` | `false` | Timed slide changes. |
170
- | `auto.play.speedMs` | `number` | `3000` | Delay between autoplay advances. |
171
- | `auto.play.pauseMs` | `number` | `1000` | Delay after interaction before autoplay resumes. |
172
- | `auto.play.pauseOnHover` | `boolean` | `true` | Pauses autoplay while hovering. |
173
- | `auto.scroll.enabled` | `boolean` | `false` | Continuous timed scrolling. |
174
- | `auto.scroll.speedMs` | `number` | `0.3` | Continuous auto-scroll speed. |
175
- | `auto.scroll.pauseMs` | `number` | `1000` | Delay after interaction before auto-scroll resumes. |
176
- | `auto.scroll.pauseOnHover` | `boolean` | `true` | Pauses while hovering. |
177
- | `transitions.loading.enabled` | `boolean` | `—` | Enables the loading skeleton layer. |
178
- | `transitions.loading.force` | `boolean` | `—` | Forces the loading layer to stay visible. |
179
- | `transitions.loading.skeletonCount` | `number \| Record<string, number>` | `—` | Responsive skeleton slot count. |
180
- | `transitions.loading.renderLoading` | `({ count }) => ReactNode` | `—` | Custom loading renderer. |
181
- | `transitions.loading.skeleton` | `SliderSkeletonSpec` | `—` | Built-in skeleton spec, including per-slot overrides with `layout.slots` and centered peek support via `centering: "first"`. |
182
- | `transitions.intro.renderIntro` | `({ active, containerProps }, content) => ReactNode` | `—` | Custom intro wrapper. |
183
- | `transitions.intro.staggerMs` | `number` | `—` | Delay between item reveals. |
184
- | `transitions.intro.transform` | `number \| string` | `—` | Initial intro transform. |
185
- | `transitions.intro.durationMs` | `number` | `—` | Intro duration. |
186
- | `transitions.intro.easing` | `string` | `—` | Intro easing. |
351
+ ```typescript
352
+ import { Slider } from "react-motion-gallery/slider";
353
+ import { sliderArrows } from "react-motion-gallery/slider/arrows";
354
+ import { sliderParallax } from "react-motion-gallery/slider/parallax";
355
+
356
+ <Slider plugins={[sliderArrows(), sliderParallax({ bleedPct: "8%" })]}>
357
+ {slides}
358
+ </Slider>;
359
+ ```
360
+
361
+ | Import | Factory | Notes |
362
+ | --- | --- | --- |
363
+ | `react-motion-gallery/slider/arrows` | `sliderArrows(options)` | Previous/next arrows. |
364
+ | `react-motion-gallery/slider/dots` | `sliderDots(options)` | Pagination dots. |
365
+ | `react-motion-gallery/slider/progress` | `sliderProgress(options)` | Progress bar or custom progress renderer. |
366
+ | `react-motion-gallery/slider/scrollbar` | `sliderScrollbar(options)` | Range-style position control. |
367
+ | `react-motion-gallery/slider/ripple` | `sliderRipple(options)` | Enables ripple feedback for controls that call `createRipple`. |
368
+ | `react-motion-gallery/slider/auto-play` | `sliderAutoPlay(options)` | Timed slide changes. |
369
+ | `react-motion-gallery/slider/auto-scroll` | `sliderAutoScroll(options)` | Timed continuous advancement. |
370
+ | `react-motion-gallery/slider/auto-height` | `sliderAutoHeight(options)` | Measures active slide height and gates slider readiness until measured. |
371
+ | `react-motion-gallery/slider/lazy-load` | `sliderLazyLoad(options)` | Adds lazy media attributes to slide images and videos. |
372
+ | `react-motion-gallery/slider/parallax` | `sliderParallax(options)` | Parallax slide wrapper. |
373
+ | `react-motion-gallery/slider/scale` | `sliderScale(options)` | Scales non-active slides. |
374
+ | `react-motion-gallery/slider/fade` | `sliderFade(options)` | Fades non-active slides. |
375
+ | `react-motion-gallery/slider/crossfade` | `sliderCrossfade(options)` | Enables crossfade-aware control navigation. |
376
+ | `react-motion-gallery/slider/fullscreen` | `sliderFullscreen()` | Bridges a `GalleryCore layout="slider"` slider to fullscreen. |
377
+ | `react-motion-gallery/slider/loading` | `sliderLoading(options)` | Basic custom loading overlay. Prefer `SliderSkeleton` for structured skeleton and restore. |
187
378
 
188
379
  ### Slider loading skeletons
189
380
 
190
- `transitions.loading.skeleton` lets you describe a placeholder layout that mirrors the final slider instead of falling back to generic blocks. This is especially useful for variable-width slides, mixed aspect ratios, and center-aligned peek carousels.
381
+ Use `SliderSkeleton` to own slider loading. `useSliderReady()` exposes the slider ref plus a settled `ready` flag; `isSlidesBuilt()` remains a lower-level DOM-built signal and is not the right fade-out trigger.
191
382
 
192
383
  `layout.slots` is the per-slide override system. Define the shared placeholder once with `layout.item` and `layout.itemWrapStyle`, then override any individual slot with `slots[index]`. Slot `itemWrapStyle` values merge on top of the base wrap style, while `slot.item` can replace the placeholder node entirely for that slot.
193
384
 
194
- `centering: "first"` is designed for center-aligned peek sliders. When the real slider uses `align="center"` and the skeleton uses `mode: "peek"` with `layout.kind: "slider"`, the built-in skeleton renderer inserts the leading spacer needed to center the first visible placeholder. You should not add that spacer manually, and it does not apply when you replace the built-in skeleton with `transitions.loading.renderLoading`.
385
+ `itemWrapStyle` now supports wrapper-only `border` and `boxShadow` values. Wrapper `width`, `height`, and `aspectRatio` are treated as outer border-box dimensions, so the inner placeholder shrinks by the border thickness. Use simple uniform border shorthands such as `1px solid #cbd5e1` when you want the built-in sizing math to account for the border width.
386
+
387
+ `text` nodes render one skeleton bar per `lines` value. `barHeight` controls the bar height and can be a single number or a numeric min-width map. `lineHeight` remains the full line-box multiplier and now accepts the same numeric min-width maps. `lines` can be a single number or a numeric min-width map such as `{ 0: 3, 767: 2, 1200: 1 }`. Use `lastBarWidth` to override the shortened trailing bar width; it defaults to `68%` of the text block width and can also be responsive with numeric min-width keys.
388
+
389
+ `centering: "first"` is designed for center-aligned peek sliders. When the real slider uses `align="center"` and the skeleton uses `mode: "peek"` with `layout.kind: "slider"`, the skeleton renderer inserts the leading spacer needed to center the first visible placeholder. You should not add that spacer manually.
390
+
391
+ When you provide `SliderSkeleton.timing`, `exitMs` controls both how long the loading layer remains mounted after exit starts and its opacity transition duration.
195
392
 
196
393
  ```typescript
197
- import { Slider } from "react-motion-gallery";
394
+ import { SliderSkeleton } from "react-motion-gallery/skeleton/slider";
395
+ import { Slider } from "react-motion-gallery/slider";
396
+ import { useSliderReady } from "react-motion-gallery/slider/ready";
198
397
 
199
398
  const slides = [
200
399
  { src: "https://picsum.photos/id/1020/660/960", width: 220, height: 320 },
@@ -203,47 +402,47 @@ const slides = [
203
402
  ];
204
403
 
205
404
  export function VariableWidthSkeletonSlider() {
405
+ const { ref: sliderRef, ready: sliderReady } = useSliderReady();
406
+
206
407
  return (
207
- <Slider
208
- align="center"
209
- transitions={{
210
- loading: {
211
- skeletonCount: 2,
212
- skeleton: {
213
- mode: "peek",
214
- centering: "first",
215
- layout: {
216
- kind: "slider",
217
- direction: "row",
218
- style: { gap: 20 },
219
- item: {
220
- kind: "rect",
221
- style: {
222
- width: "100%",
223
- height: "100%",
224
- borderRadius: 12,
225
- },
226
- },
227
- slots: slides.map((slide) => ({
228
- itemWrapStyle: {
229
- width: slide.width,
230
- height: slide.height,
231
- },
232
- })),
408
+ <SliderSkeleton
409
+ ready={sliderReady}
410
+ layout={{
411
+ mode: "peek",
412
+ centering: "first",
413
+ visibleCount: 2,
414
+ layout: {
415
+ kind: "slider",
416
+ direction: "row",
417
+ style: { gap: 20 },
418
+ item: {
419
+ kind: "rect",
420
+ style: {
421
+ width: "100%",
422
+ height: "100%",
423
+ borderRadius: 12,
233
424
  },
234
425
  },
426
+ slots: slides.map((slide) => ({
427
+ itemWrapStyle: {
428
+ width: slide.width,
429
+ height: slide.height,
430
+ },
431
+ })),
235
432
  },
236
433
  }}
237
434
  >
238
- {slides.map((slide, index) => (
239
- <img
240
- key={slide.src}
241
- src={slide.src}
242
- alt={`Slide ${index + 1}`}
243
- style={{ width: slide.width, height: slide.height, objectFit: "cover" }}
244
- />
245
- ))}
246
- </Slider>
435
+ <Slider ref={sliderRef} align="center">
436
+ {slides.map((slide, index) => (
437
+ <img
438
+ key={slide.src}
439
+ src={slide.src}
440
+ alt={`Slide ${index + 1}`}
441
+ style={{ width: slide.width, height: slide.height, objectFit: "cover" }}
442
+ />
443
+ ))}
444
+ </Slider>
445
+ </SliderSkeleton>
247
446
  );
248
447
  }
249
448
  ```
@@ -254,6 +453,7 @@ export function VariableWidthSkeletonSlider() {
254
453
  | --- | --- | --- |
255
454
  | `mode` | `"fit" \| "peek"` | `"peek"` preserves partial next or previous slide visibility in the loading state. |
256
455
  | `centering` | `"first"` | Adds the leading spacer needed for the first visible slot when using the built-in centered peek skeleton flow. |
456
+ | `visibleCount` | `number \| Record<string, number>` | Responsive count of visible skeleton slots. |
257
457
  | `className` | `string \| undefined` | Applied to the skeleton overlay root. |
258
458
  | `style` | `React.CSSProperties \| undefined` | Inline styles for the skeleton overlay root. |
259
459
  | `layout` | `SliderSkeletonNode \| undefined` | Structured placeholder layout tree. Use `kind: "slider"` to model slide tracks. |
@@ -267,36 +467,29 @@ export function VariableWidthSkeletonSlider() {
267
467
  | --- | --- | --- |
268
468
  | `kind` | `"slider"` | Slider-specific skeleton layout root. |
269
469
  | `style` | `SkeletonContainerStyle \| Record<string, SkeletonContainerStyle>` | Track-level container styles such as `gap`, `padding`, `align`, `justify`, `width`, and `maxWidth`. |
270
- | `count` | `number \| undefined` | Optional explicit slot count for the layout. Falls back to `transitions.loading.skeletonCount`. |
470
+ | `count` | `number \| undefined` | Optional explicit slot count for the layout. Falls back to `visibleCount` on the surrounding slider skeleton spec. |
271
471
  | `item` | `SkeletonNode` | Default placeholder node rendered in each slot. |
272
- | `itemWrapStyle` | `SkeletonBaseStyle \| undefined` | Shared wrapper size and margin rules for every slot. |
472
+ | `itemWrapStyle` | `SliderSkeletonWrapStyle \| undefined` | Shared wrapper size, margin, border, and box-shadow rules for every slot. Border sizing is border-box. |
273
473
  | `slots` | `SliderSkeletonSlot[] \| undefined` | Per-slot overrides for variable widths, heights, aspect ratios, or custom placeholder nodes. |
274
474
  | `direction` | `"row" \| "col" \| undefined` | Slot flow direction. `centering: "first"` only affects row layouts. |
275
- | `children` | `SkeletonNode[] \| undefined` | Optional extra skeleton content rendered after the slider row. |
475
+ | `children` | `SkeletonNode[] \| undefined` | Optional extra skeleton content rendered after the slider row. It does not affect `--rmg-slider-initial-height` or reserve live layout space. |
276
476
 
277
477
  #### `SliderSkeletonSlot`
278
478
 
279
479
  | Field | Type | Notes |
280
480
  | --- | --- | --- |
281
481
  | `item` | `SkeletonNode \| undefined` | Replaces the base `layout.item` for one slot. |
282
- | `itemWrapStyle` | `SkeletonBaseStyle \| undefined` | Merges on top of the base `layout.itemWrapStyle` for one slot. |
482
+ | `itemWrapStyle` | `SliderSkeletonWrapStyle \| undefined` | Merges on top of the base `layout.itemWrapStyle` for one slot, including wrapper borders and shadows. |
283
483
 
284
- `SkeletonNode` supports these building blocks: `rect`, `square`, `circle`, `text`, `media`, `row`, `col`, and `stack`.
484
+ `SkeletonNode` supports these building blocks: `rect`, `square`, `circle`, `text`, `media`, `row`, `col`, and `stack`. `text.barHeight` controls the bar height, `text.lines` controls how many wrapped skeleton rows render for that text block, and `text.lastBarWidth` controls the trailing bar width.
285
485
 
286
- ### Slider motion and effect options
486
+ ### Slider motion options
287
487
 
288
488
  | Option | Type | Default | Notes |
289
489
  | --- | --- | --- | --- |
290
490
  | `motion.selectDuration` | `number` | `25` | Duration for snapped selection motion. |
291
491
  | `motion.freeScrollDuration` | `number` | `43` | Duration for free-scroll settling. |
292
492
  | `motion.friction` | `number` | `0.68` | Drag and settling friction. |
293
- | `effects.parallax.enabled` | `boolean` | `—` | Enables the parallax slide treatment. |
294
- | `effects.parallax.bleedPct` | `string` | `—` | Extra image bleed around the viewport. |
295
- | `effects.parallax.borderRadius` | `string` | `—` | Radius for the parallax frame. |
296
- | `effects.parallax.sideWidth` | `string` | `—` | Side crop width used by the effect. |
297
- | `effects.scale.enabled` | `boolean` | `—` | Scales neighboring slides. |
298
- | `effects.scale.amount` | `number` | `—` | Scale multiplier for the scale effect. |
299
- | `effects.fade.enabled` | `boolean` | `—` | Fades slides based on position. |
300
493
 
301
494
  ### Slider render callback args
302
495
 
@@ -355,12 +548,22 @@ export function VariableWidthSkeletonSlider() {
355
548
  | `onSlidesBuilt` | `(cb: (nodes: HTMLElement[]) => void) => () => void` | Runs when slide nodes are ready. |
356
549
  | `whenSlidesBuilt` | `() => Promise<HTMLElement[]>` | Promise form of `onSlidesBuilt`. |
357
550
  | `isSlidesBuilt` | `() => boolean` | `true` once the slide list is ready. |
551
+ | `onReady` | `(cb: (nodes: HTMLElement[]) => void) => () => void` | Runs when the slider has built, measured, committed its index, and all plugin ready gates have cleared. |
552
+ | `whenReady` | `() => Promise<HTMLElement[]>` | Promise form of `onReady`. |
553
+ | `isReady` | `() => boolean` | `true` once the settled slider ready signal has fired. |
358
554
  | `scrollNext` | `(mode?: IndexMode) => void` | Advances one step. |
359
555
  | `scrollPrev` | `(mode?: IndexMode) => void` | Moves backward one step. |
360
556
  | `canScrollNext` | `() => boolean` | Whether next navigation is available. |
361
557
  | `canScrollPrev` | `() => boolean` | Whether previous navigation is available. |
362
558
  | `scrollProgress` | `() => number` | Current progress from `0` to `1`. |
363
559
  | `cellsInView` | `() => number[]` | Canonical cell indexes currently visible. |
560
+ | `append` | `(nodes: ReactNode \| ReactNode[]) => number` | Appends nodes and returns the new total count. |
561
+ | `prepend` | `(nodes: ReactNode \| ReactNode[]) => number` | Prepends nodes and returns the new total count. |
562
+ | `insert` | `(index: number, nodes: ReactNode \| ReactNode[]) => number` | Inserts nodes and returns the new total count. |
563
+ | `remove` | `(indexOrPredicate: number \| ((i: number) => boolean)) => number` | Removes items and returns the new total count. |
564
+ | `replace` | `(index: number, node: ReactNode) => void` | Replaces a node at an index. |
565
+ | `setItems` | `(nodes: ReactNode[]) => number` | Replaces all nodes and returns the new total count. |
566
+ | `onIndexChange` | `(cb: (i: number, meta: { mode: IndexMode }) => void) => () => void` | Subscribes to index changes. |
364
567
  | `getInternals` | `() => { slides, slider, visibleImages, selectedIndex, sliderX, sliderVelocity, isWrapping }` | Low-level internals used by fullscreen and advanced sync code. |
365
568
 
366
569
  ### `createSliderIndexChannel`
@@ -441,7 +644,7 @@ export function SliderWithThumbnails() {
441
644
  }
442
645
  ```
443
646
 
444
- The component forwards a ref to its outer thumbnail shell. The explicit `layout`, `scroll`, and `motion` defaults below are also exported as `DEFAULT_THUMBNAILS`.
647
+ The component forwards a ref to its outer thumbnail shell.
445
648
 
446
649
  ### ThumbnailSlider component props
447
650
 
@@ -500,21 +703,26 @@ The component forwards a ref to its outer thumbnail shell. The explicit `layout`
500
703
  | Option | Type | Default | Notes |
501
704
  | --- | --- | --- | --- |
502
705
  | `transitions.loading.enabled` | `boolean` | `true` | Enables the thumbnail loading layer. |
503
- | `transitions.loading.force` | `boolean` | `false` | Forces the loading layer to remain visible. |
706
+ | `transitions.loading.force` | `boolean \| { enabled?: boolean; showContent?: boolean; skeletonOpacity?: number }` | `false` | Forces the loading layer to remain visible. Set `showContent: true` to preview the real thumbnails under the skeleton, and tune the loading overlay with `skeletonOpacity`. |
504
707
  | `transitions.loading.skeletonCount` | `number \| Record<string, number>` | `—` | Responsive count for the built-in loading placeholders. |
505
708
  | `transitions.loading.mode` | `"fit" \| "peek"` | `"peek"` | `"peek"` keeps fixed-size thumbnail placeholders when width or height is explicitly set; `"fit"` divides the rail evenly across the visible count. |
506
709
  | `transitions.loading.elements.container` | `ElementStyle` | `—` | Class and inline style for the built-in loading overlay container. |
507
710
  | `transitions.loading.elements.row` | `ElementStyle` | `—` | Class and inline style for the built-in skeleton row or column wrapper. |
508
711
  | `transitions.loading.elements.thumbnail` | `ElementStyle` | `—` | Class and inline style for each built-in thumbnail placeholder. |
509
712
  | `transitions.loading.renderLoading` | `({ count }) => ReactNode` | `—` | Replaces the built-in thumbnail loading skeleton and receives the resolved responsive count. |
713
+ | `transitions.loading.timing.exitMs` | `number` | `600` | Keeps the thumbnail loading layer mounted for this long after exit starts. |
714
+ | `transitions.loading.timing.minVisibleMs` | `number` | `220` | Minimum time the loading layer stays visible before exit can begin. |
510
715
  | `transitions.intro.renderIntro` | `({ active, containerProps }, inner) => ReactNode` | `—` | Custom intro wrapper for the thumbnail rail. |
511
- | `transitions.intro.staggerMs` | `number` | `40` | Delay between thumbnail reveals. |
512
- | `transitions.intro.transform` | `string` | `"10px"` | Starting translate offset used by the default intro. |
513
- | `transitions.intro.durationMs` | `number` | `300` | Intro duration. |
514
- | `transitions.intro.easing` | `string` | `"cubic-bezier(.2,.7,.2,1)"` | Intro easing. |
716
+ | `transitions.intro.staggerMs` | `number` | `40` | Delay between thumbnail fade-ins. |
717
+ | `transitions.intro.durationMs` | `number` | `300` | Intro fade duration. |
718
+ | `transitions.intro.easing` | `string` | `"cubic-bezier(.2,.7,.2,1)"` | Intro fade easing. |
515
719
 
516
720
  `transitions.loading.elements.*` only applies to the built-in thumbnail skeleton. If you provide `transitions.loading.renderLoading`, you fully own the loading markup instead.
517
721
 
722
+ The built-in thumbnail placeholders use the same shimmer variable family as slider skeletons: `--rmg-skel-bg`, `--rmg-skel-shimmer-enabled`, `--rmg-skel-shimmer-opacity`, `--rmg-skel-shimmer-filter`, `--rmg-skel-shimmer-angle`, `--rmg-skel-shimmer-c1`, `--rmg-skel-shimmer-c2`, `--rmg-skel-shimmer-c3`, `--rmg-skel-shimmer-duration`, and `--rmg-skel-shimmer-timing`.
723
+
724
+ For thumbnails, `transitions.loading.timing.exitMs` controls both the mounted exit lifetime and the loading-layer opacity fade. The thumbnail intro can begin as soon as the loading exit starts.
725
+
518
726
  ### `createThumbnailSyncBridge`
519
727
 
520
728
  `ThumbnailSlider` creates and starts this bridge for you internally when you pass `indexChannel`. Reach for `createThumbnailSyncBridge()` only when you need to wire a local thumbnail rail to an external slider channel manually.
@@ -551,16 +759,28 @@ export function BasicGrid() {
551
759
 
552
760
  | Option | Type | Default | Notes |
553
761
  | --- | --- | --- | --- |
554
- | `children` | `React.ReactNode` | `—` | Grid items rendered in order. |
762
+ | `children` | `React.ReactNode` | `—` | Grid items rendered in order. Wrap individual cards in `Grid.Item` when they need custom spans or wrapper props. |
555
763
  | `breakpoints` | `Record<string, number>` | `xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536` | Used to resolve responsive columns and gaps. |
556
764
  | `gridItemBaseClass` | `string` | `"rmg__grid-item"` | Internal item base class override. |
557
765
  | `renderMode` | `"wrap" \| "passthrough"` | `"wrap"` | `wrap` adds an item wrapper; `passthrough` keeps child structure closer to the source node. |
558
766
 
767
+ ### Grid.Item props
768
+
769
+ `Grid.Item` is a metadata wrapper. It renders only its children, while Grid reads the wrapper props and applies them to the generated item shell.
770
+
771
+ | Option | Type | Default | Notes |
772
+ | --- | --- | --- | --- |
773
+ | `children` | `React.ReactNode` | `—` | The grid card content. |
774
+ | `span` | `number \| "full" \| Record<string, number \| "full">` | `1` | Per-item track span. `"full"` renders `grid-column: 1 / -1`; numeric values render `grid-column: span n / span n`. |
775
+ | `className` | `string` | `—` | Extra class name merged onto the grid item wrapper. |
776
+ | `style` | `React.CSSProperties` | `—` | Inline styles merged onto the grid item wrapper. |
777
+
559
778
  ### Grid options
560
779
 
561
780
  | Option | Type | Default | Notes |
562
781
  | --- | --- | --- | --- |
563
782
  | `columns` | `number \| Record<string, number>` | `—` | Fixed responsive column count. When omitted, Grid auto-fits using `minColumnWidth`. |
783
+ | `templateColumns` | `string \| Record<string, string>` | `—` | Explicit `grid-template-columns` value. Takes precedence over `columns` and `minColumnWidth`. |
564
784
  | `minColumnWidth` | `number \| string` | `160` | Minimum width used by auto-fit mode. |
565
785
  | `gap` | `number \| Record<string, number>` | `8` | Responsive grid gap. |
566
786
  | `rootClassName` | `string` | `—` | Class name for the grid root. |
@@ -570,19 +790,103 @@ export function BasicGrid() {
570
790
  | `lazyLoad.spinner` | `boolean \| ReactNode \| ((args) => ReactNode)` | `—` | Spinner override for lazy items. |
571
791
  | `lazyLoad.spinnerClassName` | `string` | `—` | Spinner wrapper class. |
572
792
  | `lazyLoad.spinnerStyle` | `React.CSSProperties` | `—` | Spinner wrapper style. |
573
- | `loading.enabled` | `boolean` | `—` | Enables the loading layer. |
574
- | `loading.force` | `boolean` | `—` | Keeps the loading layer visible even when media is ready. |
575
- | `loading.renderLoading` | `({ count }) => ReactNode` | `—` | Custom loading renderer. |
576
- | `loading.skeleton` | `GridSkeletonSpec` | `—` | Built-in grid skeleton spec. |
577
793
  | `intro.renderIntro` | `({ active, containerProps }, content) => ReactNode` | `—` | Custom intro wrapper. |
578
- | `intro.staggerMs` | `number` | `40` | Reveal stagger. |
579
- | `intro.transform` | `string` | `"translateY(10px) scale(0.99)"` | Starting transform. |
580
- | `intro.durationMs` | `number` | `300` | Intro duration. |
581
- | `intro.easing` | `string` | `"cubic-bezier(.2,.7,.2,1)"` | Intro easing. |
794
+ | `intro.staggerMs` | `number` | `60` | Reveal stagger for the fade-in. |
795
+ | `intro.durationMs` | `number` | `600` | Intro fade duration. |
796
+ | `intro.easing` | `string` | `"cubic-bezier(.2,.7,.2,1)"` | Intro fade easing. |
582
797
  | `intro.staggerLimit` | `number` | `—` | Optional cap on how many items stagger. |
583
798
 
799
+ When `lazyLoad.enabled` is true, Grid rewrites trackable image `src` values into `data-rmg-lazy-src`, reveals them on viewport intersection, then fades them in after decode and spinner exit.
800
+
584
801
  Grid fullscreen behavior is provided by `GalleryCore` and `useFullscreenController`; Grid itself does not expose a ref-based imperative API.
585
802
 
803
+ Wrap a card in `Grid.Item` when it should span tracks or needs wrapper styling:
804
+
805
+ ```typescript
806
+ <Grid columns={{ 0: 1, 720: 6, 1100: 12 }} gap={{ 0: 12, 1100: 18 }}>
807
+ <Grid.Item span={{ 0: "full", 720: 3, 1100: 6 }} className="feature-card">
808
+ <FeatureCard />
809
+ </Grid.Item>
810
+ <Grid.Item span={{ 0: "full", 720: 3, 1100: 3 }}>
811
+ <ProductCard />
812
+ </Grid.Item>
813
+ <Grid.Item span="full">
814
+ <WideEditorialCard />
815
+ </Grid.Item>
816
+ </Grid>
817
+ ```
818
+
819
+ Grid spans require explicit tracks: use `columns` or `templateColumns`. If Grid is in auto-fit mode through `minColumnWidth`, item spans are ignored because there is no stable track count to span. Responsive span maps use the same breakpoint keys as responsive numeric props, so named keys such as `md` and numeric keys such as `900` are both valid.
820
+
821
+ Use `templateColumns` when the tracks themselves need custom proportions:
822
+
823
+ ```typescript
824
+ <Grid
825
+ templateColumns={{
826
+ 0: "1fr",
827
+ 900: "minmax(0, 1.4fr) minmax(0, 1fr)",
828
+ 1200: "minmax(0, 2fr) repeat(2, minmax(0, 1fr))",
829
+ }}
830
+ gap={{ 0: 12, 1200: 18 }}
831
+ >
832
+ <Grid.Item span={{ 0: "full", 900: 2 }}>
833
+ <FeatureCard />
834
+ </Grid.Item>
835
+ </Grid>
836
+ ```
837
+
838
+ Grid no longer owns loading UI. Use `useGridReady` and wrap Grid with `GridSkeleton`, the same composition pattern used by Slider and Masonry.
839
+
840
+ Grid skeletons live in `react-motion-gallery/skeleton/grid`. Their `text` nodes use the same wrapped-line treatment as slider skeletons, including responsive `barHeight` and `lines` maps plus the configurable trailing `lastBarWidth`.
841
+
842
+ Grid skeletons inherit real item spans by default. Slot overrides in the Skeleton layout can change individual placeholder nodes or wrapper styles without losing the span applied by `Grid.Item`.
843
+
844
+ When Grid is wrapped in `GridSkeleton`, `GridSkeleton.timing.exitMs` controls both how long the loading layer stays mounted after exit starts and its opacity transition, and the real grid intro begins as soon as exit starts.
845
+
846
+ ```typescript
847
+ import { Grid, useGridReady } from "react-motion-gallery";
848
+ import { GridSkeleton, type GridSkeletonSpec } from "react-motion-gallery/skeleton/grid";
849
+
850
+ const gridSkeleton: GridSkeletonSpec = {
851
+ radius: 14,
852
+ layout: {
853
+ kind: "grid",
854
+ count: 6,
855
+ item: {
856
+ kind: "rect",
857
+ style: { aspectRatio: "4 / 5" },
858
+ },
859
+ },
860
+ };
861
+
862
+ function GridWithSkeleton({ images }: { images: { src: string; alt: string }[] }) {
863
+ const { ref: gridRef, ready: gridReady } = useGridReady();
864
+
865
+ return (
866
+ <GridSkeleton
867
+ layout={gridSkeleton}
868
+ ready={gridReady}
869
+ timing={{ minVisibleMs: 220, exitMs: 600 }}
870
+ grid={{
871
+ count: images.length,
872
+ columns: { 0: 1, 640: 2, 960: 3 },
873
+ gap: { 0: 12, 960: 20 },
874
+ }}
875
+ >
876
+ <Grid
877
+ ref={gridRef}
878
+ columns={{ 0: 1, 640: 2, 960: 3 }}
879
+ gap={{ 0: 12, 960: 20 }}
880
+ >
881
+ {images.map((image) => (
882
+ <img key={image.src} src={image.src} alt={image.alt} />
883
+ ))}
884
+ </Grid>
885
+ </GridSkeleton>
886
+ );
887
+ }
888
+ ```
889
+
586
890
  ## Masonry
587
891
 
588
892
  ```typescript
@@ -610,37 +914,156 @@ export function BasicMasonry() {
610
914
 
611
915
  | Option | Type | Default | Notes |
612
916
  | --- | --- | --- | --- |
613
- | `children` | `React.ReactNode` | `—` | Masonry items rendered in order. |
917
+ | `children` | `React.ReactNode` | `—` | Masonry items rendered in order. Wrap individual cards in `Masonry.Item` when they need custom spans or wrapper props. |
614
918
  | `breakpoints` | `Record<string, number>` | `xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536` | Used to resolve responsive columns and gaps. |
615
919
 
920
+ ### Masonry.Item props
921
+
922
+ | Option | Type | Default | Notes |
923
+ | --- | --- | --- | --- |
924
+ | `children` | `React.ReactNode` | `—` | The masonry card content. |
925
+ | `span` | `number \| "full" \| Record<string, number \| "full">` | `1` | Per-item track span. `"full"` resolves to the active column count and numeric values clamp to the current track count. |
926
+ | `className` | `string` | `—` | Extra class name merged onto the masonry item wrapper. |
927
+ | `style` | `React.CSSProperties` | `—` | Inline styles merged onto the masonry item wrapper. |
928
+
616
929
  ### Masonry options
617
930
 
618
931
  | Option | Type | Default | Notes |
619
932
  | --- | --- | --- | --- |
620
933
  | `columns` | `number \| Record<string, number>` | `—` | Responsive column count. |
621
934
  | `gap` | `number \| Record<string, number>` | `—` | Responsive gap between columns and items. |
622
- | `placement` | `"balanced" \| "roundRobin"` | `"balanced"` | `balanced` aims for even column heights. |
623
- | `estimatedItemHeight` | `number` | `—` | Hint used before measurements settle. |
935
+ | `placement` | `"balanced" \| "roundRobin" \| "horizontalOrder"` | `"balanced"` | `balanced` packs into the shortest fitting column group, `roundRobin` cycles start columns deterministically, and `horizontalOrder` preserves a stronger left-to-right scan when spans are involved. |
936
+ | `fullscreenTrigger` | `"item" \| "media"` | `"media"` | Opens fullscreen from the clicked media node or the entire masonry item shell. |
937
+ | `itemWrapClassName` | `string` | `—` | Class name added to the masonry item wrapper. |
938
+ | `itemWrapStyle` | `React.CSSProperties` | `—` | Inline styles applied to the masonry item wrapper. |
624
939
  | `as` | `React.ElementType` | `"div"` | Root HTML element or custom component. |
625
940
  | `rootRef` | `React.Ref<HTMLDivElement>` | `—` | Ref to the masonry root. |
626
941
  | `classNames.root` | `string` | `—` | Root class name. |
627
- | `classNames.column` | `string` | `—` | Column class name. |
942
+ | `classNames.column` | `string` | `—` | Retained for backwards compatibility with the legacy column-wrapper renderer. |
628
943
  | `classNames.item` | `string` | `—` | Item class name. |
629
944
  | `lazyLoad.enabled` | `boolean` | `—` | Enables lazy media loading. |
630
945
  | `lazyLoad.spinner` | `boolean \| ReactNode \| ((args) => ReactNode)` | `—` | Spinner override for lazy items. |
631
946
  | `lazyLoad.spinnerClassName` | `string` | `—` | Spinner wrapper class. |
632
947
  | `lazyLoad.spinnerStyle` | `React.CSSProperties` | `—` | Spinner wrapper style. |
633
- | `loading.enabled` | `boolean` | `—` | Enables the loading layer. |
634
- | `loading.force` | `boolean` | `—` | Forces the loading layer to stay visible. |
635
- | `loading.renderLoading` | `({ count }) => ReactNode` | `—` | Custom loading renderer. |
636
- | `loading.skeleton` | `MasonrySkeletonSpec` | `—` | Built-in masonry skeleton spec. |
637
948
  | `intro.renderIntro` | `({ active, containerProps }, content) => ReactNode` | `—` | Custom intro wrapper. |
638
- | `intro.staggerMs` | `number` | `40` | Reveal stagger. |
639
- | `intro.transform` | `string` | `"translateY(10px) scale(0.99)"` | Starting transform. |
640
- | `intro.durationMs` | `number` | `300` | Intro duration. |
641
- | `intro.easing` | `string` | `"cubic-bezier(.2,.7,.2,1)"` | Intro easing. |
949
+ | `intro.staggerMs` | `number` | `160` | Reveal stagger for the fade-in. |
950
+ | `intro.durationMs` | `number` | `600` | Intro fade duration. |
951
+ | `intro.easing` | `string` | `"cubic-bezier(.2,.7,.2,1)"` | Intro fade easing. |
642
952
  | `intro.staggerLimit` | `number` | `—` | Optional cap on how many items stagger. |
643
953
 
954
+ When `lazyLoad.enabled` is true, Masonry uses the same image shell behavior as Slider: trackable image `src` values move into `data-rmg-lazy-src`, the real images load on intersection, and the item only fades in after decode and spinner exit.
955
+
956
+ Masonry already accepts arbitrary React children, including text-containing JSX. The wrapper props are only for styling the built-in masonry item shell.
957
+
958
+ Wrap a card in `Masonry.Item` when it needs its own span, wrapper `className`, or wrapper `style`:
959
+
960
+ ```typescript
961
+ <Masonry
962
+ columns={{ 0: 1, 760: 2, 1160: 4 }}
963
+ gap={{ 0: 12, 1160: 18 }}
964
+ placement="horizontalOrder"
965
+ >
966
+ <Masonry.Item span={{ 0: 1, 760: 2, 1160: 2 }}>
967
+ <FeatureCard />
968
+ </Masonry.Item>
969
+ <Masonry.Item span={1}>
970
+ <StandardCard />
971
+ </Masonry.Item>
972
+ </Masonry>
973
+ ```
974
+
975
+ Choose a placement based on what should feel stable:
976
+
977
+ - `balanced`: best when visual balance and the shortest overall columns matter most.
978
+ - `roundRobin`: best when deterministic column assignment matters more than tight packing.
979
+ - `horizontalOrder`: best when wider cards should still read in a mostly left-to-right order.
980
+
981
+ Masonry no longer owns loading UI. Use `useMasonryReady` and wrap Masonry with `MasonrySkeleton`, the same composition pattern used by Slider and Grid.
982
+
983
+ Masonry skeletons live in `react-motion-gallery/skeleton/masonry` and can use a structured `layout` spec with the same inner node vocabulary as Grid skeletons, including `text` nodes and `itemWrapStyle`.
984
+
985
+ Live Masonry content mounts invisibly until the current item set has completed an initial measurement pass. The Skeleton wrapper stays visible during that handoff, so the first revealed layout is based on measured DOM geometry rather than approximate height hints.
986
+
987
+ `layout.slots` gives Masonry the same per-card override escape hatch that slider skeletons have. Use a slot when one card needs a different placeholder tree, wrapper styling, span, or outer height. `slot.span` can override the corresponding `Masonry.Item` span for the placeholder, `slot.ratio` maps to Masonry's card-height rhythm, and `slot.heightPx` lets you pin a specific shell height when you need an exact placeholder.
988
+
989
+ ```typescript
990
+ import { Masonry, useMasonryReady } from "react-motion-gallery";
991
+ import {
992
+ MasonrySkeleton,
993
+ type MasonrySkeletonSpec,
994
+ } from "react-motion-gallery/skeleton/masonry";
995
+
996
+ const masonrySkeleton: MasonrySkeletonSpec = {
997
+ ratios: [118, 126, 102, 146],
998
+ layout: {
999
+ kind: "masonry",
1000
+ itemWrapStyle: {
1001
+ padding: 14,
1002
+ borderRadius: 20,
1003
+ boxShadow: "0 18px 36px rgba(15, 23, 42, 0.08)",
1004
+ },
1005
+ item: {
1006
+ kind: "col",
1007
+ style: { gap: 12 },
1008
+ children: [
1009
+ {
1010
+ kind: "rect",
1011
+ style: { width: "100%", height: 180, borderRadius: 16 },
1012
+ },
1013
+ {
1014
+ kind: "text",
1015
+ barHeight: 14,
1016
+ lineHeight: 1.55,
1017
+ lines: 3,
1018
+ lastBarWidth: "74%",
1019
+ style: { width: "100%" },
1020
+ },
1021
+ ],
1022
+ },
1023
+ slots: [
1024
+ {
1025
+ ratio: 182,
1026
+ span: { 0: 1, 1100: 2 },
1027
+ item: {
1028
+ kind: "rect",
1029
+ style: { width: "100%", aspectRatio: "3 / 5", borderRadius: 16 },
1030
+ },
1031
+ },
1032
+ ],
1033
+ },
1034
+ };
1035
+
1036
+ function MasonryWithSkeleton({ items }: { items: React.ReactNode[] }) {
1037
+ const { ref: masonryRef, ready: masonryReady } = useMasonryReady();
1038
+
1039
+ return (
1040
+ <MasonrySkeleton
1041
+ layout={masonrySkeleton}
1042
+ ready={masonryReady}
1043
+ timing={{ minVisibleMs: 220, exitMs: 600 }}
1044
+ masonry={{
1045
+ count: items.length,
1046
+ columns: { 0: 1, 700: 2, 1100: 3 },
1047
+ gap: { 0: 12, 1100: 20 },
1048
+ placement: "balanced",
1049
+ }}
1050
+ >
1051
+ <Masonry
1052
+ ref={masonryRef}
1053
+ columns={{ 0: 1, 700: 2, 1100: 3 }}
1054
+ gap={{ 0: 12, 1100: 20 }}
1055
+ itemWrapStyle={{
1056
+ padding: "6px",
1057
+ borderRadius: "28px",
1058
+ }}
1059
+ >
1060
+ {items}
1061
+ </Masonry>
1062
+ </MasonrySkeleton>
1063
+ );
1064
+ }
1065
+ ```
1066
+
644
1067
  ## Entries
645
1068
 
646
1069
  `Entries` is the structured-data surface. You pass entry objects, render each media item however you want, and provide a `renderMediaContainer` function that decides whether an entry’s media should be laid out as a slider, grid, or masonry block.
@@ -710,6 +1133,16 @@ export function EntryGallery() {
710
1133
  }
711
1134
  ```
712
1135
 
1136
+ ### Entry loading, decode, and reveal flow
1137
+
1138
+ When `loading.enabled` is true, entries use two viewport gates instead of one generic fade-in. `loading.nearMargin` marks a row as near the viewport, mounts the real entry content, and starts the entry media work early. `loading.viewMargin` and `loading.threshold` record when the row has actually entered view.
1139
+
1140
+ With `loading.waitForDecode` enabled, an entry does not reveal as soon as it intersects. The built-in gate waits for every trackable media URL in that entry to load and decode; in the current entry-level gate, that means image media in the entry’s `media` array. It falls back after `loading.decodeTimeoutMs`, and entries without image media are decode-ready immediately. The row fades from skeleton to content only after both conditions are true: the row has entered view and the entry media decode gate is ready.
1141
+
1142
+ Reveal timing is assigned when each entry becomes ready, so entries fade in by actual load/decode completion order as well as viewport intersection. A later row that loads quickly can take the next reveal slot while a slower row keeps its skeleton visible until its media is ready.
1143
+
1144
+ Fullscreen close has a matching entry-aware path. If the user closes fullscreen from a slide whose owning entry has not been viewed yet, the runtime resolves the flattened fullscreen index back to the owner entry, shows a temporary loading spinner while that row mounts and decodes, scrolls the owner entry into view, forces the skeleton/content layers to their final revealed state, and then runs the close animation back to the now-visible entry media. This keeps the close animation from landing on an unrevealed skeleton or an offscreen row.
1145
+
713
1146
  ### `Entries` component props
714
1147
 
715
1148
  | Option | Type | Default | Notes |
@@ -733,11 +1166,14 @@ export function EntryGallery() {
733
1166
  | `mediaLayout` | `"slider" \| "grid" \| "masonry"` | `"slider"` | Declares the intended media layout. |
734
1167
  | `render.card` | `({ entry, entryIndex, media }) => ReactNode` | `—` | Wraps the media container in custom card UI. |
735
1168
  | `render.media` | `({ entry, entryIndex, media, mediaIndex }) => ReactNode` | `—` | Custom media renderer per media item. |
736
- | `render.overlay` | `({ entry, entryIndex, mediaIndex, link, opacity, fsIndex, style, containerProps }) => ReactNode` | `—` | Renders fullscreen overlay content for the active entry slide. |
1169
+ | `render.overlay` | `({ entry, entryIndex, media, mediaIndex, link, opacity, fsIndex, style, containerProps }) => ReactNode` | `—` | Renders fullscreen overlay content for the active entry slide. |
737
1170
  | `render.skeleton` | `({ entry, entryIndex }) => ReactNode` | `—` | Declared in the type, but the current runtime uses `loading.skeleton` instead. |
738
1171
  | `overlay` | `ElementStyle` | `—` | Styles the fullscreen overlay container that wraps `render.overlay`. |
1172
+ | `overlay.overlayCrossfadeTarget` | `"content" \| "overlay"` | `"overlay"` | Selects whether fullscreen entry changes fade only the rendered overlay content or the whole overlay layer. |
1173
+ | `overlay.overlayCrossfadeDurationMs` | `number` | `300` | Duration for fullscreen entry overlay crossfades. |
1174
+ | `overlay.overlayCrossfadeEasing` | `string` | `"cubic-bezier(.4,0,.22,1)"` | Easing for fullscreen entry overlay crossfades. |
739
1175
  | `loading.enabled` | `boolean` | `—` | Enables entry loading and decode gating. |
740
- | `loading.force` | `boolean` | `—` | Forces entry skeletons to remain visible. |
1176
+ | `loading.force` | `boolean \| { enabled?: boolean; showContent?: boolean; skeletonOpacity?: number }` | `—` | Forces entry skeletons to remain visible. Set `showContent: true` to preview mounted, ready entry content under the skeleton, and tune the loading overlay with `skeletonOpacity`. |
741
1177
  | `loading.skeleton` | `EntrySkeletonSpec \| ((args) => EntrySkeletonSpec \| null \| undefined)` | `—` | Built-in skeleton spec or resolver. |
742
1178
  | `loading.minHeight` | `number \| string` | `"260px"` | Minimum reserved height while loading. |
743
1179
  | `loading.nearMargin` | `string` | `"700px 0px"` | Preload margin used before entries enter view. |
@@ -747,13 +1183,15 @@ export function EntryGallery() {
747
1183
  | `loading.decodeTimeoutMs` | `number` | `8000` | Decode timeout fallback. |
748
1184
  | `loading.skeletonWrap` | `ElementStyle` | `—` | Styles the skeleton wrapper. |
749
1185
  | `intro.renderIntro` | `({ active, containerProps }, content) => ReactNode` | `—` | Custom intro wrapper. |
750
- | `intro.staggerMs` | `number` | `200` | Delay between entry reveals. |
751
- | `intro.durationMs` | `number` | `700` | Entry intro duration. |
752
- | `intro.easing` | `string` | `"cubic-bezier(.2,.7,.2,1)"` | Entry intro easing. |
1186
+ | `intro.staggerMs` | `number` | `200` | Delay between entry fade-ins. |
1187
+ | `intro.durationMs` | `number` | `700` | Entry intro fade duration. |
1188
+ | `intro.easing` | `string` | `"cubic-bezier(.2,.7,.2,1)"` | Entry intro fade easing. |
753
1189
  | `intro.staggerLimit` | `number` | `6` | Maximum number of entries that receive staggered delays. |
754
1190
  | `entryList` | `ElementStyle` | `—` | Styles the entry list container. |
755
1191
  | `entryRow` | `ElementStyle` | `—` | Styles each entry row container. |
756
1192
 
1193
+ Entry skeleton `text` nodes also render wrapped line bars via `lines`, matching the slider and grid skeleton behavior, including responsive `barHeight` and line counts plus configurable trailing `lastBarWidth`.
1194
+
757
1195
  ### Entry-related callback and helper types
758
1196
 
759
1197
  #### `EntryItem`
@@ -786,6 +1224,7 @@ export function EntryGallery() {
786
1224
  | --- | --- | --- |
787
1225
  | `entry` | `EntryItem` | Entry owning the active fullscreen slide. |
788
1226
  | `entryIndex` | `number` | Entry index. |
1227
+ | `media` | `MediaItem \| null` | Media item for the active fullscreen slide, when available. |
789
1228
  | `mediaIndex` | `number \| null` | Media index inside the entry when available. |
790
1229
  | `link` | `MediaEntryLink \| null` | Flattened link back to the entry/media pair. |
791
1230
  | `opacity` | `number` | Overlay opacity supplied by the runtime. |
@@ -826,9 +1265,86 @@ export function EntryGallery() {
826
1265
 
827
1266
  Fullscreen is compositional. `GalleryCore` owns the normalized fullscreen item list, your layout opens slides through that core, and `useFullscreenController` renders the portal UI.
828
1267
 
1268
+ ### Standalone fullscreen
1269
+
1270
+ Use `GalleryCore` without a `layout` prop when your own markup owns the visible surface. Call `openFullscreenAt` with the matching item index, and render the fullscreen portal once inside the core.
1271
+
1272
+ ```typescript
1273
+ import * as React from "react";
1274
+ import { GalleryCore, useGalleryCore } from "react-motion-gallery/core";
1275
+ import { useFullscreenController } from "react-motion-gallery/fullscreen";
1276
+ import { fullscreenSlider } from "react-motion-gallery/fullscreen/slider";
1277
+ import { toMediaItems } from "react-motion-gallery/media";
1278
+
1279
+ const images = [
1280
+ {
1281
+ src: "https://picsum.photos/id/1015/1600/900",
1282
+ alt: "Mountain lake",
1283
+ },
1284
+ {
1285
+ src: "https://picsum.photos/id/1018/1600/900",
1286
+ alt: "Forest path",
1287
+ },
1288
+ ];
1289
+
1290
+ const fullscreenItems = toMediaItems(images);
1291
+
1292
+ function FullscreenPortal() {
1293
+ const { fullscreenNode } = useFullscreenController({
1294
+ plugins: [fullscreenSlider()],
1295
+ fullscreen: { enabled: true },
1296
+ });
1297
+
1298
+ return <>{fullscreenNode}</>;
1299
+ }
1300
+
1301
+ function ImageButton(props: {
1302
+ image: (typeof images)[number];
1303
+ index: number;
1304
+ }) {
1305
+ const gallery = useGalleryCore();
1306
+
1307
+ const open = (event: React.MouseEvent<HTMLButtonElement>) => {
1308
+ gallery.openFullscreenAt({
1309
+ index: props.index,
1310
+ event: event.nativeEvent,
1311
+ });
1312
+ };
1313
+
1314
+ return (
1315
+ <button type="button" onClick={open}>
1316
+ <img
1317
+ src={props.image.src}
1318
+ alt={props.image.alt}
1319
+ style={{
1320
+ display: "block",
1321
+ width: 180,
1322
+ aspectRatio: "16 / 9",
1323
+ objectFit: "cover",
1324
+ }}
1325
+ />
1326
+ </button>
1327
+ );
1328
+ }
1329
+
1330
+ export function StandaloneFullscreen() {
1331
+ return (
1332
+ <GalleryCore fullscreenItems={fullscreenItems}>
1333
+ {images.map((image, index) => (
1334
+ <ImageButton key={image.src} image={image} index={index} />
1335
+ ))}
1336
+ <FullscreenPortal />
1337
+ </GalleryCore>
1338
+ );
1339
+ }
1340
+ ```
1341
+
1342
+ ### Slider fullscreen
1343
+
829
1344
  ```typescript
830
1345
  import * as React from "react";
831
1346
  import { GalleryCore, Slider, useFullscreenController } from "react-motion-gallery";
1347
+ import { fullscreenSlider } from "react-motion-gallery/fullscreen/slider";
832
1348
 
833
1349
  const slides = [
834
1350
  "https://picsum.photos/id/1015/1600/900",
@@ -838,6 +1354,7 @@ const slides = [
838
1354
 
839
1355
  function FullscreenAddon() {
840
1356
  const { fullscreenNode } = useFullscreenController({
1357
+ plugins: [fullscreenSlider()],
841
1358
  fullscreen: { enabled: true },
842
1359
  });
843
1360
 
@@ -858,13 +1375,28 @@ export function SliderWithFullscreen() {
858
1375
  }
859
1376
  ```
860
1377
 
1378
+ ### Fullscreen lazy-load handshake
1379
+
1380
+ Fullscreen keeps the base layout and fullscreen surface as separate render trees joined by one canonical index. The base layout can render thumbnails, cropped images, cards, or entries while `GalleryCore.fullscreenItems` provides the media that fullscreen renders for the same positions.
1381
+
1382
+ That index is also the communication channel for lazy loading. When a base item becomes visible, `GalleryCore` emits a base-visible event. If `fullscreen.lazyLoad.images.enabled` or `fullscreen.lazyLoad.videos.enabled` is active through `fullscreenLazyLoad()`, the fullscreen runtime listens for that event and prewarms the matching fullscreen media: images are fetched and decoded with high priority, and videos can prewarm their poster/source before being force-mounted.
1383
+
1384
+ Once the modal is open, the fullscreen slider index becomes the live gate. `fsSub` changes recompute which canonical image or video is allowed to mount or apply its source, then notify the lazy slide listeners. The active slide is always allowed; decoded images and prepared videos stay warm, and videos that were prewarmed from the base layout remain in the allowed set so navigation can land on prepared media.
1385
+
1386
+ Fullscreen also emits its visible index back through `GalleryCore`. Base media primitives use the core fullscreen state to suspend while fullscreen is active, and can use the visible fullscreen index to prewarm their matching media. Captions, overlays, and thumbnail rails stay synchronized through the same index contract.
1387
+
1388
+ For custom fullscreen images, `fullscreen.renderImage` must render a real descendant `<img>`. With `fullscreenLazyLoad({ images: { enabled: true } })`, that custom renderer participates in the same mount, spinner, load, and decode flow instead of mounting every fullscreen image eagerly.
1389
+
861
1390
  Add fullscreen thumbnails by rendering `FullscreenThumbnailSlider` with the bridge returned from `useFullscreenController`.
862
1391
 
863
1392
  ```typescript
864
1393
  import { FullscreenThumbnailSlider, useFullscreenController } from "react-motion-gallery";
1394
+ import { fullscreenSlider } from "react-motion-gallery/fullscreen/slider";
1395
+ import { fullscreenThumbnails } from "react-motion-gallery/fullscreen/thumbnails";
865
1396
 
866
1397
  function FullscreenWithThumbs({ thumbs }: { thumbs: string[] }) {
867
1398
  const { fullscreenNode, fullscreenThumbnailBridge } = useFullscreenController({
1399
+ plugins: [fullscreenSlider(), fullscreenThumbnails()],
868
1400
  fullscreen: {
869
1401
  enabled: true,
870
1402
  slider: {
@@ -892,6 +1424,7 @@ Set `fullscreen.slider.direction` when fullscreen should mirror RTL interaction:
892
1424
 
893
1425
  ```typescript
894
1426
  useFullscreenController({
1427
+ plugins: [fullscreenSlider()],
895
1428
  fullscreen: {
896
1429
  enabled: true,
897
1430
  slider: {
@@ -901,22 +1434,52 @@ useFullscreenController({
901
1434
  });
902
1435
  ```
903
1436
 
904
- ### `GalleryCore` props
1437
+ Set `fullscreen.slider.gap` to add space between fullscreen slides. It accepts the same responsive number form as the base slider, using the `GalleryCore.breakpoints` map for named breakpoint keys:
905
1438
 
906
- | Option | Type | Default | Notes |
907
- | --- | --- | --- | --- |
908
- | `children` | `React.ReactNode` | `—` | The gallery tree using the shared core. |
909
- | `layout` | `"slider" \| "grid" \| "masonry" \| "entries"` | `—` | Declares the owning base layout. |
910
- | `breakpoints` | `Record<string, number>` | `xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536` | Breakpoint map shared with descendants. |
911
- | `fullscreenItems` | `MediaItem[] \| string[]` | `[]` | Normalized fullscreen media list. |
912
- | `nodes` | `ReactNode \| ReactNode[]` | `—` | Advanced initial node list for imperative gallery state. |
1439
+ ```typescript
1440
+ useFullscreenController({
1441
+ plugins: [fullscreenSlider()],
1442
+ fullscreen: {
1443
+ enabled: true,
1444
+ slider: {
1445
+ gap: { 0: 12, md: 20, 1200: 28 },
1446
+ },
1447
+ },
1448
+ });
1449
+ ```
1450
+
1451
+ Import `fullscreenVideo` from `react-motion-gallery/fullscreen/video` for fullscreen video slides. Set `fullscreen.video.playOnOpen` to start a Plyr-backed fullscreen video when fullscreen opens directly onto that video slide:
1452
+
1453
+ ```typescript
1454
+ useFullscreenController({
1455
+ plugins: [fullscreenSlider(), fullscreenVideo()],
1456
+ fullscreen: {
1457
+ enabled: true,
1458
+ video: {
1459
+ playOnOpen: true,
1460
+ },
1461
+ },
1462
+ });
1463
+ ```
913
1464
 
914
1465
  ### `useFullscreenController` args
915
1466
 
916
1467
  | Option | Type | Default | Notes |
917
1468
  | --- | --- | --- | --- |
1469
+ | `plugins` | `FullscreenPlugin[]` | `[]` | Explicit first-party fullscreen features. At minimum, import `fullscreenSlider()` to mount the fullscreen runtime. |
918
1470
  | `fullscreen` | `FullscreenOptions` | `—` | Fullscreen behavior and rendering options. |
919
1471
 
1472
+ | Import | Factory | Notes |
1473
+ | --- | --- | --- |
1474
+ | `react-motion-gallery/fullscreen/slider` | `fullscreenSlider(options)` | Mounts the fullscreen slider runtime and accepts `fullscreen.slider` options. |
1475
+ | `react-motion-gallery/fullscreen/controls` | `fullscreenControls(options)` | Option plugin for close, arrows, and counter options. Use with `fullscreenSlider()`. |
1476
+ | `react-motion-gallery/fullscreen/captions` | `fullscreenCaptions(options)` | Adds caption rendering, placement, and caption motion runtime. Use with `fullscreenSlider()`. |
1477
+ | `react-motion-gallery/fullscreen/zoom-pan` | `fullscreenZoomPan(options)` | Adds fullscreen click zoom, pan, and pinch runtime. Use with `fullscreenSlider()`. |
1478
+ | `react-motion-gallery/fullscreen/video` | `fullscreenVideo(options)` | Adds fullscreen Plyr rendering, source/options, and `playOnOpen` runtime. Use with `fullscreenSlider()`. |
1479
+ | `react-motion-gallery/fullscreen/lazy-load` | `fullscreenLazyLoad(options)` | Adds fullscreen image and video lazy-load gates. Use with `fullscreenSlider()`. |
1480
+ | `react-motion-gallery/fullscreen/crossfade` | `fullscreenCrossfade(options)` | Option plugin for fullscreen crossfade controls, drag, and wheel behavior. Use with `fullscreenSlider()`. |
1481
+ | `react-motion-gallery/fullscreen/thumbnails` | `fullscreenThumbnails()` | Option-only plugin for fullscreen thumbnail bridge behavior. Use with `fullscreenSlider()`. |
1482
+
920
1483
  ### Recommended `useFullscreenController` return values
921
1484
 
922
1485
  | Field | Type | Notes |
@@ -937,9 +1500,10 @@ The hook returns additional refs and setters for the internal fullscreen runtime
937
1500
  | --- | --- | --- | --- |
938
1501
  | `enabled` | `boolean` | `false` | Master switch for fullscreen UI. |
939
1502
  | `items` | `MediaItem[] \| string[]` | `—` | Declared in the type, but current fullscreen media resolution comes from `GalleryCore.fullscreenItems`. |
940
- | `renderImage` | `({ item, index, isZoomed, className, baseStyle }) => ReactNode` | `—` | Custom fullscreen image renderer. Must render a real descendant `<img>`. |
1503
+ | `renderImage` | `({ item, index, isZoomed, className, baseStyle }) => ReactNode` | `—` | Custom fullscreen image renderer. Must render a real descendant `<img>`. With `lazyLoad.images.enabled`, the renderer is mounted only when the slide is allowed and the runtime watches that descendant image for load/decode readiness. |
941
1504
  | `video.source` | `(item: MediaItem, index: number) => Plyr.SourceInfo` | `—` | Builds fullscreen Plyr sources for video items. |
942
1505
  | `video.options` | `Plyr.Options \| ((item: MediaItem, index: number) => Plyr.Options)` | `—` | Builds fullscreen Plyr options. |
1506
+ | `video.playOnOpen` | `boolean` | `false` | Attempts to play the fullscreen Plyr video when fullscreen opens directly onto a video slide. Browser autoplay rules still apply. |
943
1507
  | `video.style` | `React.CSSProperties` | `—` | Fullscreen player inline style. |
944
1508
  | `video.className` | `string` | `—` | Fullscreen player class. |
945
1509
  | `controls.close.enabled` | `boolean` | `true` | Toggles the close button. |
@@ -964,9 +1528,19 @@ The hook returns additional refs and setters for the internal fullscreen runtime
964
1528
  | `caption.height` | `number` | `—` | Caption area height. |
965
1529
  | `caption.breakpoint` | `number` | `—` | Viewport cutoff for switching placement logic. |
966
1530
  | `caption.render` | `({ item, index, isZoomed }) => ReactNode` | `—` | Custom caption renderer. |
1531
+ | `caption.layout` | `"overlay" \| "slide"` | `—` | Chooses whether the caption overlays the media or lives in the slide layout. |
1532
+ | `caption.overlayCrossfadeTarget` | `"content" \| "overlay"` | `"content"` | Selects whether overlay caption changes fade only the rendered caption content or the whole overlay layer. |
1533
+ | `caption.overlayCrossfadeDurationMs` | `number` | `300` | Duration for fullscreen overlay caption crossfades. |
1534
+ | `caption.overlayCrossfadeEasing` | `string` | `"cubic-bezier(.4,0,.22,1)"` | Easing for fullscreen overlay caption crossfades. |
1535
+ | `caption.zoomFade` | `boolean` | `true` | Fades captions out on fullscreen zoom-in and back in on zoom-out. |
1536
+ | `caption.zoomFadeDurationMs` | `number` | `300` | Duration for fullscreen caption zoom fades. |
1537
+ | `caption.zoomFadeEasing` | `string` | `"cubic-bezier(.4,0,.22,1)"` | Easing for fullscreen caption zoom fades. |
1538
+ | `caption.zoomInTransform` | `string` | `""` | Optional transform applied while captions fade out on zoom-in. |
1539
+ | `caption.zoomOutTransform` | `string` | `""` | Optional transform used as the starting point when captions fade back in on zoom-out. |
967
1540
  | `slider.duration` | `number` | `25` | Fullscreen slider motion duration. |
968
1541
  | `slider.friction` | `number` | `0.68` | Fullscreen slider friction. |
969
1542
  | `slider.direction` | `"ltr" \| "rtl"` | `"ltr"` | Fullscreen slider interaction direction. |
1543
+ | `slider.gap` | `number \| Record<string, number>` | `0` | Responsive pixel gap between fullscreen slides. Named keys resolve from `GalleryCore.breakpoints`. |
970
1544
  | `zoom.clickZoomLevel` | `number` | `2.5` | Zoom level used for click-to-zoom. |
971
1545
  | `zoom.maxZoomLevel` | `number` | `3` | Maximum allowed zoom level. |
972
1546
  | `zoom.panDuration` | `number` | `43` | Pan settling duration. |
@@ -974,18 +1548,27 @@ The hook returns additional refs and setters for the internal fullscreen runtime
974
1548
  | `effects.introDuration` | `number` | `300` | Open animation duration. |
975
1549
  | `effects.introEasing` | `string` | `"cubic-bezier(.4,0,.22,1)"` | Open animation easing. |
976
1550
  | `effects.introFade` | `boolean` | `false` | Forces fade intro behavior. |
977
- | `effects.slideFade` | `boolean` | `false` | Fades between fullscreen slides. |
978
- | `effects.slideFadeDuration` | `number` | `120` | Slide-fade duration. |
979
- | `effects.slideFadeEasing` | `string` | `"cubic-bezier(.4,0,.22,1)"` | Slide-fade easing. |
980
- | `lazyLoad.images.enabled` | `boolean` | `—` | Enables fullscreen image lazy loading. |
1551
+ | `effects.crossfade.controls` | `boolean` | `false` | Uses crossfade transitions for fullscreen arrow navigation and animated slide requests. Also enables wheel crossfade unless `effects.crossfade.wheel` is provided. |
1552
+ | `effects.crossfade.drag` | `boolean` | `false` | Scrubs adjacent fullscreen slides with crossfade during drag instead of moving the track. |
1553
+ | `effects.crossfade.wheel` | `boolean \| CrossFadeWheelOptions` | `effects.crossfade.controls` | Uses wheel or touchpad travel as a one-slide-at-a-time fullscreen crossfade gesture. Set `false` to keep arrow crossfades while using normal wheel scrolling. |
1554
+ | `effects.crossfade.wheel.enabled` | `boolean` | `true` when object form is used | Enables or disables fullscreen wheel crossfade when using the object form. |
1555
+ | `effects.crossfade.wheel.sensitivity` | `number` | `5` | Multiplies wheel delta into virtual drag progress. Higher values reach the commit threshold sooner. |
1556
+ | `effects.crossfade.wheel.commitThreshold` | `number` | `0.38` | Progress needed to commit to the previous or next fullscreen slide. Values are clamped from `0` to below `0.5`. |
1557
+ | `effects.crossfade.wheel.durationMs` | `number` | `effects.crossfade.durationMs` | Fade duration after fullscreen wheel crossfade commits. |
1558
+ | `effects.crossfade.wheel.sessionGapMs` | `number` | `24` | Short quiet window used to distinguish same-direction touchpad tail from a fresh fullscreen wheel gesture after a committed wheel crossfade. |
1559
+ | `effects.crossfade.durationMs` | `number` | `120` | Shared fullscreen crossfade duration for controls, drag release, and wheel commit unless wheel overrides it. |
1560
+ | `effects.crossfade.easing` | `string` | `"cubic-bezier(.4,0,.22,1)"` | Shared fullscreen crossfade easing. |
1561
+ | `lazyLoad.images.enabled` | `boolean` | `—` | Enables fullscreen image lazy loading. Base-visible indices predecode matching fullscreen images, and fullscreen index changes allow the active image slide to mount or apply its source. |
981
1562
  | `lazyLoad.images.spinner` | `boolean \| ReactNode \| ((args) => ReactNode)` | `—` | Spinner override for fullscreen images. |
982
1563
  | `lazyLoad.images.spinnerClassName` | `string` | `—` | Spinner class for image slides. |
983
1564
  | `lazyLoad.images.spinnerStyle` | `React.CSSProperties` | `—` | Spinner style for image slides. |
984
- | `lazyLoad.videos.enabled` | `boolean` | `—` | Enables fullscreen video lazy loading. |
1565
+ | `lazyLoad.videos.enabled` | `boolean` | `—` | Opts fullscreen videos into lazy mounting. Base-visible indices prewarm matching video posters/sources and fullscreen index changes mount the active or already-prepared video slide. By default fullscreen Plyr videos mount eagerly in the hidden fullscreen tree. |
985
1566
  | `lazyLoad.videos.spinner` | `boolean \| ReactNode \| ((args) => ReactNode)` | `—` | Spinner override for fullscreen videos. |
986
1567
  | `lazyLoad.videos.spinnerClassName` | `string` | `—` | Spinner class for video slides. |
987
1568
  | `lazyLoad.videos.spinnerStyle` | `React.CSSProperties` | `—` | Spinner style for video slides. |
988
1569
 
1570
+ Fullscreen `effects.crossfade.wheel` uses the same `true`, `false`, or object form as slider wheel crossfade. Its `durationMs` default follows fullscreen `effects.crossfade.durationMs`, which defaults to `120`.
1571
+
989
1572
  ### Fullscreen callback and helper types
990
1573
 
991
1574
  #### `FsCounterArgs`
@@ -1094,37 +1677,6 @@ The hook returns additional refs and setters for the internal fullscreen runtime
1094
1677
  | `fadeDurationMs` | `number \| undefined` | Slot fade duration. |
1095
1678
  | `fadeEasing` | `string \| undefined` | Slot fade easing. |
1096
1679
 
1097
- ### `GalleryApi`
1098
-
1099
- `GalleryApi` is exported as a type from the package root. The package also exposes `GalleryCore` and `useGalleryCore()` for core-context access, but it does not expose a dedicated hook that returns a `GalleryApi`-typed instance directly.
1100
-
1101
- | Method | Signature | Notes |
1102
- | --- | --- | --- |
1103
- | `rootNode` | `() => HTMLElement \| null` | Gallery root node. |
1104
- | `containerNode` | `() => HTMLElement \| null` | Moving or content container node. |
1105
- | `getViewportNode` | `() => HTMLDivElement \| null` | Viewport node. |
1106
- | `slideNodes` | `() => HTMLElement[]` | Current slide elements. |
1107
- | `onReady` | `(cb: (nodes: HTMLElement[]) => void) => () => void` | Subscribes to readiness. |
1108
- | `whenReady` | `() => Promise<HTMLElement[]>` | Promise form of readiness. |
1109
- | `isReady` | `() => boolean` | `true` after readiness resolves. |
1110
- | `scrollTo` | `(index: number, jump?: boolean) => void` | Navigates to a slide. |
1111
- | `scrollNext` | `(jump?: boolean) => void` | Advances to the next slide. |
1112
- | `scrollPrev` | `(jump?: boolean) => void` | Moves to the previous slide. |
1113
- | `canScrollNext` | `() => boolean` | Whether next navigation is available. |
1114
- | `canScrollPrev` | `() => boolean` | Whether previous navigation is available. |
1115
- | `getIndex` | `() => number` | Current active index. |
1116
- | `selectCell` | `(index: number, jump?: boolean) => void` | Selects a cell by canonical index. |
1117
- | `scrollProgress` | `() => number` | Scroll progress from `0` to `1`. |
1118
- | `cellsInView` | `() => number[]` | Canonical cells currently visible. |
1119
- | `append` | `(nodes: ReactNode \| ReactNode[]) => number` | Appends nodes and returns the new total count. |
1120
- | `prepend` | `(nodes: ReactNode \| ReactNode[]) => number` | Prepends nodes and returns the new total count. |
1121
- | `insert` | `(index: number, nodes: ReactNode \| ReactNode[]) => number` | Inserts nodes and returns the new total count. |
1122
- | `remove` | `(indexOrPredicate: number \| ((i: number) => boolean)) => number` | Removes items and returns the new total count. |
1123
- | `replace` | `(index: number, node: ReactNode) => void` | Replaces a node at an index. |
1124
- | `setItems` | `(nodes: ReactNode[]) => number` | Replaces all nodes and returns the new total count. |
1125
- | `onIndexChange` | `(cb: (i: number, meta: { mode: IndexMode }) => void) => () => void` | Subscribes to index changes. |
1126
- | `openFullscreenAt` | `({ index, method?, event? }) => void` | Programmatically opens fullscreen at an index. |
1127
-
1128
1680
  ## Video
1129
1681
 
1130
1682
  `Video` is the gallery-aware video primitive. It mounts Plyr lazily, syncs with gallery visibility, and can be used inside `Slider`, `Grid`, `Masonry`, `Entries`, and fullscreen flows.