lane-morph
Stage pins. The lane walks. Type remaps with the scrub.
Needs pnpm add gsap
LaneMorph.svelte
<!--
Flare · lane-morph
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 = 'LANE',
accent = 'ember',
reduceMotion = false
}: {
word?: string;
accent?: 'ember' | 'paper';
reduceMotion?: boolean;
} = $props();
type Glyph = {
ch: string;
weight: number;
tracking: string;
y: number;
};
type Plate = {
id: string;
word: string;
title: string;
body: string;
tone: 'grid' | 'hatch' | 'wash' | 'ticks' | 'ember';
};
const plates = $derived<Plate[]>([
{
id: '01',
word: (word.trim() || 'LANE').toUpperCase(),
title: 'Stage',
body: 'The stage locks. The lane is about to walk.',
tone: 'grid'
},
{
id: '02',
word: 'WALK',
title: 'Track',
body: 'Vertical scroll drives the plates sideways.',
tone: 'hatch'
},
{
id: '03',
word: 'BEND',
title: 'Bend',
body: 'The word tightens, then opens with the scrub.',
tone: 'wash'
},
{
id: '04',
word: 'HOLD',
title: 'Hold',
body: 'One plate keeps the ember outline.',
tone: 'ticks'
},
{
id: '05',
word: 'COPY',
title: 'Copy',
body: 'When the pin lets go, the file is yours.',
tone: 'ember'
}
]);
let root: HTMLElement | undefined = $state();
let track: HTMLElement | undefined = $state();
let mediaQuiet = $state(false);
let active = $state(0);
let glyphs = $state<Glyph[]>(settle('LANE', 0));
const quiet = $derived(reduceMotion || mediaQuiet);
const countLabel = $derived(String(plates.length).padStart(2, '0'));
const activePlate = $derived(plates[active] ?? plates[0]);
function settle(value: string, tension: number): Glyph[] {
const tracking = (-0.07 + tension * 0.12).toFixed(3);
const weight = Math.round(560 + tension * 180);
return [...value].map((ch, index) => ({
ch,
weight,
tracking: `${tracking}em`,
y: Math.sin(index * 0.8) * tension * 4
}));
}
function morphAt(progress: number, words: string[]): { glyphs: Glyph[]; active: number } {
const last = Math.max(0, words.length - 1);
const p = Math.min(1, Math.max(0, progress));
const index = Math.round(p * last);
if (words.length < 2 || p <= 0) {
return { glyphs: settle(words[0] ?? 'LANE', 0), active: 0 };
}
if (p >= 1) {
return { glyphs: settle(words[last] ?? 'COPY', 1), 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: Glyph[] = [];
for (let k = 0; k < len; k += 1) {
const threshold = (k + 1) / (len + 1);
const useTo = local >= threshold;
const ch = (useTo ? to[k] : from[k]) ?? '';
if (!ch) continue;
const wave = Math.sin(local * Math.PI + k * 0.55);
const weight = Math.round(500 + local * 280 + Math.abs(wave) * 36);
const tracking = -0.08 + local * 0.18;
next.push({
ch,
weight,
tracking: `${tracking.toFixed(3)}em`,
y: wave * 9 * Math.sin(local * Math.PI)
});
}
return { glyphs: next.length > 0 ? next : settle(from || to, local), 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 wrap = root;
const row = track;
const words = plates.map((plate) => plate.word);
const isQuiet = quiet;
if (!wrap || isQuiet) {
const first = morphAt(0, words);
glyphs = first.glyphs;
active = 0;
return;
}
if (!row) return;
const opened = morphAt(0, words);
glyphs = opened.glyphs;
active = opened.active;
const ctx = gsap.context(() => {
gsap.fromTo(
'.word',
{ y: 16, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.28, ease: 'power2.out' }
);
gsap.to(row, {
x: () => -Math.max(row.scrollWidth - wrap.clientWidth, 0),
ease: 'none',
scrollTrigger: {
trigger: wrap,
start: 'top top',
end: '+=380%',
pin: true,
scrub: true,
invalidateOnRefresh: true,
onUpdate: (self) => {
const next = morphAt(self.progress, words);
glyphs = next.glyphs;
active = next.active;
wrap.style.setProperty('--p', String(self.progress));
}
}
});
}, wrap);
const refresh = () => ScrollTrigger.refresh();
void document.fonts?.ready.then(refresh);
return () => ctx.revert();
});
</script>
<section
bind:this={root}
class="stage"
class:paper={accent === 'paper'}
class:quiet
>
{#if quiet}
<div class="quiet-stack">
{#each plates as plate, i (plate.id)}
<article class="panel tone-{plate.tone}" class:on={i === 0}>
<p class="tag">FIG {plate.id}</p>
<h2>{plate.word}</h2>
<p class="plate-title">{plate.title}</p>
<p class="body">{plate.body}</p>
</article>
{/each}
</div>
{:else}
<div class="hud">
<p>lane-morph · pin · type</p>
<p>{activePlate.id} / {countLabel}</p>
</div>
<div class="mast">
<h2 class="word" aria-label={glyphs.map((glyph) => glyph.ch).join('')}>
{#each glyphs as glyph, i (i)}
<span
aria-hidden="true"
style:font-weight={glyph.weight}
style:letter-spacing={glyph.tracking}
style:transform={`translateY(${glyph.y}px)`}
>{glyph.ch}</span>
{/each}
</h2>
<p class="chip">Pin. Track walks. Type morphs.</p>
</div>
<div class="lane-window">
<div bind:this={track} class="track">
{#each plates as plate, i (plate.id)}
<article class="panel tone-{plate.tone}" class:on={i === active}>
<p class="tag">FIG {plate.id}</p>
<div class="field" aria-hidden="true">
<span class="ghost">{plate.word}</span>
<span class="wash"></span>
<span class="hatch"></span>
<span class="grid"></span>
<span class="ticks"></span>
<span class="corners"></span>
</div>
<h3>{plate.title}</h3>
<p class="body">{plate.body}</p>
</article>
{/each}
</div>
</div>
<div class="ruler" aria-hidden="true">
<div class="rail">
<span class="playhead"></span>
</div>
<div class="stops">
{#each plates as plate, i (plate.id)}
<span class:on={i === active}>{plate.id} {plate.title}</span>
{/each}
</div>
</div>
{/if}
</section>
<style>
.stage {
--ink: #09090b;
--paper: #f5f0ea;
--accent: #ff5a1f;
--card: #111113;
--p: 0;
container-type: inline-size;
position: relative;
height: 100dvh;
min-height: 100dvh;
overflow: hidden;
background: var(--ink);
color: var(--paper);
font-family: var(--font-display, 'Unbounded Variable', Unbounded, ui-sans-serif, sans-serif);
}
.stage.paper {
--accent: #f5f0ea;
}
.hud,
.chip,
.tag,
.ruler {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
}
.hud {
position: absolute;
z-index: 3;
top: 1.15rem;
right: 1.25rem;
left: 1.25rem;
display: flex;
justify-content: space-between;
gap: 1rem;
margin: 0;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8b8278;
}
.hud p {
margin: 0;
}
.hud p:last-child {
color: var(--accent);
}
.mast {
position: absolute;
z-index: 2;
top: 14%;
right: 1.25rem;
left: 1.25rem;
}
.word {
display: flex;
flex-wrap: wrap;
margin: 0;
font-size: clamp(3.4rem, 13cqw, 8.4rem);
font-weight: 640;
line-height: 0.84;
letter-spacing: -0.06em;
}
.word span {
display: inline-block;
}
.chip {
margin: 0.85rem 0 0;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8b8278;
}
.lane-window {
position: absolute;
z-index: 1;
right: 0;
bottom: 4.4rem;
left: 0;
height: 46%;
overflow: hidden;
}
.track {
display: flex;
width: max-content;
height: 100%;
align-items: stretch;
gap: 0.7rem;
padding: 0 1.25rem;
}
.panel {
display: flex;
flex: 0 0 62vw;
flex-direction: column;
min-width: 16rem;
padding: 0.85rem;
border: 1px solid rgba(245, 240, 234, 0.14);
background: var(--card);
}
.panel:nth-child(1) {
flex-basis: 74vw;
}
.panel:nth-child(3) {
flex-basis: 48vw;
}
.panel:nth-child(5) {
flex-basis: 70vw;
}
.panel.on {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.tag {
margin: 0;
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #8b8278;
}
.panel.on .tag {
color: var(--accent);
}
.field {
position: relative;
flex: 1;
min-height: 5.5rem;
margin: 0.7rem 0;
overflow: hidden;
background: #101012;
}
.ghost {
position: absolute;
right: 0.2rem;
bottom: -0.12em;
left: 0.35rem;
font-size: clamp(2.4rem, 8cqw, 4.2rem);
font-weight: 740;
line-height: 0.8;
letter-spacing: -0.06em;
color: color-mix(in oklab, var(--paper) 18%, transparent);
}
.wash,
.hatch,
.grid,
.ticks,
.corners {
position: absolute;
inset: 0;
pointer-events: none;
}
.wash {
background: radial-gradient(
ellipse 80% 70% at 20% 80%,
color-mix(in oklab, var(--accent) 28%, transparent),
transparent 62%
);
opacity: 0.35;
}
.panel.tone-wash .wash,
.panel.tone-ember .wash {
opacity: 1;
}
.hatch {
opacity: 0;
background: repeating-linear-gradient(
-16deg,
transparent 0 10px,
rgba(245, 240, 234, 0.05) 10px 11px
);
}
.panel.tone-hatch .hatch,
.panel.tone-ticks .hatch {
opacity: 1;
}
.grid {
background:
repeating-linear-gradient(0deg, transparent 0 16px, rgba(245, 240, 234, 0.06) 16px 17px),
repeating-linear-gradient(90deg, transparent 0 16px, rgba(245, 240, 234, 0.05) 16px 17px);
mask-image: linear-gradient(180deg, transparent, #000 18%, #000 82%, transparent);
}
.ticks {
inset: 0.45rem 0.55rem auto auto;
width: 10px;
height: calc(100% - 0.9rem);
background: repeating-linear-gradient(180deg, var(--accent) 0 2px, transparent 2px 9px);
opacity: 0.25;
}
.panel.tone-ticks .ticks,
.panel.on .ticks {
opacity: 0.85;
}
.corners {
inset: 0.4rem;
background:
linear-gradient(var(--paper), var(--paper)) 0 0 / 10px 1px no-repeat,
linear-gradient(var(--paper), var(--paper)) 0 0 / 1px 10px no-repeat,
linear-gradient(var(--paper), var(--paper)) 100% 100% / 10px 1px no-repeat,
linear-gradient(var(--paper), var(--paper)) 100% 100% / 1px 10px no-repeat;
opacity: 0.28;
}
.panel.tone-ember {
box-shadow: inset 6px 0 0 var(--accent);
}
.panel.tone-ember.on {
box-shadow:
inset 6px 0 0 var(--accent),
inset 0 0 0 1px var(--accent);
}
h3,
.plate-title {
margin: 0;
font-size: 1.15rem;
font-weight: 620;
letter-spacing: -0.04em;
}
.body {
margin: 0.4rem 0 0;
max-width: 28rem;
font-family: var(--font-body, 'IBM Plex Sans', ui-sans-serif, sans-serif);
font-size: 12px;
line-height: 1.5;
color: #c4bbb0;
}
.ruler {
position: absolute;
z-index: 3;
right: 1.25rem;
bottom: 0.85rem;
left: 1.25rem;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8b8278;
}
.rail {
position: relative;
height: 2px;
margin-bottom: 0.5rem;
background: rgba(245, 240, 234, 0.16);
}
.playhead {
position: absolute;
top: 50%;
left: calc(var(--p) * 100%);
width: 10px;
height: 10px;
border: 5px solid transparent;
border-bottom-color: var(--accent);
transform: translate(-50%, calc(-50% - 6px));
}
.stops {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.stops .on {
color: var(--accent);
}
.quiet-stack {
display: grid;
}
.stage.quiet {
height: auto;
overflow: visible;
}
.stage.quiet .panel {
min-height: 100dvh;
justify-content: flex-end;
padding: 1.5rem;
border-right: 0;
border-left: 0;
border-radius: 0;
}
.stage.quiet h2 {
margin: 0 0 0.8rem;
font-size: clamp(3.2rem, 14vw, 7rem);
font-weight: 720;
line-height: 0.86;
letter-spacing: -0.06em;
}
@media (prefers-reduced-motion: reduce) {
.stage:not(.quiet) .track {
transform: none;
}
}
</style>
Edit