FLARE

FLARE / Chapters / chapter-pin

chapter-pin

Rooms pin. The rail keeps count.

Needs pnpm add gsap

ChapterPin.svelte

<!--
  Flare · chapter-pin
  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 { gsap } from 'gsap';
	import { ScrollTrigger } from 'gsap/ScrollTrigger';

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

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

	const rooms = $derived([
		{
			title: lead.trim() || 'Rooms pin.',
			body: 'The rail keeps count. Ink stays put.',
			fig: 'FIG 01',
			mark: 'ROOM 01 - ACTIVE',
			live: 0
		},
		{
			title: 'Ember holds.',
			body: 'One outline. The rest stays ink.',
			fig: 'FIG 02',
			mark: 'ROOM 02 - ACTIVE',
			live: 1
		},
		{
			title: 'Copy the file.',
			body: 'When the pin lets go, the chapter is yours.',
			fig: 'FIG 03',
			mark: 'ROOM 03 - ACTIVE',
			live: 2
		}
	]);

	let root: HTMLElement | undefined = $state();
	let active = $state(0);

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

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

			ctx = gsap.context(() => {
				const cards = gsap.utils.toArray<HTMLElement>('.room');
				cards.forEach((card, i) => {
					const last = i === cards.length - 1;
					ScrollTrigger.create({
						trigger: card,
						start: 'top top',
						endTrigger: last ? card : cards[cards.length - 1],
						end: last ? '+=100%' : 'top top',
						pin: true,
						pinSpacing: last,
						invalidateOnRefresh: true,
						onToggle: (self) => {
							if (self.isActive) active = i;
						}
					});
					if (last) return;
					gsap.to(card, {
						scale: 0.97,
						ease: 'none',
						scrollTrigger: {
							trigger: cards[i + 1],
							start: 'top bottom',
							end: 'top top',
							scrub: true,
							invalidateOnRefresh: true
						}
					});
				});
			}, el);

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

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

<section
	bind:this={root}
	class="stack"
	class:paper={accent === 'paper'}
	class:reduce={reduceMotion}
