Copy-paste scroll blocks under MIT, built on svg-scroll-draw (11.6 KB). Each block is one React + Tailwind file that lands in your project. Add it with the shadcn CLI, or open the source and paste it in. The only package it needs is svg-scroll-draw. Visitors who ask for reduced motion see the finished state.
'use client';
/**
* signature-draw — a handwritten signature that inks itself in as it scrolls
* into view, then an underline flourish follows it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: react, svg-scroll-draw. Styling: Tailwind CSS.
*
* Swap SIGNATURE_PATH for your own: draw it in Figma/Illustrator with a single
* pen stroke, export as SVG and paste the `d` attribute. One continuous path
* draws best. Reduced motion is handled by the library: the signature is shown
* fully drawn, with no animation.
*/
import { ScrollDraw } from 'svg-scroll-draw/react';
export interface SignatureDrawProps {
/** Small label above the signature. */
label?: string;
/** Printed name under the line. Also used for the SVG's accessible name. */
name?: string;
/** Right-aligned note under the line, such as a date or a role. */
note?: string;
/** Colour of the underline flourish. Any CSS colour. */
accent?: string;
className?: string;
}
/** A capital loop, a cursive body and a trailing tail, in a 300 × 130 box. */
const SIGNATURE_PATH =
'M 18 90 C 14 62 20 34 34 28 C 48 22 62 36 58 56 C 55 70 47 80 54 88 C 60 95 70 91 76 83 ' +
'C 84 72 86 80 84 90 C 83 97 91 94 103 84 C 114 73 117 81 115 91 C 114 98 123 94 135 84 ' +
'C 148 72 162 68 175 80 C 185 90 183 102 196 92 C 210 81 220 66 236 74 C 248 80 252 90 265 82 L 275 75';
const UNDERLINE_PATH = 'M 12 112 C 90 122 210 120 282 112';
export function SignatureDraw({
label = 'Signed',
name = 'Your Name, Founder',
note = 'Est. 2026',
accent = '#ff90e8',
className = '',
}: SignatureDrawProps) {
return (
<figure
className={`mx-auto w-full max-w-xl rounded-2xl border border-neutral-200 bg-white p-6 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950 ${className}`}
>
<p className="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{label}
</p>
{/* `once` keeps the ink on the page after it has drawn. `stagger` starts the
underline a beat after the signature, so the two read as one gesture. */}
<ScrollDraw
easing="ease-out"
speed={0.6}
stagger={0.35}
once
trigger={{ start: 'top 85%', end: 'top 30%' }}
className="mt-2 text-neutral-900 dark:text-neutral-100"
>
<svg
viewBox="0 0 300 130"
fill="none"
role="img"
aria-label={`Signature: ${name}`}
className="block h-auto w-full"
>
<path
d={SIGNATURE_PATH}
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d={UNDERLINE_PATH} stroke={accent} strokeWidth={1.8} strokeLinecap="round" />
</svg>
</ScrollDraw>
<figcaption className="mt-2 flex items-baseline justify-between gap-4 font-mono text-xs text-neutral-600 dark:text-neutral-400">
<span>{name}</span>
<span>{note}</span>
</figcaption>
</figure>
);
}
export default SignatureDraw;
<script setup lang="ts">
/**
* signature-draw — a handwritten signature that inks itself in as it scrolls
* into view, then an underline flourish follows it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: vue 3, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: label, name, note, accent, className (a plain `class` on the
* component works too: it lands on the <figure>).
*
* Swap SIGNATURE_PATH for your own: draw it in Figma/Illustrator with a single
* pen stroke, export as SVG and paste the `d` attribute. One continuous path
* draws best. Reduced motion is handled by the library: the signature is shown
* fully drawn, with no animation.
*/
import { ScrollDraw } from 'svg-scroll-draw/vue';
export interface SignatureDrawProps {
/** Small label above the signature. */
label?: string;
/** Printed name under the line. Also used for the SVG's accessible name. */
name?: string;
/** Right-aligned note under the line, such as a date or a role. */
note?: string;
/** Colour of the underline flourish. Any CSS colour. */
accent?: string;
className?: string;
}
withDefaults(defineProps<SignatureDrawProps>(), {
label: 'Signed',
name: 'Your Name, Founder',
note: 'Est. 2026',
accent: '#ff90e8',
className: '',
});
/** A capital loop, a cursive body and a trailing tail, in a 300 × 130 box. */
const SIGNATURE_PATH =
'M 18 90 C 14 62 20 34 34 28 C 48 22 62 36 58 56 C 55 70 47 80 54 88 C 60 95 70 91 76 83 ' +
'C 84 72 86 80 84 90 C 83 97 91 94 103 84 C 114 73 117 81 115 91 C 114 98 123 94 135 84 ' +
'C 148 72 162 68 175 80 C 185 90 183 102 196 92 C 210 81 220 66 236 74 C 248 80 252 90 265 82 L 275 75';
const UNDERLINE_PATH = 'M 12 112 C 90 122 210 120 282 112';
/**
* One object for the life of the component. The options never depend on the
* props, so changing the name or the accent re-renders the SVG without
* restarting the draw.
*/
const TRIGGER = { start: 'top 85%', end: 'top 30%' };
</script>
<template>
<figure
:class="[
'mx-auto w-full max-w-xl rounded-2xl border border-neutral-200 bg-white p-6 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950',
className,
]"
>
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">{{ label }}</p>
<!-- `once` keeps the ink on the page after it has drawn. `stagger` starts the
underline a beat after the signature, so the two read as one gesture. -->
<ScrollDraw
easing="ease-out"
:speed="0.6"
:stagger="0.35"
once
:trigger="TRIGGER"
class="mt-2 text-neutral-900 dark:text-neutral-100"
>
<svg
viewBox="0 0 300 130"
fill="none"
role="img"
:aria-label="`Signature: ${name}`"
class="block h-auto w-full"
>
<path
:d="SIGNATURE_PATH"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path :d="UNDERLINE_PATH" :stroke="accent" stroke-width="1.8" stroke-linecap="round" />
</svg>
</ScrollDraw>
<figcaption class="mt-2 flex items-baseline justify-between gap-4 font-mono text-xs text-neutral-600 dark:text-neutral-400">
<span>{{ name }}</span>
<span>{{ note }}</span>
</figcaption>
</figure>
</template>
<script lang="ts" module>
/**
* signature-draw — a handwritten signature that inks itself in as it scrolls
* into view, then an underline flourish follows it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: label, name, note, accent, className.
*
* Swap SIGNATURE_PATH for your own: draw it in Figma/Illustrator with a single
* pen stroke, export as SVG and paste the `d` attribute. One continuous path
* draws best. Reduced motion is handled by the library: the signature is shown
* fully drawn, with no animation.
*/
export interface SignatureDrawProps {
/** Small label above the signature. */
label?: string;
/** Printed name under the line. Also used for the SVG's accessible name. */
name?: string;
/** Right-aligned note under the line, such as a date or a role. */
note?: string;
/** Colour of the underline flourish. Any CSS colour. */
accent?: string;
className?: string;
}
/** A capital loop, a cursive body and a trailing tail, in a 300 × 130 box. */
const SIGNATURE_PATH =
'M 18 90 C 14 62 20 34 34 28 C 48 22 62 36 58 56 C 55 70 47 80 54 88 C 60 95 70 91 76 83 ' +
'C 84 72 86 80 84 90 C 83 97 91 94 103 84 C 114 73 117 81 115 91 C 114 98 123 94 135 84 ' +
'C 148 72 162 68 175 80 C 185 90 183 102 196 92 C 210 81 220 66 236 74 C 248 80 252 90 265 82 L 275 75';
const UNDERLINE_PATH = 'M 12 112 C 90 122 210 120 282 112';
</script>
<script lang="ts">
import { scrollDraw } from 'svg-scroll-draw/svelte';
let {
label = 'Signed',
name = 'Your Name, Founder',
note = 'Est. 2026',
accent = '#ff90e8',
className = '',
}: SignatureDrawProps = $props();
</script>
<figure
class="mx-auto w-full max-w-xl rounded-2xl border border-neutral-200 bg-white p-6 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950 {className}"
>
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{label}
</p>
<!-- `once` keeps the ink on the page after it has drawn. `stagger` starts the
underline a beat after the signature, so the two read as one gesture.
The options never depend on the props, so changing the name or the accent
re-renders the SVG without restarting the draw. -->
<div
use:scrollDraw={{
easing: 'ease-out',
speed: 0.6,
stagger: 0.35,
once: true,
trigger: { start: 'top 85%', end: 'top 30%' },
}}
class="mt-2 text-neutral-900 dark:text-neutral-100"
>
<svg
viewBox="0 0 300 130"
fill="none"
role="img"
aria-label="Signature: {name}"
class="block h-auto w-full"
>
<path
d={SIGNATURE_PATH}
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d={UNDERLINE_PATH} stroke={accent} stroke-width="1.8" stroke-linecap="round" />
</svg>
</div>
<figcaption
class="mt-2 flex items-baseline justify-between gap-4 font-mono text-xs text-neutral-600 dark:text-neutral-400"
>
<span>{name}</span>
<span>{note}</span>
</figcaption>
</figure>
02blockreveal-cards
Reveal Cards
A grid of feature cards that fade up one after another as the grid scrolls into view.
SVG paths ink themselves in as the reader scrolls, tied to their position.
02
Reveal anything
Fade, slide or scale any element into place. One call for a whole grid.
03
Count up
Numbers climb from zero to their value as the section comes into view.
04
Split text
Headlines arrive word by word or letter by letter, still one string in the DOM.
05
Pin and scrub
Hold a panel in place while the story scrolls past it.
06
Motion-aware
Visitors who ask for reduced motion get the finished page, not the animation.
Sourcecomponents/scroll/reveal-cards.tsx93 lines
components/scroll/reveal-cards.tsx93 lines
'use client';
/**
* reveal-cards — a grid of feature cards that fade up one after another as the
* grid scrolls into view.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: react, svg-scroll-draw. Styling: Tailwind CSS.
*
* Each card reveals once and stays revealed. Reduced motion is handled by the
* library: every card is shown in its final state, with no animation.
*/
import { useEffect, useRef } from 'react';
import { scrollReveal } from 'svg-scroll-draw/reveal';
export interface RevealCard {
title: string;
body: string;
}
export interface RevealCardsProps {
eyebrow?: string;
heading?: string;
cards?: RevealCard[];
className?: string;
}
const DEFAULT_CARDS: RevealCard[] = [
{ title: 'Draw on scroll', body: 'SVG paths ink themselves in as the reader scrolls, tied to their position.' },
{ title: 'Reveal anything', body: 'Fade, slide or scale any element into place. One call for a whole grid.' },
{ title: 'Count up', body: 'Numbers climb from zero to their value as the section comes into view.' },
{ title: 'Split text', body: 'Headlines arrive word by word or letter by letter, still one string in the DOM.' },
{ title: 'Pin and scrub', body: 'Hold a panel in place while the story scrolls past it.' },
{ title: 'Motion-aware', body: 'Visitors who ask for reduced motion get the finished page, not the animation.' },
];
export function RevealCards({
eyebrow = 'Features',
heading = 'Everything moves when it should',
cards = DEFAULT_CARDS,
className = '',
}: RevealCardsProps) {
const gridRef = useRef<HTMLUListElement>(null);
useEffect(() => {
const grid = gridRef.current;
if (!grid) return;
const items = Array.from(grid.querySelectorAll<HTMLElement>('[data-reveal-card]'));
// `stagger` starts each card in a row a fifth of the way into its
// neighbour's reveal, so cards in the same row still arrive in reading order.
const reveal = scrollReveal(items, {
from: { opacity: 0, y: 40, scale: 0.97 },
stagger: 0.2,
once: true,
});
// StrictMode mounts effects twice in development: destroy() restores the
// original inline styles, so the second run starts clean.
return () => reveal.destroy();
// Re-run only when the number of cards changes, not on every new array.
}, [cards.length]);
return (
<section className={`mx-auto w-full max-w-5xl ${className}`}>
<p className="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
<h2 className="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">
{heading}
</h2>
<ul ref={gridRef} className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card, i) => (
<li
key={card.title}
data-reveal-card=""
className="rounded-2xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-950"
>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-[#ff90e8] font-mono text-xs font-medium text-neutral-950">
{String(i + 1).padStart(2, '0')}
</span>
<h3 className="mt-4 text-lg font-semibold text-neutral-950 dark:text-neutral-50">{card.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">{card.body}</p>
</li>
))}
</ul>
</section>
);
}
export default RevealCards;
<script setup lang="ts">
/**
* reveal-cards — a grid of feature cards that fade up one after another as the
* grid scrolls into view.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: vue 3, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: eyebrow, heading, cards ({ title, body }[]), className.
*
* Each card reveals once and stays revealed. Reduced motion is handled by the
* library: every card is shown in its final state, with no animation.
*/
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { scrollReveal, type ScrollRevealInstance } from 'svg-scroll-draw/reveal';
export interface RevealCard {
title: string;
body: string;
}
export interface RevealCardsProps {
eyebrow?: string;
heading?: string;
cards?: RevealCard[];
className?: string;
}
const props = withDefaults(defineProps<RevealCardsProps>(), {
eyebrow: 'Features',
heading: 'Everything moves when it should',
// A factory, so each instance gets its own array.
cards: (): RevealCard[] => [
{ title: 'Draw on scroll', body: 'SVG paths ink themselves in as the reader scrolls, tied to their position.' },
{ title: 'Reveal anything', body: 'Fade, slide or scale any element into place. One call for a whole grid.' },
{ title: 'Count up', body: 'Numbers climb from zero to their value as the section comes into view.' },
{ title: 'Split text', body: 'Headlines arrive word by word or letter by letter, still one string in the DOM.' },
{ title: 'Pin and scrub', body: 'Hold a panel in place while the story scrolls past it.' },
{ title: 'Motion-aware', body: 'Visitors who ask for reduced motion get the finished page, not the animation.' },
],
className: '',
});
const gridRef = ref<HTMLUListElement | null>(null);
let reveal: ScrollRevealInstance | null = null;
function start(): void {
const grid = gridRef.value;
if (!grid) return;
const items = Array.from(grid.querySelectorAll<HTMLElement>('[data-reveal-card]'));
// `stagger` starts each card in a row a fifth of the way into its
// neighbour's reveal, so cards in the same row still arrive in reading order.
reveal = scrollReveal(items, {
from: { opacity: 0, y: 40, scale: 0.97 },
stagger: 0.2,
once: true,
});
}
function stop(): void {
// destroy() restores the original inline styles, so a rebuild starts clean.
reveal?.destroy();
reveal = null;
}
onMounted(start);
// Rebuild only when the number of cards changes, not on every new array.
// `flush: 'post'` waits for the new list to be in the DOM before reading it.
watch(
() => props.cards.length,
() => {
stop();
start();
},
{ flush: 'post' },
);
onBeforeUnmount(stop);
</script>
<template>
<section :class="['mx-auto w-full max-w-5xl', className]">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{{ eyebrow }}
</p>
<h2 class="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">
{{ heading }}
</h2>
<ul ref="gridRef" class="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<li
v-for="(card, i) in cards"
:key="card.title"
data-reveal-card=""
class="rounded-2xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-950"
>
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-[#ff90e8] font-mono text-xs font-medium text-neutral-950"
>
{{ String(i + 1).padStart(2, '0') }}
</span>
<h3 class="mt-4 text-lg font-semibold text-neutral-950 dark:text-neutral-50">{{ card.title }}</h3>
<p class="mt-2 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">{{ card.body }}</p>
</li>
</ul>
</section>
</template>
<script lang="ts" module>
/**
* reveal-cards — a grid of feature cards that fade up one after another as the
* grid scrolls into view.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: eyebrow, heading, cards ({ title, body }[]), className.
*
* Each card reveals once and stays revealed. Reduced motion is handled by the
* library: every card is shown in its final state, with no animation.
*/
export interface RevealCard {
title: string;
body: string;
}
export interface RevealCardsProps {
eyebrow?: string;
heading?: string;
cards?: RevealCard[];
className?: string;
}
const DEFAULT_CARDS: RevealCard[] = [
{ title: 'Draw on scroll', body: 'SVG paths ink themselves in as the reader scrolls, tied to their position.' },
{ title: 'Reveal anything', body: 'Fade, slide or scale any element into place. One call for a whole grid.' },
{ title: 'Count up', body: 'Numbers climb from zero to their value as the section comes into view.' },
{ title: 'Split text', body: 'Headlines arrive word by word or letter by letter, still one string in the DOM.' },
{ title: 'Pin and scrub', body: 'Hold a panel in place while the story scrolls past it.' },
{ title: 'Motion-aware', body: 'Visitors who ask for reduced motion get the finished page, not the animation.' },
];
</script>
<script lang="ts">
import { scrollReveal } from 'svg-scroll-draw/reveal';
let {
eyebrow = 'Features',
heading = 'Everything moves when it should',
cards = DEFAULT_CARDS,
className = '',
}: RevealCardsProps = $props();
let grid: HTMLUListElement;
// A derived number only notifies when its value changes, so a new array of
// the same length does not rebuild the reveal.
const count = $derived(cards.length);
// Runs after the list is in the DOM, and again (after the cleanup) whenever
// the number of cards changes. `count` is its one dependency.
$effect(() => {
count;
const items = Array.from(grid.querySelectorAll<HTMLElement>('[data-reveal-card]'));
// `stagger` starts each card in a row a fifth of the way into its
// neighbour's reveal, so cards in the same row still arrive in reading order.
const reveal = scrollReveal(items, {
from: { opacity: 0, y: 40, scale: 0.97 },
stagger: 0.2,
once: true,
});
// destroy() restores the original inline styles, so a rebuild starts clean.
return () => reveal.destroy();
});
</script>
<section class="mx-auto w-full max-w-5xl {className}">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
<h2 class="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">
{heading}
</h2>
<ul bind:this={grid} class="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each cards as card, i (card.title)}
<li
data-reveal-card=""
class="rounded-2xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-950"
>
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-[#ff90e8] font-mono text-xs font-medium text-neutral-950"
>
{String(i + 1).padStart(2, '0')}
</span>
<h3 class="mt-4 text-lg font-semibold text-neutral-950 dark:text-neutral-50">{card.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">{card.body}</p>
</li>
{/each}
</ul>
</section>
03blockstat-counter-row
Stat Counter Row
A row of three or four stats that count up from zero as the row scrolls into view.
<script lang="ts" module>
/**
* stat-counter-row — a row of numbers that count up from zero as the row
* scrolls into view.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: eyebrow, stats ({ value, label, prefix?, suffix?, decimals? }[]),
* className.
*
* The default stats are placeholders: replace them with your own. Screen
* readers get the final value once, not every intermediate number, and the
* final value reserves its width so the layout does not jump while counting.
* Reduced motion is handled by the library: the final value is shown at once.
*/
export interface Stat {
value: number;
label: string;
prefix?: string;
suffix?: string;
/** Digits after the decimal point. Default 0. */
decimals?: number;
}
export interface StatCounterRowProps {
eyebrow?: string;
stats?: Stat[];
className?: string;
}
const DEFAULT_STATS: Stat[] = [
{ value: 12480, label: 'Orders shipped', suffix: '+' },
{ value: 38, label: 'Cities served' },
{ value: 99.2, label: 'On-time rate', suffix: '%', decimals: 1 },
{ value: 4.8, label: 'Average review', suffix: '/5', decimals: 1 },
];
// Ends on the number's bottom, a point it reaches even at the very end of a
// page; `top 45%` can be out of reach there.
const TRIGGER = { start: 'top bottom', end: 'bottom 90%' };
function formatNumber(n: number, decimals: number): string {
return n.toLocaleString('en-US', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
</script>
<script lang="ts">
import { scrollCounterAction } from 'svg-scroll-draw/svelte';
let {
eyebrow = 'Sample numbers, swap in your own',
stats = DEFAULT_STATS,
className = '',
}: StatCounterRowProps = $props();
</script>
<section class="mx-auto w-full max-w-5xl {className}">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
<dl
class="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-neutral-200 bg-neutral-200 lg:grid-cols-4 dark:border-neutral-800 dark:bg-neutral-800"
>
{#each stats as stat (stat.label)}
{@const decimals = stat.decimals ?? 0}
{@const number = formatNumber(stat.value, decimals)}
{@const final = `${stat.prefix ?? ''}${number}${stat.suffix ?? ''}`}
<div class="flex flex-col-reverse gap-2 bg-white p-6 sm:p-8 dark:bg-neutral-950">
<dt class="text-sm text-neutral-600 dark:text-neutral-400">{stat.label}</dt>
<dd class="text-4xl font-medium tracking-tight text-neutral-950 tabular-nums sm:text-5xl dark:text-neutral-50">
<span class="sr-only">{final}</span>
<!-- Both cells of the inline-grid share one grid area: the invisible
final value holds the width, the counter paints over it. The
action rebuilds the counter only when an option really changes. -->
<span aria-hidden="true" class="inline-flex items-baseline">
{stat.prefix}<span class="inline-grid">
<span class="invisible col-start-1 row-start-1">{number}</span>
<span
use:scrollCounterAction={{
from: 0,
to: stat.value,
format: (n: number) => formatNumber(n, decimals),
easing: 'ease-out',
once: true,
trigger: TRIGGER,
}}
class="col-start-1 row-start-1 text-left"
></span>
</span>
{#if stat.suffix}
<span class="ml-0.5 text-[0.6em] text-neutral-600 dark:text-neutral-400">{stat.suffix}</span>
{/if}
</span>
</dd>
</div>
{/each}
</dl>
</section>
04blocksticky-feature
Sticky Feature
A pinned left panel with a progress rail while numbered steps scroll past on the right.
'use client';
/**
* sticky-feature — a left panel that stays in place while the steps on the
* right scroll past it. The panel tracks the step in view and fills a progress
* rail as the reader moves through the list.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: react, svg-scroll-draw. Styling: Tailwind CSS.
*
* The pin is plain CSS `position: sticky` rather than `scrollPin`, because
* scrollPin moves the element into a wrapper div and React expects to own that
* node. Sticky does not work inside an ancestor with `overflow: hidden`, so keep
* this block out of one. Below the `md` breakpoint it becomes a single column.
*
* Reduced motion is handled by the library: steps are shown at full opacity and
* the rail is full. The active-step highlight is state, not motion, so it keeps
* working either way.
*/
import { useEffect, useRef, useState } from 'react';
import { scrollAnimate } from 'svg-scroll-draw';
import { ScrollAnimate } from 'svg-scroll-draw/react';
export interface FeatureStep {
title: string;
body: string;
}
export interface StickyFeatureProps {
eyebrow?: string;
heading?: string;
intro?: string;
steps?: FeatureStep[];
className?: string;
}
const DEFAULT_STEPS: FeatureStep[] = [
{ title: 'Install', body: 'Add one package. It has no dependencies of its own, so nothing else comes with it.' },
{ title: 'Wrap', body: 'Put the component around the SVG or element you want to move.' },
{ title: 'Tune', body: 'Pick an easing and a trigger window. The defaults are a good start.' },
{ title: 'Ship', body: 'Server-rendered markup stays readable. The motion is added in the browser.' },
];
export function StickyFeature({
eyebrow = 'How it works',
heading = 'Four steps from idea to motion',
intro = 'The panel stays put while the steps scroll by.',
steps = DEFAULT_STEPS,
className = '',
}: StickyFeatureProps) {
const [active, setActive] = useState(0);
const listRef = useRef<HTMLOListElement>(null);
const railRef = useRef<HTMLSpanElement>(null);
// Progress rail: scales from 0 to 1 between the first step reaching the middle
// of the viewport and the last step being fully on screen. The rail sits inside
// the sticky panel, so its own position never changes; `triggerElement`
// measures the scrolling list instead.
useEffect(() => {
const list = listRef.current;
const rail = railRef.current;
if (!list || !rail) return;
const anim = scrollAnimate(rail, {
props: { transform: ['scaleY(0)', 'scaleY(1)'] },
triggerElement: list,
trigger: { start: 'top 60%', end: 'bottom 85%' },
easing: 'linear',
});
return () => anim.destroy();
}, []);
// Keyed on the titles, not the count: the list items are keyed by title, so
// the same number of renamed steps (a locale switch) replaces every <li>, and
// an observer kept on the old ones froze the highlight.
const titles = steps.map((s) => s.title).join('\u0000');
// Active step: the last step with any part in the top half of the viewport.
// A set of visible steps, rather than a thin band, so a fast scroll that jumps
// a step straight past the middle still lands on the right one.
useEffect(() => {
const list = listRef.current;
if (!list || typeof IntersectionObserver === 'undefined') return;
const items = Array.from(list.querySelectorAll<HTMLElement>('[data-step]'));
const inView = new Set<number>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const index = Number((entry.target as HTMLElement).dataset.step);
if (entry.isIntersecting) inView.add(index);
else inView.delete(index);
}
if (inView.size) setActive(Math.max(...inView));
},
{ rootMargin: '0px 0px -50% 0px' },
);
items.forEach((item) => observer.observe(item));
return () => observer.disconnect();
}, [titles]);
return (
<section className={`mx-auto grid w-full max-w-5xl gap-10 md:grid-cols-2 md:gap-16 ${className}`}>
{/* Pinned panel */}
<div className="md:sticky md:top-24 md:self-start">
<p className="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
<h2 className="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">
{heading}
</h2>
<p className="mt-3 text-neutral-600 dark:text-neutral-400">{intro}</p>
<div className="mt-8 hidden gap-5 md:flex">
<span className="relative w-1 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
<span ref={railRef} className="absolute inset-0 origin-top bg-[#ff90e8]" style={{ transform: 'scaleY(0)' }} />
</span>
<ol className="space-y-3">
{steps.map((step, i) => (
<li
key={step.title}
aria-current={i === active ? 'step' : undefined}
className={`flex items-baseline gap-3 text-sm transition-colors duration-300 ${
i === active
? 'font-semibold text-neutral-950 dark:text-neutral-50'
: 'text-neutral-600 dark:text-neutral-400'
}`}
>
<span className="font-mono text-xs">{String(i + 1).padStart(2, '0')}</span>
{step.title}
</li>
))}
</ol>
</div>
</div>
{/* Scrolling steps */}
<ol ref={listRef} className="space-y-6 md:space-y-[30vh] md:py-[10vh]">
{steps.map((step, i) => (
<li key={step.title} data-step={i}>
<ScrollAnimate
props={{ opacity: [0.3, 1], transform: ['translateY(24px)', 'translateY(0px)'] }}
trigger={{ start: 'top 85%', end: 'top 55%' }}
easing="ease-out"
className="rounded-2xl border border-neutral-200 bg-white p-6 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950"
>
<span className="font-mono text-xs text-neutral-600 dark:text-neutral-400">
Step {String(i + 1).padStart(2, '0')}
</span>
<h3 className="mt-2 text-xl font-semibold text-neutral-950 dark:text-neutral-50">{step.title}</h3>
<p className="mt-2 leading-relaxed text-neutral-600 dark:text-neutral-400">{step.body}</p>
</ScrollAnimate>
</li>
))}
</ol>
</section>
);
}
export default StickyFeature;
<script setup lang="ts">
/**
* sticky-feature — a left panel that stays in place while the steps on the
* right scroll past it. The panel tracks the step in view and fills a progress
* rail as the reader moves through the list.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: vue 3, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: eyebrow, heading, intro, steps ({ title, body }[]), className (a plain
* `class` on the component works too: it lands on the <section>).
*
* The pin is plain CSS `position: sticky` rather than `scrollPin`, because
* scrollPin moves the element into a wrapper div and Vue expects to own that
* node. Sticky does not work inside an ancestor with `overflow: hidden`, so keep
* this block out of one. Below the `md` breakpoint it becomes a single column.
*
* Reduced motion is handled by the library: steps are shown at full opacity and
* the rail is full. The active-step highlight is state, not motion, so it keeps
* working either way.
*/
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { scrollAnimate, type ScrollDrawInstance } from 'svg-scroll-draw';
import { ScrollAnimate, type ScrollAnimateOptions } from 'svg-scroll-draw/vue';
export interface FeatureStep {
title: string;
body: string;
}
export interface StickyFeatureProps {
eyebrow?: string;
heading?: string;
intro?: string;
steps?: FeatureStep[];
className?: string;
}
const props = withDefaults(defineProps<StickyFeatureProps>(), {
eyebrow: 'How it works',
heading: 'Four steps from idea to motion',
intro: 'The panel stays put while the steps scroll by.',
// A factory, so each instance gets its own array.
steps: (): FeatureStep[] => [
{ title: 'Install', body: 'Add one package. It has no dependencies of its own, so nothing else comes with it.' },
{ title: 'Wrap', body: 'Put the component around the SVG or element you want to move.' },
{ title: 'Tune', body: 'Pick an easing and a trigger window. The defaults are a good start.' },
{ title: 'Ship', body: 'Server-rendered markup stays readable. The motion is added in the browser.' },
],
className: '',
});
/**
* Each step card fades and rises as it enters. One object for the life of the
* component, so a re-render (the active step changing) never rebuilds a card.
*/
const CARD_OPTIONS: ScrollAnimateOptions = {
props: { opacity: [0.3, 1], transform: ['translateY(24px)', 'translateY(0px)'] },
trigger: { start: 'top 85%', end: 'top 55%' },
easing: 'ease-out',
};
const pad = (n: number): string => String(n).padStart(2, '0');
const active = ref(0);
const listRef = ref<HTMLOListElement | null>(null);
const railRef = ref<HTMLSpanElement | null>(null);
let rail: ScrollDrawInstance | null = null;
let observer: IntersectionObserver | null = null;
// Progress rail: scales from 0 to 1 between the first step reaching the middle
// of the viewport and the last step being fully on screen. The rail sits inside
// the sticky panel, so its own position never changes; `triggerElement`
// measures the scrolling list instead.
function startRail(): void {
const list = listRef.value;
const el = railRef.value;
if (!list || !el) return;
rail = scrollAnimate(el, {
props: { transform: ['scaleY(0)', 'scaleY(1)'] },
triggerElement: list,
trigger: { start: 'top 60%', end: 'bottom 85%' },
easing: 'linear',
});
}
// Active step: the last step with any part in the top half of the viewport.
// A set of visible steps, rather than a thin band, so a fast scroll that jumps
// a step straight past the middle still lands on the right one.
function observeSteps(): void {
observer?.disconnect();
observer = null;
const list = listRef.value;
if (!list || typeof IntersectionObserver === 'undefined') return;
const items = Array.from(list.querySelectorAll<HTMLElement>('[data-step]'));
const inView = new Set<number>();
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const index = Number((entry.target as HTMLElement).dataset.step);
if (entry.isIntersecting) inView.add(index);
else inView.delete(index);
}
if (inView.size) active.value = Math.max(...inView);
},
{ rootMargin: '0px 0px -50% 0px' },
);
items.forEach((item) => io.observe(item));
observer = io;
}
onMounted(() => {
startRail();
observeSteps();
});
// Re-observe when the step <li>s are replaced, not on every new array. They are
// keyed by title, so a new array with the same titles keeps them, while a step
// added, removed or renamed (a locale switch, say) brings new ones that the old
// observer would never see. `flush: 'post'` waits for them to be in the DOM.
watch(() => JSON.stringify(props.steps.map((step) => step.title)), observeSteps, { flush: 'post' });
onBeforeUnmount(() => {
// destroy() puts back the rail's own inline style.
rail?.destroy();
rail = null;
observer?.disconnect();
observer = null;
});
</script>
<template>
<section :class="['mx-auto grid w-full max-w-5xl gap-10 md:grid-cols-2 md:gap-16', className]">
<!-- Pinned panel -->
<div class="md:sticky md:top-24 md:self-start">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{{ eyebrow }}
</p>
<h2 class="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">
{{ heading }}
</h2>
<p class="mt-3 text-neutral-600 dark:text-neutral-400">{{ intro }}</p>
<div class="mt-8 hidden gap-5 md:flex">
<span class="relative w-1 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
<span ref="railRef" class="absolute inset-0 origin-top bg-[#ff90e8]" style="transform: scaleY(0)" />
</span>
<ol class="space-y-3">
<li
v-for="(step, i) in steps"
:key="step.title"
:aria-current="i === active ? 'step' : undefined"
:class="[
'flex items-baseline gap-3 text-sm transition-colors duration-300',
i === active
? 'font-semibold text-neutral-950 dark:text-neutral-50'
: 'text-neutral-600 dark:text-neutral-400',
]"
>
<span class="font-mono text-xs">{{ pad(i + 1) }}</span>{{ step.title }}
</li>
</ol>
</div>
</div>
<!-- Scrolling steps -->
<ol ref="listRef" class="space-y-6 md:space-y-[30vh] md:py-[10vh]">
<li v-for="(step, i) in steps" :key="step.title" :data-step="i">
<ScrollAnimate
:options="CARD_OPTIONS"
class="rounded-2xl border border-neutral-200 bg-white p-6 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950"
>
<span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">Step {{ pad(i + 1) }}</span>
<h3 class="mt-2 text-xl font-semibold text-neutral-950 dark:text-neutral-50">{{ step.title }}</h3>
<p class="mt-2 leading-relaxed text-neutral-600 dark:text-neutral-400">{{ step.body }}</p>
</ScrollAnimate>
</li>
</ol>
</section>
</template>
<script lang="ts" module>
/**
* sticky-feature — a left panel that stays in place while the steps on the
* right scroll past it. The panel tracks the step in view and fills a progress
* rail as the reader moves through the list.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: eyebrow, heading, intro, steps ({ title, body }[]), className (for
* the <section>: a Svelte component does not pass a plain `class` on).
*
* The pin is plain CSS `position: sticky` rather than `scrollPin`, because
* scrollPin moves the element into a wrapper div and Svelte expects to own that
* node. Sticky does not work inside an ancestor with `overflow: hidden`, so keep
* this block out of one. Below the `md` breakpoint it becomes a single column.
*
* Reduced motion is handled by the library: steps are shown at full opacity and
* the rail is full. The active-step highlight is state, not motion, so it keeps
* working either way.
*/
import type { ScrollAnimateOptions } from 'svg-scroll-draw/svelte';
export interface FeatureStep {
title: string;
body: string;
}
export interface StickyFeatureProps {
eyebrow?: string;
heading?: string;
intro?: string;
steps?: FeatureStep[];
className?: string;
}
const DEFAULT_STEPS: FeatureStep[] = [
{ title: 'Install', body: 'Add one package. It has no dependencies of its own, so nothing else comes with it.' },
{ title: 'Wrap', body: 'Put the component around the SVG or element you want to move.' },
{ title: 'Tune', body: 'Pick an easing and a trigger window. The defaults are a good start.' },
{ title: 'Ship', body: 'Server-rendered markup stays readable. The motion is added in the browser.' },
];
/**
* Each step card fades and rises as it enters. One object for the life of the
* component, so the action never sees new options and never rebuilds a card.
*/
const CARD_OPTIONS: ScrollAnimateOptions = {
props: { opacity: [0.3, 1], transform: ['translateY(24px)', 'translateY(0px)'] },
trigger: { start: 'top 85%', end: 'top 55%' },
easing: 'ease-out',
};
const pad = (n: number): string => String(n).padStart(2, '0');
</script>
<script lang="ts">
import { scrollAnimate } from 'svg-scroll-draw';
import { scrollAnimate as scrollAnimateAction } from 'svg-scroll-draw/svelte';
let {
eyebrow = 'How it works',
heading = 'Four steps from idea to motion',
intro = 'The panel stays put while the steps scroll by.',
steps = DEFAULT_STEPS,
className = '',
}: StickyFeatureProps = $props();
let active = $state(0);
let listEl = $state<HTMLOListElement>();
let railEl = $state<HTMLSpanElement>();
// Progress rail: scales from 0 to 1 between the first step reaching the middle
// of the viewport and the last step being fully on screen. The rail sits inside
// the sticky panel, so its own position never changes; `triggerElement`
// measures the scrolling list instead.
$effect(() => {
const list = listEl;
const rail = railEl;
if (!list || !rail) return;
const anim = scrollAnimate(rail, {
props: { transform: ['scaleY(0)', 'scaleY(1)'] },
triggerElement: list,
trigger: { start: 'top 60%', end: 'bottom 85%' },
easing: 'linear',
});
// destroy() puts back the rail's own inline style.
return () => anim.destroy();
});
// Keyed on the titles, not the count: the list items are keyed by title, so
// the same number of renamed steps (a locale switch) replaces every <li>, and
// an observer kept on the old ones froze the highlight. A string, so a new
// array with the same titles does not re-run the effect below.
const titles = $derived(steps.map((s) => s.title).join('\u0000'));
// Active step: the last step with any part in the top half of the viewport.
// A set of visible steps, rather than a thin band, so a fast scroll that jumps
// a step straight past the middle still lands on the right one.
$effect(() => {
// Read here so the effect re-runs, after the DOM update, when the titles do.
void titles;
const list = listEl;
if (!list || typeof IntersectionObserver === 'undefined') return;
const items = Array.from(list.querySelectorAll<HTMLElement>('[data-step]'));
const inView = new Set<number>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const index = Number((entry.target as HTMLElement).dataset.step);
if (entry.isIntersecting) inView.add(index);
else inView.delete(index);
}
if (inView.size) active = Math.max(...inView);
},
{ rootMargin: '0px 0px -50% 0px' },
);
items.forEach((item) => observer.observe(item));
return () => observer.disconnect();
});
</script>
<section class="mx-auto grid w-full max-w-5xl gap-10 md:grid-cols-2 md:gap-16 {className}">
<!-- Pinned panel -->
<div class="md:sticky md:top-24 md:self-start">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
<h2 class="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">
{heading}
</h2>
<p class="mt-3 text-neutral-600 dark:text-neutral-400">{intro}</p>
<div class="mt-8 hidden gap-5 md:flex">
<span class="relative w-1 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
<span bind:this={railEl} class="absolute inset-0 origin-top bg-[#ff90e8]" style="transform: scaleY(0)"></span>
</span>
<ol class="space-y-3">
{#each steps as step, i (step.title)}
<li
aria-current={i === active ? 'step' : undefined}
class="flex items-baseline gap-3 text-sm transition-colors duration-300 {i === active
? 'font-semibold text-neutral-950 dark:text-neutral-50'
: 'text-neutral-600 dark:text-neutral-400'}"
>
<span class="font-mono text-xs">{pad(i + 1)}</span>{step.title}
</li>
{/each}
</ol>
</div>
</div>
<!-- Scrolling steps -->
<ol bind:this={listEl} class="space-y-6 md:space-y-[30vh] md:py-[10vh]">
{#each steps as step, i (step.title)}
<li data-step={i}>
<div
use:scrollAnimateAction={CARD_OPTIONS}
class="rounded-2xl border border-neutral-200 bg-white p-6 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950"
>
<span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">Step {pad(i + 1)}</span>
<h3 class="mt-2 text-xl font-semibold text-neutral-950 dark:text-neutral-50">{step.title}</h3>
<p class="mt-2 leading-relaxed text-neutral-600 dark:text-neutral-400">{step.body}</p>
</div>
</li>
{/each}
</ol>
</section>
05blockheadline-text-reveal
Headline Text Reveal
A hero headline whose words rise into place one after another on scroll.
'use client';
/**
* headline-text-reveal — a hero headline whose words rise into place one after
* another as it scrolls into view, followed by the supporting line.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: react, svg-scroll-draw. Styling: Tailwind CSS.
*
* The text stays one string in the server HTML, so search engines and screen
* readers get the full sentence. Reduced motion is handled by the library: the
* words are shown in place, with no animation.
*
* At the very top of a page the headline is already inside the trigger window
* on load, so it starts part-way revealed. For a reveal that begins as soon as
* the reader scrolls, pass `trigger={{ start: 'top bottom', end: 'top 20%' }}`.
*/
import { ScrollText } from 'svg-scroll-draw/react';
export interface HeadlineTextRevealProps {
eyebrow?: string;
/** Plain text only: the words are split at runtime. */
headline?: string;
/** Plain text only. */
subline?: string;
/** Heading level for the headline. Use 'h1' when this is the page's hero. */
as?: 'h1' | 'h2' | 'h3';
/** Scroll window for the headline, e.g. { start: 'top 90%', end: 'top 30%' }. */
trigger?: { start?: string; end?: string };
className?: string;
}
export function HeadlineTextReveal({
eyebrow = 'Single-origin coffee',
headline = 'Coffee from three farms, roasted the week you drink it',
subline = 'Words arrive in reading order, at the pace the reader sets with their own scroll.',
as = 'h2',
trigger = { start: 'top 90%', end: 'top 35%' },
className = '',
}: HeadlineTextRevealProps) {
return (
<section className={`mx-auto w-full max-w-4xl text-center ${className}`}>
<p className="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
{/* `key` remounts the split if the text changes: ScrollText reads its
string once, on mount, and splits it into word spans. */}
<ScrollText
key={headline}
as={as}
split="words"
stagger={0.08}
from={{ opacity: 0, y: 32 }}
easing="ease-out"
once
trigger={trigger}
className="mt-4 text-[clamp(2.25rem,6vw,4.5rem)] font-medium leading-[0.95] tracking-tight text-neutral-950 text-balance dark:text-neutral-50"
>
{headline}
</ScrollText>
<ScrollText
key={subline}
as="p"
split="words"
stagger={0.03}
from={{ opacity: 0, y: 12 }}
easing="ease-out"
once
trigger={{ start: 'top 92%', end: 'top 55%' }}
className="mx-auto mt-6 max-w-2xl text-lg text-neutral-600 dark:text-neutral-400"
>
{subline}
</ScrollText>
</section>
);
}
export default HeadlineTextReveal;
<script setup lang="ts">
/**
* headline-text-reveal — a hero headline whose words rise into place one after
* another as it scrolls into view, followed by the supporting line.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: vue 3, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: eyebrow, headline, subline, as, trigger, className (a plain `class`
* lands on the <section> too).
*
* The text stays one string in the server HTML, so search engines and screen
* readers get the full sentence. Reduced motion is handled by the library: the
* words are shown in place, with no animation.
*
* At the very top of a page the headline is already inside the trigger window
* on load, so it starts part-way revealed. For a reveal that begins as soon as
* the reader scrolls, pass :trigger="{ start: 'top bottom', end: 'top 20%' }".
*/
import { computed } from 'vue';
import { ScrollText, type ScrollTextOptions } from 'svg-scroll-draw/vue';
export interface HeadlineTextRevealProps {
eyebrow?: string;
/** Plain text only: the words are split at runtime. */
headline?: string;
/** Plain text only. */
subline?: string;
/** Heading level for the headline. Use 'h1' when this is the page's hero. */
as?: 'h1' | 'h2' | 'h3';
/** Scroll window for the headline, e.g. { start: 'top 90%', end: 'top 30%' }. */
trigger?: { start?: string; end?: string };
className?: string;
}
const props = withDefaults(defineProps<HeadlineTextRevealProps>(), {
eyebrow: 'Single-origin coffee',
headline: 'Coffee from three farms, roasted the week you drink it',
subline: 'Words arrive in reading order, at the pace the reader sets with their own scroll.',
as: 'h2',
trigger: () => ({ start: 'top 90%', end: 'top 35%' }),
className: '',
});
// ScrollText rebuilds its engine when these options change (here: `trigger`),
// and leaves it alone when an equal object is passed again.
const headlineOptions = computed<ScrollTextOptions>(() => ({
split: 'words',
stagger: 0.08,
from: { opacity: 0, y: 32 },
easing: 'ease-out',
once: true,
trigger: props.trigger,
}));
const sublineOptions: ScrollTextOptions = {
split: 'words',
stagger: 0.03,
from: { opacity: 0, y: 12 },
easing: 'ease-out',
once: true,
trigger: { start: 'top 92%', end: 'top 55%' },
};
// The `:key` on each ScrollText below remounts the split if its text changes
// (and the headline's heading level): ScrollText reads its string once, on
// mount, and splits it into word spans.
</script>
<template>
<section :class="['mx-auto w-full max-w-4xl text-center', className]">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{{ eyebrow }}
</p>
<ScrollText
:key="`${as}|${headline}`"
:tag="as"
:options="headlineOptions"
class="mt-4 text-[clamp(2.25rem,6vw,4.5rem)] font-medium leading-[0.95] tracking-tight text-neutral-950 text-balance dark:text-neutral-50"
>
{{ headline }}
</ScrollText>
<ScrollText
:key="subline"
tag="p"
:options="sublineOptions"
class="mx-auto mt-6 max-w-2xl text-lg text-neutral-600 dark:text-neutral-400"
>
{{ subline }}
</ScrollText>
</section>
</template>
<script lang="ts" module>
/**
* headline-text-reveal — a hero headline whose words rise into place one after
* another as it scrolls into view, followed by the supporting line.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5, svg-scroll-draw. Styling: Tailwind CSS.
*
* Props: eyebrow, headline, subline, as, trigger, className (for the
* <section>: a Svelte component does not pass a plain `class` on).
*
* The text stays one string in the server HTML, so search engines and screen
* readers get the full sentence. Reduced motion is handled by the library: the
* words are shown in place, with no animation.
*
* At the very top of a page the headline is already inside the trigger window
* on load, so it starts part-way revealed. For a reveal that begins as soon as
* the reader scrolls, pass trigger={{ start: 'top bottom', end: 'top 20%' }}.
*/
import type { ScrollTextOptions } from 'svg-scroll-draw/svelte';
export interface HeadlineTextRevealProps {
eyebrow?: string;
/** Plain text only: the words are split at runtime. */
headline?: string;
/** Plain text only. */
subline?: string;
/** Heading level for the headline. Use 'h1' when this is the page's hero. */
as?: 'h1' | 'h2' | 'h3';
/** Scroll window for the headline, e.g. { start: 'top 90%', end: 'top 30%' }. */
trigger?: { start?: string; end?: string };
className?: string;
}
const SUBLINE_OPTIONS: ScrollTextOptions = {
split: 'words',
stagger: 0.03,
from: { opacity: 0, y: 12 },
easing: 'ease-out',
once: true,
trigger: { start: 'top 92%', end: 'top 55%' },
};
</script>
<script lang="ts">
import { scrollTextAction } from 'svg-scroll-draw/svelte';
let {
eyebrow = 'Single-origin coffee',
headline = 'Coffee from three farms, roasted the week you drink it',
subline = 'Words arrive in reading order, at the pace the reader sets with their own scroll.',
as = 'h2',
trigger = { start: 'top 90%', end: 'top 35%' },
className = '',
}: HeadlineTextRevealProps = $props();
// The action rebuilds its engine when these options change (here: `trigger`),
// and leaves it alone when an equal object is passed again.
const headlineOptions: ScrollTextOptions = $derived({
split: 'words',
stagger: 0.08,
from: { opacity: 0, y: 32 },
easing: 'ease-out',
once: true,
trigger,
});
</script>
<section class="mx-auto w-full max-w-4xl text-center {className}">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
<!-- {#key} remounts the split if the text changes (and the headline's heading
level): the action reads its string once, on mount, and splits it into
word spans that Svelte does not know about. -->
{#key `${as}|${headline}`}
<svelte:element
this={as}
use:scrollTextAction={headlineOptions}
class="mt-4 text-[clamp(2.25rem,6vw,4.5rem)] font-medium leading-[0.95] tracking-tight text-neutral-950 text-balance dark:text-neutral-50"
>
{headline}
</svelte:element>
{/key}
{#key subline}
<p
use:scrollTextAction={SUBLINE_OPTIONS}
class="mx-auto mt-6 max-w-2xl text-lg text-neutral-600 dark:text-neutral-400"
>
{subline}
</p>
{/key}
</section>
06blockpinned-feature-tour
Pinned Feature Tour
A section pinned under your header while three features take turns, each with a line drawing that inks itself as the pin moves on.
'use client';
/**
* pinned-feature-tour — a section that pins just below your sticky header while
* its features take turns. Each feature has a line drawing that inks itself in
* as the reader scrolls through that feature's share of the pin, and its copy
* steps forward in the list beside it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: react, svg-scroll-draw. Styling: Tailwind CSS.
*
* Uses `scrollPin` (svg-scroll-draw/pin) to hold the stage still and
* `scrollDraw` (svg-scroll-draw) for the drawings. The drawings follow the
* pin's `onProgress` through `seek()`, not their own place on the page. Unlike
* sticky-feature, nothing scrolls past: the whole stage stays put for
* (features − 1) screens of scroll and the features change in place.
*
* Props: `eyebrow` and `heading` for the intro above the tour; `features`, each
* a title, a body and the path `d` strings of its drawing in a 200 × 160 box;
* `headerOffset`, the height in px of your sticky header (0 if you have none);
* `className` for the outer section.
*
* While pinned the stage is `position: fixed`. An ancestor with a transform,
* filter, perspective or `contain: paint` makes fixed relative to itself, so
* keep the block out of one. The page must scroll on the window. The pin's
* length is measured once, on mount, so a phone's URL bar showing and hiding
* never changes it; after a rotation the tour keeps its first length.
*
* Reduced motion: the library shows every drawing finished and `seek()` does
* nothing; this file turns off the cross-fades. The pin and the switching keep
* working, because pinning holds the content still rather than moving it.
*/
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { scrollDraw } from 'svg-scroll-draw';
import { scrollPin } from 'svg-scroll-draw/pin';
export interface TourFeature {
title: string;
body: string;
/** Path `d` strings in a 200 × 160 box, drawn together as the reader scrolls through this feature. */
art: string[];
}
export interface PinnedFeatureTourProps {
eyebrow?: string;
heading?: string;
features?: TourFeature[];
/** Height in px of your sticky header: the tour pins just below it. 0 if you have none. */
headerOffset?: number;
className?: string;
}
const DEFAULT_FEATURES: TourFeature[] = [
{
title: 'From three farms',
body: 'A washed lot from Ethiopia, a honey lot from Costa Rica and a natural from Colombia, bought direct from the people who grow them.',
// Two hills, a row of coffee bushes and the sun.
art: [
'M 8 112 C 44 84 78 88 104 98 S 160 80 192 92',
'M 8 138 C 58 116 122 132 192 120',
'M 30 104 q 7 -11 14 0 q 7 -11 14 0 q 7 -11 14 0 q 7 -11 14 0',
'M 150 26 A 16 16 0 1 1 150 58 A 16 16 0 1 1 150 26',
],
},
{
title: 'Roasted on Tuesday',
body: 'Small batches every Tuesday morning, so the bag you open was roasted days ago, not months.',
// A drum roaster on its legs, with its chimney and the flame under it.
art: [
'M 64 46 H 136 A 32 32 0 0 1 136 110 H 64 A 32 32 0 0 1 64 46 Z',
'M 100 62 A 16 16 0 1 1 100 94 A 16 16 0 1 1 100 62',
'M 120 46 V 24 H 132 V 46',
'M 70 110 V 128 M 130 110 V 128',
'M 100 152 C 88 142 92 132 100 124 C 108 132 112 142 100 152 Z',
],
},
{
title: 'At your door in two days',
body: 'Packed the morning after roasting and shipped free on orders over $40, in bags you can compost.',
// A parcel, its tape and two speed lines.
art: [
'M 50 62 L 100 42 L 150 62 L 150 118 L 100 138 L 50 118 Z',
'M 50 62 L 100 82 L 150 62 M 100 82 V 138',
'M 76 52 L 126 72',
'M 18 96 H 38 M 24 110 H 40',
],
},
];
/*
* Where three features would not fit on the pinned stage (phones, and screens
* too short for the list) only the current one is shown. While pinned, whatever
* does not fit can never be scrolled to. The others stay in the page for screen
* readers: sr-only, not display: none.
*/
const COMPACT_HIDE = 'max-md:sr-only [@media(max-height:640px)]:sr-only';
const COMPACT_SHOW = 'hidden max-md:inline [@media(max-height:640px)]:inline';
const pad = (n: number) => String(n).padStart(2, '0');
export function PinnedFeatureTour({
eyebrow = 'How it works',
heading = 'From the farm to your door in a week',
features = DEFAULT_FEATURES,
headerOffset = 64,
className = '',
}: PinnedFeatureTourProps) {
const stageRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0);
const current = Math.min(active, features.length - 1);
// Rebuild the drawings and the pin when the art changes, not on every new array.
const artKey = features.map((f) => f.art.join(' ')).join('\n');
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const arts = Array.from(stage.querySelectorAll<SVGSVGElement>('[data-tour-art]'));
const n = arts.length;
if (!n) return;
// One drawing per feature, moved by the pin rather than by its own position.
// Each starts empty: the first one draws once the stage has pinned.
const drawings = arts.map((svg) => scrollDraw(svg));
drawings.forEach((d) => d.seek(0));
const pin = scrollPin(stage, {
top: headerOffset,
// n − 1 screens of scroll: three features get two-thirds of a screen each.
// One feature still gets one screen: a pin of 0 has no progress to report.
// vh is CSS's vh (svg-scroll-draw 2.20.0+): a rotation re-measures it,
// the URL bar showing and hiding does not, so the tour never stretches
// under the reader's thumb.
pinDistance: `${Math.max(1, n - 1) * 100}vh`,
// 0 where the pin starts, 1 where it ends. Cut into n equal shares: the
// feature whose share the reader is in is current; earlier drawings are
// finished, later ones empty, the current one as far as its share.
onProgress: (p) => {
const i = Math.min(n - 1, Math.floor(p * n));
setActive(i);
drawings.forEach((d, j) => d.seek(j < i ? 1 : j > i ? 0 : p * n - i));
},
});
// StrictMode mounts effects twice in development: destroy() puts the stage
// back where it was and restores its styles, so the second run starts clean.
return () => {
pin.destroy();
drawings.forEach((d) => d.destroy());
};
}, [artKey, headerOffset]);
return (
<section className={`mx-auto w-full max-w-5xl ${className}`}>
<p className="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
<h2 className="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">
{heading}
</h2>
{/* scrollPin moves the stage into a wrapper div of its own. So the stage is
not this component's root (React removes its root from where it put it),
and it is the only child of this div, so React never has to insert a
node beside it. Style the stage by its own classes, never by its parent. */}
<div className="mt-10">
{/* One screen below the header, from the same number the pin uses.
100svh where supported, so the phone's toolbars never cover the bottom. */}
<div
ref={stageRef}
style={{ '--tour-header': `${headerOffset}px` } as CSSProperties}
className="box-border h-[calc(100vh_-_var(--tour-header))] py-4 supports-[height:100svh]:h-[calc(100svh_-_var(--tour-header))] md:py-6"
>
<div className="grid h-full grid-cols-1 content-center items-center gap-6 md:grid-cols-2 md:grid-rows-[minmax(0,1fr)] md:gap-12">
{/* The drawings, stacked: only the current one is shown. Keyed by
position, not title: the effect holds these nodes and only rebuilds
when the art changes, so a renamed feature must keep its svg. */}
<div className="relative mx-auto aspect-[5/4] w-full max-w-[280px] rounded-3xl border border-neutral-200 bg-white text-neutral-900 md:max-h-full md:max-w-none dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100">
{features.map((f, i) => (
<svg
key={i}
data-tour-art=""
data-on={i === current || undefined}
viewBox="0 0 200 160"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="absolute left-[8%] top-[8%] h-[84%] w-[84%] overflow-visible opacity-0 transition-opacity duration-300 motion-reduce:transition-none data-[on]:opacity-100"
>
{f.art.map((d, j) => (
<path key={j} d={d} />
))}
</svg>
))}
</div>
<ol className="grid gap-7">
{features.map((f, i) => (
<li
key={f.title}
aria-current={i === current ? 'step' : undefined}
// Not the current feature: greyed by colour, not opacity, so every feature keeps WCAG AA contrast (at 35% opacity the text measured about 2:1).
className={`transition-colors duration-300 motion-reduce:transition-none ${
i === current ? '' : COMPACT_HIDE
}`}
>
<span className="flex items-center gap-2 font-mono text-xs">
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full font-medium transition-colors duration-300 motion-reduce:transition-none ${i === current ? 'bg-[#ff90e8] text-neutral-950' : 'bg-neutral-200 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'}`}>
{pad(i + 1)}
</span>
{/* With one feature on screen, say how many there are. */}
<span aria-hidden="true" className={`${COMPACT_SHOW} text-neutral-600 dark:text-neutral-400`}>
of {pad(features.length)}
</span>
</span>
<h3 className={`mt-3 text-2xl font-semibold leading-tight ${i === current ? 'text-neutral-950 dark:text-neutral-50' : 'text-neutral-600 dark:text-neutral-400'}`}>
{f.title}
</h3>
<p className={`mt-1.5 max-w-[36ch] leading-relaxed ${i === current ? 'text-neutral-600 dark:text-neutral-300' : 'text-neutral-600 dark:text-neutral-400'}`}>{f.body}</p>
</li>
))}
</ol>
</div>
</div>
</div>
</section>
);
}
export default PinnedFeatureTour;
<script lang="ts">
/**
* pinned-feature-tour — a section that pins just below your sticky header while
* its features take turns. Each feature has a line drawing that inks itself in
* as the reader scrolls through that feature's share of the pin, and its copy
* steps forward in the list beside it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: vue 3, svg-scroll-draw. Styling: Tailwind CSS.
*
* Uses `scrollPin` (svg-scroll-draw/pin) to hold the stage still and
* `scrollDraw` (svg-scroll-draw) for the drawings. The drawings follow the
* pin's `onProgress` through `seek()`, not their own place on the page. Unlike
* sticky-feature, nothing scrolls past: the whole stage stays put for
* (features − 1) screens of scroll and the features change in place.
*
* Props: `eyebrow` and `heading` for the intro above the tour; `features`, each
* a title, a body and the path `d` strings of its drawing in a 200 × 160 box;
* `headerOffset`, the height in px of your sticky header (0 if you have none);
* `className` for the outer section (a plain `class` lands there too).
*
* While pinned the stage is `position: fixed`. An ancestor with a transform,
* filter, perspective or `contain: paint` makes fixed relative to itself, so
* keep the block out of one. The page must scroll on the window. The pin's
* length is measured once, on mount, so a phone's URL bar showing and hiding
* never changes it; after a rotation the tour keeps its first length.
*
* Reduced motion: the library shows every drawing finished and `seek()` does
* nothing; this file turns off the cross-fades. The pin and the switching keep
* working, because pinning holds the content still rather than moving it.
*/
export interface TourFeature {
title: string;
body: string;
/** Path `d` strings in a 200 × 160 box, drawn together as the reader scrolls through this feature. */
art: string[];
}
export interface PinnedFeatureTourProps {
eyebrow?: string;
heading?: string;
features?: TourFeature[];
/** Height in px of your sticky header: the tour pins just below it. 0 if you have none. */
headerOffset?: number;
className?: string;
}
// In a plain <script> so the props' defaults can use it: defineProps() is
// hoisted out of setup and cannot see what <script setup> declares.
const DEFAULT_FEATURES: TourFeature[] = [
{
title: 'From three farms',
body: 'A washed lot from Ethiopia, a honey lot from Costa Rica and a natural from Colombia, bought direct from the people who grow them.',
// Two hills, a row of coffee bushes and the sun.
art: [
'M 8 112 C 44 84 78 88 104 98 S 160 80 192 92',
'M 8 138 C 58 116 122 132 192 120',
'M 30 104 q 7 -11 14 0 q 7 -11 14 0 q 7 -11 14 0 q 7 -11 14 0',
'M 150 26 A 16 16 0 1 1 150 58 A 16 16 0 1 1 150 26',
],
},
{
title: 'Roasted on Tuesday',
body: 'Small batches every Tuesday morning, so the bag you open was roasted days ago, not months.',
// A drum roaster on its legs, with its chimney and the flame under it.
art: [
'M 64 46 H 136 A 32 32 0 0 1 136 110 H 64 A 32 32 0 0 1 64 46 Z',
'M 100 62 A 16 16 0 1 1 100 94 A 16 16 0 1 1 100 62',
'M 120 46 V 24 H 132 V 46',
'M 70 110 V 128 M 130 110 V 128',
'M 100 152 C 88 142 92 132 100 124 C 108 132 112 142 100 152 Z',
],
},
{
title: 'At your door in two days',
body: 'Packed the morning after roasting and shipped free on orders over $40, in bags you can compost.',
// A parcel, its tape and two speed lines.
art: [
'M 50 62 L 100 42 L 150 62 L 150 118 L 100 138 L 50 118 Z',
'M 50 62 L 100 82 L 150 62 M 100 82 V 138',
'M 76 52 L 126 72',
'M 18 96 H 38 M 24 110 H 40',
],
},
];
/*
* Where three features would not fit on the pinned stage (phones, and screens
* too short for the list) only the current one is shown. While pinned, whatever
* does not fit can never be scrolled to. The others stay in the page for screen
* readers: sr-only, not display: none.
*/
const COMPACT_HIDE = 'max-md:sr-only [@media(max-height:640px)]:sr-only';
const COMPACT_SHOW = 'hidden max-md:inline [@media(max-height:640px)]:inline';
const pad = (n: number) => String(n).padStart(2, '0');
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { scrollDraw } from 'svg-scroll-draw';
import { scrollPin } from 'svg-scroll-draw/pin';
const props = withDefaults(defineProps<PinnedFeatureTourProps>(), {
eyebrow: 'How it works',
heading: 'From the farm to your door in a week',
features: () => DEFAULT_FEATURES,
headerOffset: 64,
className: '',
});
const stageRef = ref<HTMLDivElement | null>(null);
const active = ref(0);
const current = computed(() => Math.min(active.value, props.features.length - 1));
// Rebuild the drawings and the pin when the art changes, not on every new array.
const artKey = computed(() => props.features.map((f) => f.art.join(' ')).join('\n'));
let teardown: (() => void) | null = null;
function stop() {
teardown?.();
teardown = null;
}
function start() {
// destroy() puts the stage back where it was and restores its styles, so the
// new pin measures the stage as the template laid it out.
stop();
const stage = stageRef.value;
if (!stage) return;
const arts = Array.from(stage.querySelectorAll<SVGSVGElement>('[data-tour-art]'));
const n = arts.length;
if (!n) return;
// One drawing per feature, moved by the pin rather than by its own position.
// Each starts empty: the first one draws once the stage has pinned.
const drawings = arts.map((svg) => scrollDraw(svg));
drawings.forEach((d) => d.seek(0));
const pin = scrollPin(stage, {
top: props.headerOffset,
// n − 1 screens of scroll: three features get two-thirds of a screen each.
// One feature still gets one screen: a pin of 0 has no progress to report.
// vh is CSS's vh (svg-scroll-draw 2.20.0+): a rotation re-measures it,
// the URL bar showing and hiding does not, so the tour never stretches
// under the reader's thumb.
pinDistance: `${Math.max(1, n - 1) * 100}vh`,
// 0 where the pin starts, 1 where it ends. Cut into n equal shares: the
// feature whose share the reader is in is current; earlier drawings are
// finished, later ones empty, the current one as far as its share.
onProgress: (p) => {
const i = Math.min(n - 1, Math.floor(p * n));
active.value = i;
drawings.forEach((d, j) => d.seek(j < i ? 1 : j > i ? 0 : p * n - i));
},
});
teardown = () => {
pin.destroy();
drawings.forEach((d) => d.destroy());
};
}
onMounted(start);
// flush: 'post' runs the rebuild after Vue has patched the DOM, so a new feature
// count is already in the stage and a new headerOffset already sizes it.
watch([artKey, () => props.headerOffset], start, { flush: 'post' });
onBeforeUnmount(stop);
</script>
<template>
<section :class="['mx-auto w-full max-w-5xl', className]">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">{{ eyebrow }}</p>
<h2 class="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">{{ heading }}</h2>
<!-- scrollPin moves the stage into a wrapper div of its own. So the stage is
not this component's root (Vue places a root's siblings by where it put
it), and it is the only child of this div, so Vue never has to insert a
node beside it. Style the stage by its own classes, never by its parent.
Its height is one screen below the header, from the same number the pin
uses: 100svh where supported, so the phone's toolbars never cover the
bottom. The style is an object, so Vue sets only that property and leaves
the pin's own inline styles alone. -->
<div class="mt-10">
<div
ref="stageRef"
:style="{ '--tour-header': `${headerOffset}px` }"
class="box-border h-[calc(100vh_-_var(--tour-header))] py-4 supports-[height:100svh]:h-[calc(100svh_-_var(--tour-header))] md:py-6"
>
<div class="grid h-full grid-cols-1 content-center items-center gap-6 md:grid-cols-2 md:grid-rows-[minmax(0,1fr)] md:gap-12">
<!-- The drawings, stacked: only the current one is shown. Keyed by
position, not title: the watcher holds these nodes and only rebuilds
when the art changes, so a renamed feature must keep its svg. -->
<div class="relative mx-auto aspect-[5/4] w-full max-w-[280px] rounded-3xl border border-neutral-200 bg-white text-neutral-900 md:max-h-full md:max-w-none dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100">
<svg
v-for="(f, i) in features"
:key="i"
data-tour-art=""
:data-on="i === current || undefined"
viewBox="0 0 200 160"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="absolute left-[8%] top-[8%] h-[84%] w-[84%] overflow-visible opacity-0 transition-opacity duration-300 motion-reduce:transition-none data-[on]:opacity-100"
>
<path v-for="(d, j) in f.art" :key="j" :d="d" />
</svg>
</div>
<ol class="grid gap-7">
<li
v-for="(f, i) in features"
:key="f.title"
:aria-current="i === current ? 'step' : undefined"
:class="['transition-colors duration-300 motion-reduce:transition-none', i === current ? '' : COMPACT_HIDE]"
>
<!-- Not the current feature: greyed by colour, not opacity, so every feature keeps WCAG AA contrast (at 35% opacity the text measured about 2:1). -->
<span class="flex items-center gap-2 font-mono text-xs">
<span :class="['inline-flex h-7 w-7 items-center justify-center rounded-full font-medium transition-colors duration-300 motion-reduce:transition-none', i === current ? 'bg-[#ff90e8] text-neutral-950' : 'bg-neutral-200 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300']">{{ pad(i + 1) }}</span>
<!-- With one feature on screen, say how many there are. -->
<span aria-hidden="true" :class="[COMPACT_SHOW, 'text-neutral-600 dark:text-neutral-400']">of {{ pad(features.length) }}</span>
</span>
<h3 :class="['mt-3 text-2xl font-semibold leading-tight', i === current ? 'text-neutral-950 dark:text-neutral-50' : 'text-neutral-600 dark:text-neutral-400']">{{ f.title }}</h3>
<p :class="['mt-1.5 max-w-[36ch] leading-relaxed', i === current ? 'text-neutral-600 dark:text-neutral-300' : 'text-neutral-600 dark:text-neutral-400']">{{ f.body }}</p>
</li>
</ol>
</div>
</div>
</div>
</section>
</template>
<script lang="ts" module>
/**
* pinned-feature-tour — a section that pins just below your sticky header while
* its features take turns. Each feature has a line drawing that inks itself in
* as the reader scrolls through that feature's share of the pin, and its copy
* steps forward in the list beside it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5, svg-scroll-draw. Styling: Tailwind CSS.
*
* Uses `scrollPin` (svg-scroll-draw/pin) to hold the stage still and
* `scrollDraw` (svg-scroll-draw) for the drawings. The drawings follow the
* pin's `onProgress` through `seek()`, not their own place on the page. Unlike
* sticky-feature, nothing scrolls past: the whole stage stays put for
* (features − 1) screens of scroll and the features change in place.
*
* Props: `eyebrow` and `heading` for the intro above the tour; `features`, each
* a title, a body and the path `d` strings of its drawing in a 200 × 160 box;
* `headerOffset`, the height in px of your sticky header (0 if you have none);
* `className` for the outer section (a Svelte component does not pass a plain
* `class` on).
*
* While pinned the stage is `position: fixed`. An ancestor with a transform,
* filter, perspective or `contain: paint` makes fixed relative to itself, so
* keep the block out of one. The page must scroll on the window. The pin's
* length is measured once, on mount, so a phone's URL bar showing and hiding
* never changes it; after a rotation the tour keeps its first length.
*
* Reduced motion: the library shows every drawing finished and `seek()` does
* nothing; this file turns off the cross-fades. The pin and the switching keep
* working, because pinning holds the content still rather than moving it.
*/
export interface TourFeature {
title: string;
body: string;
/** Path `d` strings in a 200 × 160 box, drawn together as the reader scrolls through this feature. */
art: string[];
}
export interface PinnedFeatureTourProps {
eyebrow?: string;
heading?: string;
features?: TourFeature[];
/** Height in px of your sticky header: the tour pins just below it. 0 if you have none. */
headerOffset?: number;
className?: string;
}
const DEFAULT_FEATURES: TourFeature[] = [
{
title: 'From three farms',
body: 'A washed lot from Ethiopia, a honey lot from Costa Rica and a natural from Colombia, bought direct from the people who grow them.',
// Two hills, a row of coffee bushes and the sun.
art: [
'M 8 112 C 44 84 78 88 104 98 S 160 80 192 92',
'M 8 138 C 58 116 122 132 192 120',
'M 30 104 q 7 -11 14 0 q 7 -11 14 0 q 7 -11 14 0 q 7 -11 14 0',
'M 150 26 A 16 16 0 1 1 150 58 A 16 16 0 1 1 150 26',
],
},
{
title: 'Roasted on Tuesday',
body: 'Small batches every Tuesday morning, so the bag you open was roasted days ago, not months.',
// A drum roaster on its legs, with its chimney and the flame under it.
art: [
'M 64 46 H 136 A 32 32 0 0 1 136 110 H 64 A 32 32 0 0 1 64 46 Z',
'M 100 62 A 16 16 0 1 1 100 94 A 16 16 0 1 1 100 62',
'M 120 46 V 24 H 132 V 46',
'M 70 110 V 128 M 130 110 V 128',
'M 100 152 C 88 142 92 132 100 124 C 108 132 112 142 100 152 Z',
],
},
{
title: 'At your door in two days',
body: 'Packed the morning after roasting and shipped free on orders over $40, in bags you can compost.',
// A parcel, its tape and two speed lines.
art: [
'M 50 62 L 100 42 L 150 62 L 150 118 L 100 138 L 50 118 Z',
'M 50 62 L 100 82 L 150 62 M 100 82 V 138',
'M 76 52 L 126 72',
'M 18 96 H 38 M 24 110 H 40',
],
},
];
/*
* Where three features would not fit on the pinned stage (phones, and screens
* too short for the list) only the current one is shown. While pinned, whatever
* does not fit can never be scrolled to. The others stay in the page for screen
* readers: sr-only, not display: none.
*/
const COMPACT_HIDE = 'max-md:sr-only [@media(max-height:640px)]:sr-only';
const COMPACT_SHOW = 'hidden max-md:inline [@media(max-height:640px)]:inline';
const pad = (n: number) => String(n).padStart(2, '0');
</script>
<script lang="ts">
import { scrollDraw } from 'svg-scroll-draw';
import { scrollPin } from 'svg-scroll-draw/pin';
let {
eyebrow = 'How it works',
heading = 'From the farm to your door in a week',
features = DEFAULT_FEATURES,
headerOffset = 64,
className = '',
}: PinnedFeatureTourProps = $props();
let stageEl = $state<HTMLDivElement>();
let active = $state(0);
const current = $derived(Math.min(active, features.length - 1));
// Rebuild the drawings and the pin when the art changes, not on every new array:
// a string, so an equal one does not re-run the effect below.
const artKey = $derived(features.map((f) => f.art.join(' ')).join('\n'));
// Runs after Svelte has updated the DOM, so a new feature count is already in
// the stage and a new headerOffset already sizes it.
$effect(() => {
void artKey;
const top = headerOffset;
const stage = stageEl;
if (!stage) return;
const arts = Array.from(stage.querySelectorAll<SVGSVGElement>('[data-tour-art]'));
const n = arts.length;
if (!n) return;
// One drawing per feature, moved by the pin rather than by its own position.
// Each starts empty: the first one draws once the stage has pinned.
const drawings = arts.map((svg) => scrollDraw(svg));
drawings.forEach((d) => d.seek(0));
const pin = scrollPin(stage, {
top,
// n − 1 screens of scroll: three features get two-thirds of a screen each.
// One feature still gets one screen: a pin of 0 has no progress to report.
// vh is CSS's vh (svg-scroll-draw 2.20.0+): a rotation re-measures it,
// the URL bar showing and hiding does not, so the tour never stretches
// under the reader's thumb.
pinDistance: `${Math.max(1, n - 1) * 100}vh`,
// 0 where the pin starts, 1 where it ends. Cut into n equal shares: the
// feature whose share the reader is in is current; earlier drawings are
// finished, later ones empty, the current one as far as its share.
onProgress: (p) => {
const i = Math.min(n - 1, Math.floor(p * n));
active = i;
drawings.forEach((d, j) => d.seek(j < i ? 1 : j > i ? 0 : p * n - i));
},
});
// destroy() puts the stage back where it was and restores its styles, so the
// next run measures the stage as the template laid it out.
return () => {
pin.destroy();
drawings.forEach((d) => d.destroy());
};
});
</script>
<section class="mx-auto w-full max-w-5xl {className}">
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{eyebrow}
</p>
<h2 class="mt-3 text-3xl font-medium tracking-tight text-neutral-950 sm:text-4xl dark:text-neutral-50">
{heading}
</h2>
<!-- scrollPin moves the stage into a wrapper div of its own. So the stage is
not this component's root (Svelte removes a component's top-level nodes
from where it put them), and it is the only child of this div, so Svelte
never has to insert a node beside it. Style the stage by its own classes,
never by its parent. -->
<div class="mt-10">
<!-- One screen below the header, from the same number the pin uses.
100svh where supported, so the phone's toolbars never cover the bottom.
`style:` sets only that property, so the pin's own inline styles stay. -->
<div
bind:this={stageEl}
style:--tour-header="{headerOffset}px"
class="box-border h-[calc(100vh_-_var(--tour-header))] py-4 supports-[height:100svh]:h-[calc(100svh_-_var(--tour-header))] md:py-6"
>
<div class="grid h-full grid-cols-1 content-center items-center gap-6 md:grid-cols-2 md:grid-rows-[minmax(0,1fr)] md:gap-12">
<!-- The drawings, stacked: only the current one is shown. Keyed by
position, not title: the effect holds these nodes and only rebuilds
when the art changes, so a renamed feature must keep its svg. -->
<div class="relative mx-auto aspect-[5/4] w-full max-w-[280px] rounded-3xl border border-neutral-200 bg-white text-neutral-900 md:max-h-full md:max-w-none dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100">
{#each features as f, i (i)}
<svg
data-tour-art=""
data-on={i === current || undefined}
viewBox="0 0 200 160"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="absolute left-[8%] top-[8%] h-[84%] w-[84%] overflow-visible opacity-0 transition-opacity duration-300 motion-reduce:transition-none data-[on]:opacity-100"
>
{#each f.art as d, j (j)}
<path {d} />
{/each}
</svg>
{/each}
</div>
<ol class="grid gap-7">
{#each features as f, i (f.title)}
<li
aria-current={i === current ? 'step' : undefined}
class="transition-colors duration-300 motion-reduce:transition-none {i === current ? '' : COMPACT_HIDE}"
>
<!-- Not the current feature: greyed by colour, not opacity, so every feature keeps WCAG AA contrast (at 35% opacity the text measured about 2:1). -->
<span class="flex items-center gap-2 font-mono text-xs">
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full font-medium transition-colors duration-300 motion-reduce:transition-none {i === current ? 'bg-[#ff90e8] text-neutral-950' : 'bg-neutral-200 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'}">
{pad(i + 1)}
</span>
<!-- With one feature on screen, say how many there are. -->
<span aria-hidden="true" class="{COMPACT_SHOW} text-neutral-600 dark:text-neutral-400">
of {pad(features.length)}
</span>
</span>
<h3 class="mt-3 text-2xl font-semibold leading-tight {i === current ? 'text-neutral-950 dark:text-neutral-50' : 'text-neutral-600 dark:text-neutral-400'}">
{f.title}
</h3>
<p class="mt-1.5 max-w-[36ch] leading-relaxed {i === current ? 'text-neutral-600 dark:text-neutral-300' : 'text-neutral-600 dark:text-neutral-400'}">{f.body}</p>
</li>
{/each}
</ol>
</div>
</div>
</div>
</section>
07blockhorizontal-case-strip
Horizontal Case Strip
A row of case studies that scrolls sideways while the page scrolls down: a pixel of scroll moves it a pixel, with a count, a progress rail and keyboard focus that brings each card into view.
'use client';
/**
* horizontal-case-strip — a row of case-study cards that slides sideways as the
* reader scrolls down. The stage holds still while the row crosses it, and a
* count and a progress rail show how far along the reader is.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: react, svg-scroll-draw 2.18.1 or later (`scrollHorizontal` from
* `svg-scroll-draw/horizontal`, measured against the sticky stage).
* Styling: Tailwind CSS.
*
* Uses scrollHorizontal (svg-scroll-draw/horizontal) with no distance and no
* trigger: it finds the sticky stage, moves the row by its width minus the
* stage's, and measures again when the window, the page, the row or the section
* changes size. Its onProgress drives the count and the rail. Before 2.18.1 the
* row moved by its width minus the window's, so the stage's side padding left
* the end of the last case off screen.
*
* The section is the row's travel plus one screen tall, so a pixel of scroll
* moves the row a pixel at any width. The stage sticks with CSS
* `position: sticky`: keep the block out of any ancestor with
* `overflow: hidden`, and if a reset stops sideways scrolling on html or body,
* use `overflow-x: clip` there instead. Cards are sized against the stage, not
* the window, so the block works in a page container as well as full width.
*
* Tab reaches every case, including the ones still off to the right: the page
* scrolls to the point where the focused case is in view.
*
* Reduced motion: scrollHorizontal keeps scrubbing on purpose, because holding
* the row at its end would hide every case but the last. This block has a
* layout that needs no movement, so visitors who ask for reduced motion get the
* cases as a plain grid, with no sticky stage, and the block switches over if
* the setting changes while the page is open.
*
* Props: heading, cases (client, title, text, meta, href, and cover: line-art
* paths in a 200 × 150 box), linkLabel, className (on the outer section).
*/
import { useEffect, useId, useRef } from 'react';
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';
export interface CaseStudy {
/** Who the work was for. Shown before the title, and read after the link text. */
client: string;
title: string;
/** One or two sentences. */
text: string;
/** Discipline and year, e.g. 'Packaging · 2025'. */
meta: string;
href: string;
/** The cover's line drawing: SVG path `d` strings in a 200 × 150 box, stroked, not filled. */
cover: string[];
}
export interface HorizontalCaseStripProps {
heading?: string;
cases?: CaseStudy[];
/** Visible text of each case's link. Screen readers also hear the client's name. */
linkLabel?: string;
/** Classes for the outer section, e.g. a background colour. Not overflow-hidden: it unsticks the stage. */
className?: string;
}
export const DEFAULT_CASES: CaseStudy[] = [
{
client: 'Tidewater Ferries',
title: 'A timetable you can read from the pier',
text: 'Signs and a printed timetable for six crossings, set large enough to read with a bag in each hand.',
meta: 'Wayfinding · 2025',
href: '/work/tidewater-ferries',
cover: [
'M 40 92 L 160 92 L 148 108 L 52 108 Z',
'M 70 92 V 76 H 128 V 92',
'M 112 76 V 62 H 122 V 76',
'M 20 122 q 10 -8 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0',
],
},
{
client: 'Hollow Oak Bakery',
title: 'Boxes that survive the bike courier',
text: 'A fold-flat box for loaves and tarts, tested on cobbles, that the bakery folds in eleven seconds.',
meta: 'Packaging · 2025',
href: '/work/hollow-oak-bakery',
cover: [
'M 60 62 L 100 46 L 140 62 L 140 108 L 100 124 L 60 108 Z',
'M 60 62 L 100 78 L 140 62 M 100 78 V 124',
'M 82 54 Q 100 34 118 54',
],
},
{
client: 'Northfield Library',
title: 'Late returns, without the guilt',
text: 'Reminder letters and a returns page rewritten so that people come back instead of staying away.',
meta: 'Service design · 2024',
href: '/work/northfield-library',
cover: [
'M 58 50 V 112 H 74 V 50 Z',
'M 78 42 V 112 H 92 V 42 Z',
'M 100 56 L 113 52 L 130 110 L 117 114 Z',
'M 44 112 H 156',
],
},
{
client: 'Meridian Cycling Club',
title: 'Route maps that fold into a jersey pocket',
text: 'Twelve club routes on one sheet, with the climbs drawn to scale and the cafés where they matter.',
meta: 'Print · 2024',
href: '/work/meridian-cycling-club',
cover: [
'M 40 52 L 80 42 L 120 52 L 160 42 V 108 L 120 118 L 80 108 L 40 118 Z',
'M 80 42 V 108 M 120 52 V 118',
'M 52 100 C 70 80 90 96 104 76 S 138 72 148 58',
],
},
];
/** Custom property on the section that holds the row's travel, in px. */
const TRAVEL = '--case-strip-travel';
/** How far the row travels: its width minus the stage's content box. */
function travelOf(stage: HTMLElement, track: HTMLElement): number {
const cs = getComputedStyle(stage);
const seen = stage.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
return Math.max(0, track.scrollWidth - seen);
}
/** Focus from the keyboard or a screen reader, not from a pointer. */
function isKeyboardFocus(el: Element): boolean {
try {
return el.matches(':focus-visible');
} catch {
// A browser without :focus-visible: keep the keyboard path working.
return true;
}
}
/** Starts the strip: runway height, scrub, count, rail and keyboard. Returns its cleanup. */
function startStrip(
runway: HTMLElement,
stage: HTMLElement,
track: HTMLElement,
count: HTMLElement | null,
fill: HTMLElement | null,
): () => void {
const last = Math.max(0, track.children.length - 1);
// A pixel of scroll moves the row a pixel: the runway is the row's travel
// plus the one screen the stage stays stuck for. Set before scrollHorizontal,
// so its first measurement already sees the right runway; after that the
// observer keeps the height current, and the strip follows it on its own.
const fit = () => runway.style.setProperty(TRAVEL, `${travelOf(stage, track)}px`);
fit();
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(fit);
resize?.observe(track);
resize?.observe(stage);
// -1, not 0, so the first call always writes the count, even when a re-run
// finds another number left on screen.
let shown = -1;
const strip = scrollHorizontal(track, {
onProgress: (p) => {
if (fill) fill.style.transform = `scaleX(${p})`;
// The nearest case: with four, it ticks over at 1/6, 1/2 and 5/6.
const i = Math.round(p * last);
if (i !== shown && count) {
count.textContent = String(i + 1).padStart(2, '0');
shown = i;
}
},
});
// The stage cannot scroll (overflow: clip), so a case reached with Tab is
// brought into view by scrolling the page to it, on the next frame: the
// browser runs its own scroll to the focused link after focusin, and undoes
// one made inside the handler.
let frame = 0;
const onFocus = (e: FocusEvent) => {
const target = e.target as Element;
// Keyboard focus only. Chrome focuses a link on mousedown, and a row that
// moves before mouseup puts the click on the row instead of the link, so
// clicking the second case's link did nothing.
if (!isKeyboardFocus(target)) return;
const item = target.closest('li');
if (!item || item.parentElement !== track) return;
const travel = travelOf(stage, track);
// How far into the row the case starts. Both boxes carry the same
// translateX, so it cancels out.
const at = item.getBoundingClientRect().left - track.getBoundingClientRect().left;
const p = travel > 0 ? Math.min(1, at / travel) : 0;
const top = runway.getBoundingClientRect().top + window.scrollY + p * (runway.offsetHeight - window.innerHeight);
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => window.scrollTo({ top }));
};
track.addEventListener('focusin', onFocus);
return () => {
cancelAnimationFrame(frame);
track.removeEventListener('focusin', onFocus);
resize?.disconnect();
// Puts the row's own inline transform back.
strip.destroy();
runway.style.removeProperty(TRAVEL);
};
}
export function HorizontalCaseStrip({
heading = 'Selected work',
cases = DEFAULT_CASES,
linkLabel = 'Read the case study',
className = '',
}: HorizontalCaseStripProps) {
const headingId = useId();
const runwayRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLOListElement>(null);
const countRef = useRef<HTMLElement>(null);
const fillRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const runway = runwayRef.current;
const stage = stageRef.current;
const track = trackRef.current;
if (!runway || !stage || !track) return;
// No strip when reduced motion is asked for: the motion-safe: classes
// below already leave the cases as a grid. Follows the setting live.
const reduce = typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-reduced-motion: reduce)')
: null;
let stop: (() => void) | undefined;
const sync = () => {
stop?.();
stop = reduce?.matches
? undefined
: startStrip(runway, stage, track, countRef.current, fillRef.current);
};
sync();
reduce?.addEventListener('change', sync);
// StrictMode mounts effects twice in development: the cleanup restores the
// row's transform and the section's height, so the second run starts clean.
return () => {
reduce?.removeEventListener('change', sync);
stop?.();
};
// Re-run only when the number of cases changes: a new array with the same
// cases changes nothing the strip measured, and a resize is followed anyway.
}, [cases.length]);
const total = String(cases.length).padStart(2, '0');
return (
// The runway: the scroll room the stage sticks inside. Its height is the
// row's travel (set from the effect) plus one screen; svh where supported,
// so a phone's collapsing toolbar does not change it.
<section
ref={runwayRef}
aria-labelledby={headingId}
className={`text-neutral-950 dark:text-neutral-50 motion-safe:h-[calc(var(--case-strip-travel,200vh)_+_100vh)] motion-safe:supports-[height:100svh]:h-[calc(var(--case-strip-travel,200vh)_+_100svh)] ${className}`}
>
{/* The stage. overflow-clip, not overflow-hidden: a hidden box can still
be scrolled by focus, which shifts the whole row and leaves it
shifted. minmax(0,1fr) columns: an auto column would grow to the
row's full width and take the header with it. container-type lets the
cards size against the stage rather than the window. */}
<div
ref={stageRef}
className="box-border grid grid-cols-[minmax(0,1fr)] gap-[clamp(20px,4vh,40px)] px-[6vw] py-[clamp(24px,7vh,72px)] [container-type:inline-size] motion-safe:sticky motion-safe:top-0 motion-safe:h-screen motion-safe:supports-[height:100svh]:h-[100svh] motion-safe:grid-rows-[auto_minmax(0,1fr)] motion-safe:overflow-clip"
>
<header className="flex items-center gap-4">
<h2 id={headingId} className="text-[clamp(24px,3vw,36px)] font-semibold leading-[1.1] tracking-[-0.01em]">
{heading}
</h2>
{/* Both aria-hidden: the list already tells a screen reader how many
cases there are. */}
<p
aria-hidden="true"
className="ml-auto flex-none whitespace-nowrap font-mono text-[13px] font-medium leading-none tabular-nums motion-reduce:hidden"
>
<b ref={countRef}>01</b> / {total}
</p>
<span
aria-hidden="true"
className="relative h-0.5 flex-[0_0_min(24vw,200px)] bg-neutral-200 motion-reduce:hidden dark:bg-neutral-800"
>
<span ref={fillRef} className="absolute inset-0 origin-left bg-current [transform:scaleX(0)]" />
</span>
</header>
{/* The row: as wide as its cases (w-max), so it can be measured and
moved, and never narrower than the stage (min-w-full), so a row that
already fits is not pushed the wrong way. role="list": Safari drops
the list role from a list styled list-style: none, and the count
above is hidden on the promise that the list gives it. */}
<ol
ref={trackRef}
role="list"
className="m-0 grid list-none gap-6 p-0 sm:grid-cols-2 motion-safe:flex motion-safe:w-max motion-safe:min-w-full"
>
{cases.map((c, i) => (
// Narrower than the stage (cqw: the stage's width, wherever it is
// placed), so the next case shows at the edge and the reader can
// tell the row goes on.
<li
key={`${c.client}-${c.title}`}
className="flex min-h-0 flex-col motion-safe:w-[min(78cqw,520px)]"
>
{/* Fills the height the text leaves, down to nothing on a short screen. */}
<svg
viewBox="0 0 200 150"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="box-border aspect-[4/3] w-full rounded-2xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950 motion-safe:aspect-auto motion-safe:min-h-0 motion-safe:flex-[1_1_auto]"
>
{c.cover.map((d, j) => (
<path key={j} d={d} />
))}
</svg>
<p className="mt-4 font-mono text-xs font-semibold leading-none text-[#3056d3] dark:text-[#8aa4ff]">
{String(i + 1).padStart(2, '0')} · {c.client}
</p>
<h3 className="mb-1.5 mt-2 text-[clamp(19px,2vw,24px)] font-semibold leading-[1.2]">{c.title}</h3>
<p className="max-w-[44ch] text-[15px] leading-[1.55] text-neutral-700 dark:text-neutral-300">{c.text}</p>
<p className="mt-2 text-[13px] leading-[1.4] text-neutral-600 dark:text-neutral-400">{c.meta}</p>
<a
href={c.href}
className="mt-2.5 self-start text-[15px] font-medium leading-[1.4] underline underline-offset-[3px] focus-visible:outline-offset-[3px] focus-visible:[outline:2px_solid_#3056d3] dark:focus-visible:[outline-color:#8aa4ff]"
>
{linkLabel}
<span className="sr-only">: {c.client}</span>
</a>
</li>
))}
</ol>
</div>
</section>
);
}
export default HorizontalCaseStrip;
<script lang="ts">
/**
* horizontal-case-strip — a row of case-study cards that slides sideways as the
* reader scrolls down. The stage holds still while the row crosses it, and a
* count and a progress rail show how far along the reader is.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: vue 3.5 or later (useId), svg-scroll-draw 2.18.1 or later
* (`scrollHorizontal` from `svg-scroll-draw/horizontal`, measured against the
* sticky stage).
* Styling: Tailwind CSS.
*
* Uses scrollHorizontal (svg-scroll-draw/horizontal) with no distance and no
* trigger: it finds the sticky stage, moves the row by its width minus the
* stage's, and measures again when the window, the page, the row or the section
* changes size. Its onProgress drives the count and the rail. Before 2.18.1 the
* row moved by its width minus the window's, so the stage's side padding left
* the end of the last case off screen.
*
* The section is the row's travel plus one screen tall, so a pixel of scroll
* moves the row a pixel at any width. The stage sticks with CSS
* `position: sticky`: keep the block out of any ancestor with
* `overflow: hidden`, and if a reset stops sideways scrolling on html or body,
* use `overflow-x: clip` there instead. Cards are sized against the stage, not
* the window, so the block works in a page container as well as full width.
*
* Tab reaches every case, including the ones still off to the right: the page
* scrolls to the point where the focused case is in view.
*
* Reduced motion: scrollHorizontal keeps scrubbing on purpose, because holding
* the row at its end would hide every case but the last. This block has a
* layout that needs no movement, so visitors who ask for reduced motion get the
* cases as a plain grid, with no sticky stage, and the block switches over if
* the setting changes while the page is open.
*
* The strip starts in onMounted and nothing touches the DOM before it, so the
* block renders on the server too (Nuxt): the page arrives with the cases in
* place, and the browser starts the strip.
*
* Props: heading, cases (client, title, text, meta, href, and cover: line-art
* paths in a 200 × 150 box), linkLabel, className (on the outer section; a
* plain class attribute lands there too).
*/
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';
export interface CaseStudy {
/** Who the work was for. Shown before the title, and read after the link text. */
client: string;
title: string;
/** One or two sentences. */
text: string;
/** Discipline and year, e.g. 'Packaging · 2025'. */
meta: string;
href: string;
/** The cover's line drawing: SVG path `d` strings in a 200 × 150 box, stroked, not filled. */
cover: string[];
}
export interface HorizontalCaseStripProps {
heading?: string;
cases?: CaseStudy[];
/** Visible text of each case's link. Screen readers also hear the client's name. */
linkLabel?: string;
/** Classes for the outer section, e.g. a background colour. Not overflow-hidden: it unsticks the stage. */
className?: string;
}
// In a plain <script> so the props' defaults can use it: defineProps() is
// hoisted out of setup and cannot see what <script setup> declares.
const DEFAULT_CASES: CaseStudy[] = [
{
client: 'Tidewater Ferries',
title: 'A timetable you can read from the pier',
text: 'Signs and a printed timetable for six crossings, set large enough to read with a bag in each hand.',
meta: 'Wayfinding · 2025',
href: '/work/tidewater-ferries',
cover: [
'M 40 92 L 160 92 L 148 108 L 52 108 Z',
'M 70 92 V 76 H 128 V 92',
'M 112 76 V 62 H 122 V 76',
'M 20 122 q 10 -8 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0',
],
},
{
client: 'Hollow Oak Bakery',
title: 'Boxes that survive the bike courier',
text: 'A fold-flat box for loaves and tarts, tested on cobbles, that the bakery folds in eleven seconds.',
meta: 'Packaging · 2025',
href: '/work/hollow-oak-bakery',
cover: [
'M 60 62 L 100 46 L 140 62 L 140 108 L 100 124 L 60 108 Z',
'M 60 62 L 100 78 L 140 62 M 100 78 V 124',
'M 82 54 Q 100 34 118 54',
],
},
{
client: 'Northfield Library',
title: 'Late returns, without the guilt',
text: 'Reminder letters and a returns page rewritten so that people come back instead of staying away.',
meta: 'Service design · 2024',
href: '/work/northfield-library',
cover: [
'M 58 50 V 112 H 74 V 50 Z',
'M 78 42 V 112 H 92 V 42 Z',
'M 100 56 L 113 52 L 130 110 L 117 114 Z',
'M 44 112 H 156',
],
},
{
client: 'Meridian Cycling Club',
title: 'Route maps that fold into a jersey pocket',
text: 'Twelve club routes on one sheet, with the climbs drawn to scale and the cafés where they matter.',
meta: 'Print · 2024',
href: '/work/meridian-cycling-club',
cover: [
'M 40 52 L 80 42 L 120 52 L 160 42 V 108 L 120 118 L 80 108 L 40 118 Z',
'M 80 42 V 108 M 120 52 V 118',
'M 52 100 C 70 80 90 96 104 76 S 138 72 148 58',
],
},
];
/** Custom property on the section that holds the row's travel, in px. */
const TRAVEL = '--case-strip-travel';
/** How far the row travels: its width minus the stage's content box. */
function travelOf(stage: HTMLElement, track: HTMLElement): number {
const cs = getComputedStyle(stage);
const seen = stage.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
return Math.max(0, track.scrollWidth - seen);
}
/** Focus from the keyboard or a screen reader, not from a pointer. */
function isKeyboardFocus(el: Element): boolean {
try {
return el.matches(':focus-visible');
} catch {
// A browser without :focus-visible: keep the keyboard path working.
return true;
}
}
/** Starts the strip: runway height, scrub, count, rail and keyboard. Returns its cleanup. */
function startStrip(
runway: HTMLElement,
stage: HTMLElement,
track: HTMLElement,
count: HTMLElement | null,
fill: HTMLElement | null,
): () => void {
const last = Math.max(0, track.children.length - 1);
// A pixel of scroll moves the row a pixel: the runway is the row's travel
// plus the one screen the stage stays stuck for. Set before scrollHorizontal,
// so its first measurement already sees the right runway; after that the
// observer keeps the height current, and the strip follows it on its own.
const fit = () => runway.style.setProperty(TRAVEL, `${travelOf(stage, track)}px`);
fit();
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(fit);
resize?.observe(track);
resize?.observe(stage);
// -1, not 0, so the first call always writes the count, even when a re-run
// finds another number left on screen.
let shown = -1;
const strip = scrollHorizontal(track, {
onProgress: (p) => {
if (fill) fill.style.transform = `scaleX(${p})`;
// The nearest case: with four, it ticks over at 1/6, 1/2 and 5/6.
const i = Math.round(p * last);
if (i !== shown && count) {
count.textContent = String(i + 1).padStart(2, '0');
shown = i;
}
},
});
// The stage cannot scroll (overflow: clip), so a case reached with Tab is
// brought into view by scrolling the page to it, on the next frame: the
// browser runs its own scroll to the focused link after focusin, and undoes
// one made inside the handler.
let frame = 0;
const onFocus = (e: FocusEvent) => {
const target = e.target as Element;
// Keyboard focus only. Chrome focuses a link on mousedown, and a row that
// moves before mouseup puts the click on the row instead of the link, so
// clicking the second case's link did nothing.
if (!isKeyboardFocus(target)) return;
const item = target.closest('li');
if (!item || item.parentElement !== track) return;
const travel = travelOf(stage, track);
// How far into the row the case starts. Both boxes carry the same
// translateX, so it cancels out.
const at = item.getBoundingClientRect().left - track.getBoundingClientRect().left;
const p = travel > 0 ? Math.min(1, at / travel) : 0;
const top = runway.getBoundingClientRect().top + window.scrollY + p * (runway.offsetHeight - window.innerHeight);
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => window.scrollTo({ top }));
};
track.addEventListener('focusin', onFocus);
return () => {
cancelAnimationFrame(frame);
track.removeEventListener('focusin', onFocus);
resize?.disconnect();
// Puts the row's own inline transform back.
strip.destroy();
runway.style.removeProperty(TRAVEL);
};
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue';
const props = withDefaults(defineProps<HorizontalCaseStripProps>(), {
heading: 'Selected work',
cases: () => DEFAULT_CASES,
linkLabel: 'Read the case study',
className: '',
});
const headingId = useId();
const runwayRef = ref<HTMLElement | null>(null);
const stageRef = ref<HTMLDivElement | null>(null);
const trackRef = ref<HTMLOListElement | null>(null);
const countRef = ref<HTMLElement | null>(null);
const fillRef = ref<HTMLSpanElement | null>(null);
/** Starts the strip and follows the reduced-motion setting. Returns the cleanup. */
function start(): (() => void) | undefined {
const runway = runwayRef.value;
const stage = stageRef.value;
const track = trackRef.value;
if (!runway || !stage || !track) return undefined;
// No strip when reduced motion is asked for: the motion-safe: classes
// below already leave the cases as a grid. Follows the setting live.
const reduce = typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-reduced-motion: reduce)')
: null;
let stop: (() => void) | undefined;
const sync = () => {
stop?.();
stop = reduce?.matches
? undefined
: startStrip(runway, stage, track, countRef.value, fillRef.value);
};
sync();
reduce?.addEventListener('change', sync);
// The cleanup restores the row's transform and the section's height, so a
// restart starts clean.
return () => {
reduce?.removeEventListener('change', sync);
stop?.();
};
}
let teardown: (() => void) | undefined;
onMounted(() => {
teardown = start();
});
// Restart only when the number of cases changes: a new array with the same
// cases changes nothing the strip measured, and a resize is followed anyway.
// flush: 'post', so the restart measures the row after the new cases are in it.
watch(
() => props.cases.length,
() => {
teardown?.();
teardown = start();
},
{ flush: 'post' },
);
onBeforeUnmount(() => {
teardown?.();
teardown = undefined;
});
const total = computed(() => String(props.cases.length).padStart(2, '0'));
</script>
<template>
<section
ref="runwayRef"
:aria-labelledby="headingId"
:class="[
'text-neutral-950 dark:text-neutral-50 motion-safe:h-[calc(var(--case-strip-travel,200vh)_+_100vh)] motion-safe:supports-[height:100svh]:h-[calc(var(--case-strip-travel,200vh)_+_100svh)]',
props.className,
]"
>
<!-- The section is the runway: the scroll room the stage sticks inside.
Its height is the row's travel (set from the strip) plus one screen;
svh where supported, so a phone's collapsing toolbar does not change
it. (This note sits inside the section, not above it, so the template
keeps a single root element.) -->
<!-- The stage. overflow-clip, not overflow-hidden: a hidden box can still
be scrolled by focus, which shifts the whole row and leaves it
shifted. minmax(0,1fr) columns: an auto column would grow to the
row's full width and take the header with it. container-type lets the
cards size against the stage rather than the window. -->
<div
ref="stageRef"
class="box-border grid grid-cols-[minmax(0,1fr)] gap-[clamp(20px,4vh,40px)] px-[6vw] py-[clamp(24px,7vh,72px)] [container-type:inline-size] motion-safe:sticky motion-safe:top-0 motion-safe:h-screen motion-safe:supports-[height:100svh]:h-[100svh] motion-safe:grid-rows-[auto_minmax(0,1fr)] motion-safe:overflow-clip"
>
<header class="flex items-center gap-4">
<h2 :id="headingId" class="text-[clamp(24px,3vw,36px)] font-semibold leading-[1.1] tracking-[-0.01em]">
{{ props.heading }}
</h2>
<!-- Both aria-hidden: the list already tells a screen reader how many
cases there are. -->
<p
aria-hidden="true"
class="ml-auto flex-none whitespace-nowrap font-mono text-[13px] font-medium leading-none tabular-nums motion-reduce:hidden"
>
<b ref="countRef">01</b> / {{ total }}
</p>
<span
aria-hidden="true"
class="relative h-0.5 flex-[0_0_min(24vw,200px)] bg-neutral-200 motion-reduce:hidden dark:bg-neutral-800"
>
<span ref="fillRef" class="absolute inset-0 origin-left bg-current [transform:scaleX(0)]"></span>
</span>
</header>
<!-- The row: as wide as its cases (w-max), so it can be measured and
moved, and never narrower than the stage (min-w-full), so a row that
already fits is not pushed the wrong way. role="list": Safari drops
the list role from a list styled list-style: none, and the count
above is hidden on the promise that the list gives it. -->
<ol
ref="trackRef"
role="list"
class="m-0 grid list-none gap-6 p-0 sm:grid-cols-2 motion-safe:flex motion-safe:w-max motion-safe:min-w-full"
>
<!-- Narrower than the stage (cqw: the stage's width, wherever it is
placed), so the next case shows at the edge and the reader can
tell the row goes on. -->
<li
v-for="(c, i) in props.cases"
:key="`${c.client}-${c.title}`"
class="flex min-h-0 flex-col motion-safe:w-[min(78cqw,520px)]"
>
<!-- Fills the height the text leaves, down to nothing on a short screen. -->
<svg
viewBox="0 0 200 150"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="box-border aspect-[4/3] w-full rounded-2xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950 motion-safe:aspect-auto motion-safe:min-h-0 motion-safe:flex-[1_1_auto]"
>
<path v-for="(d, j) in c.cover" :key="j" :d="d" />
</svg>
<p class="mt-4 font-mono text-xs font-semibold leading-none text-[#3056d3] dark:text-[#8aa4ff]">
{{ String(i + 1).padStart(2, '0') }} · {{ c.client }}
</p>
<h3 class="mb-1.5 mt-2 text-[clamp(19px,2vw,24px)] font-semibold leading-[1.2]">{{ c.title }}</h3>
<p class="max-w-[44ch] text-[15px] leading-[1.55] text-neutral-700 dark:text-neutral-300">{{ c.text }}</p>
<p class="mt-2 text-[13px] leading-[1.4] text-neutral-600 dark:text-neutral-400">{{ c.meta }}</p>
<a
:href="c.href"
class="mt-2.5 self-start text-[15px] font-medium leading-[1.4] underline underline-offset-[3px] focus-visible:outline-offset-[3px] focus-visible:[outline:2px_solid_#3056d3] dark:focus-visible:[outline-color:#8aa4ff]"
>{{ props.linkLabel }}<span class="sr-only">: {{ c.client }}</span></a>
</li>
</ol>
</div>
</section>
</template>
<script module lang="ts">
/**
* horizontal-case-strip — a row of case-study cards that slides sideways as the
* reader scrolls down. The stage holds still while the row crosses it, and a
* count and a progress rail show how far along the reader is.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5.20 or later ($props.id), svg-scroll-draw 2.18.1 or later
* (`scrollHorizontal` from `svg-scroll-draw/horizontal`, measured against the
* sticky stage).
* Styling: Tailwind CSS.
*
* Uses scrollHorizontal (svg-scroll-draw/horizontal) with no distance and no
* trigger: it finds the sticky stage, moves the row by its width minus the
* stage's, and measures again when the window, the page, the row or the section
* changes size. Its onProgress drives the count and the rail. Before 2.18.1 the
* row moved by its width minus the window's, so the stage's side padding left
* the end of the last case off screen.
*
* The section is the row's travel plus one screen tall, so a pixel of scroll
* moves the row a pixel at any width. The stage sticks with CSS
* `position: sticky`: keep the block out of any ancestor with
* `overflow: hidden`, and if a reset stops sideways scrolling on html or body,
* use `overflow-x: clip` there instead. Cards are sized against the stage, not
* the window, so the block works in a page container as well as full width.
*
* Tab reaches every case, including the ones still off to the right: the page
* scrolls to the point where the focused case is in view.
*
* Reduced motion: scrollHorizontal keeps scrubbing on purpose, because holding
* the row at its end would hide every case but the last. This block has a
* layout that needs no movement, so visitors who ask for reduced motion get the
* cases as a plain grid, with no sticky stage, and the block switches over if
* the setting changes while the page is open.
*
* The strip starts in an $effect, which runs only in the browser, after mount,
* and nothing touches the DOM before it, so the block renders on the server too
* (SvelteKit): the page arrives with the cases in place, and the browser starts
* the strip.
*
* Props: heading, cases (client, title, text, meta, href, and cover: line-art
* paths in a 200 × 150 box), linkLabel, className (on the outer section).
*/
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';
export interface CaseStudy {
/** Who the work was for. Shown before the title, and read after the link text. */
client: string;
title: string;
/** One or two sentences. */
text: string;
/** Discipline and year, e.g. 'Packaging · 2025'. */
meta: string;
href: string;
/** The cover's line drawing: SVG path `d` strings in a 200 × 150 box, stroked, not filled. */
cover: string[];
}
export interface HorizontalCaseStripProps {
heading?: string;
cases?: CaseStudy[];
/** Visible text of each case's link. Screen readers also hear the client's name. */
linkLabel?: string;
/** Classes for the outer section, e.g. a background colour. Not overflow-hidden: it unsticks the stage. */
className?: string;
}
const DEFAULT_CASES: CaseStudy[] = [
{
client: 'Tidewater Ferries',
title: 'A timetable you can read from the pier',
text: 'Signs and a printed timetable for six crossings, set large enough to read with a bag in each hand.',
meta: 'Wayfinding · 2025',
href: '/work/tidewater-ferries',
cover: [
'M 40 92 L 160 92 L 148 108 L 52 108 Z',
'M 70 92 V 76 H 128 V 92',
'M 112 76 V 62 H 122 V 76',
'M 20 122 q 10 -8 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0 t 20 0',
],
},
{
client: 'Hollow Oak Bakery',
title: 'Boxes that survive the bike courier',
text: 'A fold-flat box for loaves and tarts, tested on cobbles, that the bakery folds in eleven seconds.',
meta: 'Packaging · 2025',
href: '/work/hollow-oak-bakery',
cover: [
'M 60 62 L 100 46 L 140 62 L 140 108 L 100 124 L 60 108 Z',
'M 60 62 L 100 78 L 140 62 M 100 78 V 124',
'M 82 54 Q 100 34 118 54',
],
},
{
client: 'Northfield Library',
title: 'Late returns, without the guilt',
text: 'Reminder letters and a returns page rewritten so that people come back instead of staying away.',
meta: 'Service design · 2024',
href: '/work/northfield-library',
cover: [
'M 58 50 V 112 H 74 V 50 Z',
'M 78 42 V 112 H 92 V 42 Z',
'M 100 56 L 113 52 L 130 110 L 117 114 Z',
'M 44 112 H 156',
],
},
{
client: 'Meridian Cycling Club',
title: 'Route maps that fold into a jersey pocket',
text: 'Twelve club routes on one sheet, with the climbs drawn to scale and the cafés where they matter.',
meta: 'Print · 2024',
href: '/work/meridian-cycling-club',
cover: [
'M 40 52 L 80 42 L 120 52 L 160 42 V 108 L 120 118 L 80 108 L 40 118 Z',
'M 80 42 V 108 M 120 52 V 118',
'M 52 100 C 70 80 90 96 104 76 S 138 72 148 58',
],
},
];
/** Custom property on the section that holds the row's travel, in px. */
const TRAVEL = '--case-strip-travel';
/** How far the row travels: its width minus the stage's content box. */
function travelOf(stage: HTMLElement, track: HTMLElement): number {
const cs = getComputedStyle(stage);
const seen = stage.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
return Math.max(0, track.scrollWidth - seen);
}
/** Focus from the keyboard or a screen reader, not from a pointer. */
function isKeyboardFocus(el: Element): boolean {
try {
return el.matches(':focus-visible');
} catch {
// A browser without :focus-visible: keep the keyboard path working.
return true;
}
}
/** Starts the strip: runway height, scrub, count, rail and keyboard. Returns its cleanup. */
function startStrip(
runway: HTMLElement,
stage: HTMLElement,
track: HTMLElement,
count: HTMLElement | null,
fill: HTMLElement | null,
): () => void {
const last = Math.max(0, track.children.length - 1);
// A pixel of scroll moves the row a pixel: the runway is the row's travel
// plus the one screen the stage stays stuck for. Set before scrollHorizontal,
// so its first measurement already sees the right runway; after that the
// observer keeps the height current, and the strip follows it on its own.
const fit = () => runway.style.setProperty(TRAVEL, `${travelOf(stage, track)}px`);
fit();
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(fit);
resize?.observe(track);
resize?.observe(stage);
// -1, not 0, so the first call always writes the count, even when a re-run
// finds another number left on screen.
let shown = -1;
const strip = scrollHorizontal(track, {
onProgress: (p) => {
if (fill) fill.style.transform = `scaleX(${p})`;
// The nearest case: with four, it ticks over at 1/6, 1/2 and 5/6.
const i = Math.round(p * last);
if (i !== shown && count) {
count.textContent = String(i + 1).padStart(2, '0');
shown = i;
}
},
});
// The stage cannot scroll (overflow: clip), so a case reached with Tab is
// brought into view by scrolling the page to it, on the next frame: the
// browser runs its own scroll to the focused link after focusin, and undoes
// one made inside the handler.
let frame = 0;
const onFocus = (e: FocusEvent) => {
const target = e.target as Element;
// Keyboard focus only. Chrome focuses a link on mousedown, and a row that
// moves before mouseup puts the click on the row instead of the link, so
// clicking the second case's link did nothing.
if (!isKeyboardFocus(target)) return;
const item = target.closest('li');
if (!item || item.parentElement !== track) return;
const travel = travelOf(stage, track);
// How far into the row the case starts. Both boxes carry the same
// translateX, so it cancels out.
const at = item.getBoundingClientRect().left - track.getBoundingClientRect().left;
const p = travel > 0 ? Math.min(1, at / travel) : 0;
const top = runway.getBoundingClientRect().top + window.scrollY + p * (runway.offsetHeight - window.innerHeight);
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => window.scrollTo({ top }));
};
track.addEventListener('focusin', onFocus);
return () => {
cancelAnimationFrame(frame);
track.removeEventListener('focusin', onFocus);
resize?.disconnect();
// Puts the row's own inline transform back.
strip.destroy();
runway.style.removeProperty(TRAVEL);
};
}
</script>
<script lang="ts">
import { untrack } from 'svelte';
let {
heading = 'Selected work',
cases = DEFAULT_CASES,
linkLabel = 'Read the case study',
className = '',
}: HorizontalCaseStripProps = $props();
const headingId = $props.id();
// Set by bind:this once, at mount. Plain variables, not $state: the elements
// never change, so the effect below need not follow them.
let runway: HTMLElement;
let stage: HTMLDivElement;
let track: HTMLOListElement;
let count: HTMLElement;
let fill: HTMLSpanElement;
// The number of cases. A $derived, so a new array of the same length leaves it
// unchanged, and the effect below, which depends on it alone, does not re-run.
const caseCount = $derived(cases.length);
const total = $derived(String(caseCount).padStart(2, '0'));
$effect(() => {
// Re-run only when the number of cases changes: a new array with the same
// cases changes nothing the strip measured, and a resize is followed anyway.
// Reading the count here is what makes it the dependency; everything below
// runs untracked. An $effect runs after the DOM update, so a restart
// measures the row with the new cases already in it.
caseCount;
return untrack(() => {
if (!runway || !stage || !track) return;
// No strip when reduced motion is asked for: the motion-safe: classes
// below already leave the cases as a grid. Follows the setting live.
const reduce = typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-reduced-motion: reduce)')
: null;
let stop: (() => void) | undefined;
const sync = () => {
stop?.();
stop = reduce?.matches
? undefined
: startStrip(runway, stage, track, count ?? null, fill ?? null);
};
sync();
reduce?.addEventListener('change', sync);
// The cleanup restores the row's transform and the section's height, so a
// restart starts clean.
return () => {
reduce?.removeEventListener('change', sync);
stop?.();
};
});
});
</script>
<!-- The runway: the scroll room the stage sticks inside. Its height is the
row's travel (set from the effect) plus one screen; svh where supported,
so a phone's collapsing toolbar does not change it. -->
<section
bind:this={runway}
aria-labelledby={headingId}
class="text-neutral-950 dark:text-neutral-50 motion-safe:h-[calc(var(--case-strip-travel,200vh)_+_100vh)] motion-safe:supports-[height:100svh]:h-[calc(var(--case-strip-travel,200vh)_+_100svh)] {className}"
>
<!-- The stage. overflow-clip, not overflow-hidden: a hidden box can still
be scrolled by focus, which shifts the whole row and leaves it
shifted. minmax(0,1fr) columns: an auto column would grow to the
row's full width and take the header with it. container-type lets the
cards size against the stage rather than the window. -->
<div
bind:this={stage}
class="box-border grid grid-cols-[minmax(0,1fr)] gap-[clamp(20px,4vh,40px)] px-[6vw] py-[clamp(24px,7vh,72px)] [container-type:inline-size] motion-safe:sticky motion-safe:top-0 motion-safe:h-screen motion-safe:supports-[height:100svh]:h-[100svh] motion-safe:grid-rows-[auto_minmax(0,1fr)] motion-safe:overflow-clip"
>
<header class="flex items-center gap-4">
<h2 id={headingId} class="text-[clamp(24px,3vw,36px)] font-semibold leading-[1.1] tracking-[-0.01em]">
{heading}
</h2>
<!-- Both aria-hidden: the list already tells a screen reader how many
cases there are. -->
<p
aria-hidden="true"
class="ml-auto flex-none whitespace-nowrap font-mono text-[13px] font-medium leading-none tabular-nums motion-reduce:hidden"
>
<b bind:this={count}>01</b> / {total}
</p>
<span
aria-hidden="true"
class="relative h-0.5 flex-[0_0_min(24vw,200px)] bg-neutral-200 motion-reduce:hidden dark:bg-neutral-800"
>
<span bind:this={fill} class="absolute inset-0 origin-left bg-current [transform:scaleX(0)]"></span>
</span>
</header>
<!-- The row: as wide as its cases (w-max), so it can be measured and
moved, and never narrower than the stage (min-w-full), so a row that
already fits is not pushed the wrong way. role="list": Safari drops
the list role from a list styled list-style: none, and the count
above is hidden on the promise that the list gives it, so the role
Svelte calls redundant is kept on purpose. -->
<!-- svelte-ignore a11y_no_redundant_roles -->
<ol
bind:this={track}
role="list"
class="m-0 grid list-none gap-6 p-0 sm:grid-cols-2 motion-safe:flex motion-safe:w-max motion-safe:min-w-full"
>
{#each cases as c, i (`${c.client}-${c.title}`)}
<!-- Narrower than the stage (cqw: the stage's width, wherever it is
placed), so the next case shows at the edge and the reader can
tell the row goes on. -->
<li class="flex min-h-0 flex-col motion-safe:w-[min(78cqw,520px)]">
<!-- Fills the height the text leaves, down to nothing on a short screen. -->
<svg
viewBox="0 0 200 150"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="box-border aspect-[4/3] w-full rounded-2xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950 motion-safe:aspect-auto motion-safe:min-h-0 motion-safe:flex-[1_1_auto]"
>
{#each c.cover as d, j (j)}
<path {d} />
{/each}
</svg>
<p class="mt-4 font-mono text-xs font-semibold leading-none text-[#3056d3] dark:text-[#8aa4ff]">
{String(i + 1).padStart(2, '0')} · {c.client}
</p>
<h3 class="mb-1.5 mt-2 text-[clamp(19px,2vw,24px)] font-semibold leading-[1.2]">{c.title}</h3>
<p class="max-w-[44ch] text-[15px] leading-[1.55] text-neutral-700 dark:text-neutral-300">{c.text}</p>
<p class="mt-2 text-[13px] leading-[1.4] text-neutral-600 dark:text-neutral-400">{c.meta}</p>
<a
href={c.href}
class="mt-2.5 self-start text-[15px] font-medium leading-[1.4] underline underline-offset-[3px] focus-visible:outline-offset-[3px] focus-visible:[outline:2px_solid_#3056d3] dark:focus-visible:[outline-color:#8aa4ff]"
>{linkLabel}<span class="sr-only">: {c.client}</span></a>
</li>
{/each}
</ol>
</div>
</section>
08blockvideo-scrub
Video Scrub
A pinned product film scrubbed by scroll, with chapters that follow the film. Bring your own film, encoded with a keyframe every few frames.
A sprung steel band and memory-foam cushions, 254 g all in.
02
Taken apart
The cup comes apart layer by layer: the cap, the shell, the board.
03
The part you hear
A 40 mm driver with a beryllium-coated dome, 4 Hz to 40 kHz.
Sourcecomponents/scroll/video-scrub.tsx223 lines
components/scroll/video-scrub.tsx223 lines
'use client';
/**
* video-scrub — a product film pinned to the screen and scrubbed by the scroll,
* with numbered chapters beside it that follow the film.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: react, svg-scroll-draw 2.18 or later (`scrollVideo` from
* `svg-scroll-draw/video`, with `triggerElement` and `onProgress`).
* Styling: Tailwind CSS.
*
* Props: `src` is required, the URL of your film; there is no default film.
* Optional: `fallbackSrc` (an MP4 copy for browsers that cannot play `src`),
* `poster`, `label` (what the film shows, for screen readers), `chapters`,
* `from` and `to` (the stretch of the file to scrub, in seconds) and
* `className`. The default chapters and label are placeholders: replace them.
*
* The track is four screens tall and the stage inside it is sticky, so the
* stage stays stuck for three screens of scroll, and that stretch is the film.
* For more or less scroll per film, change `h-[400vh]` on the track. The scrub
* is measured on the track, not the video: a video in a sticky stage moves with
* the scroll, so its own box gives the wrong window, and a phone's address bar
* showing or hiding is a resize that measures it wrong again.
*
* Encode the film for scrubbing. Every scroll frame is a seek, and a seek
* decodes from the last keyframe; exports usually put one every few seconds.
* Put one every few frames (`-g 3`): measured in Chromium on a 5-second film,
* that kept every seek under 7.3 ms, against up to 53.6 ms with one keyframe
* for the whole clip. `-an` drops the audio, and `-auto-alt-ref 0` turns off
* alternate reference frames, which tripled seek times on a test film.
*
* ffmpeg -i film.mov -an -c:v libvpx -cpu-used 1 -pix_fmt yuv420p \
* -g 3 -auto-alt-ref 0 -b:v 2400k -crf 4 -qmin 0 -qmax 26 film.webm
*
* For `fallbackSrc`, keep the same short keyframe interval:
*
* ffmpeg -i film.mov -an -c:v libx264 -pix_fmt yuv420p -g 3 -crf 20 \
* -movflags +faststart film.mp4
*
* Serve the film from a server that answers Range requests (206 Partial
* Content, Accept-Ranges: bytes), or it will not scrub once deployed. Sticky
* does not work inside an ancestor with `overflow: hidden`, `auto` or `scroll`;
* use `overflow: clip` there, or keep this block out of one.
*
* Below the `md` breakpoint the stage shows one chapter at a time, so it still
* fits one screen. Reduced motion is handled by the library for the film: it
* shows the last frame and does not scrub. This block then shows every chapter
* and drops the scroll room, so nobody scrolls three screens past a still.
*/
import { useEffect, useRef, useState } from 'react';
import { scrollVideo } from 'svg-scroll-draw/video';
export interface FilmChapter {
/** Where this chapter takes over: a point in the film from 0 (start) to 1 (end). */
from: number;
title: string;
body: string;
}
export interface VideoScrubProps {
/** Required. A muted film encoded for scrubbing (see above). */
src: string;
/** An MP4 copy of the film, encoded the same way, for browsers that cannot play `src`. */
fallbackSrc?: string;
/** Image shown until the film's first frame is ready. */
poster?: string;
/** What the film shows, for screen readers. */
label?: string;
/** In film order, the first from 0. Each one shows until the next one's `from`. */
chapters?: FilmChapter[];
/** Where the scrub starts in the file, in seconds. Default 0. */
from?: number;
/** Where the scrub stops in the file, in seconds. Leave it out to use the whole file. */
to?: number;
className?: string;
}
const DEFAULT_CHAPTERS: FilmChapter[] = [
{ from: 0, title: 'Built to be worn all day', body: 'A sprung steel band and memory-foam cushions, 254 g all in.' },
{ from: 0.45, title: 'Taken apart', body: 'The cup comes apart layer by layer: the cap, the shell, the board.' },
{ from: 0.75, title: 'The part you hear', body: 'A 40 mm driver with a beryllium-coated dome, 4 Hz to 40 kHz.' },
];
/** The last chapter that has started at film progress `p` (0 to 1). */
function chapterAt(chapters: FilmChapter[], p: number): number {
return chapters.reduce((at, chapter, i) => (p >= chapter.from ? i : at), 0);
}
export function VideoScrub({
src,
fallbackSrc,
poster,
label = 'Headphones turning, then taken apart layer by layer',
chapters = DEFAULT_CHAPTERS,
from,
to,
className = '',
}: VideoScrubProps) {
const trackRef = useRef<HTMLElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const chaptersRef = useRef(chapters);
// The film's progress (0 to 1), kept only when it crosses into another
// chapter, and the current chapter is worked out from it on every render.
// Keeping every frame's progress would re-render 60 times a second. Keeping
// a chapter number would go stale when `chapters` changes and no frame
// follows, as under reduced motion, where the film reports only once.
const [progress, setProgress] = useState(0);
const [motionChanges, setMotionChanges] = useState(0);
// The progress callback reads the latest chapters through a ref, so new
// chapters do not restart the film.
useEffect(() => {
chaptersRef.current = chapters;
}, [chapters]);
// scrollVideo reads prefers-reduced-motion once, when it starts, while the
// layout below follows the setting live (motion-safe / motion-reduce). Start
// the film again when the setting changes, so the two never disagree.
useEffect(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
const onChange = () => setMotionChanges((n) => n + 1);
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
}, []);
useEffect(() => {
const track = trackRef.current;
const video = videoRef.current;
if (!track || !video) return;
const film = scrollVideo(video, {
from,
to,
// The stage is sticky, so the video's own box moves with the scroll.
// Measure the track that holds the scroll room instead: `top top` to
// `bottom bottom` is exactly the stretch the stage is stuck.
triggerElement: track,
trigger: { start: 'top top', end: 'bottom bottom' },
// Runs every frame while the film is on screen. Handing back the same
// value while the chapter is unchanged lets React skip the render. Under
// reduced motion it runs once, with 1, for the last frame.
onProgress: (p) => {
const list = chaptersRef.current;
setProgress((kept) => (chapterAt(list, kept) === chapterAt(list, p) ? kept : p));
},
});
// StrictMode mounts effects twice in development: destroy() removes every
// listener and observer, so the second run starts clean.
return () => film.destroy();
// A new `src` or `fallbackSrc` remounts the <video> (see its `key`), so
// bind the new element.
}, [src, fallbackSrc, from, to, motionChanges]);
const current = chapterAt(chapters, progress);
return (
// The track holds the scroll room: the stage stays stuck for its height
// minus one screen, and the film runs exactly that long. Under reduced
// motion there is nothing to scroll through, so it collapses to the stage.
<section
ref={trackRef}
className={`relative h-[400vh] bg-[#121212] text-[#ece8e1] motion-reduce:h-auto ${className}`}
>
{/* One screen tall, measured on the small viewport (svh) so a phone's
address bar never hides the bottom of it; plain vh where svh is not
supported. A minimum rather than a fixed height, so content that does
not fit (every chapter at once, under reduced motion) grows the stage
instead of spilling out of it. */}
<div className="sticky top-0 box-border grid min-h-screen grid-cols-1 content-center items-center gap-5 px-[6vw] supports-[height:100svh]:min-h-[100svh] md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] md:gap-12 motion-reduce:py-16">
{/* No autoplay and no controls: the scroll is the playhead. With a
fallback, the two files become <source>s, tried in order. */}
<video
key={`${src}|${fallbackSrc ?? ''}`}
ref={videoRef}
src={fallbackSrc ? undefined : src}
poster={poster}
muted
playsInline
preload="auto"
aria-label={label}
className="block aspect-[4/3] h-auto max-h-[80vh] w-full object-cover"
>
{fallbackSrc && (
<>
<source src={src} />
<source src={fallbackSrc} type="video/mp4" />
</>
)}
</video>
{/* On a phone the chapters share one grid cell and cross-fade, so the
stage keeps the height of the tallest one and the film above never
jumps. Under reduced motion they are all shown, in full, in order. */}
<ol className="m-0 grid list-none gap-7 p-0">
{chapters.map((chapter, i) => {
const isCurrent = i === current;
return (
<li
key={chapter.title}
aria-current={isCurrent ? 'step' : undefined}
className={`transition-[opacity,visibility,color] duration-300 motion-reduce:transition-none max-md:motion-safe:col-start-1 max-md:motion-safe:row-start-1 ${
isCurrent
? 'opacity-100'
: 'text-[#817f7c] motion-reduce:text-[#ece8e1] max-md:motion-safe:invisible max-md:motion-safe:opacity-0'
}`}
>
{/* Not the current chapter: greyed by colour, not opacity, so it keeps WCAG AA on the dark ground (at 32% opacity it measured under 2:1). */}
<span className={`font-mono text-xs font-semibold leading-none ${isCurrent ? 'text-[#d0a15c]' : 'text-[#9b7947] motion-reduce:text-[#d0a15c]'}`}>
{String(i + 1).padStart(2, '0')}
</span>
<h3 className="mb-1.5 mt-2 text-2xl font-semibold leading-[1.2]">{chapter.title}</h3>
<p className="m-0 max-w-[34ch] text-base leading-[1.6]">{chapter.body}</p>
</li>
);
})}
</ol>
</div>
</section>
);
}
export default VideoScrub;
<script setup lang="ts">
/**
* video-scrub — a product film pinned to the screen and scrubbed by the scroll,
* with numbered chapters beside it that follow the film.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: vue 3, svg-scroll-draw 2.18 or later (`scrollVideo` from
* `svg-scroll-draw/video`, with `triggerElement` and `onProgress`).
* Styling: Tailwind CSS.
*
* Props: `src` is required, the URL of your film; there is no default film.
* Optional: `fallbackSrc` (an MP4 copy for browsers that cannot play `src`),
* `poster`, `label` (what the film shows, for screen readers), `chapters`
* ({ from, title, body }[]), `from` and `to` (the stretch of the file to scrub,
* in seconds) and `className` (a plain `class` lands on the section too). The
* default chapters and label are placeholders: replace them.
*
* The track is four screens tall and the stage inside it is sticky, so the
* stage stays stuck for three screens of scroll, and that stretch is the film.
* For more or less scroll per film, change `h-[400vh]` on the track. The scrub
* is measured on the track, not the video: a video in a sticky stage moves with
* the scroll, so its own box gives the wrong window, and a phone's address bar
* showing or hiding is a resize that measures it wrong again.
*
* Encode the film for scrubbing. Every scroll frame is a seek, and a seek
* decodes from the last keyframe; exports usually put one every few seconds.
* Put one every few frames (`-g 3`): measured in Chromium on a 5-second film,
* that kept every seek under 7.3 ms, against up to 53.6 ms with one keyframe
* for the whole clip. `-an` drops the audio, and `-auto-alt-ref 0` turns off
* alternate reference frames, which tripled seek times on a test film.
*
* ffmpeg -i film.mov -an -c:v libvpx -cpu-used 1 -pix_fmt yuv420p \
* -g 3 -auto-alt-ref 0 -b:v 2400k -crf 4 -qmin 0 -qmax 26 film.webm
*
* For `fallbackSrc`, keep the same short keyframe interval:
*
* ffmpeg -i film.mov -an -c:v libx264 -pix_fmt yuv420p -g 3 -crf 20 \
* -movflags +faststart film.mp4
*
* Serve the film from a server that answers Range requests (206 Partial
* Content, Accept-Ranges: bytes), or it will not scrub once deployed. Sticky
* does not work inside an ancestor with `overflow: hidden`, `auto` or `scroll`;
* use `overflow: clip` there, or keep this block out of one.
*
* Below the `md` breakpoint the stage shows one chapter at a time, so it still
* fits one screen. Reduced motion is handled by the library for the film: it
* shows the last frame and does not scrub. This block then shows every chapter
* and drops the scroll room, so nobody scrolls three screens past a still.
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { scrollVideo } from 'svg-scroll-draw/video';
export interface FilmChapter {
/** Where this chapter takes over: a point in the film from 0 (start) to 1 (end). */
from: number;
title: string;
body: string;
}
export interface VideoScrubProps {
/** Required. A muted film encoded for scrubbing (see above). */
src: string;
/** An MP4 copy of the film, encoded the same way, for browsers that cannot play `src`. */
fallbackSrc?: string;
/** Image shown until the film's first frame is ready. */
poster?: string;
/** What the film shows, for screen readers. */
label?: string;
/** In film order, the first from 0. Each one shows until the next one's `from`. */
chapters?: FilmChapter[];
/** Where the scrub starts in the file, in seconds. Default 0. */
from?: number;
/** Where the scrub stops in the file, in seconds. Leave it out to use the whole file. */
to?: number;
className?: string;
}
const props = withDefaults(defineProps<VideoScrubProps>(), {
label: 'Headphones turning, then taken apart layer by layer',
// A factory, so each instance gets its own array.
chapters: (): FilmChapter[] => [
{ from: 0, title: 'Built to be worn all day', body: 'A sprung steel band and memory-foam cushions, 254 g all in.' },
{ from: 0.45, title: 'Taken apart', body: 'The cup comes apart layer by layer: the cap, the shell, the board.' },
{ from: 0.75, title: 'The part you hear', body: 'A 40 mm driver with a beryllium-coated dome, 4 Hz to 40 kHz.' },
],
className: '',
});
/** The last chapter that has started at film progress `p` (0 to 1). */
function chapterAt(chapters: FilmChapter[], p: number): number {
return chapters.reduce((at, chapter, i) => (p >= chapter.from ? i : at), 0);
}
const trackRef = ref<HTMLElement | null>(null);
const videoRef = ref<HTMLVideoElement | null>(null);
// The film's progress (0 to 1), kept only when it crosses into another
// chapter, and the current chapter is worked out from it on every render.
// Keeping every frame's progress would re-render 60 times a second. Keeping
// a chapter number would go stale when `chapters` changes and no frame
// follows, as under reduced motion, where the film reports only once.
const progress = ref(0);
const motionChanges = ref(0);
const current = computed(() => chapterAt(props.chapters, progress.value));
// scrollVideo reads prefers-reduced-motion once, when it starts, while the
// layout below follows the setting live (motion-safe / motion-reduce). Start
// the film again when the setting changes, so the two never disagree.
let motionQuery: MediaQueryList | null = null;
const onMotionChange = () => {
motionChanges.value += 1;
};
let film: ReturnType<typeof scrollVideo> | null = null;
function stop(): void {
// destroy() removes every listener and observer, so a rebuild starts clean.
film?.destroy();
film = null;
}
function start(): void {
stop();
const track = trackRef.value;
const video = videoRef.value;
if (!track || !video) return;
film = scrollVideo(video, {
from: props.from,
to: props.to,
// The stage is sticky, so the video's own box moves with the scroll.
// Measure the track that holds the scroll room instead: `top top` to
// `bottom bottom` is exactly the stretch the stage is stuck.
triggerElement: track,
trigger: { start: 'top top', end: 'bottom bottom' },
// Runs every frame while the film is on screen. It reads the chapters
// from the props when it runs, so new chapters do not restart the film,
// and it keeps the old value while the chapter is unchanged, so Vue does
// not re-render. Under reduced motion it runs once, with 1, for the last
// frame.
onProgress: (p) => {
const list = props.chapters;
if (chapterAt(list, progress.value) !== chapterAt(list, p)) progress.value = p;
},
});
}
onMounted(() => {
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
motionQuery.addEventListener('change', onMotionChange);
start();
});
// A new `src` or `fallbackSrc` remounts the <video> (see its `key`), so bind
// the new element: `flush: 'post'` runs this after the DOM update, once
// `videoRef` holds it. The svg-scroll-draw/vue composable is not used here
// because it keeps the element it started on.
watch(
[() => props.src, () => props.fallbackSrc, () => props.from, () => props.to, motionChanges],
start,
{ flush: 'post' },
);
onBeforeUnmount(() => {
motionQuery?.removeEventListener('change', onMotionChange);
motionQuery = null;
stop();
});
</script>
<template>
<section
ref="trackRef"
:class="['relative h-[400vh] bg-[#121212] text-[#ece8e1] motion-reduce:h-auto', className]"
>
<!-- This section is the track, and it holds the scroll room: the stage
stays stuck for its height minus one screen, and the film runs exactly
that long. Under reduced motion there is nothing to scroll through, so
it collapses to the stage. (The comment sits inside the section so the
component keeps a single root element, and `class` falls through.) -->
<!-- The stage: one screen tall, measured on the small viewport (svh) so a
phone's address bar never hides the bottom of it; plain vh where svh
is not supported. A minimum rather than a fixed height, so content
that does not fit (every chapter at once, under reduced motion) grows
the stage instead of spilling out of it. -->
<div
class="sticky top-0 box-border grid min-h-screen grid-cols-1 content-center items-center gap-5 px-[6vw] supports-[height:100svh]:min-h-[100svh] md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] md:gap-12 motion-reduce:py-16"
>
<!-- No autoplay and no controls: the scroll is the playhead. With a
fallback, the two files become <source>s, tried in order. -->
<video
:key="`${src}|${fallbackSrc ?? ''}`"
ref="videoRef"
:src="fallbackSrc ? undefined : src"
:poster="poster"
muted
playsinline
preload="auto"
:aria-label="label"
class="block aspect-[4/3] h-auto max-h-[80vh] w-full object-cover"
>
<template v-if="fallbackSrc">
<source :src="src" />
<source :src="fallbackSrc" type="video/mp4" />
</template>
</video>
<!-- On a phone the chapters share one grid cell and cross-fade, so the
stage keeps the height of the tallest one and the film above never
jumps. Under reduced motion they are all shown, in full, in order. -->
<ol class="m-0 grid list-none gap-7 p-0">
<li
v-for="(chapter, i) in chapters"
:key="chapter.title"
:aria-current="i === current ? 'step' : undefined"
:class="[
'transition-[opacity,visibility,color] duration-300 motion-reduce:transition-none max-md:motion-safe:col-start-1 max-md:motion-safe:row-start-1',
i === current
? 'opacity-100'
: 'text-[#817f7c] motion-reduce:text-[#ece8e1] max-md:motion-safe:invisible max-md:motion-safe:opacity-0',
]"
>
<!-- Not the current chapter: greyed by colour, not opacity, so it keeps WCAG AA on the dark ground (at 32% opacity it measured under 2:1). -->
<span :class="['font-mono text-xs font-semibold leading-none', i === current ? 'text-[#d0a15c]' : 'text-[#9b7947] motion-reduce:text-[#d0a15c]']">{{
String(i + 1).padStart(2, '0')
}}</span>
<h3 class="mb-1.5 mt-2 text-2xl font-semibold leading-[1.2]">{{ chapter.title }}</h3>
<p class="m-0 max-w-[34ch] text-base leading-[1.6]">{{ chapter.body }}</p>
</li>
</ol>
</div>
</section>
</template>
<script module lang="ts">
/**
* video-scrub — a product film pinned to the screen and scrubbed by the scroll,
* with numbered chapters beside it that follow the film.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5, svg-scroll-draw 2.18 or later (`scrollVideo` from
* `svg-scroll-draw/video`, with `triggerElement` and `onProgress`).
* Styling: Tailwind CSS.
*
* Props: `src` is required, the URL of your film; there is no default film.
* Optional: `fallbackSrc` (an MP4 copy for browsers that cannot play `src`),
* `poster`, `label` (what the film shows, for screen readers), `chapters`
* ({ from, title, body }[]), `from` and `to` (the stretch of the file to scrub,
* in seconds) and `className`. The default chapters and label are
* placeholders: replace them.
*
* The track is four screens tall and the stage inside it is sticky, so the
* stage stays stuck for three screens of scroll, and that stretch is the film.
* For more or less scroll per film, change `h-[400vh]` on the track. The scrub
* is measured on the track, not the video: a video in a sticky stage moves with
* the scroll, so its own box gives the wrong window, and a phone's address bar
* showing or hiding is a resize that measures it wrong again.
*
* Encode the film for scrubbing. Every scroll frame is a seek, and a seek
* decodes from the last keyframe; exports usually put one every few seconds.
* Put one every few frames (`-g 3`): measured in Chromium on a 5-second film,
* that kept every seek under 7.3 ms, against up to 53.6 ms with one keyframe
* for the whole clip. `-an` drops the audio, and `-auto-alt-ref 0` turns off
* alternate reference frames, which tripled seek times on a test film.
*
* ffmpeg -i film.mov -an -c:v libvpx -cpu-used 1 -pix_fmt yuv420p \
* -g 3 -auto-alt-ref 0 -b:v 2400k -crf 4 -qmin 0 -qmax 26 film.webm
*
* For `fallbackSrc`, keep the same short keyframe interval:
*
* ffmpeg -i film.mov -an -c:v libx264 -pix_fmt yuv420p -g 3 -crf 20 \
* -movflags +faststart film.mp4
*
* Serve the film from a server that answers Range requests (206 Partial
* Content, Accept-Ranges: bytes), or it will not scrub once deployed. Sticky
* does not work inside an ancestor with `overflow: hidden`, `auto` or `scroll`;
* use `overflow: clip` there, or keep this block out of one.
*
* Below the `md` breakpoint the stage shows one chapter at a time, so it still
* fits one screen. Reduced motion is handled by the library for the film: it
* shows the last frame and does not scrub. This block then shows every chapter
* and drops the scroll room, so nobody scrolls three screens past a still.
*/
import { scrollVideo } from 'svg-scroll-draw/video';
export interface FilmChapter {
/** Where this chapter takes over: a point in the film from 0 (start) to 1 (end). */
from: number;
title: string;
body: string;
}
export interface VideoScrubProps {
/** Required. A muted film encoded for scrubbing (see above). */
src: string;
/** An MP4 copy of the film, encoded the same way, for browsers that cannot play `src`. */
fallbackSrc?: string;
/** Image shown until the film's first frame is ready. */
poster?: string;
/** What the film shows, for screen readers. */
label?: string;
/** In film order, the first from 0. Each one shows until the next one's `from`. */
chapters?: FilmChapter[];
/** Where the scrub starts in the file, in seconds. Default 0. */
from?: number;
/** Where the scrub stops in the file, in seconds. Leave it out to use the whole file. */
to?: number;
className?: string;
}
const DEFAULT_CHAPTERS: FilmChapter[] = [
{ from: 0, title: 'Built to be worn all day', body: 'A sprung steel band and memory-foam cushions, 254 g all in.' },
{ from: 0.45, title: 'Taken apart', body: 'The cup comes apart layer by layer: the cap, the shell, the board.' },
{ from: 0.75, title: 'The part you hear', body: 'A 40 mm driver with a beryllium-coated dome, 4 Hz to 40 kHz.' },
];
/** The last chapter that has started at film progress `p` (0 to 1). */
function chapterAt(chapters: FilmChapter[], p: number): number {
return chapters.reduce((at, chapter, i) => (p >= chapter.from ? i : at), 0);
}
</script>
<script lang="ts">
import { untrack } from 'svelte';
let {
src,
fallbackSrc,
poster,
label = 'Headphones turning, then taken apart layer by layer',
chapters = DEFAULT_CHAPTERS,
from,
to,
className = '',
}: VideoScrubProps = $props();
let track: HTMLElement;
// $state: a new `src` or `fallbackSrc` replaces the <video> (see the {#key}
// block below), and the effect follows the element bind:this hands it.
let video = $state<HTMLVideoElement>();
// The film's progress (0 to 1), kept only when it crosses into another
// chapter, and the current chapter is worked out from it. Keeping every
// frame's progress would update the page 60 times a second. Keeping a chapter
// number would go stale when `chapters` changes and no frame follows, as under
// reduced motion, where the film reports only once.
let progress = $state(0);
let motionChanges = $state(0);
const current = $derived(chapterAt(chapters, progress));
// scrollVideo reads prefers-reduced-motion once, when it starts, while the
// layout below follows the setting live (motion-safe / motion-reduce). Start
// the film again when the setting changes, so the two never disagree.
$effect(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
const onChange = () => {
motionChanges += 1;
};
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
});
$effect(() => {
// What the film is rebuilt for: the <video> (a new one for each `src` and
// `fallbackSrc`), the stretch of the file, and a reduced-motion change.
// Reading them here makes them the effect's dependencies; the start below
// runs untracked, so the chapters and the progress it reads are not.
const el = video;
const options = { from, to };
motionChanges;
if (!track || !el) return;
const film = untrack(() =>
scrollVideo(el, {
...options,
// The stage is sticky, so the video's own box moves with the scroll.
// Measure the track that holds the scroll room instead: `top top` to
// `bottom bottom` is exactly the stretch the stage is stuck.
triggerElement: track,
trigger: { start: 'top top', end: 'bottom bottom' },
// Runs every frame while the film is on screen. It reads the chapters
// from the props when it runs, so new chapters do not restart the film,
// and it keeps the old value while the chapter is unchanged, so nothing
// updates. Under reduced motion it runs once, with 1, for the last frame.
onProgress: (p) => {
if (chapterAt(chapters, progress) !== chapterAt(chapters, p)) progress = p;
},
}),
);
// destroy() removes every listener and observer, so a rebuild starts clean.
return () => film.destroy();
});
</script>
<!-- The track holds the scroll room: the stage stays stuck for its height
minus one screen, and the film runs exactly that long. Under reduced
motion there is nothing to scroll through, so it collapses to the stage. -->
<section
bind:this={track}
class="relative h-[400vh] bg-[#121212] text-[#ece8e1] motion-reduce:h-auto {className}"
>
<!-- One screen tall, measured on the small viewport (svh) so a phone's
address bar never hides the bottom of it; plain vh where svh is not
supported. A minimum rather than a fixed height, so content that does
not fit (every chapter at once, under reduced motion) grows the stage
instead of spilling out of it. -->
<div
class="sticky top-0 box-border grid min-h-screen grid-cols-1 content-center items-center gap-5 px-[6vw] supports-[height:100svh]:min-h-[100svh] md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] md:gap-12 motion-reduce:py-16"
>
<!-- No autoplay and no controls: the scroll is the playhead. With a
fallback, the two files become <source>s, tried in order. A new file
makes a new <video>: a changed <source> is not loaded again. -->
{#key `${src}|${fallbackSrc ?? ''}`}
<video
bind:this={video}
src={fallbackSrc ? undefined : src}
{poster}
muted
playsinline
preload="auto"
aria-label={label}
class="block aspect-[4/3] h-auto max-h-[80vh] w-full object-cover"
>
{#if fallbackSrc}
<source {src} />
<source src={fallbackSrc} type="video/mp4" />
{/if}
</video>
{/key}
<!-- On a phone the chapters share one grid cell and cross-fade, so the
stage keeps the height of the tallest one and the film above never
jumps. Under reduced motion they are all shown, in full, in order. -->
<ol class="m-0 grid list-none gap-7 p-0">
{#each chapters as chapter, i (chapter.title)}
<li
aria-current={i === current ? 'step' : undefined}
class="transition-[opacity,visibility,color] duration-300 motion-reduce:transition-none max-md:motion-safe:col-start-1 max-md:motion-safe:row-start-1 {i === current
? 'opacity-100'
: 'text-[#817f7c] motion-reduce:text-[#ece8e1] max-md:motion-safe:invisible max-md:motion-safe:opacity-0'}"
>
<!-- Not the current chapter: greyed by colour, not opacity, so it keeps WCAG AA on the dark ground (at 32% opacity it measured under 2:1). -->
<span class="font-mono text-xs font-semibold leading-none {i === current ? 'text-[#d0a15c]' : 'text-[#9b7947] motion-reduce:text-[#d0a15c]'}">{String(i + 1).padStart(2, '0')}</span>
<h3 class="mb-1.5 mt-2 text-2xl font-semibold leading-[1.2]">{chapter.title}</h3>
<p class="m-0 max-w-[34ch] text-base leading-[1.6]">{chapter.body}</p>
</li>
{/each}
</ol>
</div>
</section>
09blockreading-progress
Reading Progress
A thin bar at the top of the screen that fills as the reader moves through an article.
We close on Wednesdays now, and here is what changed
The bakery on Kessel Street · 2 min read
For eleven years the bakery on Kessel Street opened six days a week. The ovens were lit at four, the first rye came out at half past five, and by Friday everyone behind the counter was running on coffee and habit. Last March we tried something smaller: open four days, Thursday to Sunday, with Wednesday kept for the work that never fitted between customers.
Why we stopped
It was not about rest at first. It was about waste. On Tuesdays and Wednesdays we baked for the crowd we hoped for and sold to the crowd we had, and about one loaf in five went to the food bank or the compost. Those days only looked like they paid their way because the weekend was paying for them.
Closing on the slow days let us bake later on the busy ones. The second bake moved to eleven, so the sourdough that used to sit on the shelf from dawn came out in time for lunch. A regular asked whether we had changed the flour. We had changed the clock.
What the numbers did
Six months in, we sell about as many loaves a week as before, in four days instead of six. Waste fell from a fifth of each bake to roughly one loaf in twenty. Takings dipped in the first month, while people learned the new days, and were back to normal by the third.
Wednesday changed the most. With the door locked, the team tests recipes, fixes what broke during the week and trains the apprentice on the shaping bench without a queue at the counter. The proofing cabinet that leaked all winter was mended on the second Wednesday. Two of the loaves on today's board started as Wednesday experiments.
What we would tell another small shop
Count what you throw away before you count what you sell; the quiet days are often the expensive ones. Tell customers early and in plain words, on the door, on the receipts and at the till. We lost fewer regulars to the new hours than to the six weeks the road outside was dug up.
We are not going back. The ovens still light at four, only now four times a week, and the people who light them have Monday and Tuesday to themselves.
'use client';
/**
* reading-progress — an article with a thin bar fixed to the top of the
* viewport that fills as the reader moves through it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: react, svg-scroll-draw 2.19 or later (`scrollProgress` re-measures
* when the layout changes, such as images loading in the article; earlier
* versions re-measure only on a window resize, so the bar would fill before
* the end). Styling: Tailwind CSS.
*
* Uses `scrollProgress` from svg-scroll-draw/progress. It writes the reader's
* position as a CSS variable, --reading-progress (0 to 1), and the bar's fill is
* plain CSS, scaleX(var(--reading-progress)), so React does not render per frame.
* The bar is empty when the article's top reaches the top of the viewport and
* full when the article's bottom reaches the bottom of the viewport.
*
* Pass your article as `children`. The default is a placeholder essay, long on
* purpose: the bar appears only for an article taller than the viewport, and
* only while the article is on screen. `kicker`, `title` and `byline` fill the
* header, `as` sets the title's heading level and `accent` colours the bar.
*
* The bar is decorative and aria-hidden: the scroll position already tells
* assistive technology where the reader is, and a progressbar whose value
* changed on every frame would only add noise. The library has no
* reduced-motion path for scrollProgress, so the block sets its own: the fill
* moves only as far as the reader scrolls, like a scrollbar, so it stays; the
* fade in and out is removed.
*
* `position: fixed` places the bar against the viewport unless an ancestor has a
* transform, filter, backdrop-filter, perspective or contain (page-transition
* wrappers often add one), so keep the block out of one. The bar sits on the top
* edge at z-50; to place it under a fixed site header instead, change `top-0` to
* the header's height (`top-16` for a 64px header).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { scrollProgress } from 'svg-scroll-draw/progress';
export interface ReadingProgressProps {
/** The article body. Defaults to a placeholder essay. */
children?: ReactNode;
kicker?: string;
title?: string;
/** Line under the title, such as the author and the reading time. */
byline?: string;
/** Heading level for the title. Use 'h1' when the article is the page's main content. */
as?: 'h1' | 'h2';
/** Colour of the bar's fill. Any CSS colour. */
accent?: string;
className?: string;
}
export function ReadingProgress({
children,
kicker = 'Field notes',
title = 'We close on Wednesdays now, and here is what changed',
byline = 'The bakery on Kessel Street · 2 min read',
as: Heading = 'h2',
accent = '#ff90e8',
className = '',
}: ReadingProgressProps) {
const frameRef = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
// scrollProgress writes its variable on the element it measures. Set on the
// article, every element inside would inherit the new value and be restyled
// with it on every scroll frame. `frame` is an empty layer over the same box
// with only the bar inside, so the bar is all that is restyled.
const progress = scrollProgress(frame, {
variable: '--reading-progress',
// Only the linear value is used: skip writing the eased one.
easedVariable: null,
// The end is a point every page can reach: however the page ends, the
// article's bottom can always scroll up to the bottom of the viewport.
trigger: { start: 'top top', end: 'bottom bottom' },
});
// scrollProgress updates only while the element it measures is on screen.
// That is invisible for most uses, but this bar is fixed and stays in view:
// a jump over the whole article in one step, or the browser restoring a
// scroll position past the article after load, would leave the bar showing a
// value that is no longer true. So the bar is shown only while the article
// is on screen, where the library keeps it exact on every frame.
//
// It also stays hidden for an article no taller than the viewport. There is
// nothing to scroll through, and the window runs backwards (its end comes
// before its start), so the bar would start full and empty as you scroll.
let onScreen = false;
const sync = () => setShown(onScreen && frame.getBoundingClientRect().height > window.innerHeight);
const io = new IntersectionObserver((entries) => {
// Entries arrive in order, so the last one is the current state.
for (const entry of entries) onScreen = entry.isIntersecting;
sync();
});
io.observe(frame);
// The article's height changes as images and fonts load; the viewport's
// changes on a resize, including a mobile toolbar showing or hiding. The
// library re-measures its own window on both; these only decide `shown`.
// ResizeObserver is missing in jsdom, where component tests often stub
// IntersectionObserver alone.
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(sync);
ro?.observe(frame);
window.addEventListener('resize', sync);
// StrictMode mounts effects twice in development: destroy() removes the
// variable, so the second run starts clean.
return () => {
progress.destroy();
io.disconnect();
ro?.disconnect();
window.removeEventListener('resize', sync);
};
}, []);
return (
<article className={`relative mx-auto w-full max-w-2xl ${className}`}>
{/* The layer scrollProgress measures and writes to: it covers the article
exactly and takes no clicks, so text under it can still be selected. */}
<div ref={frameRef} aria-hidden="true" className="pointer-events-none absolute inset-0">
<div
className={`fixed inset-x-0 top-0 z-50 h-[3px] bg-neutral-900/10 print:hidden motion-safe:transition-opacity motion-safe:duration-300 dark:bg-white/15 ${
shown ? 'opacity-100' : 'opacity-0'
}`}
>
{/* Transform, not width, so a scroll frame never triggers layout. Until
the effect runs the variable is unset and the fallback keeps the
fill empty. It fills from the reading start: the right on an RTL page. */}
<div
className="h-full origin-left will-change-transform [transform:scaleX(var(--reading-progress,0))] rtl:origin-right"
style={{ backgroundColor: accent }}
/>
</div>
</div>
<header>
<p className="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{kicker}
</p>
<Heading className="mt-3 text-4xl font-medium tracking-tight text-neutral-950 text-balance sm:text-5xl dark:text-neutral-50">
{title}
</Heading>
<p className="mt-4 text-sm text-neutral-600 dark:text-neutral-400">{byline}</p>
</header>
<div className="mt-10 space-y-6 text-lg leading-relaxed text-neutral-700 dark:text-neutral-300">
{children ?? <PlaceholderEssay heading={Heading === 'h1' ? 'h2' : 'h3'} />}
</div>
</article>
);
}
/** Placeholder copy. Replace it by passing your own article as children. */
function PlaceholderEssay({ heading: Subheading }: { heading: 'h2' | 'h3' }) {
const subheading = 'pt-4 text-2xl font-semibold tracking-tight text-neutral-950 dark:text-neutral-50';
return (
<>
<p>
For eleven years the bakery on Kessel Street opened six days a week. The ovens were lit at four,
the first rye came out at half past five, and by Friday everyone behind the counter was running on
coffee and habit. Last March we tried something smaller: open four days, Thursday to Sunday, with
Wednesday kept for the work that never fitted between customers.
</p>
<Subheading className={subheading}>Why we stopped</Subheading>
<p>
It was not about rest at first. It was about waste. On Tuesdays and Wednesdays we baked for the
crowd we hoped for and sold to the crowd we had, and about one loaf in five went to the food bank or
the compost. Those days only looked like they paid their way because the weekend was paying for them.
</p>
<p>
Closing on the slow days let us bake later on the busy ones. The second bake moved to eleven, so the
sourdough that used to sit on the shelf from dawn came out in time for lunch. A regular asked whether
we had changed the flour. We had changed the clock.
</p>
<Subheading className={subheading}>What the numbers did</Subheading>
<p>
Six months in, we sell about as many loaves a week as before, in four days instead of six. Waste
fell from a fifth of each bake to roughly one loaf in twenty. Takings dipped in the first month,
while people learned the new days, and were back to normal by the third.
</p>
<p>
Wednesday changed the most. With the door locked, the team tests recipes, fixes what broke during
the week and trains the apprentice on the shaping bench without a queue at the counter. The proofing
cabinet that leaked all winter was mended on the second Wednesday. Two of the loaves on today's
board started as Wednesday experiments.
</p>
<Subheading className={subheading}>What we would tell another small shop</Subheading>
<p>
Count what you throw away before you count what you sell; the quiet days are often the expensive
ones. Tell customers early and in plain words, on the door, on the receipts and at the till. We lost
fewer regulars to the new hours than to the six weeks the road outside was dug up.
</p>
<p>
We are not going back. The ovens still light at four, only now four times a week, and the people who
light them have Monday and Tuesday to themselves.
</p>
</>
);
}
export default ReadingProgress;
<script setup lang="ts">
/**
* reading-progress — an article with a thin bar fixed to the top of the
* viewport that fills as the reader moves through it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: vue 3, svg-scroll-draw 2.19 or later (`scrollProgress` re-measures
* when the layout changes, such as images loading in the article; earlier
* versions re-measure only on a window resize, so the bar would fill before
* the end). Styling: Tailwind CSS.
*
* Props: kicker, title, byline, as, accent, className (a plain `class` lands
* on the <article> too).
*
* Uses `scrollProgress` from svg-scroll-draw/progress. It writes the reader's
* position as a CSS variable, --reading-progress (0 to 1), and the bar's fill is
* plain CSS, scaleX(var(--reading-progress)), so Vue does not render per frame.
* The bar is empty when the article's top reaches the top of the viewport and
* full when the article's bottom reaches the bottom of the viewport.
*
* Pass your article in the default slot. The default is a placeholder essay,
* long on purpose: the bar appears only for an article taller than the
* viewport, and only while the article is on screen. `kicker`, `title` and
* `byline` fill the header, `as` sets the title's heading level and `accent`
* colours the bar.
*
* The bar is decorative and aria-hidden: the scroll position already tells
* assistive technology where the reader is, and a progressbar whose value
* changed on every frame would only add noise. The library has no
* reduced-motion path for scrollProgress, so the block sets its own: the fill
* moves only as far as the reader scrolls, like a scrollbar, so it stays; the
* fade in and out is removed.
*
* `position: fixed` places the bar against the viewport unless an ancestor has a
* transform, filter, backdrop-filter, perspective or contain (page-transition
* wrappers, such as a route <Transition> that slides, often add one), so keep
* the block out of one. The bar sits on the top edge at z-50; to place it under
* a fixed site header instead, change `top-0` to the header's height (`top-16`
* for a 64px header).
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { scrollProgress } from 'svg-scroll-draw/progress';
export interface ReadingProgressProps {
kicker?: string;
title?: string;
/** Line under the title, such as the author and the reading time. */
byline?: string;
/** Heading level for the title. Use 'h1' when the article is the page's main content. */
as?: 'h1' | 'h2';
/** Colour of the bar's fill. Any CSS colour. */
accent?: string;
className?: string;
}
const props = withDefaults(defineProps<ReadingProgressProps>(), {
kicker: 'Field notes',
title: 'We close on Wednesdays now, and here is what changed',
byline: 'The bakery on Kessel Street · 2 min read',
as: 'h2',
accent: '#ff90e8',
className: '',
});
// The placeholder's section headings sit one level under the title.
const subheading = computed(() => (props.as === 'h1' ? 'h2' : 'h3'));
const frameRef = ref<HTMLDivElement | null>(null);
const shown = ref(false);
let stop: (() => void) | null = null;
onMounted(() => {
const frame = frameRef.value;
if (!frame) return;
// scrollProgress writes its variable on the element it measures. Set on the
// article, every element inside would inherit the new value and be restyled
// with it on every scroll frame. `frame` is an empty layer over the same box
// with only the bar inside, so the bar is all that is restyled.
const progress = scrollProgress(frame, {
variable: '--reading-progress',
// Only the linear value is used: skip writing the eased one.
easedVariable: null,
// The end is a point every page can reach: however the page ends, the
// article's bottom can always scroll up to the bottom of the viewport.
trigger: { start: 'top top', end: 'bottom bottom' },
});
// scrollProgress updates only while the element it measures is on screen.
// That is invisible for most uses, but this bar is fixed and stays in view:
// a jump over the whole article in one step, or the browser restoring a
// scroll position past the article after load, would leave the bar showing a
// value that is no longer true. So the bar is shown only while the article
// is on screen, where the library keeps it exact on every frame.
//
// It also stays hidden for an article no taller than the viewport. There is
// nothing to scroll through, and the window runs backwards (its end comes
// before its start), so the bar would start full and empty as you scroll.
let onScreen = false;
// Vue skips the re-render when the value is unchanged.
const sync = () => {
shown.value = onScreen && frame.getBoundingClientRect().height > window.innerHeight;
};
const io = new IntersectionObserver((entries) => {
// Entries arrive in order, so the last one is the current state.
for (const entry of entries) onScreen = entry.isIntersecting;
sync();
});
io.observe(frame);
// The article's height changes as images and fonts load; the viewport's
// changes on a resize, including a mobile toolbar showing or hiding. The
// library re-measures its own window on both; these only decide `shown`.
// ResizeObserver is missing in jsdom, where component tests often stub
// IntersectionObserver alone.
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(sync);
ro?.observe(frame);
window.addEventListener('resize', sync);
// destroy() removes the variable along with the library's listeners, so a
// remount starts clean.
stop = () => {
progress.destroy();
io.disconnect();
ro?.disconnect();
window.removeEventListener('resize', sync);
};
});
onBeforeUnmount(() => {
stop?.();
stop = null;
});
</script>
<template>
<article :class="['relative mx-auto w-full max-w-2xl', className]">
<!-- The layer scrollProgress measures and writes to: it covers the article
exactly and takes no clicks, so text under it can still be selected. -->
<div ref="frameRef" aria-hidden="true" class="pointer-events-none absolute inset-0">
<div
:class="[
'fixed inset-x-0 top-0 z-50 h-[3px] bg-neutral-900/10 print:hidden motion-safe:transition-opacity motion-safe:duration-300 dark:bg-white/15',
shown ? 'opacity-100' : 'opacity-0',
]"
>
<!-- Transform, not width, so a scroll frame never triggers layout. Until
onMounted runs the variable is unset and the fallback keeps the
fill empty. It fills from the reading start: the right on an RTL page. -->
<div
class="h-full origin-left will-change-transform [transform:scaleX(var(--reading-progress,0))] rtl:origin-right"
:style="{ backgroundColor: accent }"
/>
</div>
</div>
<header>
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{{ kicker }}
</p>
<component
:is="as"
class="mt-3 text-4xl font-medium tracking-tight text-neutral-950 text-balance sm:text-5xl dark:text-neutral-50"
>
{{ title }}
</component>
<p class="mt-4 text-sm text-neutral-600 dark:text-neutral-400">{{ byline }}</p>
</header>
<div class="mt-10 space-y-6 text-lg leading-relaxed text-neutral-700 dark:text-neutral-300">
<slot>
<!-- Placeholder copy. Replace it by passing your own article in the default slot. -->
<p>
For eleven years the bakery on Kessel Street opened six days a week. The ovens were lit at four,
the first rye came out at half past five, and by Friday everyone behind the counter was running on
coffee and habit. Last March we tried something smaller: open four days, Thursday to Sunday, with
Wednesday kept for the work that never fitted between customers.
</p>
<component :is="subheading" class="pt-4 text-2xl font-semibold tracking-tight text-neutral-950 dark:text-neutral-50">
Why we stopped
</component>
<p>
It was not about rest at first. It was about waste. On Tuesdays and Wednesdays we baked for the
crowd we hoped for and sold to the crowd we had, and about one loaf in five went to the food bank or
the compost. Those days only looked like they paid their way because the weekend was paying for them.
</p>
<p>
Closing on the slow days let us bake later on the busy ones. The second bake moved to eleven, so the
sourdough that used to sit on the shelf from dawn came out in time for lunch. A regular asked whether
we had changed the flour. We had changed the clock.
</p>
<component :is="subheading" class="pt-4 text-2xl font-semibold tracking-tight text-neutral-950 dark:text-neutral-50">
What the numbers did
</component>
<p>
Six months in, we sell about as many loaves a week as before, in four days instead of six. Waste
fell from a fifth of each bake to roughly one loaf in twenty. Takings dipped in the first month,
while people learned the new days, and were back to normal by the third.
</p>
<p>
Wednesday changed the most. With the door locked, the team tests recipes, fixes what broke during
the week and trains the apprentice on the shaping bench without a queue at the counter. The proofing
cabinet that leaked all winter was mended on the second Wednesday. Two of the loaves on today's
board started as Wednesday experiments.
</p>
<component :is="subheading" class="pt-4 text-2xl font-semibold tracking-tight text-neutral-950 dark:text-neutral-50">
What we would tell another small shop
</component>
<p>
Count what you throw away before you count what you sell; the quiet days are often the expensive
ones. Tell customers early and in plain words, on the door, on the receipts and at the till. We lost
fewer regulars to the new hours than to the six weeks the road outside was dug up.
</p>
<p>
We are not going back. The ovens still light at four, only now four times a week, and the people who
light them have Monday and Tuesday to themselves.
</p>
</slot>
</div>
</article>
</template>
<script module lang="ts">
/**
* reading-progress — an article with a thin bar fixed to the top of the
* viewport that fills as the reader moves through it.
*
* Built on svg-scroll-draw (MIT): https://svg-scroll-draw.vercel.app/blocks
* Needs: svelte 5, svg-scroll-draw 2.19 or later (`scrollProgress` re-measures
* when the layout changes, such as images loading in the article; earlier
* versions re-measure only on a window resize, so the bar would fill before
* the end). Styling: Tailwind CSS.
*
* Uses `scrollProgress` from svg-scroll-draw/progress. It writes the reader's
* position as a CSS variable, --reading-progress (0 to 1), and the bar's fill is
* plain CSS, scaleX(var(--reading-progress)), so Svelte does not update the
* page per frame. The bar is empty when the article's top reaches the top of
* the viewport and full when the article's bottom reaches the bottom of the
* viewport.
*
* Pass your article as the component's children (the content between its
* tags). The default is a placeholder essay, long on purpose: the bar appears
* only for an article taller than the viewport, and only while the article is
* on screen. `kicker`, `title` and `byline` fill the header, `as` sets the
* title's heading level and `accent` colours the bar.
*
* The bar is decorative and aria-hidden: the scroll position already tells
* assistive technology where the reader is, and a progressbar whose value
* changed on every frame would only add noise. The library has no
* reduced-motion path for scrollProgress, so the block sets its own: the fill
* moves only as far as the reader scrolls, like a scrollbar, so it stays; the
* fade in and out is removed.
*
* `position: fixed` places the bar against the viewport unless an ancestor has a
* transform, filter, backdrop-filter, perspective or contain (page-transition
* wrappers often add one), so keep the block out of one. The bar sits on the top
* edge at z-50; to place it under a fixed site header instead, change `top-0` to
* the header's height (`top-16` for a 64px header).
*/
import type { Snippet } from 'svelte';
import { scrollProgress } from 'svg-scroll-draw/progress';
export interface ReadingProgressProps {
/** The article body. Defaults to a placeholder essay. */
children?: Snippet;
kicker?: string;
title?: string;
/** Line under the title, such as the author and the reading time. */
byline?: string;
/** Heading level for the title. Use 'h1' when the article is the page's main content. */
as?: 'h1' | 'h2';
/** Colour of the bar's fill. Any CSS colour. */
accent?: string;
className?: string;
}
</script>
<script lang="ts">
let {
children,
kicker = 'Field notes',
title = 'We close on Wednesdays now, and here is what changed',
byline = 'The bakery on Kessel Street · 2 min read',
as: heading = 'h2',
accent = '#ff90e8',
className = '',
}: ReadingProgressProps = $props();
// The placeholder's section headings sit one level under the title.
const subheading = $derived(heading === 'h1' ? 'h2' : 'h3');
const subheadingClass = 'pt-4 text-2xl font-semibold tracking-tight text-neutral-950 dark:text-neutral-50';
// Set by bind:this once, at mount; the effect below runs once, after it.
let frame: HTMLDivElement;
let shown = $state(false);
$effect(() => {
if (!frame) return;
const el = frame;
// scrollProgress writes its variable on the element it measures. Set on the
// article, every element inside would inherit the new value and be restyled
// with it on every scroll frame. `frame` is an empty layer over the same box
// with only the bar inside, so the bar is all that is restyled.
const progress = scrollProgress(el, {
variable: '--reading-progress',
// Only the linear value is used: skip writing the eased one.
easedVariable: null,
// The end is a point every page can reach: however the page ends, the
// article's bottom can always scroll up to the bottom of the viewport.
trigger: { start: 'top top', end: 'bottom bottom' },
});
// scrollProgress updates only while the element it measures is on screen.
// That is invisible for most uses, but this bar is fixed and stays in view:
// a jump over the whole article in one step, or the browser restoring a
// scroll position past the article after load, would leave the bar showing a
// value that is no longer true. So the bar is shown only while the article
// is on screen, where the library keeps it exact on every frame.
//
// It also stays hidden for an article no taller than the viewport. There is
// nothing to scroll through, and the window runs backwards (its end comes
// before its start), so the bar would start full and empty as you scroll.
let onScreen = false;
// Svelte skips the update when the value is unchanged.
const sync = () => {
shown = onScreen && el.getBoundingClientRect().height > window.innerHeight;
};
const io = new IntersectionObserver((entries) => {
// Entries arrive in order, so the last one is the current state.
for (const entry of entries) onScreen = entry.isIntersecting;
sync();
});
io.observe(el);
// The article's height changes as images and fonts load; the viewport's
// changes on a resize, including a mobile toolbar showing or hiding. The
// library re-measures its own window on both; these only decide `shown`.
// ResizeObserver is missing in jsdom, where component tests often stub
// IntersectionObserver alone.
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(sync);
ro?.observe(el);
window.addEventListener('resize', sync);
// destroy() removes the variable along with the library's listeners, so a
// remount starts clean.
return () => {
progress.destroy();
io.disconnect();
ro?.disconnect();
window.removeEventListener('resize', sync);
};
});
</script>
<article class="relative mx-auto w-full max-w-2xl {className}">
<!-- The layer scrollProgress measures and writes to: it covers the article
exactly and takes no clicks, so text under it can still be selected. -->
<div bind:this={frame} aria-hidden="true" class="pointer-events-none absolute inset-0">
<div
class="fixed inset-x-0 top-0 z-50 h-[3px] bg-neutral-900/10 print:hidden motion-safe:transition-opacity motion-safe:duration-300 dark:bg-white/15 {shown
? 'opacity-100'
: 'opacity-0'}"
>
<!-- Transform, not width, so a scroll frame never triggers layout. Until
the effect runs the variable is unset and the fallback keeps the
fill empty. It fills from the reading start: the right on an RTL page. -->
<div
class="h-full origin-left will-change-transform [transform:scaleX(var(--reading-progress,0))] rtl:origin-right"
style:background-color={accent}
></div>
</div>
</div>
<header>
<p class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{kicker}
</p>
<svelte:element
this={heading}
class="mt-3 text-4xl font-medium tracking-tight text-neutral-950 text-balance sm:text-5xl dark:text-neutral-50"
>
{title}
</svelte:element>
<p class="mt-4 text-sm text-neutral-600 dark:text-neutral-400">{byline}</p>
</header>
<div class="mt-10 space-y-6 text-lg leading-relaxed text-neutral-700 dark:text-neutral-300">
{#if children}
{@render children()}
{:else}
<!-- Placeholder copy. Replace it by passing your own article as children. -->
<p>
For eleven years the bakery on Kessel Street opened six days a week. The ovens were lit at four,
the first rye came out at half past five, and by Friday everyone behind the counter was running on
coffee and habit. Last March we tried something smaller: open four days, Thursday to Sunday, with
Wednesday kept for the work that never fitted between customers.
</p>
<svelte:element this={subheading} class={subheadingClass}>Why we stopped</svelte:element>
<p>
It was not about rest at first. It was about waste. On Tuesdays and Wednesdays we baked for the
crowd we hoped for and sold to the crowd we had, and about one loaf in five went to the food bank or
the compost. Those days only looked like they paid their way because the weekend was paying for them.
</p>
<p>
Closing on the slow days let us bake later on the busy ones. The second bake moved to eleven, so the
sourdough that used to sit on the shelf from dawn came out in time for lunch. A regular asked whether
we had changed the flour. We had changed the clock.
</p>
<svelte:element this={subheading} class={subheadingClass}>What the numbers did</svelte:element>
<p>
Six months in, we sell about as many loaves a week as before, in four days instead of six. Waste
fell from a fifth of each bake to roughly one loaf in twenty. Takings dipped in the first month,
while people learned the new days, and were back to normal by the third.
</p>
<p>
Wednesday changed the most. With the door locked, the team tests recipes, fixes what broke during
the week and trains the apprentice on the shaping bench without a queue at the counter. The proofing
cabinet that leaked all winter was mended on the second Wednesday. Two of the loaves on today's
board started as Wednesday experiments.
</p>
<svelte:element this={subheading} class={subheadingClass}>What we would tell another small shop</svelte:element>
<p>
Count what you throw away before you count what you sell; the quiet days are often the expensive
ones. Tell customers early and in plain words, on the door, on the receipts and at the till. We lost
fewer regulars to the new hours than to the six weeks the road outside was dug up.
</p>
<p>
We are not going back. The ovens still light at four, only now four times a week, and the people who
light them have Monday and Tuesday to themselves.
</p>
{/if}
</div>
</article>
10Manual install
Without the shadcn CLI
Install the package, then paste a block's source into components/scroll/. Every block is a client component, so it works in the Next.js App Router as-is.