FLARE

FLARE / Chapters / mask-reveal

mask-reveal

An ink window opens to full bleed.

Needs pnpm add gsap

MaskReveal.svelte

<!--
  Flare · mask-reveal
  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.
  Media: pass mediaSrc (gallery uses /blocks/mask-yard.jpg).
-->
<script lang="ts">
	import { gsap } from 'gsap';
	import { ScrollTrigger } from 'gsap/ScrollTrigger';

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

	let {
		headline = 'Preview. Copy.',
		accent = 'ember',
		reduceMotion = false,
		mediaSrc = '/blocks/mask-yard.jpg'
	}: {
		headline?: string;
		accent?: 'ember' | 'paper';
		reduceMotion?: boolean;
		mediaSrc?: string;
	} = $props();

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

	$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 tl = gsap.timeline({
					scrollTrigger: {
						trigger: el,
						start: 'top top',
						end: '+=160%',
						pin: true,
						scrub: 0.8,
						invalidateOnRefresh: true
					}
				});

				tl.fromTo(
					'.window',
					{ clipPath: 'inset(16% 20% round 24px)' },
					{ clipPath: 'inset(0% 0% round 0px)', duration: 1, ease: 'none' },
					0
				)
					.fromTo(
						'.rim',
						{ top: '16%', right: '20%', bottom: '16%', left: '20%', borderRadius: 24 },
						{ top: 0, right: 0, bottom: 0, left: 0, borderRadius: 0, duration: 1, ease: 'none' },
						0
					)
					.fromTo('.media', { scale: 1.1 }, { scale: 1, duration: 1, ease: 'none' }, 0)
					.fromTo(
						'.copy h2',
						{ letterSpacing: '0.08em' },
						{ letterSpacing: '-0.04em', duration: 1, ease: 'none' },
						0
					)
					.fromTo('.caption', { opacity: 0 }, { opacity: 1, duration: 0.42, ease: 'none' }, 0.2);
			}, el);

			const refresh = () => {
				if (!cancelled) ScrollTrigger.refresh();
			};
			void document.fonts?.ready.then(refresh);
			el.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="reveal"
	class:paper={accent === 'paper'}
	class:reduce={reduceMotion}
>
	<div class="window">
		<img class="media" src={mediaSrc} alt="Ink city at dusk. Ember wash on the skyline." />
	</div>
	<div class="rim" aria-hidden="true"></div>
	<div class="copy">
		<p class="kicker">mask-reveal</p>
		<h2>{headline.trim() || 'Preview. Copy.'}</h2>
		<p class="caption">The window opens. The still goes full bleed.</p>
	</div>
</section>

<style>
	.reveal {
		--ink: #09090b;
		--paper: #f5f0ea;
		--accent: #ff5a1f;
		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);
	}

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

	.window {
		position: absolute;
		inset: 0;
		overflow: hidden;
		clip-path: inset(16% 20% round 24px);
	}

	.media {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
		transform-origin: center;
		background: #121214;
	}

	.rim {
		position: absolute;
		z-index: 1;
		inset: 16% 20%;
		border: 1px solid var(--accent);
		border-radius: 24px;
		pointer-events: none;
	}

	.copy {
		position: relative;
		z-index: 2;
		display: flex;
		height: 100dvh;
		flex-direction: column;
		justify-content: center;
		padding: 0 7vw;
		max-width: 22rem;
	}

	.kicker {
		margin: 0 0 0.7rem;
		font-family: 'IBM Plex Mono', ui-monospace, monospace;
		font-size: 11px;
		letter-spacing: 0.14em;
		text-transform: uppercase;
		color: var(--accent);
	}

	h2 {
		margin: 0;
		max-width: 14ch;
		font-size: clamp(1.45rem, 2.6vw, 2rem);
		font-weight: 620;
		line-height: 1.05;
		letter-spacing: -0.04em;
		text-shadow: 0 1px 18px rgba(9, 9, 11, 0.55);
	}

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

	.reveal.reduce .window {
		clip-path: inset(0);
	}

	.reveal.reduce .rim {
		inset: 0;
		border-radius: 0;
	}

	.reveal.reduce .media {
		transform: none;
	}

	.reveal.reduce .caption {
		opacity: 1;
	}

	@media (prefers-reduced-motion: reduce) {
		.window {
			clip-path: inset(0);
		}

		.rim {
			inset: 0;
			border-radius: 0;
		}

		.media {
			transform: none;
		}

		.caption {
			opacity: 1;
		}
	}
</style>

Edit

Accent