FLARE

FLARE / Chapters / lane-scrub

lane-scrub

Scroll down. The track pans sideways.

Needs pnpm add gsap

LaneScrub.svelte

<!--
  Flare · lane-scrub
  Paste into a SvelteKit 5 + Tailwind v4 app. Needs: pnpm add gsap
  Type: Unbounded + IBM Plex Sans (host loads fontsource). IBM Plex Mono for HUD/meta.
  Photo: pass photoSrc (gallery uses /blocks/lane-still.jpg). Only the photo panel renders a still.
-->
<script lang="ts">
	import { gsap } from 'gsap';
	import { ScrollTrigger } from 'gsap/ScrollTrigger';

	if (typeof window !== 'undefined') {
		gsap.registerPlugin(ScrollTrigger);
	}

	let {
		label = 'INK',
		accent = 'ember',
		reduceMotion = false,
		photoSrc = '/blocks/lane-still.jpg'
	}: {
		label?: string;
		accent?: 'ember' | 'paper';
		reduceMotion?: boolean;
		photoSrc?: string;
	} = $props();

	const panels = $derived([
		{
			id: '01',
			mark: (label.trim() || 'INK').toUpperCase(),
			kind: 'type' as const,
			width: '28rem',
			title: label.trim() || 'INK',
			body: 'Scroll down. The track pans sideways. Four beats, then it stops.'
		},
		{
			id: '02',
			mark: 'STILL',
			kind: 'photo' as const,
			width: '22rem',
			title: 'Still',
			body: 'A photographic lock. Not a box. The lane holds this frame while you scrub.'
		},
		{
			id: '03',
			mark: 'EMBER',
			kind: 'type' as const,
			width: '20rem',
			title: 'Ember',
			body: 'One line of heat. The scrub is a hard stop. It does not loop.'
		},
		{
			id: '04',
			mark: 'COPY',
			kind: 'ember' as const,
			width: '26rem',
			title: 'Copy',
			body: 'Copy the file after the scrub ends. The chapter is yours.'
		}
	]);

	let root: HTMLElement | undefined = $state();
	let track: HTMLElement | undefined = $state();
	let tick = $state(0);
	let playhead = $state(0);

	const active = $derived(panels[tick] ?? panels[0]);

	$effect(() => {
		const wrap = root;
		const row = track;
		const forced = reduceMotion;
		if (!wrap || !row) return;
		let ctx: gsap.Context | undefined;
		let cancelled = false;

		const run = () => {
			if (cancelled || !root || !track) return;
			if (forced || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

			ctx = gsap.context(() => {
				const viewportW = () => wrap.clientWidth;
				const travel = () => Math.max(row.scrollWidth - viewportW(), 0);
				gsap.to(row, {
					x: () => -travel(),
					ease: 'none',
					scrollTrigger: {
						trigger: wrap,
						start: 'top top',
						end: () => `+=${travel()}`,
						pin: true,
						scrub: 1,
						invalidateOnRefresh: true,
						onUpdate: (self) => {
							playhead = self.progress;
							tick = self.progress >= 1 ? 3 : Math.min(3, Math.floor(self.progress * 4));
						}
					}
				});
			}, wrap);

			const refresh = () => {
				if (!cancelled) ScrollTrigger.refresh();
			};
			void document.fonts?.ready.then(refresh);
			wrap.querySelectorAll('img').forEach((img) => {
				if (!img.complete) img.addEventListener('load', refresh, { once: true });
			});
		};

		run();
		return () => {
			cancelled = true;
			ctx?.revert();
		};
	});
</script>

<section
	bind:this={root}
	class="lane"
	class:paper={accent === 'paper'}
	class:reduce={reduceMotion}
>
	<div class="hud">
		<p>lane-scrub · scroll Y · pan X</p>
		<p>{active.id} / {active.mark}</p>
	</div>

	<div bind:this={track} class="track">
		{#each panels as panel, i (panel.id)}
			<article class="panel {panel.kind}" class:on={i === tick} style:flex-basis={panel.width}>
				<p class="tag">{panel.id} / {panel.mark}</p>
				{#if panel.kind === 'photo'}
					<figure>
						<img src={photoSrc} alt="Ink still. Dark facade, grid of glass." />
						<figcaption>{panel.body}</figcaption>
					</figure>
				{:else}
					<div
						class="field"
						class:hot={panel.kind === 'ember'}
						class:warm={panel.mark === 'EMBER'}
						aria-hidden="true"
					>
						<span class="ghost">{panel.mark}</span>
						<span class="wash"></span>
						<span class="hatch"></span>
						<span class="grid"></span>
						<span class="ticks"></span>
						<span class="corners"></span>
						<span class="fig">FIG {panel.id}</span>
						<span class="specimen">
							<em>00</em>
							<em>12</em>
							<em>24</em>
							<em>36</em>
						</span>
						{#if panel.kind === 'ember'}
							<div class="ember-col">
								{#each panels as step, n (step.id)}
									<span class:hot={n === tick}>{step.id}</span>
								{/each}
							</div>
						{/if}
					</div>
					<h2>{panel.title}</h2>
					<p class="body">{panel.body}</p>
				{/if}
			</article>
		{/each}
	</div>

	<div class="ruler" aria-hidden="true">
		<div class="rail">
			<span class="playhead" style:left="{playhead * 100}%"></span>
		</div>
		<div class="stops">
			{#each panels as panel, i (panel.id)}
				<span class:on={i === tick}>{panel.id} {panel.mark}</span>
			{/each}
		</div>
	</div>
</section>

<style>
	.lane {
		--ink: #09090b;
		--paper: #f5f0ea;
		--accent: #ff5a1f;
		--card: #111113;
		container-type: inline-size;
		position: relative;
		min-height: 100dvh;
		overflow: hidden;
		background: var(--ink);
		color: var(--paper);
		font-family: var(--font-display, 'Unbounded Variable', Unbounded, ui-sans-serif, sans-serif);
	}

	.lane.paper {
		--accent: #f5f0ea;
	}

	.hud {
		position: absolute;
		z-index: 2;
		top: 1.15rem;
		right: 1.25rem;
		left: 1.25rem;
		display: flex;
		justify-content: space-between;
		gap: 1rem;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 11px;
		letter-spacing: 0.08em;
		text-transform: uppercase;
		color: #8b8278;
	}

	.hud p {
		margin: 0;
	}

	.hud p:last-child {
		color: var(--accent);
	}

	.track {
		display: flex;
		height: 100dvh;
		width: max-content;
		align-items: stretch;
		padding: 3.6rem 0 4.2rem;
		gap: 1px;
		background: rgba(245, 240, 234, 0.1);
	}

	.panel {
		flex-grow: 0;
		flex-shrink: 0;
		display: flex;
		flex-direction: column;
		min-width: 14rem;
		padding: 1.25rem;
		background: var(--ink);
	}

	.panel.photo {
		padding: 1.25rem 1.25rem 1.1rem;
		background: #101012;
	}

	.panel.ember {
		background: #101012;
		box-shadow: inset 8px 0 0 var(--accent);
	}

	.tag {
		margin: 0;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 11px;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		color: #8b8278;
	}

	.panel.on .tag {
		color: var(--accent);
	}

	.field {
		position: relative;
		flex: 1;
		min-height: 11rem;
		margin: 0.85rem 0 0.95rem;
		overflow: hidden;
		border: 1px solid rgba(245, 240, 234, 0.16);
		background:
			linear-gradient(180deg, rgba(245, 240, 234, 0.03), transparent 28%),
			#101012;
	}

	.field.warm {
		border-color: color-mix(in oklab, var(--accent) 28%, rgba(245, 240, 234, 0.14));
	}

	.field.hot {
		border-color: color-mix(in oklab, var(--accent) 48%, rgba(245, 240, 234, 0.12));
		box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 22%, transparent);
	}

	.ghost {
		position: absolute;
		z-index: 1;
		right: 0.2rem;
		bottom: -0.08em;
		left: 0.35rem;
		font-size: clamp(3.8rem, 14cqw, 7rem);
		font-weight: 760;
		line-height: 0.76;
		letter-spacing: -0.07em;
		color: color-mix(in oklab, var(--paper) 20%, transparent);
		pointer-events: none;
	}

	.field.warm .ghost,
	.field.hot .ghost {
		color: color-mix(in oklab, var(--accent) 34%, transparent);
	}

	.wash {
		position: absolute;
		inset: -22%;
		background:
			radial-gradient(
				ellipse 78% 58% at 24% 68%,
				color-mix(in oklab, var(--accent) 38%, transparent),
				transparent 62%
			),
			radial-gradient(circle at 86% 10%, color-mix(in oklab, var(--paper) 12%, transparent), transparent 26%);
	}

	.field.warm .wash {
		background:
			radial-gradient(
				ellipse 90% 70% at 70% 40%,
				color-mix(in oklab, var(--accent) 46%, transparent),
				transparent 58%
			),
			radial-gradient(circle at 12% 80%, color-mix(in oklab, var(--accent) 16%, transparent), transparent 32%);
	}

	.hatch {
		position: absolute;
		inset: 0;
		opacity: 0.55;
		background: repeating-linear-gradient(
			-18deg,
			transparent 0 13px,
			rgba(245, 240, 234, 0.045) 13px 14px
		);
	}

	.field.warm .hatch,
	.field.hot .hatch {
		background: repeating-linear-gradient(
			18deg,
			transparent 0 11px,
			color-mix(in oklab, var(--accent) 14%, transparent) 11px 12px
		);
	}

	.grid {
		position: absolute;
		inset: 0;
		background:
			repeating-linear-gradient(
				0deg,
				transparent 0 18px,
				rgba(245, 240, 234, 0.07) 18px 19px
			),
			repeating-linear-gradient(
				90deg,
				transparent 0 18px,
				rgba(245, 240, 234, 0.05) 18px 19px
			);
		mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
	}

	.ticks {
		position: absolute;
		top: 0.55rem;
		right: 0.6rem;
		bottom: 0.55rem;
		width: 12px;
		background:
			repeating-linear-gradient(
				180deg,
				var(--accent) 0 2px,
				transparent 2px 10px
			);
		opacity: 0.8;
	}

	.corners {
		position: absolute;
		inset: 0.45rem;
		border: 1px solid transparent;
		background:
			linear-gradient(var(--paper), var(--paper)) 0 0 / 12px 1px no-repeat,
			linear-gradient(var(--paper), var(--paper)) 0 0 / 1px 12px no-repeat,
			linear-gradient(var(--paper), var(--paper)) 100% 0 / 12px 1px no-repeat,
			linear-gradient(var(--paper), var(--paper)) 100% 0 / 1px 12px no-repeat,
			linear-gradient(var(--paper), var(--paper)) 0 100% / 12px 1px no-repeat,
			linear-gradient(var(--paper), var(--paper)) 0 100% / 1px 12px no-repeat,
			linear-gradient(var(--paper), var(--paper)) 100% 100% / 12px 1px no-repeat,
			linear-gradient(var(--paper), var(--paper)) 100% 100% / 1px 12px no-repeat;
		opacity: 0.35;
		pointer-events: none;
	}

	.fig {
		position: absolute;
		z-index: 1;
		top: 0.7rem;
		left: 0.75rem;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 10px;
		letter-spacing: 0.16em;
		color: #8b8278;
	}

	.field.hot .fig,
	.field.warm .fig {
		color: color-mix(in oklab, var(--accent) 70%, #8b8278);
	}

	.specimen {
		position: absolute;
		z-index: 1;
		top: 2.1rem;
		left: 0.75rem;
		display: flex;
		flex-direction: column;
		gap: 0.35rem;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 10px;
		letter-spacing: 0.12em;
		color: rgba(245, 240, 234, 0.32);
	}

	.specimen em {
		font-style: normal;
		padding-bottom: 0.35rem;
		border-bottom: 1px solid rgba(245, 240, 234, 0.12);
	}

	h2 {
		margin: 0 0 0.55rem;
		max-width: 12ch;
		font-size: clamp(2rem, 4cqw, 3.4rem);
		font-weight: 720;
		line-height: 0.92;
		letter-spacing: -0.04em;
	}

	.body {
		margin: 0;
		max-width: 28rem;
		font-family: var(--font-body, 'IBM Plex Sans', ui-sans-serif, sans-serif);
		font-size: 12px;
		line-height: 1.6;
		color: #c4bbb0;
	}

	figure {
		display: flex;
		flex: 1;
		flex-direction: column;
		min-height: 0;
		margin: 1rem 0 0;
	}

	img {
		display: block;
		width: 100%;
		flex: 1;
		object-fit: cover;
		background: #161618;
	}

	figcaption {
		margin: 0.7rem 0 0;
		max-width: 28rem;
		font-family: var(--font-body, 'IBM Plex Sans', ui-sans-serif, sans-serif);
		font-size: 12px;
		line-height: 1.5;
		color: #c4bbb0;
	}

	.ember-col {
		position: absolute;
		z-index: 1;
		left: 0.75rem;
		bottom: 0.7rem;
		display: flex;
		flex-direction: column;
		gap: 0.45rem;
		margin: 0;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 11px;
		letter-spacing: 0.14em;
		color: rgba(245, 240, 234, 0.28);
	}

	.ember-col span {
		display: block;
		padding-bottom: 0.45rem;
		border-bottom: 1px solid rgba(245, 240, 234, 0.18);
	}

	.ember-col span.hot {
		color: var(--accent);
		border-bottom-color: var(--accent);
	}

	.ruler {
		position: absolute;
		z-index: 2;
		right: 1.25rem;
		bottom: 0.85rem;
		left: 1.25rem;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 10px;
		letter-spacing: 0.08em;
		text-transform: uppercase;
		color: #8b8278;
	}

	.rail {
		position: relative;
		height: 2px;
		margin-bottom: 0.55rem;
		background: rgba(245, 240, 234, 0.16);
	}

	.playhead {
		position: absolute;
		top: 50%;
		width: 10px;
		height: 10px;
		border: 5px solid transparent;
		border-bottom-color: var(--accent);
		transform: translate(-50%, calc(-50% - 6px));
	}

	.stops {
		display: flex;
		justify-content: space-between;
	}

	.stops .on {
		color: var(--accent);
	}

	.lane.reduce {
		overflow-x: auto;
		scroll-snap-type: x mandatory;
	}

	.lane.reduce .track {
		transform: none;
	}

	.lane.reduce .panel {
		scroll-snap-align: start;
	}

	@media (prefers-reduced-motion: reduce) {
		.lane {
			overflow-x: auto;
			scroll-snap-type: x mandatory;
		}

		.track {
			transform: none;
		}

		.panel {
			scroll-snap-align: start;
		}
	}
</style>

Edit

Accent