main
svelte 259 lines 8.44 KB
Raw
1 <script lang="ts">
2 import { onMount, tick } from 'svelte';
3
4 const cards = [
5 {
6 key: 'login',
7 title: 'No Login',
8 description: 'Run the command immediately without accounts or auth flows.'
9 },
10 {
11 key: 'billing',
12 title: 'No Billing',
13 description: 'No credit card, no plan gate, and no billing step before go-live.'
14 },
15 {
16 key: 'cloud',
17 title: 'No Cloud SaaS',
18 description: 'No dashboard, region picker, or managed cloud setup to get started.'
19 },
20 {
21 key: 'permissionless',
22 title: 'Permissionless',
23 description:
24 'Use the public registry or attach your own relay. No approval required.'
25 }
26 ] as const;
27
28 const cardCount = cards.length;
29 const loopBoundaryIndex = cardCount + 1;
30 const transitionDurationMs = 700;
31 const slideGap = 16;
32
33 const carouselSlides = [cards[cardCount - 1], ...cards, cards[0]];
34
35 let trackIndex = $state(1);
36 let transitionEnabled = $state(true);
37 let slideSize = $state(328);
38 let dragOffset = $state(0);
39 let isDragging = $state(false);
40 let reducedMotion = $state(false);
41
42 let dragStartX: number | null = null;
43 let dragOffsetRef = 0;
44 let pointerIdRef: number | null = null;
45
46 const renderedTrackIndex = $derived(
47 Math.min(Math.max(trackIndex, 0), carouselSlides.length - 1)
48 );
49 const trackTranslateX = $derived(
50 `calc(50% - ${slideSize / 2}px - ${renderedTrackIndex * (slideSize + slideGap)}px ${dragOffset >= 0 ? '+' : '-'} ${Math.abs(dragOffset)}px)`
51 );
52
53 // Reduced motion detection
54 $effect(() => {
55 if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
56 const media = window.matchMedia('(prefers-reduced-motion: reduce)');
57 const sync = () => {
58 reducedMotion = media.matches;
59 };
60 sync();
61 media.addEventListener('change', sync);
62 return () => media.removeEventListener('change', sync);
63 });
64
65 // Slide size on resize
66 $effect(() => {
67 if (typeof window === 'undefined') return;
68 const update = () => {
69 if (window.innerWidth >= 1024) {
70 slideSize = 560;
71 return;
72 }
73 if (window.innerWidth >= 640) {
74 slideSize = 472;
75 return;
76 }
77 const maxMobileWidth = Math.min(window.innerWidth - 48, 368);
78 slideSize = Math.max(maxMobileWidth, 288);
79 };
80 update();
81 window.addEventListener('resize', update);
82 return () => window.removeEventListener('resize', update);
83 });
84
85 // Auto-advance
86 $effect(() => {
87 if (reducedMotion || isDragging) return;
88 const interval = window.setInterval(() => {
89 transitionEnabled = true;
90 trackIndex =
91 trackIndex >= loopBoundaryIndex ? loopBoundaryIndex : trackIndex + 1;
92 }, 2200);
93 return () => window.clearInterval(interval);
94 });
95
96 // Boundary teleport
97 $effect(() => {
98 if (isDragging) return;
99 if (trackIndex !== 0 && trackIndex !== loopBoundaryIndex) return;
100 const timer = window.setTimeout(() => {
101 transitionEnabled = false;
102 trackIndex = trackIndex === 0 ? cardCount : 1;
103 }, transitionDurationMs);
104 return () => window.clearTimeout(timer);
105 });
106
107 // Re-enable transition after teleport
108 $effect(() => {
109 if (transitionEnabled) return;
110 if (typeof window === 'undefined') return;
111 let cancelled = false;
112 tick().then(() => {
113 if (cancelled) return;
114 requestAnimationFrame(() => {
115 if (cancelled) return;
116 transitionEnabled = true;
117 });
118 });
119 return () => {
120 cancelled = true;
121 };
122 });
123
124 function finishDrag(shouldAdvance: boolean, direction: 'next' | 'prev' | null) {
125 dragStartX = null;
126 dragOffsetRef = 0;
127 pointerIdRef = null;
128 isDragging = false;
129 transitionEnabled = true;
130 dragOffset = 0;
131
132 if (!shouldAdvance || !direction) return;
133
134 if (direction === 'next') {
135 trackIndex =
136 trackIndex >= loopBoundaryIndex ? loopBoundaryIndex : trackIndex + 1;
137 } else {
138 trackIndex = trackIndex <= 0 ? 0 : trackIndex - 1;
139 }
140 }
141
142 function handlePointerDown(event: PointerEvent) {
143 if (event.pointerType === 'mouse' && event.button !== 0) return;
144 dragStartX = event.clientX;
145 dragOffsetRef = 0;
146 pointerIdRef = event.pointerId;
147 isDragging = true;
148 transitionEnabled = false;
149 dragOffset = 0;
150 (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
151 }
152
153 function handlePointerMove(event: PointerEvent) {
154 if (!isDragging || dragStartX === null || pointerIdRef !== event.pointerId) return;
155 const nextOffset = event.clientX - dragStartX;
156 dragOffsetRef = nextOffset;
157 dragOffset = nextOffset;
158 }
159
160 function handlePointerEnd(event: PointerEvent) {
161 if (!isDragging || pointerIdRef !== event.pointerId) return;
162 const target = event.currentTarget as HTMLElement;
163 if (target.hasPointerCapture(event.pointerId)) {
164 target.releasePointerCapture(event.pointerId);
165 }
166 const threshold = Math.min(88, slideSize * 0.16);
167 const shouldAdvance = Math.abs(dragOffsetRef) > threshold;
168 const direction =
169 dragOffsetRef < 0 ? 'next' : dragOffsetRef > 0 ? 'prev' : null;
170 finishDrag(shouldAdvance, direction);
171 }
172 </script>
173
174 <div class="relative -mx-4 mt-10 w-auto sm:-mx-6 md:-mx-8">
175 <div class="overflow-hidden border-b bg-transparent" style="border-color: var(--border);">
176 <div class="relative mx-auto max-w-7xl px-3 py-6 sm:px-6 sm:py-8">
177 <!-- Glow -->
178 <div
179 class="pointer-events-none absolute inset-x-0 top-6 flex justify-center sm:top-8"
180 >
181 <div
182 class="h-28 w-28 rounded-full bg-primary/[0.16] blur-3xl dark:bg-primary/[0.22]"
183 ></div>
184 </div>
185 <!-- Edge fades -->
186 <div
187 class="pointer-events-none absolute inset-y-0 left-0 z-20 w-12 bg-linear-to-r from-background via-background/[0.74] to-transparent dark:via-background/60 sm:w-24"
188 ></div>
189 <div
190 class="pointer-events-none absolute inset-y-0 right-0 z-20 w-12 bg-linear-to-l from-background via-background/[0.74] to-transparent dark:via-background/60 sm:w-24"
191 ></div>
192
193 <div class="relative h-[328px] sm:h-[360px]">
194 <!-- svelte-ignore a11y_no_static_element_interactions -->
195 <div
196 onpointerdown={handlePointerDown}
197 onpointermove={handlePointerMove}
198 onpointerup={handlePointerEnd}
199 onpointercancel={handlePointerEnd}
200 class="flex h-full items-start gap-4 px-1 pt-6 sm:px-4 sm:pt-8 {transitionEnabled &&
201 !isDragging
202 ? 'transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]'
203 : ''} {isDragging ? 'cursor-grabbing' : 'cursor-grab'}"
204 style="transform: translateX({trackTranslateX}); touch-action: pan-y;"
205 >
206 {#each carouselSlides as card, index (card.key + '-' + index)}
207 {@const distance = Math.abs(index - trackIndex)}
208 {@const isActive = index === trackIndex}
209 <article
210 class="relative shrink-0 overflow-hidden rounded-[1.65rem] border px-5 py-5 text-left transition-[opacity,transform,box-shadow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] h-[244px] sm:h-[268px] sm:px-7 sm:py-6
211 {isActive
212 ? 'border-primary/[0.24] bg-white/[0.92] shadow-[0_24px_54px_rgba(15,23,42,0.08)] dark:bg-white/[0.08] dark:shadow-[0_26px_60px_rgba(0,0,0,0.22)]'
213 : distance === 1
214 ? 'border-border/70 bg-background/[0.78] shadow-[0_14px_32px_rgba(15,23,42,0.04)] dark:bg-white/[0.04]'
215 : 'border-border/60 bg-background/[0.70] shadow-none dark:bg-white/[0.03]'}"
216 class:translate-y-0={isActive}
217 class:scale-100={isActive}
218 class:translate-y-5={!isActive}
219 class:scale-95={!isActive}
220 style="width: {slideSize}px; opacity: {isActive ? 1 : distance === 1 ? 0.62 : 0.3};"
221 aria-hidden={!isActive}
222 >
223 <div
224 class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.12),transparent_36%)] dark:bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.14),transparent_36%)]"
225 ></div>
226 <div class="relative flex h-full flex-col">
227 <div class="flex items-center gap-4">
228 <span
229 class="inline-flex rounded-full bg-primary/[0.12] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary"
230 >
231 Portal
232 </span>
233 </div>
234 <div class="mt-10 space-y-3">
235 <h3
236 class="max-w-[12ch] text-[1.9rem] font-semibold leading-[0.92] tracking-tight text-foreground sm:text-[2.2rem]"
237 >
238 {card.title}
239 </h3>
240 <p
241 class="max-w-[34ch] text-[0.98rem] leading-6 text-text-muted sm:text-[1rem]"
242 >
243 {card.description}
244 </p>
245 </div>
246 <div class="mt-auto pt-8">
247 <div
248 class="h-px w-16"
249 style="background: var(--gradient-primary-fade);"
250 ></div>
251 </div>
252 </div>
253 </article>
254 {/each}
255 </div>
256 </div>
257 </div>
258 </div>
259 </div>