Flux

Eight effects from real projects. With code.

Contents

Contents

ContentsIndex.vue
// Hover any row in the index above
gsap.to(title, {
  duration: 0.7,
  overwrite: true,
  scrambleText: {
    text: title.dataset.text,
    chars: "ABCDEFGHIJKLMNOPQRSTUVWXYZ#%&*",
    revealDelay: 0.1,
  },
});

SplitText + ScrollTrigger

Line by line.

Every line gets its own mask. The scrollbar does the printing.

SplitText checks where the browser actually broke your lines, wraps each one in a mask and splits again when fonts load or the window changes size. Every headline on this site runs on it. The paragraph on the right is tied to the scroll, so scroll back up and watch it unprint.

Good motion works like good typesetting. It has rhythm, it knows where the line breaks, and it never makes you wait for the point.

LinesReveal.vue
SplitText.create(paragraph, {
  type: "lines,words",
  mask: "lines",        // each line clips itself
  autoSplit: true,      // re-split on resize & font load
  onSplit(self) {
    return gsap.timeline({
      scrollTrigger: { trigger: paragraph, start: "top 80%", end: "bottom 35%", scrub: 1 },
    })
      .from(self.lines, { yPercent: 100, stagger: 0.1 })
      .fromTo(self.words, { opacity: 0.12 }, { opacity: 1, stagger: 0.02 }, 0);
  },
});

Flip

Move the furniture.

Swap a class, keep the thread. Flip remembers where everything was.

You never animate to a layout. You change the class, and Flip works out how each tile gets from its old spot to its new one. Grid, list and stack are three plain CSS rules. The motion comes free.

ANº 01
BNº 02
CNº 03
DNº 04
ENº 05
FNº 06
FlipGrid.vue
// 1. record where everything is
state = Flip.getState(".flip__tile");
// 2. let Vue change the class
layout.value = "stack";

// 3. after the DOM updates, animate from old → new
await nextTick();
Flip.from(state, {
  duration: 0.9,
  ease: "power3.inOut",
  stagger: 0.04,
  absolute: true,
});

Draggable + InertiaPlugin

Throw it.

Physical UI is mostly about letting go.

Draggable handles the pointer, touch and the edges. InertiaPlugin reads how fast you let go and lets each sticker glide out, bouncing off the sides. A little tilt on the way and a little wobble on landing, and suddenly it feels like paper.

Grab and throw

NEW!
Fragile, handle with care
Nº 01
Approved
★
Print me
ThrowDesk.vue
Draggable.create(".sticker", {
  bounds: desk,
  inertia: true,          // InertiaPlugin: momentum on release
  edgeResistance: 0.7,
  onDrag() {
    // tilt into the direction of travel
    gsap.to(this.target, { rotation: this.deltaX * 1.6 });
  },
  onThrowComplete() {
    gsap.to(this.target, { rotation: gsap.utils.random(-12, 12), ease: "elastic.out(1, 0.5)" });
  },
});

Raw WebGL

Ink without a printer.

No three.js, no textures. One fragment shader and three inks.

Noise folded into itself makes those slow, inky waves. I squash it down to paper, black and red, then sprinkle grain on top that changes every frame, so it flickers like a real riso pass. Your cursor pushes the ink around. If you like it, the export page turns it into a poster.

Make a poster
Move your cursor to push the ink
utils/riso.ts
// fragment shader: domain-warped fbm
vec2 q = vec2(fbm(uv * 1.6 + t), fbm(uv * 1.6 + vec2(5.2, 1.3) - t));
float pull = exp(-length(uv - mouse) * 3.5);
vec2 r = vec2(fbm(uv * 1.6 + 4.0 * q + pull), fbm(uv * 1.6 + 4.0 * q - pull));
float f = fbm(uv * 1.6 + 4.0 * r) + grain * 0.07;

// posterize into three spot inks
vec3 col = f < 0.37 ? ink : f < 0.47 ? accent : paper;

ScrollTrigger · containerAnimation

Sideways.

