FLARE

FLARE / Chapters / split-masthead

split-masthead

Left column holds. Right column walks.

Needs pnpm add gsap

SplitMasthead.svelte

<!--
  Flare · split-masthead
  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.
-->
<script lang="ts">
	import { untrack } from 'svelte';
	import { gsap } from 'gsap';
	import { ScrollTrigger } from 'gsap/ScrollTrigger';

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

	let {
		lead = 'Flare',
		accent = 'ember',
		reduceMotion = false
	}: {
		lead?: string;
		accent?: 'ember' | 'paper';
		reduceMotion?: boolean;
	} = $props();

	const words = $derived([lead.trim() || 'Flare', 'Preview', 'Copy']);

	const rooms = $derived([
		{
			title: 'Flare is the spine.',
			body: 'The left column holds the word. The right column is the walk. Ink stays put.',
			shot: 'lock' as const,
			fig: 'FIG 01'
		},
		{
			title: 'Preview is the live chapter.',
			body: 'Scroll is the only transport. You watch the frame while it is still loose.',
			shot: 'cut' as const,
			fig: 'FIG 02'
		},
		{
			title: 'Copy the file. It is yours.',
			body: 'Paste the .svelte. Add gsap. The pin still runs.',
			shot: 'keep' as const,
			fig: 'FIG 03'
		}
	]);

	let root: HTMLElement | undefined = $state();
	let rail: HTMLElement | undefined = $state();
	let track: HTMLElement | undefined = $state();
	let active = $state(0);
	let fills = $state([0, 0, 0]);

	function unitFill(value: number): number {
		if (!Number.isFinite(value) || value <= 0.001) return 0;
		if (value >= 0.999) return 1;
		return value;
	}

	function roomFills(count: number, index: number, progress: number): number[] {
		const safeCount = Math.max(0, count);
		const room = Math.max(0, Math.min(Math.max(safeCount - 1, 0), index));
		const p = unitFill(progress);
		return Array.from({ length: safeCount }, (_, idx) => {
			if (idx < room) return 1;
			if (idx === room) return p;
			return 0;
		});
	}

	function paintRoom(index: number, progress: number, count: number, words?: HTMLElement[]) {
		const room = count <= 0 ? 0 : Math.max(0, Math.min(count - 1, index));
		const next = roomFills(count, room, progress);
		active = room;
		fills = next;
		words?.forEach((node, i) => {
			node.style.setProperty('--fill', `${unitFill(next[i] ?? 0) * 100}%`);
		});
	}

	function roomBounds(walk: ScrollTrigger, steps: HTMLElement[]): number[] {
		const y = walk.scroll();
		const start = walk.start;
		const end = walk.end;
		const bounds = [start];
		for (let i = 1; i < steps.length; i++) {
			bounds.push(y + steps[i].getBoundingClientRect().top);
		}
		bounds.push(end);
		for (let i = 1; i < bounds.length - 1; i++) {
			bounds[i] = Math.max(bounds[i - 1], Math.min(end, Math.max(start, bounds[i])));
		}
		return bounds;
	}

	function walkRoom(
		y: number,
		progress: number,
		bounds: number[],
		count: number
	): { room: number; fill: number } {
		if (count <= 0 || progress <= 0) return { room: 0, fill: 0 };
		if (progress >= 0.999) return { room: count - 1, fill: 1 };
		const end = bounds[bounds.length - 1];
		if (end !== undefined && y >= end - 2) return { room: count - 1, fill: 1 };
		let room = 0;
		for (let i = 0; i < count; i++) {
			const next = bounds[i + 1] ?? bounds[bounds.length - 1];
			if (y >= next) room = Math.min(count - 1, i + 1);
			else {
				room = i;
				break;
			}
		}
		const a = bounds[room] ?? 0;
		const b = bounds[room + 1] ?? a;
		return { room, fill: b <= a ? 1 : (y - a) / (b - a) };
	}

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

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

			untrack(() => {
				ctx = gsap.context(() => {
					const steps = gsap.utils.toArray<HTMLElement>('.room');
					const words = gsap.utils.toArray<HTMLElement>('.word');
					const count = steps.length;
					paintRoom(0, 0, count, words);

					if (reduced) {
						steps.forEach((step, i) => {
							ScrollTrigger.create({
								trigger: step,
								start: 'top 45%',
								end: 'bottom 45%',
								invalidateOnRefresh: true,
								onToggle: (self) => {
									if (self.isActive) active = i;
								}
							});
						});
						return;
					}

					gsap.set('.word', { opacity: 1, x: 0, y: 0 });

					const stacked = el.clientWidth <= 768;
					const applyWalk = (self: ScrollTrigger) => {
						const { room, fill } = walkRoom(
							self.scroll(),
							self.progress,
							roomBounds(self, steps),
							count
						);
						paintRoom(room, fill, count, words);
					};

					ScrollTrigger.create({
						trigger: stacked ? el : left,
						start: 'top top',
						endTrigger: right,
						end: 'bottom bottom',
						pin: stacked ? false : true,
						pinSpacing: false,
						scrub: true,
						invalidateOnRefresh: true,
						refreshPriority: -1,
						onRefreshInit: () => {
							gsap.set('.word', { opacity: 1, x: 0, y: 0 });
						},
						onRefresh: (self) => {
							applyWalk(self);
						},
						onUpdate: (self) => {
							applyWalk(self);
						}
					});
				}, el);
			});

			void document.fonts?.ready.then(() => {
				if (!cancelled) ScrollTrigger.refresh();
			});
		};

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

