FLARE

FLARE / Chapters / deck-pin

deck-pin

Cards stick. The top one keeps the line.

Needs pnpm add gsap

DeckPin.svelte

<!--
  Flare · deck-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 = 'Preview',
		accent = 'ember',
		reduceMotion = false
	}: {
		lead?: string;
		accent?: 'ember' | 'paper';
		reduceMotion?: boolean;
	} = $props();

	const cards = $derived([
		{
			kind: 'front',
			title: lead.trim() || 'Preview',
			body: 'Front card. One ember hairline. Live on the page. This is Flare.'
		},
		{
			kind: 'mid',
			title: 'Copy',
			body: 'The unit is the .svelte. Preview, then copy. No kit, no registry.'
		},
		{
			kind: 'back',
			title: 'Yours',
			body: 'When the pin ends, the chapter is yours. Paste it. Add gsap.'
		}
	]);

	let root: HTMLElement | undefined = $state();
	let front = $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 deck = gsap.utils.toArray<HTMLElement>('.card');
				deck.forEach((card, i) => {
					const last = i === deck.length - 1;
					if (last) {
						ScrollTrigger.create({
							trigger: card,
							start: 'top top',
							end: 'bottom bottom',
							invalidateOnRefresh: true,
							onToggle: (self) => {
								if (self.isActive) front = i;
							}
						});
						return;
					}
					ScrollTrigger.create({
						trigger: card,
						start: 'top top',
						endTrigger: deck[deck.length - 1],
						end: 'top top',
						pin: true,
						pinSpacing: false,
						invalidateOnRefresh: true,
						onToggle: (self) => {
							if (self.isActive) front = i;
						}
					});
					gsap.to(card, {
						scale: 0.92,
						opacity: 0.55,
						ease: 'none',
						scrollTrigger: {
							trigger: deck[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="deck"
	class:paper={accent === 'paper'}
	class:reduce={reduceMotion}
>
	{#each cards as card, i (card.kind)}
		<article class="card" class:front={i === front} data-kind={card.kind}>
			<h2>{card.title}</h2>
			<p>{card.body}</p>
		</article>
	{/each}
</section>

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

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

	.card {
		position: sticky;
		top: 0;
		display: flex;
		min-height: 100dvh;
		flex-direction: column;
		justify-content: flex-end;
		padding: 8vh 8vw 12vh;
		border-radius: 12px 12px 0 0;
		background: var(--card);
		box-shadow: 0 -40px 80px rgba(0, 0, 0, 0.35);
		transform-origin: center top;
	}

	.card[data-kind='front'] {
		background:
			radial-gradient(circle at 88% 12%, color-mix(in oklab, var(--accent) 22%, transparent), transparent 28%),
			var(--card);
	}

	.card[data-kind='mid'] {
		background: #161618;
	}

	.card[data-kind='back'] {
		background: linear-gradient(180deg, #1a1a1e, #111113);
		box-shadow: inset 0 -6px 0 rgba(245, 240, 234, 0.16), 0 -40px 80px rgba(0, 0, 0, 0.35);
	}

	.card.front {
		box-shadow:
			inset 0 1px 0 var(--accent),
			0 -40px 80px rgba(0, 0, 0, 0.35);
	}

	.card.front[data-kind='back'] {
		box-shadow:
			inset 0 1px 0 var(--accent),
			inset 0 -6px 0 rgba(245, 240, 234, 0.16),
			0 -40px 80px rgba(0, 0, 0, 0.35);
	}

	h2 {
		margin: 0;
		max-width: 12ch;
		font-size: clamp(3.2rem, 8vw, 6.5rem);
		font-weight: 760;
		line-height: 0.86;
		letter-spacing: -0.05em;
	}

	p {
		margin: 1.1rem 0 0;
		max-width: 36rem;
		font-family: var(--font-body, 'IBM Plex Sans', ui-sans-serif, sans-serif);
		font-size: 14px;
		line-height: 1.6;
		color: var(--paper);
	}

	.deck.reduce .card {
		position: relative;
		transform: none;
		opacity: 1;
	}

	@media (prefers-reduced-motion: reduce) {
		.card {
			position: relative;
			transform: none;
			opacity: 1;
		}
	}
</style>

Edit

Accent