This commit is contained in:
wires 2026-05-12 02:33:24 -04:00
parent 4712d80d4d
commit a95cc32580
8 changed files with 294 additions and 418 deletions

View file

@ -1,51 +0,0 @@
<script lang="ts">
import type { ScrollerStep } from '../@types/global';
interface Props {
index: number;
steps: ScrollerStep[];
preload?: number;
stackBackground?: boolean;
}
let { index, steps, preload = 1, stackBackground = true }: Props = $props();
function showStep(i: number) {
if (preload === 0) return true;
if (stackBackground) return i >= 0;
return i >= index - preload && i <= index + preload;
}
function isVisible(i: number) {
if (stackBackground) return i <= index;
return i === index;
}
</script>
{#each steps as step, i}
{#if showStep(i)}
<div
class={`step-background step-${i + 1} w-full absolute`}
class:visible={isVisible(i)}
class:invisible={!isVisible(i)}
>
<step.background {...step.backgroundProps || {}}></step.background>
</div>
{/if}
{/each}
<style lang="scss">
.step-background {
opacity: 0;
will-change: opacity;
transition: 0.35s opacity ease;
&.visible {
opacity: 1;
}
&.invisible {
opacity: 0;
}
}
</style>

View file

@ -1,64 +0,0 @@
<script lang="ts">
import type { ScrollerStep } from '../@types/global';
import { Markdown } from '@reuters-graphics/svelte-markdown';
interface Props {
steps: ScrollerStep[];
}
let { steps }: Props = $props();
</script>
{#each steps as step, i}
<div
class="step-foreground-container step-{i +
1} mb-20 h-screen flex items-center justify-center"
>
{#if step.foreground === '' || !step.foreground}
<!-- Empty foreground -->
<div class="empty-step-foreground"></div>
{#if typeof step.altText === 'string'}
<div class="background-alt-text visually-hidden">
<Markdown source={step.altText} />
</div>
{/if}
{:else}
<div class="step-foreground w-full">
{#if typeof step.foreground === 'string'}
<Markdown source={step.foreground} />
{:else}
<step.foreground {...step.foregroundProps || {}}></step.foreground>
{/if}
</div>
{#if typeof step.altText === 'string'}
<div class="background-alt-text visually-hidden">
<Markdown source={step.altText} />
</div>
{/if}
{/if}
</div>
{/each}
<style lang="scss">
@use './mixins' as mixins;
div.step-foreground-container {
width: initial;
max-width: initial;
.step-foreground {
max-width: calc(mixins.$column-width-normal * 0.9);
border-radius: 0.25rem;
@include mixins.fpy-5;
@include mixins.fpx-4;
background: rgba(255, 255, 255, 0.9);
:global(p:last-child) {
margin-block-end: 0;
}
:global(*:first-child) {
margin-block-start: 0;
}
}
}
</style>

View file

@ -0,0 +1,186 @@
<script lang="ts">
import ScrollerBase from '../ScrollerBase/ScrollerBase.svelte';
import Squash from './Squash.svelte';
import GraphicBlock from '../GraphicBlock/GraphicBlock.svelte';
import Block from '../Block/Block.svelte';
import type {
ContainerWidth,
ForegroundPosition,
} 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. */
animationPeak?: number;
/** Width of the background */
backgroundWidth?: ContainerWidth;
/** Position of the foreground */
foregroundPosition?: ForegroundPosition;
/** Threshold prop passed to svelte-scroller */
threshold?: number;
/** Top prop passed to svelte-scroller */
top?: number;
/** Bottom prop passed to svelte-scroller */
bottom?: number;
/** Parallax prop passed to svelte-scroller */
parallax?: boolean;
/** ID of the scroller container */
id?: string;
/** Set a class to target with SCSS */
class?: string;
/** The currently active section */
index?: number;
/** How far the section has scrolled past the threshold */
offset?: number;
/** How far the foreground has travelled */
progress?: number;
}
let {
graphics,
animationDuration = 350,
animationPeak = 0.8,
backgroundWidth = 'fluid',
foregroundPosition = 'middle',
threshold = 0.5,
top = 0,
bottom = 1,
parallax = false,
id = '',
class: cls = '',
index = $bindable(0),
offset = $bindable(0),
progress = $bindable(0),
}: Props = $props();
</script>
<Block width="fluid" class="scroller-container {cls}" {id}>
<ScrollerBase
bind:index
bind:offset
bind:progress
{threshold}
{top}
{bottom}
{parallax}
query="div.step-foreground-container"
>
{#snippet backgroundSnippet()}
<div
class="background min-h-screen relative p-0 flex justify-center"
class:right={foregroundPosition === 'left opposite'}
class:left={foregroundPosition === 'right opposite'}
aria-hidden="true"
>
<div class="scroller-graphic-well w-full">
<Block
width={backgroundWidth}
class="background-container my-0 min-h-screen flex justify-center items-center relative"
>
<Squash
{graphics}
{index}
{offset}
{animationDuration}
{animationPeak}
{backgroundWidth}
/>
</Block>
</div>
</div>
{/snippet}
{#snippet foregroundSnippet()}
<div class="foreground {foregroundPosition} w-full">
{#each graphics as graphic}
<div class="step-foreground-container">
<GraphicBlock
title={graphic.title}
description={graphic.description}
notes={graphic.notes}
>
<!-- GraphicBlock requires the children snippet to be defined, but currently, our image is in the background snippet. -->
{''}
</GraphicBlock>
</div>
{/each}
</div>
{/snippet}
</ScrollerBase>
</Block>
<style lang="scss">
div.background {
&.left {
width: 50%;
float: left;
@media (max-width: 1200px) {
justify-content: center;
width: 100%;
float: initial;
}
}
&.right {
width: 50%;
float: right;
@media (max-width: 1200px) {
justify-content: center;
width: 100%;
float: initial;
}
}
div.scroller-graphic-well {
padding: 0 15px;
}
}
div.foreground {
&.right {
width: 50%;
float: right;
@media (max-width: 1200px) {
width: 100%;
float: initial;
}
}
&.left {
width: 50%;
float: left;
@media (max-width: 1200px) {
width: 100%;
float: initial;
}
}
}
.step-foreground-container {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
padding: 1em;
margin: 0 auto;
max-width: var(--normal-column-width, 660px);
position: relative;
pointer-events: none;
> :global(*) {
pointer-events: auto;
}
}
</style>

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { getResponsiveSizes } from '@utils/propValidators';
import type { ContainerWidth } from '@components/@types/global';
type Graphic = {
src: string;
srcset: string;
alt: string;
width?: ContainerWidth;
};
interface Props {
graphics: Graphic[];
index: number;
offset: number;
animationDuration?: number;
animationPeak?: number;
backgroundWidth?: ContainerWidth;
}
let {
graphics,
index,
offset,
animationDuration = 350,
animationPeak = 0.8,
backgroundWidth = 'fluid',
}: Props = $props();
let imgEl = $state<HTMLImageElement | null>(null);
// The target step graphic we want to transition to based on scroll position
let targetIndex = $derived(
offset >= animationPeak && index < graphics.length - 1 ? index + 1 : index
);
// The actual graphic currently visible on screen
let displayedIndex = $state(0);
$effect(() => {
// When the target index changes, fire the animation but delay the graphic swap!
if (targetIndex !== displayedIndex) {
if (imgEl) {
// Force a browser reflow to instantly restart the time-based animation
imgEl.style.animation = 'none';
imgEl.offsetHeight;
imgEl.style.animation = '';
}
const swapTimeout = setTimeout(() => {
displayedIndex = targetIndex;
}, animationDuration * 0.8);
return () => clearTimeout(swapTimeout);
}
});
</script>
<img
bind:this={imgEl}
class="graphic"
style="--animation-time: {animationDuration}ms;"
src={graphics[displayedIndex].src}
srcset={graphics[displayedIndex].srcset}
sizes={getResponsiveSizes(backgroundWidth)}
alt={graphics[displayedIndex].alt}
/>
<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;
}
}
</style>

View file

@ -1,143 +0,0 @@
<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>

View file

@ -1,15 +1,11 @@
<script lang="ts"> <script lang="ts">
import ScrollerBase from '../components/ScrollerBase/ScrollerBase.svelte'; import ScrollerSquash from '@components/ScrollerAnimate/ScrollerAnimate.svelte';
import { getResponsiveSizes } from '../utils/propValidators';
// Types // Types
import type { import type {
ContainerWidth, ContainerWidth,
ForegroundPosition, ForegroundPosition,
ScrollerStep, } from '@components/@types/global';
} from '../components/@types/global';
import Article from '@components/Article/Article.svelte';
type Graphic = { type Graphic = {
src: string; src: string;
@ -19,10 +15,8 @@
description?: string; description?: string;
notes?: string; notes?: string;
width?: ContainerWidth; width?: ContainerWidth;
}; };
interface Props { interface Props {
graphics: Graphic[]; graphics: Graphic[];
/** Duration of the animation, milliseconds **/ /** Duration of the animation, milliseconds **/
@ -43,131 +37,12 @@
animationDuration = 350, animationDuration = 350,
animationPeak = 0.8, animationPeak = 0.8,
}: Props = $props(); }: Props = $props();
let count = $state(1);
let index = $state(0);
let offset = $state(0);
let progress = $state(0);
// reset animation on index change, swap graphic at peak distortion (80%)
let displayedIndex = $state(0);
let imgEl = $state<HTMLImageElement | null>(null);
$effect(() => {
const newIndex = index; // track changes
if (!imgEl) return;
imgEl.style.animation = 'none';
imgEl.offsetHeight; // force reflow
imgEl.style.animation = '';
const timeout = setTimeout(() => {
displayedIndex = newIndex;
}, animationDuration * animationPeak);
return () => clearTimeout(timeout);
});
</script> </script>
<Article> <ScrollerSquash
{graphics}
<ScrollerBase {animationDuration}
bind:count {animationPeak}
bind:index {backgroundWidth}
bind:offset {foregroundPosition}
bind:progress
query="div.step-foreground-container"
>
{#snippet backgroundSnippet()}
<div
class="background min-h-screen relative p-0 flex justify-center"
class:right={foregroundPosition === 'left opposite'}
class:left={foregroundPosition === 'right opposite'}
aria-hidden="true"
>
<img
bind:this={imgEl}
class="graphic"
style="--animation-time: {animationDuration}ms"
src={graphics[displayedIndex].src}
srcset={graphics[displayedIndex].srcset}
sizes={getResponsiveSizes(backgroundWidth)}
alt={graphics[displayedIndex].alt}
/> />
</div>
{/snippet}
{#snippet foregroundSnippet()}
{#each graphics as graphic, i}
<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>
</Article>
<style lang="scss">
@use 'mixins' as mixins;
.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;
}
}
// we play this
@keyframes stretch {
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;
/* background-color: rgba(0, 0, 0, 0.2); */
padding: 1em;
margin: 0 0 2em 0;
max-width: var(--normal-column-width, 660px);
position: relative;
}
</style>

View file

@ -1,6 +1,6 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import Squash from './Squash.svelte'; import ScrollerAnimate from '@components/ScrollerAnimate/ScrollerAnimate.svelte';
import Article from '@components/Article/Article.svelte'; import Article from '@components/Article/Article.svelte';
import BodyText from '@components/BodyText/BodyText.svelte'; import BodyText from '@components/BodyText/BodyText.svelte';
@ -35,7 +35,7 @@ const graphics = await Promise.all([
/> />
<Article> <Article>
<BodyText text="Cras eu consectetur justo, eu luctus felis. Suspendisse lectus enim, bibendum vitae euismod et, condimentum quis tortor. Donec sit amet orci nibh. Vivamus facilisis nunc quis nunc feugiat placerat. Nunc ut augue vitae metus fermentum venenatis vel at dui. Nulla aliquet nibh porttitor odio imperdiet, in ullamcorper augue bibendum. Cras ornare nunc ut sollicitudin egestas. Suspendisse neque ipsum, faucibus eu hendrerit porttitor, tincidunt a tortor. Aliquam at facilisis diam, a ullamcorper lacus. Suspendisse pharetra faucibus venenatis. Donec efficitur condimentum neque ut vehicula." /> <BodyText text="Cras eu consectetur justo, eu luctus felis. Suspendisse lectus enim, bibendum vitae euismod et, condimentum quis tortor. Donec sit amet orci nibh. Vivamus facilisis nunc quis nunc feugiat placerat. Nunc ut augue vitae metus fermentum venenatis vel at dui. Nulla aliquet nibh porttitor odio imperdiet, in ullamcorper augue bibendum. Cras ornare nunc ut sollicitudin egestas. Suspendisse neque ipsum, faucibus eu hendrerit porttitor, tincidunt a tortor. Aliquam at facilisis diam, a ullamcorper lacus. Suspendisse pharetra faucibus venenatis. Donec efficitur condimentum neque ut vehicula." />
<Squash client:only="svelte" {graphics} backgroundWidth="normal" /> <ScrollerAnimate client:only="svelte" {graphics} backgroundWidth="normal" />
<BodyText text="Sed feugiat, lacus id elementum tristique, urna elit consectetur mauris, in commodo ipsum neque sed turpis. Ut elit ex, pharetra laoreet leo vitae, rhoncus condimentum ligula. Morbi et sagittis tellus. Proin sed felis euismod ipsum lacinia feugiat. Donec finibus pretium dignissim. Integer ornare egestas scelerisque. Donec purus elit, viverra vitae felis at, volutpat sollicitudin tellus. Vivamus fermentum dictum dapibus. Phasellus non ligula ac augue efficitur ultrices sit amet at quam. Fusce dapibus luctus ex, non semper massa commodo eget." /> <BodyText text="Sed feugiat, lacus id elementum tristique, urna elit consectetur mauris, in commodo ipsum neque sed turpis. Ut elit ex, pharetra laoreet leo vitae, rhoncus condimentum ligula. Morbi et sagittis tellus. Proin sed felis euismod ipsum lacinia feugiat. Donec finibus pretium dignissim. Integer ornare egestas scelerisque. Donec purus elit, viverra vitae felis at, volutpat sollicitudin tellus. Vivamus fermentum dictum dapibus. Phasellus non ligula ac augue efficitur ultrices sit amet at quam. Fusce dapibus luctus ex, non semper massa commodo eget." />
</Article> </Article>
</Layout> </Layout>

View file

@ -1,22 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
import Article from '@components/Article/Article.svelte';
import BodyText from '@components/BodyText/BodyText.svelte';
import Headline from '@components/Headline/Headline.svelte';
---
<Layout>
<Headline
hed={'Unfriendly skies'}
/>
<Article>
<BodyText text="Cras eu consectetur justo, eu luctus felis. Suspendisse lectus enim, bibendum vitae euismod et, condimentum quis tortor. Donec sit amet orci nibh. Vivamus facilisis nunc quis nunc feugiat placerat. Nunc ut augue vitae metus fermentum venenatis vel at dui. Nulla aliquet nibh porttitor odio imperdiet, in ullamcorper augue bibendum. Cras ornare nunc ut sollicitudin egestas. Suspendisse neque ipsum, faucibus eu hendrerit porttitor, tincidunt a tortor. Aliquam at facilisis diam, a ullamcorper lacus. Suspendisse pharetra faucibus venenatis. Donec efficitur condimentum neque ut vehicula." />
<BodyText text="Sed feugiat, lacus id elementum tristique, urna elit consectetur mauris, in commodo ipsum neque sed turpis. Ut elit ex, pharetra laoreet leo vitae, rhoncus condimentum ligula. Morbi et sagittis tellus. Proin sed felis euismod ipsum lacinia feugiat. Donec finibus pretium dignissim. Integer ornare egestas scelerisque. Donec purus elit, viverra vitae felis at, volutpat sollicitudin tellus. Vivamus fermentum dictum dapibus. Phasellus non ligula ac augue efficitur ultrices sit amet at quam. Fusce dapibus luctus ex, non semper massa commodo eget." />
</Article>
</Layout>