<section
	bind:this={root}
	class="mast"
	class:paper={accent === 'paper'}
	class:reduce={reduceMotion}
>
	<div class="shell">
		<aside bind:this={rail} class="rail">
			<div class="rail-lock">
				<p class="meta">SPLIT</p>
				<div class="words">
					{#each words as word, i (word + i)}
						<p class="line" class:on={i === active}>
							<span class="idx">{String(i + 1).padStart(2, '0')}</span>
							<span
								class="word"
								class:empty={unitFill(fills[i] ?? 0) === 0}
								class:full={unitFill(fills[i] ?? 0) === 1}
								data-fill={unitFill(fills[i] ?? 0)}
								style:--fill={`${unitFill(fills[i] ?? 0) * 100}%`}
							>
								<span class="idle">{word}</span>
								<span class="ember" aria-hidden="true">{word}</span>
							</span>
						</p>
					{/each}
				</div>
				<p class="progress">
					<span>{String(active + 1).padStart(2, '0')}</span>
					<span>/ 03</span>
				</p>
			</div>
		</aside>
		<div bind:this={track} class="track">
			{#each rooms as room, i (room.shot)}
				<article class="room">
					<p class="room-idx">{String(i + 1).padStart(2, '0')}</p>
					<h2>{room.title}</h2>
					<p class="lede">{room.body}</p>
					<figure class="shot" data-shot={room.shot}>
						<div class="subject" aria-hidden="true">
							<span class="wash"></span>
							<span class="grid"></span>
							<span class="rule"></span>
							{#if room.shot === 'lock'}
								<span class="crop"></span>
								<span class="cross"></span>
								<span class="specimen">FLARE</span>
							{:else if room.shot === 'cut'}
								<span class="slats"></span>
								<span class="head"></span>
								<span class="window"></span>
								<span class="specimen">LIVE</span>
							{:else}
								<span class="bar"></span>
								<span class="ghost">COPY</span>
								<span class="strip"></span>
							{/if}
						</div>
						<figcaption>{room.fig}</figcaption>
					</figure>
				</article>
			{/each}
		</div>
	</div>
</section>

<style>
	.mast {
		--ink: #09090b;
		--paper: #f5f0ea;
		--accent: #ff5a1f;
		container-type: inline-size;
		background: var(--ink);
		color: var(--paper);
		font-family: var(--font-display, 'Unbounded Variable', Unbounded, ui-sans-serif, sans-serif);
	}

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

	.shell {
		display: grid;
		grid-template-columns: minmax(11rem, 38%) 1fr;
		align-items: start;
		min-height: 100dvh;
	}

	.rail {
		position: relative;
		z-index: 2;
		align-self: start;
		height: 100dvh;
		max-height: 100dvh;
		min-height: 100dvh;
		overflow: hidden;
		border-right: 1px solid rgba(245, 240, 234, 0.16);
		background: var(--ink);
	}

	.rail-lock {
		container-type: inline-size;
		position: sticky;
		top: 0;
		display: flex;
		height: 100dvh;
		flex-direction: column;
		justify-content: space-between;
		gap: 1.5rem;
		padding: 1.25rem max(12px, 1rem) 1.35rem 1.25rem;
		background: var(--ink);
	}

	.meta,
	.idx,
	.progress,
	.room-idx,
	figcaption {
		margin: 0;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 11px;
		letter-spacing: 0.14em;
		text-transform: uppercase;
		color: #8b8278;
	}

	.words {
		display: flex;
		flex: 1;
		min-width: 0;
		flex-direction: column;
		justify-content: center;
		gap: 0.35rem;
	}

	.line {
		display: grid;
		grid-template-columns: 1.55rem minmax(0, 1fr);
		align-items: baseline;
		gap: 0.4rem;
		margin: 0;
		min-width: 0;
		padding-bottom: 0.22rem;
		border-bottom: 1px solid transparent;
	}

	.line.on {
		border-bottom-color: var(--accent);
	}

	.line.on .idx::before {
		content: '';
		position: absolute;
		left: -0.45rem;
		top: 0.55em;
		width: 5px;
		height: 5px;
		background: var(--accent);
	}

	.line.on .idx,
	.progress span:first-child {
		color: var(--accent);
	}

	.idx {
		position: relative;
	}

	.word {
		position: relative;
		display: block;
		min-width: 0;
		overflow: hidden;
		font-size: clamp(2.25rem, 18cqi, 5rem);
		font-weight: 720;
		line-height: 0.92;
		letter-spacing: -0.05em;
		white-space: nowrap;
		opacity: 1;
		color: var(--paper);
	}

	.idle,
	.ember {
		display: block;
	}

	.ember {
		position: absolute;
		top: 0;
		left: 0;
		color: var(--accent);
		pointer-events: none;
	}

	.idle {
		clip-path: inset(0 0 var(--fill, 0%) 0);
	}

	.word.empty .ember {
		visibility: hidden;
	}

	.word.full .idle {
		visibility: hidden;
		clip-path: inset(0 0 100% 0);
	}

	.mast.reduce .ember {
		display: none;
	}

	.mast.reduce .idle {
		clip-path: none;
	}

	.mast.reduce .line.on .idle {
		color: var(--accent);
	}

	.progress {
		display: flex;
		gap: 0.4rem;
	}

	.track {
		padding: 14vh 6vw 20vh;
	}

	.room {
		position: relative;
		min-height: 88dvh;
		padding: 0.2rem 0 12vh;
	}

	.room-idx {
		position: absolute;
		top: 0;
		right: 0;
	}

	h2 {
		margin: 0;
		max-width: 16ch;
		font-size: clamp(1.55rem, 2.2vw, 2rem);
		font-weight: 560;
		letter-spacing: -0.03em;
		line-height: 1.15;
	}

	.lede {
		margin: 1rem 0 0;
		max-width: 48ch;
		font-family: var(--font-body, 'IBM Plex Sans', ui-sans-serif, sans-serif);
		font-size: 13px;
		line-height: 1.7;
		color: #c4bbb0;
	}

	.shot {
		margin: 2rem 0 0;
		max-width: 42rem;
	}

	.subject {
		position: relative;
		height: min(38vh, 20rem);
		overflow: hidden;
		border: 1px solid rgba(245, 240, 234, 0.2);
		background: #101012;
	}

	.wash,
	.grid,
	.rule,
	.crop,
	.cross,
	.slats,
	.head,
	.window,
	.specimen,
	.bar,
	.ghost,
	.strip {
		position: absolute;
		pointer-events: none;
	}

	.wash {
		inset: -20%;
		background: radial-gradient(
			ellipse 60% 50% at 28% 70%,
			color-mix(in oklab, var(--accent) 36%, transparent),
			transparent 62%
		);
	}

	.grid {
		inset: 0;
		background:
			repeating-linear-gradient(0deg, transparent 0 20px, rgba(245, 240, 234, 0.055) 20px 21px),
			repeating-linear-gradient(90deg, transparent 0 20px, rgba(245, 240, 234, 0.04) 20px 21px);
		mask-image: linear-gradient(180deg, transparent, #000 18%, #000 82%, transparent);
	}

	.rule {
		top: 0.7rem;
		right: 0.75rem;
		bottom: 0.7rem;
		width: 10px;
		background: repeating-linear-gradient(180deg, var(--accent) 0 2px, transparent 2px 12px);
		opacity: 0.7;
	}

	.shot[data-shot='lock'] .crop {
		inset: 12% 14%;
		border: 1px solid rgba(245, 240, 234, 0.28);
		background:
			linear-gradient(var(--accent), var(--accent)) 0 0 / 18px 1px no-repeat,
			linear-gradient(var(--accent), var(--accent)) 0 0 / 1px 18px no-repeat,
			linear-gradient(var(--accent), var(--accent)) 100% 0 / 18px 1px no-repeat,
			linear-gradient(var(--accent), var(--accent)) 100% 0 / 1px 18px no-repeat,
			linear-gradient(var(--accent), var(--accent)) 0 100% / 18px 1px no-repeat,
			linear-gradient(var(--accent), var(--accent)) 0 100% / 1px 18px no-repeat,
			linear-gradient(var(--accent), var(--accent)) 100% 100% / 18px 1px no-repeat,
			linear-gradient(var(--accent), var(--accent)) 100% 100% / 1px 18px no-repeat;
	}

	.shot[data-shot='lock'] .cross {
		inset: 22% 24%;
		background:
			linear-gradient(var(--paper), var(--paper)) 50% 0 / 1px 100% no-repeat,
			linear-gradient(var(--paper), var(--paper)) 0 50% / 100% 1px no-repeat;
		opacity: 0.16;
	}

	.shot[data-shot='lock'] .specimen {
		right: 1rem;
		bottom: 0.15rem;
		left: 1rem;
		font-size: clamp(2.8rem, 7vw, 4.6rem);
		font-weight: 760;
		line-height: 0.78;
		letter-spacing: -0.06em;
		color: color-mix(in oklab, var(--paper) 22%, transparent);
	}

	.shot[data-shot='cut'] .slats {
		inset: 0;
		background: repeating-linear-gradient(
			90deg,
			transparent 0 18px,
			rgba(245, 240, 234, 0.09) 18px 19px,
			transparent 19px 42px
		);
	}

	.shot[data-shot='cut'] .head {
		top: 0;
		bottom: 0;
		left: 38%;
		width: 2px;
		background: var(--accent);
	}

	.shot[data-shot='cut'] .window {
		top: 18%;
		bottom: 18%;
		left: 22%;
		width: 36%;
		border: 1px solid color-mix(in oklab, var(--accent) 55%, rgba(245, 240, 234, 0.2));
		background: color-mix(in oklab, var(--ink) 55%, transparent);
	}

	.shot[data-shot='cut'] .specimen {
		top: 42%;
		left: 26%;
		font-size: 11px;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		letter-spacing: 0.16em;
		color: var(--accent);
	}

	.shot[data-shot='cut'] .wash {
		background: linear-gradient(115deg, #161618, #0c0c0e);
	}

	.shot[data-shot='keep'] .bar {
		top: 0;
		right: 0;
		left: 0;
		height: 4px;
		background: var(--accent);
	}

	.shot[data-shot='keep'] .ghost {
		right: 0.6rem;
		bottom: 0.2rem;
		left: 0.7rem;
		font-size: clamp(3rem, 8vw, 5.2rem);
		font-weight: 760;
		line-height: 0.78;
		letter-spacing: -0.06em;
		color: color-mix(in oklab, var(--accent) 28%, transparent);
	}

	.shot[data-shot='keep'] .strip {
		top: 1.4rem;
		left: 0.9rem;
		width: 42%;
		height: 4.2rem;
		background:
			linear-gradient(rgba(245, 240, 234, 0.22), rgba(245, 240, 234, 0.22)) 0 0 / 72% 1px no-repeat,
			linear-gradient(rgba(245, 240, 234, 0.14), rgba(245, 240, 234, 0.14)) 0 12px / 100% 1px no-repeat,
			linear-gradient(rgba(245, 240, 234, 0.1), rgba(245, 240, 234, 0.1)) 0 24px / 54% 1px no-repeat,
			linear-gradient(var(--accent), var(--accent)) 0 36px / 38% 1px no-repeat;
	}

	figcaption {
		margin: 0.65rem 0 0;
	}

	@container (max-width: 768px) {
		.shell {
			grid-template-columns: 1fr;
		}

		.rail {
			height: auto;
			max-height: none;
			min-height: 0;
			overflow: hidden;
			border-right: 0;
			border-bottom: 1px solid rgba(245, 240, 234, 0.16);
		}

		.rail-lock {
			position: relative;
			height: auto;
			padding: 1.25rem max(12px, 1.1rem) 1.1rem;
		}

		.word {
			font-size: clamp(2.1rem, 14cqi, 3.4rem);
		}
	}

	.mast.reduce .rail-lock {
		position: sticky;
	}

	.mast.reduce .word {
		opacity: 1;
		transform: none;
	}

	@media (prefers-reduced-motion: reduce) {
		.rail-lock {
			position: sticky;
		}

		.word {
			opacity: 1;
			transform: none;
		}

		.ember {
			display: none;
		}

		.idle {
			clip-path: none;
		}

		.line.on .idle {
			color: var(--accent);
		}
	}
</style>

Edit

Accent