FLARE / Chapters / type-linger
type-linger
Rooms stack. Letters linger as the next title forms.
Needs pnpm add gsap
TypeLinger.svelte
<!--
Flare · type-linger
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 = 'CHARGE',
accent = 'ember',
reduceMotion = false
}: {
word?: string;
accent?: 'ember' | 'paper';
reduceMotion?: boolean;
} = $props();
type Slot = {
form: string;
formOpacity: number;
formY: number;
linger: string;
lingerOpacity: number;
lingerY: number;
};
type Room = {
id: string;
word: string;
body: string;
tone: 'charge' | 'hold' | 'release';
};
const rooms = $derived<Room[]>([
{
id: '01',
word: (word.trim() || 'CHARGE').toUpperCase(),
body: 'The room takes the charge.',
tone: 'charge'
},
{
id: '02',
word: 'HOLD',
body: 'Leftover letters stay while the next word forms.',
tone: 'hold'
},
{
id: '03',
word: 'RELEASE',
body: 'The last room lets the line go.',
tone: 'release'
}
]);
let root: HTMLElement | undefined = $state();
let roomTrack: HTMLElement | undefined = $state();
let mediaQuiet = $state(false);
let active = $state(0);
let slots = $state<Slot[]>(finished('CHARGE'));
const quiet = $derived(reduceMotion || mediaQuiet);
const activeRoom = $derived(rooms[active] ?? rooms[0]);
function finished(value: string): Slot[] {
return [...value].map((ch) => ({
form: ch,
formOpacity: 1,
formY: 0,
linger: '',
lingerOpacity: 0,
lingerY: 0
}));
}
function lingerAt(progress: number, words: string[]): { slots: Slot[]; active: number } {
const last = Math.max(0, words.length - 1);
const p = Math.min(1, Math.max(0, progress));
if (words.length < 2 || p <= 0) {
return { slots: finished(words[0] ?? 'CHARGE'), active: 0 };
}
if (p >= 1) {
return { slots: finished(words[last] ?? 'RELEASE'), active: last };
}
const scaled = p * last;
const fromIndex = Math.min(last - 1, Math.floor(scaled));
const local = scaled - fromIndex;
const from = words[fromIndex] ?? '';
const to = words[fromIndex + 1] ?? '';
const len = Math.max(from.length, to.length);
const next: Slot[] = [];
for (let k = 0; k < len; k += 1) {
const fromCh = from[k] ?? '';
const toCh = to[k] ?? '';
const stagger = len <= 1 ? 0 : k / (len - 1);
const formStart = stagger * 0.4;
const lingerEnd = 0.55 + stagger * 0.45;
if (fromCh && fromCh === toCh) {
next.push({
form: fromCh,
formOpacity: 1,
formY: 0,
linger: '',
lingerOpacity: 0,
lingerY: 0
});
continue;
}
let lingerOpacity = 0;
let lingerY = 0;
if (fromCh && local < lingerEnd) {
const fadeStart = Math.max(0, lingerEnd - 0.2);
lingerOpacity = local <= fadeStart ? 1 : Math.max(0, (lingerEnd - local) / 0.2);
lingerY = (1 - lingerOpacity) * -22;
}
let formOpacity = 0;
let formY = 18;
if (toCh && local >= formStart) {
const formed = Math.min(1, (local - formStart) / 0.38);
formOpacity = formed;
formY = (1 - formed) * 18;
}
if (!fromCh && formOpacity <= 0) continue;
if (!toCh && lingerOpacity <= 0) continue;
next.push({
form: toCh,
formOpacity,
formY,
linger: fromCh,
lingerOpacity,
lingerY
});
}
const index = local > 0.55 ? fromIndex + 1 : fromIndex;
return { slots: next.length > 0 ? next : finished(to || from), active: index };
}
$effect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const sync = () => {
mediaQuiet = media.matches;
};
sync();
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
});
$effect(() => {
const el = root;
const row = roomTrack;
const words = rooms.map((room) => room.word);
const isQuiet = quiet;
if (!el || isQuiet) {
slots = finished(words[0] ?? 'CHARGE');
active = 0;
return;
}
if (!row) return;
const opened = lingerAt(0, words);
slots = opened.slots;
active = opened.active;
const ctx = gsap.context(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: 'top top',
end: '+=320%',
pin: true,
scrub: true,
invalidateOnRefresh: true,
onUpdate: (self) => {
const next = lingerAt(self.progress, words);
slots = next.slots;
active = next.active;
}
}
});
tl.to(row, { yPercent: -66.666, ease: 'none' }, 0);
}, el);
void document.fonts?.ready.then(() => ScrollTrigger.refresh());
return () => ctx.revert();
});
</script>
<section
bind:this={root}
class="linger"
class:paper={accent === 'paper'}
class:quiet
>
{#if quiet}
<div class="quiet-stack">
{#each rooms as room, i (room.id)}
<article class="room {room.tone}">
<p class="idx">
<span class="tick" class:on={i === 0}></span>
{room.id}
</p>
<h2>{room.word}</h2>
<p class="body">{room.body}</p>
</article>
{/each}
</div>
{:else}
<div class="pin">
<div bind:this={roomTrack} class="room-track">
{#each rooms as room (room.id)}
<article class="room {room.tone}">
<div class="field" aria-hidden="true">
<span class="wash"></span>
<span class="grid"></span>
<span class="beam"></span>
</div>
<p class="body">{room.body}</p>
</article>
{/each}
</div>
<div class="overlay">
<ol class="ticks" aria-label="Rooms">
{#each rooms as room, i (room.id)}
<li class:on={i === active}>
<span class="tick"></span>
{room.id}
</li>
{/each}
</ol>
<div class="title-block">
<p class="meta">type-linger · {activeRoom.id} / 03</p>
<h2 aria-label={activeRoom.word}>
{#each slots as slot, i (i)}
<span class="cell" aria-hidden="true">
{#if slot.linger && slot.lingerOpacity > 0}
<span
class="ghost"
style:opacity={slot.lingerOpacity}
style:transform={`translateY(${slot.lingerY}px)`}
>{slot.linger}</span>
{/if}
{#if slot.form && slot.formOpacity > 0}
<span
class="form"
style:opacity={slot.formOpacity}
style:transform={`translateY(${slot.formY}px)`}
>{slot.form}</span>
{/if}
</span>
{/each}
</h2>
<p class="chip">Letters stay. The next word forms.</p>
</div>
</div>
</div>
{/if}
</section>
<style>
.linger {
--ink: #09090b;
--paper: #f5f0ea;
--accent: #ff5a1f;
position: relative;
background: var(--ink);
color: var(--paper);
font-family: var(--font-display, 'Unbounded Variable', Unbounded, ui-sans-serif, sans-serif);
}
.linger.paper {
--accent: #f5f0ea;
}
.pin {
position: relative;
height: 100dvh;
overflow: hidden;
}
.room-track {
display: flex;
flex-direction: column;
height: 300%;
}
.room {
position: relative;
display: flex;
flex: 1 0 0;
flex-direction: column;
justify-content: flex-end;
min-height: 0;
padding: 12vh 8vw 8vh;
background: var(--ink);
}
.field {
position: absolute;
inset: 0;
pointer-events: none;
}
.wash,
.grid,
.beam {
position: absolute;
inset: 0;
}
.wash {
background: radial-gradient(
ellipse 55% 42% at 70% 40%,
color-mix(in oklab, var(--accent) 22%, transparent),
transparent 68%
);
}
.room.hold .wash {
background: radial-gradient(
ellipse 40% 30% at 18% 70%,
color-mix(in oklab, var(--paper) 10%, transparent),
transparent 70%
);
}
.room.release .wash {
background: radial-gradient(
ellipse 70% 50% at 80% 80%,
color-mix(in oklab, var(--accent) 34%, transparent),
transparent 64%
);
}
.grid {
background:
repeating-linear-gradient(0deg, transparent 0 48px, rgba(245, 240, 234, 0.045) 48px 49px),
repeating-linear-gradient(90deg, transparent 0 48px, rgba(245, 240, 234, 0.045) 48px 49px);
mask-image: radial-gradient(ellipse at 50% 45%, #000 10%, transparent 72%);
}
.beam {
background: linear-gradient(
108deg,
transparent 58%,
color-mix(in oklab, var(--accent) 16%, transparent) 64%,
transparent 70%
);
opacity: 0.2;
}
.room.release .beam {
opacity: 0.85;
}
.overlay {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1.5rem;
padding: 8vh 6vw;
pointer-events: none;
}
.ticks {
display: flex;
flex-direction: column;
gap: 0.85rem;
margin: 0;
padding: 0;
list-style: none;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 12px;
letter-spacing: 0.14em;
color: #8b8278;
}
.ticks li,
.idx {
display: flex;
align-items: center;
gap: 0.55rem;
}
.ticks li.on {
color: var(--accent);
}
.tick {
display: block;
width: 1.1rem;
height: 2px;
background: rgba(245, 240, 234, 0.28);
}
.ticks li.on .tick,
.tick.on {
background: var(--accent);
}
.title-block {
min-width: 0;
}
.meta,
.chip,
.idx {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
}
.meta,
.chip {
margin: 0;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8b8278;
}
.meta {
margin-bottom: 0.8rem;
color: var(--accent);
}
h2 {
display: flex;
flex-wrap: wrap;
margin: 0;
font-size: clamp(3.2rem, 12vw, 8.5rem);
font-weight: 740;
line-height: 0.84;
letter-spacing: -0.06em;
}
.cell {
display: grid;
min-width: 0.42em;
}
.cell > span {
grid-area: 1 / 1;
display: inline-block;
}
.ghost {
color: color-mix(in oklab, var(--paper) 42%, transparent);
}
.chip {
margin-top: 0.9rem;
}
.body {
position: relative;
z-index: 1;
margin: 0;
max-width: 26rem;
font-family: var(--font-body, 'IBM Plex Sans', ui-sans-serif, sans-serif);
font-size: 14px;
line-height: 1.5;
color: #c4bbb0;
}
.quiet-stack {
display: grid;
}
.linger.quiet .room {
min-height: 100dvh;
flex-basis: auto;
}
.idx {
position: relative;
z-index: 1;
margin: 0 0 1rem;
font-size: 12px;
letter-spacing: 0.14em;
color: var(--accent);
}
@media (max-width: 720px) {
.overlay {
grid-template-columns: 1fr;
align-content: center;
}
.ticks {
flex-direction: row;
}
}
</style>
Edit