HOW TO
MOVE TYPE
A builder's guide to the KINETIK manifesto — variable-font axis animation, a hand-rolled split-text utility, scroll-velocity marquees and ScrollTrigger pinning choreography. Every excerpt below is real code from the live page. One HTML file. No build step. No images.
Concept & Art Direction
*The rule that generates everything else: type is the only image. If the page needs a picture, a texture, or a 3D scene to be interesting, the typography has failed. So every thesis of the manifesto is staged with exactly three materials — a paper field, ink letterforms, and motion. The aesthetic is Swiss International Style pushed to brutalist honesty: exposed column grid, hard rules, oversized folio numbers, index numbers and asterisks doing the work ornaments usually do.
The one liberty taken with the Swiss canon is the accent. Instead of Müller-Brockmann red, a single acid chartreuse — used ruthlessly sparingly: the progress bar, the ticker strip, slider thumbs, asterisks. If the accent appears more than a few times per viewport, it stops being an accent.
Palette — three inks, no gradientsSections flip between black-on-white and white-on-black as you scroll (thesis 06 is the
hinge). The flip is just a data-theme attribute on <body> driving two CSS
custom properties, with a slow custom-eased transition so the page never passes through an
unreadable mid-state — both background and foreground animate on the same curve.
The Typefaces & Their Axes
*Anybody (Etcetera Type Co., Google Fonts) is the star because it is variable on
two axes at once — weight wght 100–900 and width wdth 50–150. That
dual-axis range is the entire animation vocabulary of the site. Space Grotesk sets
running text; Fragment Mono handles annotations, labels and this guide's code.
Load the axes explicitly in the Google Fonts URL — if you omit the range you get a static instance and nothing will animate:
https://fonts.googleapis.com/css2?family=Anybody:wdth,wght@50..150,100..900
&family=Fragment+Mono&family=Space+Grotesk:wght@300..700&display=swap
Hand-Rolled Split-Text
*Per-letter animation needs per-letter elements. No paid plugin required — twenty lines wrap
each character in a span.ch, grouped into span.word wrappers so lines never
break mid-word, with the original string preserved as an aria-label for screen
readers:
/* hand-rolled split-text — from index.html */
function split(el){
const text = el.textContent;
el.setAttribute('aria-label', text.trim());
el.textContent = '';
text.trim().split(/\s+/).forEach((word, wi, arr) => {
const w = document.createElement('span');
w.className = 'word'; w.setAttribute('aria-hidden','true');
[...word].forEach(ch => {
const s = document.createElement('span');
s.className = 'ch'; s.textContent = ch;
w.appendChild(s);
});
el.appendChild(w);
if(wi < arr.length - 1) el.appendChild(document.createTextNode(' '));
});
return [...el.querySelectorAll('.ch')];
}
The crucial CSS trick: each letter exposes its axes as custom properties, so anything — GSAP, a rAF loop, a media query — can drive them per letter:
.ch{
display:inline-block;
font-variation-settings:"wght" var(--w,700), "wdth" var(--d,100);
}
Scrubbing an Axis with Scroll
*GSAP tweens CSS custom properties natively. Thesis 01 ("weight is emphasis") pins its section and scrubs every letter from hairline to black, staggered so the emphasis rolls across the word like a wave under your thumb:
/* 01 — WEIGHT IS EMPHASIS */
const d01 = split(q('#d01'));
d01.forEach(c => c.style.setProperty('--w', 100));
gsap.to(d01, {
'--w': 900, ease: 'none',
stagger: { each: 0.12 },
scrollTrigger: {
trigger: '#t01', start: 'top top',
end: '+=170%', scrub: 0.4, pin: true, anticipatePin: 1
}
});
Thesis 02 does the same on --d (width) with three chained staggers — expand from the
left, compress from the right, settle from the center — which reads as a bellows. Thesis 07 scrubs
two properties against each other (tracking up while weight falls), so the word travels from
crowded-and-black to spread-and-light in one gesture. Scrub values around 0.4 give the
axis a slight inertia so it feels mechanical rather than glued to the scrollbar.
Each pinned scene also carries a live axis readout — a small mono chip on the section rule
that prints the current value (WGHT 412, TRACK +0.18EM, SCALE ×2.88)
from the trigger's onUpdate. Instrumentation is ornament, in the Swiss sense: it is
honest about what the machine is doing.
Pinning Choreography
*Ten pinned scenes in a row would be exhausting. The manifesto alternates three scene types so the scroll has rhythm at the macro level too:
- Scrubbed + pinned (01 weight, 02 width, 07 tracking, 08 scale) — the reader's scroll is the timeline. Pin for 150–190% of viewport height, always with
anticipatePin: 1. - Triggered (04 gravity, 09 decode) — a timeline fires on
onEnterand rewinds ononLeaveBack, so the drop replays on every revisit. - Ambient (03 rhythm, 05 velocity, 10 alive) — infinite loops or ticker-driven engines that pause when offscreen via
onToggle, so the page never burns frames it isn't showing.
/* ambient scenes pause offscreen */
ScrollTrigger.create({
trigger: '#t03', start: 'top 75%', end: 'bottom 25%',
onToggle: self => waves.forEach(w => self.isActive ? w.play() : w.pause())
});
The gravity drop (thesis 04) is a plain from tween with a bounce ease and randomized
rotation, landing on letters pre-tilted by ±4° so the word settles like dropped furniture
instead of snapping to a grid:
const d04 = split(q('#d04'));
d04.forEach(c => gsap.set(c, { rotation: gsap.utils.random(-4, 4) }));
drop.from(d04, {
y: '-115vh', rotation: () => gsap.utils.random(-40, 40),
duration: 1.5, ease: 'bounce.out',
stagger: { each: 0.08, from: 'random' }
});
Scroll-Velocity Marquees
*Thesis 05's strips don't run at a fixed speed — they read the page's scroll velocity and
answer with speed and shear. One invisible ScrollTrigger samples velocity; a single
gsap.ticker callback decays it, smooths it with a lerp, and feeds every strip. The
strip content is duplicated once in the HTML, and the x position wraps at half the scroll
width for a seamless loop:
let velTarget = 0, velSmooth = 0;
ScrollTrigger.create({ onUpdate: self => { velTarget = self.getVelocity(); } });
gsap.ticker.add(() => {
const dt = gsap.ticker.deltaRatio() / 60;
velTarget *= 0.9; // decay when scrolling stops
velSmooth += (velTarget - velSmooth) * 0.08; // lerp = analog feel
const skew = gsap.utils.clamp(-14, 14, velSmooth / -220);
marquees.forEach(m => {
const boost = Math.abs(velSmooth) * m.velFactor; // speed answers scroll
m.x += m.dir * (m.base + boost) * dt;
const h = m.half(); // wrap at 50% for the loop
if(m.x <= -h) m.x += h;
if(m.x > 0) m.x -= h;
m.inner.style.transform = `translate3d(${m.x}px,0,0) skewX(${skew * -m.dir}deg)`;
});
});
Cursor-Proximity Distortion
*On the hero (and thesis 10), letters swell toward wght 900 / wdth 142 as the cursor
approaches. The engine caches each letter's center (re-measured every 24 frames, because width
changes shift neighbors), computes a distance falloff, and eases current values toward targets
with a lerp — never a tween, so it responds instantly and settles softly:
const dx = mx - m.x, dy = my - m.y;
const inf = Math.max(0, 1 - Math.hypot(dx, dy) / R); // linear falloff
if(inf > 0){
const k = Math.pow(inf, 1.6); // sharpen the bulge
targetW += (maxW - targetW) * k;
targetD += (maxD - targetD) * k;
}
m.w += (targetW - m.w) * 0.16; // lerp toward target
c.style.setProperty('--w', m.w.toFixed(1));
Groups register with an IntersectionObserver and the loop skips any group that
isn't on screen — the hero engine costs nothing once you scroll past it.
Performance Notes
*font-variation-settings re-shapes text every frame. It stays cheap only
because each scene animates one short word (5–9 letters) and only while pinned or on screen.
Never scrub axes on a paragraph.translate3d/rotate —
compositor-only. The marquee never touches layout; it wraps a cached half-width.gsap.ticker. One clock, one style-write pass per frame.onToggle; proximity groups gate on IntersectionObserver;
letter rects re-measure every 24 frames instead of every frame.prefers-reduced-motion, each thesis renders as a static full-strength
composition — the weight ramp of 01 is applied letter-by-letter as fixed values, the
playground still works.Replicate It
*- Vendor GSAP and ScrollTrigger locally (
libs/gsap.min.js,libs/ScrollTrigger.min.js) and load the variable fonts with explicit axis ranges (FIG B.1). - Define the two-property theme (
--bg/--fg) on:root, flip it with adata-themeattribute, and givebodya slow eased transition on background and color. - Build the split utility (FIG C.1) and the
.chrule exposing--w/--d(FIG C.2). This is the foundation of every effect. - Write each thesis as a full-viewport section: an index number, a mono label, one display word. Choose its scene type — scrubbed, triggered, or ambient — and keep neighbors different types.
- Wire the scrubbed scenes with
pin: true, scrub: 0.4and stagger the axis tween per letter (FIG D.1). - Add the shared ticker: velocity sampling, marquee wrap, proximity lerp (FIGS F.1, G.1).
- Stage the specimen playground with native range inputs styled square, driving the same CSS custom properties; RANDOMIZE is one
gsap.toon a state object withexpo.out. - Add the honesty layer: fixed column grid lines, folio counter driven by ScrollTriggers, scramble-decode labels, acid progress bar. Then delete every ornament that isn't type.
TEN THESES ON MOVING LETTERS — EACH ONE PROVING ITSELF