You scroll down. The story moves left.

The section below pins itself and slides four panels across while you keep scrolling. Each giant word has its own trigger, measured against the sideways movement instead of the page, so it drifts and leans as it passes.

Sideways.vue
const slide = gsap.to(track, {
  x: () => -(track.scrollWidth - innerWidth),
  ease: "none",
  scrollTrigger: { trigger: section, pin: true, scrub: 1, invalidateOnRefresh: true },
});

// nested triggers ride the horizontal tween
gsap.fromTo(word, { xPercent: 20 }, {
  xPercent: -20,
  scrollTrigger: { trigger: word, containerAnimation: slide, scrub: true },
});
01 / 04

Pin

The section stays put while the page keeps scrolling underneath.

02 / 04

Scrub

The timeline's playhead hangs straight off the scrollbar.

03 / 04

Nest

Child triggers measure against the sideways tween, not the page.

04 / 04

Release

Unpin, hand the scroll back, on to the next one.

gsap.ticker · utils.wrap

Speed reads.

A marquee with a speedometer.

One ticker callback chases your scroll speed, wraps each row at half its width and leans the type the faster you go. Stop, and it settles back into a lazy drift. The band under the homepage cover is this exact component.

VelocityMarquee.vue
gsap.ticker.add((_t, delta) => {
  velocity += (scrollY - lastY - velocity) * 0.12;
  lastY = scrollY;

  offset += dir * (0.6 + Math.abs(velocity) * 0.5) * (delta / 16.67);
  offset = gsap.utils.wrap(-row.scrollWidth / 2, 0, offset);

  row.style.translate = `${offset}px 0`;
  setSkew(gsap.utils.clamp(-14, 14, -velocity * 0.4));
});

Variable fonts · CSS custom properties

Heavy until you look.

Bricolage Grotesque has a weight axis and a width axis. Distance drives both.

Every letter carries two custom properties that end up in font-variation-settings. GSAP eases them letter by letter depending on how far away your pointer is, so the type turns thin and narrow wherever you look. The big name on the homepage is the same component.

Come closer.

ProximityText.vue
const weight = gsap.utils.pipe(
  gsap.utils.clamp(0, radius),
  gsap.utils.mapRange(0, radius, 200, 800),
);

// .px-char { font-variation-settings: "wght" var(--wght), "wdth" var(--wdth) }
for (const c of chars) {
  const d = distance(pointer, center(c));
  gsap.to(c, { "--wght": weight(d), "--wdth": width(d), overwrite: "auto" });
}

Canvas 2D · lenticular

Both sides now.

The prize from the cereal box, rebuilt.

Both pictures are painted offscreen, then redrawn every frame as interleaved seven-pixel strips. Each strip picks its side from the viewing angle plus its own position, so the image flips in a sweep instead of all at once. A ridge pattern and a travelling sheen do the rest.

Move across the print

Lenticular.vue
for (let x = 0; x < w; x += strip) {
  // each lens flips a little later than its neighbour → a sweep
  const local = angle + (x / w - 0.5) * 0.6;
  const t = clamp(0, 1, (local + 0.15) / 0.3);

  ctx.globalAlpha = 1;
  ctx.drawImage(sideA, x, 0, strip, h, x, 0, strip, h);
  ctx.globalAlpha = smoothstep(t);
  ctx.drawImage(sideB, x, 0, strip, h, x, 0, strip, h);
}

// ridges: a strip-wide gradient repeated as a pattern
ctx.fillStyle = ctx.createPattern(lens, "repeat");
ctx.fillRect(0, 0, w, h);

Library

Take it with you.

Five of them in one 4 KB script.

Get flux.js

Type

Bricolage Grotesque, Instrument Serif, JetBrains Mono

Motion

GSAP 3: SplitText, ScrambleText, Flip, Draggable, Inertia, ScrollTrigger · Canvas 2D · WebGL

Scroll

Lenis, synced to the GSAP ticker

Built with

Nuxt 3 · Vue 3 · Tailwind