Created
January 22, 2025 18:51
-
-
Save bionboy/150a1f1c80a1df40737c5f0eec537b62 to your computer and use it in GitHub Desktop.
Parallax Component (Svelte 5)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import type { Snippet } from 'svelte'; | |
| import { mode } from 'mode-watcher'; | |
| import { Star } from 'lucide-svelte'; | |
| type Props = { | |
| title?: string; | |
| titleContent?: Snippet; | |
| foregroundContent?: Snippet; | |
| midgroundContent?: Snippet; | |
| backgroundContent?: Snippet; | |
| includeBackground?: boolean; | |
| moveWithScroll?: boolean; | |
| moveWithMouse?: boolean; | |
| }; | |
| const { | |
| title = 'Parallax', | |
| titleContent, | |
| foregroundContent, | |
| midgroundContent, | |
| backgroundContent, | |
| includeBackground = true, | |
| moveWithScroll = true, | |
| moveWithMouse = true | |
| }: Props = $props(); | |
| let scroll = $state({ x: 0, y: 0 }); | |
| let mouse = $state({ x: 0, y: 0 }); | |
| let hero = $state({ width: 0, height: 0, offset: { x: 0, y: 0 } }); | |
| let islandPositions = $derived([ | |
| createPositions(hero.width, hero.height, 5), | |
| createPositions(hero.width, hero.height, 10), | |
| createPositions(hero.width, hero.height, 20) | |
| ]); | |
| let parallaxShift = $derived(getShift()); | |
| let defaultIslandContent = $derived($mode === 'light' ? cloud : star); | |
| function getShift(scale: number = 1) { | |
| let input = { x: 0, y: 0 }; | |
| if (moveWithMouse) { | |
| if (mouse.x !== 0) input.x = mouse.x - hero.width / 2; | |
| if (mouse.y !== 0) input.y = mouse.y - hero.height / 2; | |
| } | |
| if (moveWithScroll) { | |
| input.x += scroll.x; | |
| input.y += scroll.y; | |
| } | |
| return { | |
| x: input.x * scale + 'px', | |
| y: input.y * scale + 'px' | |
| }; | |
| } | |
| const getRandomRotation = () => { | |
| const rotationRange = 90; | |
| const rotation = (Math.random() - 0.5) * rotationRange; | |
| return rotation + 'deg'; | |
| }; | |
| function createPositions(width: number, height: number, amount: number) { | |
| const overscanFactor = 1; | |
| const result: number[][] = []; | |
| for (let j = 0; j < amount; j++) { | |
| result[j] = [ | |
| width / 2 + (Math.random() - 0.5) * (width * overscanFactor), | |
| height / 2 + (Math.random() - 0.5) * (height * overscanFactor) | |
| ]; | |
| } | |
| return result; | |
| } | |
| </script> | |
| <svelte:window bind:scrollY={scroll.y} bind:scrollX={scroll.x} /> | |
| <div | |
| class="hero" | |
| class:light-background={includeBackground && $mode === 'light'} | |
| class:dark-background={includeBackground && $mode === 'dark'} | |
| role="presentation" | |
| onmousemove={(event: MouseEvent) => (mouse = { x: event.layerX, y: event.layerY })} | |
| bind:clientWidth={hero.width} | |
| bind:clientHeight={hero.height} | |
| style:--x-shift={parallaxShift.x} | |
| style:--y-shift={parallaxShift.y} | |
| > | |
| <div class="parallax"> | |
| {#each islandPositions[2] as coords} | |
| {@render island('background', coords[0], coords[1], backgroundContent)} | |
| {/each} | |
| <div class="blur-panel background"></div> | |
| {#each islandPositions[1] as coords} | |
| {@render island('midground', coords[0], coords[1], midgroundContent)} | |
| {/each} | |
| <div class="blur-panel midground"></div> | |
| {#each islandPositions[0] as coords} | |
| {@render island('foreground', coords[0], coords[1], foregroundContent)} | |
| {/each} | |
| <div class="blur-panel foreground"></div> | |
| </div> | |
| <div class="hero-content"> | |
| {#if titleContent} | |
| {@render titleContent()} | |
| {:else} | |
| <h1 class="hero-title">{title}</h1> | |
| {/if} | |
| </div> | |
| </div> | |
| {#snippet island(level: string = 'foreground', x: number = 0, y: number = 0, content?: Snippet)} | |
| <div class="island {level}" style="left:{x}px; top:{y}px;"> | |
| {@render (content ?? defaultIslandContent)()} | |
| </div> | |
| {/snippet} | |
| {#snippet cloud()} | |
| <div class="cloud"> | |
| <div class="cloud-part cloud-part-1"></div> | |
| <div class="cloud-part cloud-part-2"></div> | |
| <div class="cloud-part cloud-part-3"></div> | |
| </div> | |
| <style> | |
| .cloud { | |
| position: relative; | |
| width: 200px; | |
| height: 60px; | |
| background: #fff; | |
| border-radius: 50px; | |
| box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); | |
| } | |
| .cloud-part { | |
| position: absolute; | |
| background: #fff; | |
| border-radius: 50%; | |
| box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); | |
| } | |
| .cloud-part-1 { | |
| width: 80px; | |
| height: 80px; | |
| top: -40px; | |
| left: 20px; | |
| } | |
| .cloud-part-2 { | |
| width: 100px; | |
| height: 100px; | |
| top: -50px; | |
| left: 80px; | |
| } | |
| .cloud-part-3 { | |
| width: 60px; | |
| height: 60px; | |
| top: -30px; | |
| left: 150px; | |
| } | |
| </style> | |
| {/snippet} | |
| {#snippet star()} | |
| <div class="rotate-0" style:--tw-rotate={getRandomRotation()}> | |
| <Star size="3rem" class="stroke-yellow-400 fill-yellow-400"></Star> | |
| </div> | |
| {/snippet} | |
| <style lang="postcss"> | |
| .hero { | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: clip; | |
| height: 100%; | |
| &.light-background { | |
| background-image: linear-gradient( | |
| to bottom right in hsl, | |
| hsl(180, 100%, 55%), | |
| hsl(215, 100%, 74%) | |
| ); | |
| } | |
| &.dark-background { | |
| @apply bg-black; | |
| } | |
| } | |
| .hero-content { | |
| position: relative; | |
| text-align: center; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| @apply pointer-events-none; | |
| /* | |
| TODO: 3d tilting card effect | |
| https://www.youtube.com/watch?v=Z-3tPXf9a7M | |
| */ | |
| /* transform: rotate3d(0.1, 0.1, 0, calc(var(--asdf-shift, 0) * 0.1)); */ | |
| } | |
| .hero-title { | |
| font-size: 4rem; | |
| /* color: hsl(180, 20%, 30%); */ | |
| } | |
| .parallax { | |
| @apply absolute size-full; | |
| } | |
| .blur-panel { | |
| @apply absolute size-full; | |
| &.background { | |
| @apply backdrop-blur-[1px]; | |
| } | |
| &.midground { | |
| @apply backdrop-blur-[1px]; | |
| } | |
| &.foreground { | |
| } | |
| } | |
| .island { | |
| --shift-scale: 1; | |
| --size-scale: 1; | |
| --blur-amount: 0px; | |
| --opacity: 1; | |
| position: absolute; | |
| /* | |
| shift inner content to center of coordinates | |
| then translate based on input | |
| */ | |
| transform: translate(-50%, -50%) | |
| translate( | |
| calc(var(--x-shift) * var(--shift-scale)), | |
| calc(var(--y-shift) * var(--shift-scale)) | |
| ) | |
| scale(var(--size-scale)); | |
| opacity: var(--opacity); | |
| &.foreground { | |
| --shift-scale: 0.8; | |
| --size-scale: 1; | |
| --opacity: 0.9; | |
| } | |
| &.midground { | |
| --shift-scale: 0.5; | |
| --size-scale: 0.7; | |
| --opacity: 0.9; | |
| } | |
| &.background { | |
| --shift-scale: 0.2; | |
| --size-scale: 0.5; | |
| --opacity: 0.8; | |
| } | |
| } | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment