scratch/src/components/ScrollerSquash/ScrollerSquash.svelte
2026-05-11 22:00:11 -04:00

143 lines
3.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import ScrollerBase from '../ScrollerBase/ScrollerBase.svelte';
import type { ContainerWidth } from '../@types/global';
type Graphic = {
src: string;
srcset: string;
alt: string;
title?: string;
description?: string;
notes?: string;
width?: ContainerWidth;
};
interface Props {
graphics: Graphic[];
/** Duration of the squash animation in milliseconds. */
animationDuration?: number;
/** When to swap the image during the animation (01). E.g. 0.8 = swap at 80% through. */
animationSwap?: number;
/**
* Raw CSS string injected into the document head for this component instance.
* Use the `.graphic` selector to target the image element.
* Define any required @keyframes here too.
*
* @example
* graphicCss="@keyframes bounce { to { translate: 0 -20px; } } .graphic { animation: bounce 0.5s ease alternate infinite; }"
*/
graphicCss?: string;
}
let {
graphics,
animationDuration = 350,
animationSwap = 0.8,
graphicCss,
}: Props = $props();
let count = $state(1);
let index = $state(0);
let offset = $state(0);
let progress = $state(0);
let displayedIndex = $state(0);
let imgEl = $state<HTMLImageElement | null>(null);
// Inject graphicCss into the document head as a real style element.
$effect(() => {
if (!graphicCss) return;
const style = document.createElement('style');
style.textContent = graphicCss;
document.head.appendChild(style);
return () => style.remove();
});
// On index change: reset the CSS animation, then swap the graphic at the
// configured point through the animation (animationSwap).
$effect(() => {
const newIndex = index; // track reactive dependency
if (!imgEl) return;
imgEl.style.animation = 'none';
imgEl.offsetHeight; // force reflow to restart the animation
imgEl.style.animation = '';
const timeout = setTimeout(() => {
displayedIndex = newIndex;
}, animationDuration * animationSwap);
return () => clearTimeout(timeout);
});
</script>
<div class="photo-scroller">
<ScrollerBase
bind:count
bind:index
bind:offset
bind:progress
query="div.step-foreground-container"
>
{#snippet backgroundSnippet()}
<img
bind:this={imgEl}
class="graphic"
style="--animation-time: {animationDuration}ms"
src={graphics[displayedIndex].src}
srcset={graphics[displayedIndex].srcset}
sizes="(min-width: 690px) 660px, calc(100vw - 30px)"
alt={graphics[displayedIndex].alt}
/>
{/snippet}
{#snippet foregroundSnippet()}
{#each graphics as graphic}
<div class="step-foreground-container">
{#if graphic.title}<h2>{graphic.title}</h2>{/if}
{#if graphic.description}<p>{graphic.description}</p>{/if}
{#if graphic.notes}<aside>{graphic.notes}</aside>{/if}
</div>
{/each}
{/snippet}
</ScrollerBase>
</div>
<style lang="scss">
.graphic {
display: block;
object-fit: contain;
transform-origin: bottom center;
animation: squash var(--animation-time) linear(0, 0.417 25.5%, 0.867 49.4%, 1 57.7%, 0.925 65.1%, 0.908 68.6%, 0.902 72.2%, 0.916 78.2%, 0.988 92.1%, 1) forwards;
}
@keyframes squash {
0% {
scale: 1;
translate: 0;
}
50% {
scale: 0.8 1.2;
translate: 0 -8px;
}
80% {
scale: 1.4 0.6;
translate: 0 -16px;
}
100% {
scale: 1;
translate: 0;
}
}
.step-foreground-container {
height: 50vh;
padding: 1em;
margin: 0 0 2em 0;
max-width: var(--normal-column-width, 660px);
position: relative;
}
</style>