FLARE

FLARE / Chapters / type-charge

type-charge

One line. One glyph of heat.

Needs pnpm add gsap

TypeCharge.svelte

<!--
  Flare · type-charge
  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 {
		word = 'FLARE',
		accent = 'ember',
		reduceMotion = false,
		atmosphere = true
	}: {
		word?: string;
		accent?: 'ember' | 'paper';
		reduceMotion?: boolean;
		atmosphere?: boolean;
	} = $props();

	const glyphs = $derived([...(word.trim() || 'FLARE').toUpperCase()]);
	const heat = $derived(glyphs.find((glyph) => 'AEIOU'.includes(glyph)) ?? glyphs[1] ?? glyphs[0] ?? '');

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

	$effect(() => {
		const el = root;
		const live = word;
		const forced = reduceMotion;
		if (!el) return;
		void live;
		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 tl = gsap.timeline({
					scrollTrigger: {
						trigger: el,
						start: 'top top',
						end: '+=140%',
						pin: true,
						scrub: true,
						invalidateOnRefresh: true
					}
				});

				tl.fromTo('.line', { scale: 0.94 }, { scale: 1, ease: 'none' }, 0)
					.to('.glyph', { yPercent: -18, opacity: 0, stagger: 0.02, ease: 'none' }, 0.72);
			}, el);

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

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

<section
	bind:this={root}
	class="charge"
	class:paper={accent === 'paper'}
	class:reduce={reduceMotion}
>
	{#if atmosphere}
		<div class="field" aria-hidden="true">
			<div class="wash"></div>
			<div class="grid"></div>
			<div class="beam"></div>
			<div class="grain"></div>
		</div>
	{/if}
	<p class="meta">Flare / type-charge</p>
	<h2 class="line" aria-label={word.trim() || 'FLARE'}>
		{#each glyphs as glyph, i (i)}
			<span class="glyph" class:heat={glyph === heat}>{glyph}</span>
		{/each}
	</h2>
</section>

<style>
	.charge {
		--ink: #09090b;
		--paper: #f5f0ea;
		--accent: #ff5a1f;
		position: relative;
		display: grid;
		min-height: 100dvh;
		place-items: center;
		overflow: hidden;
		background: var(--ink);
		color: var(--paper);
		font-family: var(--font-display, 'Unbounded Variable', Unbounded, ui-sans-serif, sans-serif);
	}

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

	.field {
		position: absolute;
		inset: 0;
		z-index: 0;
		pointer-events: none;
	}

	.wash {
		position: absolute;
		inset: -12%;
		background:
			radial-gradient(
				ellipse 58% 42% at 48% 54%,
				color-mix(in oklab, var(--accent) 24%, transparent),
				transparent 64%
			),
			radial-gradient(circle at 78% 12%, color-mix(in oklab, var(--paper) 7%, transparent), transparent 26%);
	}

	.grid {
		position: absolute;
		inset: 0;
		background:
			repeating-linear-gradient(
				0deg,
				transparent 0 56px,
				rgba(245, 240, 234, 0.04) 56px 57px
			),
			repeating-linear-gradient(
				90deg,
				transparent 0 56px,
				rgba(245, 240, 234, 0.04) 56px 57px
			);
		mask-image: radial-gradient(ellipse at 50% 48%, #000 12%, transparent 72%);
	}

	.beam {
		position: absolute;
		inset: 0;
		background:
			linear-gradient(
				107deg,
				transparent 46.5%,
				color-mix(in oklab, var(--accent) 14%, transparent) 50%,
				transparent 53.5%
			),
			linear-gradient(98deg, transparent 61%, rgba(245, 240, 234, 0.045) 64%, transparent 67%);
	}

	.grain {
		position: absolute;
		inset: 0;
		opacity: 0.1;
		mix-blend-mode: overlay;
		background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.7'/%3E%3C/svg%3E");
		background-size: 160px 160px;
	}

	.meta {
		position: absolute;
		z-index: 1;
		bottom: 1.25rem;
		left: 1.25rem;
		margin: 0;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 11px;
		color: var(--paper);
	}

	.line {
		position: relative;
		z-index: 1;
		display: flex;
		margin: 0;
		font-size: clamp(4.5rem, 22vw, 26vw);
		font-weight: 780;
		line-height: 0.8;
		letter-spacing: -0.06em;
		transform-origin: center;
	}

	.glyph {
		display: inline-block;
	}

	.heat {
		color: var(--accent);
	}

	.charge.reduce .glyph,
	.charge.reduce .line {
		transform: none;
		opacity: 1;
	}

	@media (prefers-reduced-motion: reduce) {
		.glyph {
			transform: none;
			opacity: 1;
		}

		.line {
			transform: none;
		}
	}
</style>

Edit

Accent