>
	<div class="ticks-layer">
		<ol class="ticks" aria-label="Rooms">
			{#each rooms as room, i (room.fig)}
				<li>
					<a href="#room-{i + 1}" class:on={i === active}>
						<span class="tick"></span>
						{String(i + 1).padStart(2, '0')}
						<span class="sr">{room.title}</span>
					</a>
				</li>
			{/each}
		</ol>
	</div>

	{#each rooms as room, i (room.fig)}
		<article class="room" id="room-{i + 1}">
			<div class="frame" aria-hidden="true">
				<span class="corner tl"></span>
				<span class="corner tr"></span>
				<span class="corner bl"></span>
				<span class="corner br"></span>
				<span class="hair"></span>
			</div>
			<p class="fig">{room.fig}</p>
			<div class="band">
				<div class="copy">
					<h2>{room.title}</h2>
					<p>{room.body}</p>
				</div>
				<div class="plates" aria-hidden="true">
					{#each [0, 1, 2] as plate (plate)}
						<span class="plate" class:ember={plate === room.live} style:--i={plate}>
							{#if plate === room.live}
								<span class="mark">{room.mark}</span>
							{/if}
						</span>
					{/each}
				</div>
			</div>
		</article>
	{/each}
</section>

<style>
	.stack {
		--ink: #09090b;
		--paper: #f5f0ea;
		--accent: #ff5a1f;
		--card: #111113;
		--hairline: rgba(245, 240, 234, 0.2);
		position: relative;
		background: var(--ink);
		color: var(--paper);
		font-family: var(--font-display, 'Unbounded Variable', Unbounded, ui-sans-serif, sans-serif);
	}

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

	.ticks-layer {
		position: absolute;
		inset: 0;
		z-index: 3;
		pointer-events: none;
	}

	.ticks {
		position: sticky;
		top: 0;
		z-index: 3;
		display: flex;
		flex-direction: column;
		justify-content: center;
		gap: 0.85rem;
		margin: 0 0 0 auto;
		width: fit-content;
		height: 100dvh;
		padding: 0 1.25rem 0 0;
		list-style: none;
		pointer-events: auto;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 12px;
		color: #8b8278;
	}

	.ticks a {
		display: flex;
		align-items: center;
		gap: 0.55rem;
		color: inherit;
		text-decoration: none;
	}

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

	.tick {
		width: 1px;
		height: 0.9rem;
		background: transparent;
	}

	.ticks .on .tick {
		background: var(--accent);
	}

	.sr {
		position: absolute;
		width: 1px;
		height: 1px;
		overflow: hidden;
		clip: rect(0 0 0 0);
	}

	.room {
		position: relative;
		display: flex;
		min-height: 100dvh;
		flex-direction: column;
		justify-content: center;
		padding: 12vh 18vw 12vh 7vw;
		background: var(--ink);
		transform-origin: center top;
	}

	.frame {
		position: absolute;
		inset: 1.1rem 4.5rem 1.1rem 1.4rem;
		pointer-events: none;
	}

	.corner {
		position: absolute;
		width: 14px;
		height: 14px;
		border-color: var(--paper);
		border-style: solid;
		border-width: 0;
	}

	.tl {
		top: 0;
		left: 0;
		border-top-width: 1px;
		border-left-width: 1px;
	}

	.tr {
		top: 0;
		right: 0;
		border-top-width: 1px;
		border-right-width: 1px;
	}

	.bl {
		bottom: 0;
		left: 0;
		border-bottom-width: 1px;
		border-left-width: 1px;
	}

	.br {
		bottom: 0;
		right: 0;
		border-bottom-width: 1px;
		border-right-width: 1px;
	}

	.hair {
		position: absolute;
		top: 0;
		left: 1.35rem;
		width: 18px;
		height: 1px;
		background: var(--accent);
	}

	.fig {
		position: relative;
		margin: 0 0 2.2rem;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 11px;
		letter-spacing: 0.16em;
		text-transform: uppercase;
		color: #8b8278;
	}

	.band {
		display: grid;
		grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
		gap: 2.5rem 3rem;
		align-items: center;
	}

	.copy {
		min-width: 0;
	}

	h2 {
		margin: 0;
		max-width: 9ch;
		font-size: clamp(2.6rem, 6vw, 4.6rem);
		font-weight: 720;
		line-height: 0.92;
		letter-spacing: -0.05em;
		text-wrap: pretty;
	}

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

	.plates {
		position: relative;
		min-height: 14rem;
	}

	.plate {
		position: absolute;
		right: calc(var(--i) * 1.15rem);
		bottom: calc(var(--i) * 1.05rem);
		z-index: calc(1 + var(--i));
		width: 78%;
		height: 68%;
		border: 1px solid var(--hairline);
		background: var(--card);
	}

	.plate.ember {
		z-index: 5;
		border-color: var(--accent);
	}

	.mark {
		position: absolute;
		right: 0.85rem;
		bottom: 0.75rem;
		left: 0.85rem;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 11px;
		letter-spacing: 0.14em;
		text-transform: uppercase;
		color: var(--paper);
	}

	@media (max-width: 768px) {
		.room {
			padding: 10vh 4.75rem 10vh 1.25rem;
		}

		.frame {
			inset: 0.9rem 3.6rem 0.9rem 0.85rem;
		}

		.band {
			grid-template-columns: 1fr;
			gap: 1.75rem;
		}

		.plates {
			min-height: 11rem;
		}

		h2 {
			font-size: clamp(2.2rem, 12vw, 3.2rem);
		}
	}

	.stack.reduce .room {
		position: relative;
		transform: none;
	}

	.stack.reduce .ticks {
		float: none;
		height: auto;
		flex-direction: row;
		flex-wrap: wrap;
		padding: 1rem 6vw;
	}

	@media (prefers-reduced-motion: reduce) {
		.room {
			position: relative;
			transform: none;
		}

		.ticks {
			float: none;
			height: auto;
			flex-direction: row;
			flex-wrap: wrap;
			padding: 1rem 6vw;
		}
	}
</style>

Edit

Accent