@snowcone-app/ui 0.1.42 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +18 -4
  3. package/package.json +9 -5
  4. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  5. package/src/components/LoadingOverlayPrism.tsx +251 -0
  6. package/src/composed/AddToCart.tsx +229 -0
  7. package/src/composed/ArtAlignment.tsx +703 -0
  8. package/src/composed/ArtSelector.tsx +290 -0
  9. package/src/composed/ArtworkCustomizer.tsx +212 -0
  10. package/src/composed/CanvasEditor.tsx +79 -0
  11. package/src/composed/ColorPicker.tsx +111 -0
  12. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  13. package/src/composed/HeroProductImage.tsx +1071 -0
  14. package/src/composed/Lightbox.index.ts +2 -0
  15. package/src/composed/Lightbox.tsx +230 -0
  16. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  17. package/src/composed/PlacementTabs.tsx +179 -0
  18. package/src/composed/ProductCard.tsx +298 -0
  19. package/src/composed/ProductGallery.tsx +54 -0
  20. package/src/composed/ProductImage.tsx +129 -0
  21. package/src/composed/ProductList.tsx +147 -0
  22. package/src/composed/ProductOptions.tsx +305 -0
  23. package/src/composed/RealtimeMockup.tsx +121 -0
  24. package/src/composed/TileCount.tsx +348 -0
  25. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  26. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  27. package/src/composed/carousels/index.ts +11 -0
  28. package/src/composed/carousels/types.ts +58 -0
  29. package/src/composed/grids/MasonryGrid.tsx +238 -0
  30. package/src/composed/grids/index.ts +9 -0
  31. package/src/composed/search/CurrentRefinements.tsx +80 -0
  32. package/src/composed/search/Filters.tsx +49 -0
  33. package/src/composed/search/FiltersButton.tsx +57 -0
  34. package/src/composed/search/FiltersDrawer.tsx +375 -0
  35. package/src/composed/search/ProductGrid.tsx +118 -0
  36. package/src/composed/search/ProductHit.tsx +56 -0
  37. package/src/composed/search/SearchBox.tsx +109 -0
  38. package/src/composed/search/SearchProvider.tsx +136 -0
  39. package/src/composed/search/facetConfig.ts +16 -0
  40. package/src/composed/search/index.ts +22 -0
  41. package/src/composed/search/meilisearchAdapter.ts +20 -0
  42. package/src/composed/search/types.ts +22 -0
  43. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  44. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  45. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  46. package/src/composed/zoom/index.ts +12 -0
  47. package/src/composed/zoom/types.ts +12 -0
  48. package/src/design-system/ColorPalette.tsx +126 -0
  49. package/src/design-system/ColorSwatch.tsx +49 -0
  50. package/src/design-system/DesignSystemPage.tsx +130 -0
  51. package/src/design-system/ThemeSwitcher.tsx +181 -0
  52. package/src/design-system/TypographyScale.tsx +106 -0
  53. package/src/design-system/index.ts +5 -0
  54. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  55. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  56. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  57. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  58. package/src/hooks/useBrand.ts +41 -0
  59. package/src/hooks/useCanvasContext.ts +127 -0
  60. package/src/hooks/useDeviceDetection.ts +64 -0
  61. package/src/hooks/useFocusTrap.ts +70 -0
  62. package/src/hooks/useImagePreloader.ts +268 -0
  63. package/src/hooks/useImageTransition.ts +608 -0
  64. package/src/hooks/usePlacementsProcessor.ts +74 -0
  65. package/src/hooks/useProductGallery.ts +193 -0
  66. package/src/hooks/useProductPage.ts +467 -0
  67. package/src/hooks/useRenderGuard.ts +96 -0
  68. package/src/hooks/useScrollDirection.ts +196 -0
  69. package/src/hooks/viewport/index.ts +25 -0
  70. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  71. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  72. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  73. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  74. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  75. package/src/hooks/visibility/index.ts +15 -0
  76. package/src/hooks/visibility/observerPool.ts +150 -0
  77. package/src/index.ts +240 -0
  78. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  79. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  80. package/src/layouts/hero-zoom/index.ts +30 -0
  81. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  82. package/src/layouts/hero-zoom/types.ts +113 -0
  83. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  84. package/src/layouts/index.ts +9 -0
  85. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  86. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  87. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  88. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  89. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  90. package/src/layouts/pdp/index.ts +40 -0
  91. package/src/lib/env.ts +15 -0
  92. package/src/lib/locale.ts +167 -0
  93. package/src/lib/router.tsx +46 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/lightbox/README.md +77 -0
  96. package/src/next/index.tsx +26 -0
  97. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  98. package/src/patterns/Product.tsx +850 -0
  99. package/src/patterns/ProductPageProvider.tsx +224 -0
  100. package/src/patterns/RealtimeProvider.tsx +1162 -0
  101. package/src/patterns/ShopProvider.tsx +603 -0
  102. package/src/personalization/PersonalizationBridge.tsx +235 -0
  103. package/src/personalization/PersonalizationContext.ts +29 -0
  104. package/src/personalization/PersonalizationInputs.tsx +110 -0
  105. package/src/personalization/PersonalizationProvider.tsx +407 -0
  106. package/src/personalization/canvas-stub.d.ts +22 -0
  107. package/src/personalization/index.ts +43 -0
  108. package/src/personalization/types.ts +48 -0
  109. package/src/personalization/usePersonalization.ts +32 -0
  110. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  111. package/src/personalization/utils.ts +59 -0
  112. package/src/primitives/BrandLogo.tsx +65 -0
  113. package/src/primitives/BrandName.tsx +51 -0
  114. package/src/primitives/Button.tsx +123 -0
  115. package/src/primitives/ColorSwatch.tsx +221 -0
  116. package/src/primitives/DragHintAnimation.tsx +190 -0
  117. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  118. package/src/primitives/FloatingActionGroup.tsx +176 -0
  119. package/src/primitives/ProductPrice.tsx +171 -0
  120. package/src/primitives/ProgressiveBlur.tsx +295 -0
  121. package/src/primitives/ThemeToggle.tsx +125 -0
  122. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  123. package/src/primitives/accordion.tsx +280 -0
  124. package/src/primitives/badge.tsx +137 -0
  125. package/src/primitives/card.tsx +61 -0
  126. package/src/primitives/checkbox.tsx +56 -0
  127. package/src/primitives/collapsible.tsx +51 -0
  128. package/src/primitives/drawer.tsx +828 -0
  129. package/src/primitives/dropdown-menu.tsx +197 -0
  130. package/src/primitives/fieldset.tsx +73 -0
  131. package/src/primitives/index.ts +138 -0
  132. package/src/primitives/input.tsx +91 -0
  133. package/src/primitives/kbd.tsx +130 -0
  134. package/src/primitives/label.tsx +20 -0
  135. package/src/primitives/link.tsx +182 -0
  136. package/src/primitives/popover.tsx +80 -0
  137. package/src/primitives/radio-group.tsx +79 -0
  138. package/src/primitives/scroll-fade.tsx +159 -0
  139. package/src/primitives/select.tsx +170 -0
  140. package/src/primitives/separator.tsx +25 -0
  141. package/src/primitives/slider.tsx +221 -0
  142. package/src/primitives/spinner.tsx +72 -0
  143. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  144. package/src/primitives/stories/Badge.stories.tsx +221 -0
  145. package/src/primitives/stories/Button.stories.tsx +185 -0
  146. package/src/primitives/stories/Card.stories.tsx +171 -0
  147. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  148. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  149. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  150. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  151. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  152. package/src/primitives/stories/Input.stories.tsx +172 -0
  153. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  154. package/src/primitives/stories/Label.stories.tsx +98 -0
  155. package/src/primitives/stories/Link.stories.tsx +260 -0
  156. package/src/primitives/stories/Popover.stories.tsx +178 -0
  157. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  158. package/src/primitives/stories/Select.stories.tsx +222 -0
  159. package/src/primitives/stories/Separator.stories.tsx +134 -0
  160. package/src/primitives/stories/Slider.stories.tsx +203 -0
  161. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  162. package/src/primitives/stories/Surface.stories.tsx +257 -0
  163. package/src/primitives/stories/Switch.stories.tsx +131 -0
  164. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  165. package/src/primitives/stories/TextField.stories.tsx +139 -0
  166. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  167. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  168. package/src/primitives/surface.tsx +86 -0
  169. package/src/primitives/switch.tsx +35 -0
  170. package/src/primitives/tabs.tsx +206 -0
  171. package/src/primitives/text-field.tsx +84 -0
  172. package/src/primitives/textarea.tsx +50 -0
  173. package/src/primitives/tooltip.tsx +58 -0
  174. package/src/services/CanvasExportService.ts +518 -0
  175. package/src/styles/base.css +380 -0
  176. package/src/styles/defaults.css +280 -0
  177. package/src/styles/globals.css +1242 -0
  178. package/src/styles/index.css +17 -0
  179. package/src/styles/ne-themes.css +4740 -0
  180. package/src/styles/tailwind.css +11 -0
  181. package/src/styles/tokens.css +117 -0
  182. package/src/styles/utilities.css +188 -0
  183. package/src/themes/apply-theme.ts +449 -0
  184. package/src/themes/getThemeStyles.ts +454 -0
  185. package/src/themes/index.ts +48 -0
  186. package/src/themes/oklch-theme.ts +283 -0
  187. package/src/themes/presets.ts +989 -0
  188. package/src/themes/types.ts +386 -0
  189. package/src/themes/useTheme.tsx +450 -0
  190. package/src/utils/dev-warnings.ts +161 -0
  191. package/src/utils/devWarnings.ts +153 -0
  192. package/dist/styles.css +0 -1
