| 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> |