@snowcone-app/ui 0.1.43 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +18 -4
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1071 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- 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";
|