@@ -0,0 +1,209 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, useCallback } from "react";
4
+ import type { ReactNode, CSSProperties } from "react";
5
+
6
+ /**
7
+ * Configuration for HeroShrinkLayout
8
+ */
9
+ export interface HeroShrinkConfig {
10
+ /** Aspect ratio of the hero (width/height). Default: 16/9 */
11
+ aspectRatio?: number;
12
+ /** Initial height as percentage of viewport. Default: 70 */
13
+ initialHeightPercent?: number;
14
+ /** Minimum height as percentage of viewport. Default: 40 */
15
+ minHeightPercent?: number;
16
+ /** Header height in pixels. Default: 0 */
17
+ headerHeight?: number;
18
+ }
19
+
20
+ export interface HeroShrinkLayoutProps {
21
+ config?: HeroShrinkConfig;
22
+ header?: ReactNode;
23
+ hero: ReactNode;
24
+ children: ReactNode;
25
+ className?: string;
26
+ style?: CSSProperties;
27
+ }
28
+
29
+ const DEFAULT_CONFIG: Required<HeroShrinkConfig> = {
30
+ aspectRatio: 16 / 9,
31
+ initialHeightPercent: 70,
32
+ minHeightPercent: 40,
33
+ headerHeight: 0,
34
+ };
35
+
36
+ /**
37
+ * HeroShrinkLayout - GPU-accelerated hero that shrinks on scroll
38
+ *
39
+ * Unlike HeroZoomLayout which uses scale transforms, this uses
40
+ * height animation which is more performant for complex content
41
+ * like carousels.
42
+ *
43
+ * Uses CSS scroll-driven animations where supported, with JS fallback.
44
+ */
45
+ export function HeroShrinkLayout({
46
+ config = {},
47
+ header,
48
+ hero,
49
+ children,
50
+ className = "",
51
+ style,
52
+ }: HeroShrinkLayoutProps) {
53
+ const mergedConfig: Required<HeroShrinkConfig> = {
54
+ ...DEFAULT_CONFIG,
55
+ ...config,
56
+ };
57
+
58
+ const { aspectRatio, initialHeightPercent, minHeightPercent, headerHeight } = mergedConfig;
59
+
60
+ const heroRef = useRef<HTMLDivElement>(null);
61
+ const headerRef = useRef<HTMLDivElement>(null);
62
+ const [isClient, setIsClient] = useState(false);
63
+
64
+ useEffect(() => {
65
+ setIsClient(true);
66
+ }, []);
67
+
68
+ // JavaScript fallback for browsers without scroll-driven animations
69
+ useEffect(() => {
70
+ if (!isClient) return;
71
+
72
+ const supportsScrollTimeline = CSS.supports("animation-timeline", "scroll()");
73
+ if (supportsScrollTimeline) return;
74
+
75
+ const heroEl = heroRef.current;
76
+ const headerEl = headerRef.current;
77
+ if (!heroEl) return;
78
+
79
+ // Calculate heights
80
+ const vh = window.innerHeight;
81
+ const initialHeight = (initialHeightPercent / 100) * vh;
82
+ const minHeight = (minHeightPercent / 100) * vh;
83
+ const scrollRange = initialHeight - minHeight;
84
+
85
+ let ticking = false;
86
+
87
+ const updateHeight = () => {
88
+ const scrollY = window.scrollY;
89
+ const progress = Math.min(1, Math.max(0, scrollY / scrollRange));
90
+
91
+ const currentHeight = initialHeight - (progress * (initialHeight - minHeight));
92
+ heroEl.style.height = `${currentHeight}px`;
93
+
94
+ // Header slide up after hero reaches min height
95
+ if (headerEl && scrollY > scrollRange) {
96
+ const headerProgress = Math.min(1, (scrollY - scrollRange) / headerHeight);
97
+ headerEl.style.transform = `translateY(${-headerProgress * headerHeight}px)`;
98
+ } else if (headerEl) {
99
+ headerEl.style.transform = 'translateY(0)';
100
+ }
101
+
102
+ ticking = false;
103
+ };
104
+
105
+ const onScroll = () => {
106
+ if (!ticking) {
107
+ requestAnimationFrame(updateHeight);
108
+ ticking = true;
109
+ }
110
+ };
111
+
112
+ updateHeight();
113
+ window.addEventListener("scroll", onScroll, { passive: true });
114
+ return () => window.removeEventListener("scroll", onScroll);
115
+ }, [isClient, initialHeightPercent, minHeightPercent, headerHeight]);
116
+
117
+ // Calculate CSS custom properties
118
+ const cssVars = {
119
+ "--hero-shrink-initial-height": `${initialHeightPercent}svh`,
120
+ "--hero-shrink-min-height": `${minHeightPercent}svh`,
121
+ "--hero-shrink-header-height": `${headerHeight}px`,
122
+ "--hero-shrink-scroll-range": `calc(${initialHeightPercent}svh - ${minHeightPercent}svh)`,
123
+ } as CSSProperties;
124
+
125
+ const scopedStyles = `
126
+ @keyframes hero-shrink-height {
127
+ from { height: var(--hero-shrink-initial-height); }
128
+ to { height: var(--hero-shrink-min-height); }
129
+ }
130
+
131
+ @keyframes hero-shrink-header-exit {
132
+ from { transform: translateY(0); }
133
+ to { transform: translateY(calc(var(--hero-shrink-header-height) * -1)); }
134
+ }
135
+
136
+ @supports (animation-timeline: scroll()) {
137
+ [data-hero-shrink-container] {
138
+ animation: hero-shrink-height linear both;
139
+ animation-timeline: scroll();
140
+ animation-range: 0px var(--hero-shrink-scroll-range);
141
+ }
142
+
143
+ [data-hero-shrink-header] {
144
+ animation: hero-shrink-header-exit linear both;
145
+ animation-timeline: scroll();
146
+ animation-range: var(--hero-shrink-scroll-range) calc(var(--hero-shrink-scroll-range) + var(--hero-shrink-header-height));
147
+ }
148
+ }
149
+
150
+ [data-hero-shrink-container] {
151
+ position: fixed;
152
+ top: ${headerHeight}px;
153
+ left: 0;
154
+ width: 100%;
155
+ height: var(--hero-shrink-initial-height);
156
+ overflow: hidden;
157
+ will-change: height;
158
+ contain: layout style;
159
+ }
160
+
161
+ [data-hero-shrink-header] {
162
+ position: fixed;
163
+ top: 0;
164
+ left: 0;
165
+ width: 100%;
166
+ z-index: 30;
167
+ will-change: transform;
168
+ }
169
+ `;
170
+
171
+ // Spacer height to create scroll room
172
+ const spacerHeight = `calc(${initialHeightPercent}svh + ${headerHeight}px)`;
173
+
174
+ return (
175
+ <div
176
+ className={`relative min-h-screen ${className}`}
177
+ style={{ ...style, ...cssVars }}
178
+ >
179
+ <style dangerouslySetInnerHTML={{ __html: scopedStyles }} />
180
+
181
+ {/* Scroll spacer */}
182
+ <div style={{ height: spacerHeight }} />
183
+
184
+ {/* Header */}
185
+ {header && (
186
+ <div
187
+ ref={headerRef}
188
+ data-hero-shrink-header
189
+ style={{ height: `${headerHeight}px` }}
190
+ >
191
+ {header}
192
+ </div>
193
+ )}
194
+
195
+ {/* Hero container */}
196
+ <div
197
+ ref={heroRef}
198
+ data-hero-shrink-container
199
+ >
200
+ {hero}
201
+ </div>
202
+
203
+ {/* Content */}
204
+ <div className="relative z-10 bg-background">
205
+ {children}
206
+ </div>
207
+ </div>
208
+ );
209
+ }
@@ -0,0 +1,351 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import type { HeroZoomLayoutProps, HeroZoomConfig } from "./types";
5
+ import { DEFAULT_HERO_ZOOM_CONFIG } from "./types";
6
+ import { useHeroZoomScales } from "./useHeroZoomScales";
7
+
8
+ /**
9
+ * HeroZoomLayout - A scroll-driven hero image layout with zoom animation.
10
+ *
11
+ * This layout creates a cinematic scroll experience where:
12
+ * 1. A hero image starts scaled up and zooms down as the user scrolls
13
+ * 2. An optional header slides up and exits the viewport
14
+ * 3. Content below becomes visible after the animation completes
15
+ *
16
+ * Uses modern CSS scroll-driven animations (`animation-timeline: scroll()`)
17
+ * with a JavaScript fallback for browsers that don't support it.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <HeroZoomLayout
22
+ * config={{ aspectRatio: 16/9, headerHeight: 80 }}
23
+ * header={<MyHeader />}
24
+ * hero={<img src="/hero.jpg" alt="Hero" className="w-full h-full object-cover" />}
25
+ * >
26
+ * <ProductDetails />
27
+ * </HeroZoomLayout>
28
+ * ```
29
+ *
30
+ * @example Without header
31
+ * ```tsx
32
+ * <HeroZoomLayout
33
+ * hero={<video src="/hero.mp4" autoPlay muted loop className="w-full h-full object-cover" />}
34
+ * >
35
+ * <PageContent />
36
+ * </HeroZoomLayout>
37
+ * ```
38
+ */
39
+ export function HeroZoomLayout({
40
+ config = {},
41
+ header,
42
+ hero,
43
+ children,
44
+ className = "",
45
+ style,
46
+ }: HeroZoomLayoutProps) {
47
+ const mergedConfig: Required<HeroZoomConfig> = {
48
+ ...DEFAULT_HERO_ZOOM_CONFIG,
49
+ ...config,
50
+ };
51
+
52
+ const { aspectRatio, initialScale, headerHeight } = mergedConfig;
53
+
54
+ const { visual, render, isHydrated } = useHeroZoomScales(mergedConfig);
55
+ const imageContainerRef = useRef<HTMLDivElement>(null);
56
+ const headerRef = useRef<HTMLDivElement>(null);
57
+
58
+ // Force scroll to top on mount to ensure the animation starts correctly
59
+ // This prevents the browser from restoring scroll position to the middle of the page,
60
+ // which would cause the hero to start in a "zoomed" state rather than the initial full view.
61
+ useEffect(() => {
62
+ if (typeof window === "undefined") return;
63
+
64
+ // Save original setting
65
+ const originalRestoration =
66
+ "scrollRestoration" in history ? history.scrollRestoration : "auto";
67
+
68
+ // Disable auto scroll restoration
69
+ if ("scrollRestoration" in history) {
70
+ history.scrollRestoration = "manual";
71
+ }
72
+
73
+ // Force scroll to top
74
+ window.scrollTo(0, 0);
75
+
76
+ // Restore setting on unmount
77
+ return () => {
78
+ if ("scrollRestoration" in history) {
79
+ history.scrollRestoration = originalRestoration;
80
+ }
81
+ };
82
+ }, []);
83
+
84
+
85
+ // JavaScript fallback for browsers without scroll-driven animation support
86
+ useEffect(() => {
87
+ const supportsScrollTimeline = CSS.supports(
88
+ "animation-timeline",
89
+ "scroll()"
90
+ );
91
+
92
+ if (supportsScrollTimeline) {
93
+ return;
94
+ }
95
+
96
+ const imageEl = imageContainerRef.current;
97
+ const headerEl = headerRef.current;
98
+ if (!imageEl) return;
99
+
100
+ const finalHeight = window.innerWidth / aspectRatio;
101
+ // Phase 1: Scale animation range
102
+ const scaleScrollRange = finalHeight * (visual - 1);
103
+ // Phase 2: Slide-up animation range
104
+ const slideScrollRange = headerHeight;
105
+
106
+ // Transform values
107
+ const initialTransform = visual / render;
108
+ const finalTransform = 1 / render;
109
+
110
+ let ticking = false;
111
+
112
+ const updateTransform = () => {
113
+ // Skip transform updates when drawer is open
114
+ if (document.documentElement.hasAttribute("data-drawer-open")) {
115
+ ticking = false;
116
+ return;
117
+ }
118
+
119
+ const scrollY = window.scrollY;
120
+
121
+ // Phase 1: Scale down (0 to scaleScrollRange)
122
+ const scaleProgress = Math.min(
123
+ 1,
124
+ Math.max(0, scrollY / scaleScrollRange)
125
+ );
126
+ const currentScale =
127
+ initialTransform - scaleProgress * (initialTransform - finalTransform);
128
+
129
+ // Phase 2: Slide up & header exit (scaleScrollRange to totalScrollRange)
130
+ let translateY = 0;
131
+ let headerTranslateY = 0;
132
+
133
+ if (scrollY > scaleScrollRange) {
134
+ const slideProgress = Math.min(
135
+ 1,
136
+ (scrollY - scaleScrollRange) / slideScrollRange
137
+ );
138
+ translateY = -headerHeight * slideProgress;
139
+ headerTranslateY = -headerHeight * slideProgress;
140
+ }
141
+
142
+ // Apply individual transform properties (scale and translate)
143
+ imageEl.style.scale = String(currentScale);
144
+ imageEl.style.translate = `0 ${translateY}px`;
145
+
146
+ if (headerEl) {
147
+ headerEl.style.translate = `0 ${headerTranslateY}px`;
148
+ }
149
+
150
+ ticking = false;
151
+ };
152
+
153
+ const onScroll = () => {
154
+ if (!ticking) {
155
+ requestAnimationFrame(updateTransform);
156
+ ticking = true;
157
+ }
158
+ };
159
+
160
+ updateTransform();
161
+
162
+ window.addEventListener("scroll", onScroll, { passive: true });
163
+ return () => window.removeEventListener("scroll", onScroll);
164
+ }, [visual, render, aspectRatio, headerHeight]);
165
+
166
+ // UNIFIED VW-BASED SIZING
167
+ // Both SSR and hydration use the same formula, eliminating layout shift.
168
+ //
169
+ // SSR values (from config, no measurement):
170
+ // - visual = initialScale (from config)
171
+ // - render = initialScale * 2 (assume 2x DPR)
172
+ //
173
+ // Hydrated values:
174
+ // - visual = initialScale (same as SSR!)
175
+ // - render = initialScale * actualDPR (may differ from SSR)
176
+ //
177
+ // The visual scale is ALWAYS the same, so the visible size never changes.
178
+ // Only the render quality changes (higher DPR = crisper images).
179
+ //
180
+ // Container sizing formula (same for both SSR and hydrated):
181
+ // - Width: 100vw * render
182
+ // - Height: (100vw / aspectRatio) * render
183
+ // - Transform: visual / render
184
+ // - Visible width: 100vw * render * (visual/render) = 100vw * visual
185
+ // - Visible height: (100vw/aspect) * render * (visual/render) = (100vw/aspect) * visual
186
+ //
187
+ // Since visual is the same for SSR and hydration, visible size is identical = no layout shift!
188
+
189
+ // Active values - visual is always from config, render may change after hydration
190
+ const activeVisual = visual;
191
+ const activeRender = render;
192
+ const activeTransform = activeVisual / activeRender;
193
+
194
+ // Unified container sizing (vw-based)
195
+ const containerWidth = `calc(100vw * ${activeRender})`;
196
+ const containerHeight = `calc(100vw / ${aspectRatio} * ${activeRender})`;
197
+ const marginLeft = `calc(-50vw * ${activeRender})`;
198
+
199
+ // Use CSS variables to ensure exact match with the hero's visual height.
200
+ // After hydration, add header height to account for the fixed header being removed from document flow.
201
+ // Before hydration (SSR), the layout renders correctly without this adjustment.
202
+ const scrollTrackHeight = isHydrated
203
+ ? `calc(var(--hero-zoom-image-height) * var(--hero-zoom-visual-scale) + ${headerHeight}px)`
204
+ : `calc(var(--hero-zoom-image-height) * var(--hero-zoom-visual-scale))`;
205
+
206
+ // Generate scoped CSS for scroll-driven animations
207
+ const scopedStyles = `
208
+ :root {
209
+ --hero-zoom-image-height: calc(100vw / ${aspectRatio});
210
+ --hero-zoom-header-height: ${headerHeight}px;
211
+ --hero-zoom-visual-scale: ${activeVisual};
212
+ --hero-zoom-render-scale: ${activeRender};
213
+ --hero-zoom-initial-transform: ${activeTransform};
214
+ --hero-zoom-final-transform: ${1 / activeRender};
215
+ --hero-zoom-scale-scroll-range: calc(var(--hero-zoom-image-height) * (var(--hero-zoom-visual-scale) - 1));
216
+ --hero-zoom-total-scroll-range: calc(var(--hero-zoom-scale-scroll-range) + var(--hero-zoom-header-height));
217
+ }
218
+
219
+ /* Phase 1: Scale animation */
220
+ @keyframes hero-zoom-scale-down {
221
+ from { scale: var(--hero-zoom-initial-transform); }
222
+ to { scale: var(--hero-zoom-final-transform); }
223
+ }
224
+
225
+ /* Phase 2: Slide up animation */
226
+ @keyframes hero-zoom-slide-up {
227
+ from { translate: 0 0; }
228
+ to { translate: 0 calc(var(--hero-zoom-header-height) * -1); }
229
+ }
230
+
231
+ /* Header exit animation */
232
+ @keyframes hero-zoom-header-exit {
233
+ from { translate: 0 0; }
234
+ to { translate: 0 calc(var(--hero-zoom-header-height) * -1); }
235
+ }
236
+
237
+ /* Hero container - handles both scale and translate */
238
+ [data-hero-zoom-container] {
239
+ transform-origin: center top;
240
+ will-change: scale, translate;
241
+ -webkit-backface-visibility: hidden;
242
+ backface-visibility: hidden;
243
+ /* Initial scale from computed transform */
244
+ scale: ${activeTransform};
245
+ translate: 0 0;
246
+ }
247
+
248
+ /* Header - animated slide-up */
249
+ [data-hero-zoom-header] {
250
+ will-change: translate;
251
+ -webkit-backface-visibility: hidden;
252
+ backface-visibility: hidden;
253
+ contain: layout style paint;
254
+ }
255
+
256
+ /* Apply scroll-driven animations if supported */
257
+ @supports (animation-timeline: scroll()) {
258
+ [data-hero-zoom-container] {
259
+ animation: hero-zoom-scale-down linear both, hero-zoom-slide-up linear both;
260
+ animation-timeline: scroll(), scroll();
261
+ animation-range: 0px var(--hero-zoom-scale-scroll-range), var(--hero-zoom-scale-scroll-range) var(--hero-zoom-total-scroll-range);
262
+ }
263
+
264
+ [data-hero-zoom-header] {
265
+ animation: hero-zoom-header-exit linear both;
266
+ animation-timeline: scroll();
267
+ animation-range: var(--hero-zoom-scale-scroll-range) var(--hero-zoom-total-scroll-range);
268
+ }
269
+
270
+ /* Drawer no longer interferes with hero animations.
271
+ The drawer covers the hero completely, so let animation run continuously. */
272
+ }
273
+ `;
274
+
275
+ return (
276
+ <div
277
+ className={`relative min-h-screen ${className}`}
278
+ style={
279
+ {
280
+ ...style,
281
+ "--hero-zoom-image-height": `calc(100vw / ${aspectRatio})`,
282
+ "--hero-zoom-header-height": `${headerHeight}px`,
283
+ "--hero-zoom-visual-scale": activeVisual,
284
+ "--hero-zoom-render-scale": activeRender,
285
+ "--hero-zoom-initial-transform": activeTransform,
286
+ "--hero-zoom-final-transform": 1 / activeRender,
287
+ } as React.CSSProperties
288
+ }
289
+ >
290
+ <style dangerouslySetInnerHTML={{ __html: scopedStyles }} />
291
+
292
+ {/* Scroll track - determines total scrollable area */}
293
+ <div
294
+ style={{
295
+ height: scrollTrackHeight,
296
+ }}
297
+ />
298
+
299
+ {/* Optional Header */}
300
+ {header && (
301
+ <div
302
+ ref={headerRef}
303
+ data-hero-zoom-header
304
+ className="fixed left-0 w-full z-30"
305
+ style={{
306
+ height: `${headerHeight}px`,
307
+ top: 0,
308
+ }}
309
+ >
310
+ {header}
311
+ </div>
312
+ )}
313
+
314
+ {/* Hero Container - sized at render scale for crisp images */}
315
+ {/*
316
+ VW-BASED UNIFIED SIZING:
317
+ Both SSR and hydration use the exact same formula:
318
+ - Width: 100vw * render
319
+ - Height: (100vw / aspectRatio) * render
320
+ - Transform: visual / render
321
+
322
+ Since visual comes from config (not viewport measurement),
323
+ it's identical for SSR and hydration = NO LAYOUT SHIFT!
324
+
325
+ The only difference after hydration is the render scale (DPR),
326
+ which affects image quality but not visible size.
327
+ */}
328
+ <div
329
+ ref={imageContainerRef}
330
+ data-hero-zoom-container
331
+ className="fixed z-20"
332
+ style={{
333
+ width: containerWidth,
334
+ height: containerHeight,
335
+ left: "50%",
336
+ marginLeft: marginLeft,
337
+ top: header ? `${headerHeight}px` : 0,
338
+ // NOTE: Don't set inline scale here - it conflicts with CSS scroll-driven animations.
339
+ // The CSS sets the initial scale via: [data-hero-zoom-container] { scale: ${activeTransform}; }
340
+ // For JS fallback browsers, the scroll handler sets scale via imageEl.style.scale
341
+ transformOrigin: "center top",
342
+ }}
343
+ >
344
+ {hero}
345
+ </div>
346
+
347
+ {/* Content below the hero */}
348
+ <div className="relative z-10">{children}</div>
349
+ </div>
350
+ );
351
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * HeroZoomLayout - Scroll-driven hero image layout with zoom animation.
3
+ *
4
+ * A cinematic scroll experience where a hero image scales down as
5
+ * the user scrolls, with an optional header that slides up.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * import { HeroZoomLayout } from "@snowcone-app/ui";
10
+ *
11
+ * <HeroZoomLayout
12
+ * config={{ aspectRatio: 16/9 }}
13
+ * header={<MyHeader />}
14
+ * hero={<img src="/hero.jpg" alt="" className="w-full h-full object-cover" />}
15
+ * >
16
+ * <PageContent />
17
+ * </HeroZoomLayout>
18
+ * ```
19
+ */
20
+
21
+ export { HeroZoomLayout } from "./HeroZoomLayout";
22
+ export { HeroShrinkLayout } from "./HeroShrinkLayout";
23
+ export type { HeroShrinkConfig, HeroShrinkLayoutProps } from "./HeroShrinkLayout";
24
+ export { useHeroZoomScales } from "./useHeroZoomScales";
25
+ export type {
26
+ HeroZoomConfig,
27
+ HeroZoomLayoutProps,
28
+ HeroZoomScales,
29
+ } from "./types";
30
+ export { DEFAULT_HERO_ZOOM_CONFIG } from "./types";