Undertow. ← BACK TO THE FESTIVAL

Build guide — design showcase

How Undertow
was built.

A brutalist festival poster that behaves like a website: Anton at billboard scale, one film red, a letterpress hero, marquee tickers, and a projector that occasionally slips a frame. Zero images. Here is the whole trick, with code.

01 / 05

The idea

Festival sites usually try to be tasteful. Festival posters never do — they’re set in one heavy face, they shout the dates, and they get pasted over each other until the wall itself looks like the identity. Undertow takes the wall, not the brochure: pure black and off-white, hairline rules, huge condensed type, and a single red reserved for the things a poster would stamp — dates, prices, SOLD OUT.

The interaction patterns underneath are boringly proven, on purpose. The program rail is the day-tab pattern from cinema ticketing apps (Google Pay Movie Tickets, IMDb showtimes); each film row follows DICE’s event anatomy — time and venue in mono, tags, red only on the transactional bits; the ticket tiers copy Eventbrite’s microcopy discipline (“incl. booking fee”, “sales end”, a dead button that says why). Research the bones, invent the skin.

02 / 05

The stack

Design tokens live at the top of the stylesheet. The whole identity is seven values:

--bg#0a0a0a
--ink#f4f4f2
--dim#a0a0a0
--faint#5a5a5a
--red#e63329
--linergba(244,244,242,.14)
--easecubic-bezier(.85,0,.15,1)
03 / 05

Signature technique

1 · The guillotine ease, in two engines. Brutalist motion should be abrupt but controlled — a blade, not a spring. The signature curve is cubic-bezier(.85,0,.15,1). CSS gets it as a custom property; GSAP core can’t parse CSS beziers, so the same curve is solved numerically and handed to GSAP as a plain function. One easing, no drift between the two engines:

function cubicBezier(x1, y1, x2, y2) {
  const cx = 3*x1, bx = 3*(x2-x1) - cx, ax = 1 - cx - bx;
  const cy = 3*y1, by = 3*(y2-y1) - cy, ay = 1 - cy - by;
  const X = t => ((ax*t + bx)*t + cx)*t;
  const Y = t => ((ay*t + by)*t + cy)*t;
  const dX = t => (3*ax*t + 2*bx)*t + cx;
  return x => {                       // Newton–Raphson: solve t for x, sample y
    let t = x;
    for (let i = 0; i < 6; i++) {
      const e = X(t) - x; if (Math.abs(e) < 1e-5) break;
      t -= e / dX(t);
    }
    return Y(Math.min(1, Math.max(0, t)));
  };
}
const guillotine = cubicBezier(.85, 0, .15, 1);

2 · The letterpress hero. Each headline line sits inside an overflow:hidden sleeve, translated 115% down by a rule gated on the .js class — so without JavaScript nothing is ever hidden. GSAP slides the lines up in sequence like plates dropping into a press, then stamps the red period in at 2.4× scale:

tl.to('.hero-title .h-line:nth-child(1) .h-seg', { y: 0, duration: .72 }, .16)
  .to('.hero-title .h-line:nth-child(2) .h-seg', { y: 0, duration: .72 }, .28)
  .fromTo('.h-dot',
    { scale: 2.4, rotation: -18 },
    { scale: 1, rotation: 0, duration: .34 }, .78)   // the stamp
  .to('.hero-bar', { scaleX: 1, duration: .5 }, .52); // the red rule wipes in

If GSAP never arrives (CDN down), main.js adds a .loaded class and CSS transitions run the same choreography with the same bezier. Two paths, one look.

3 · The marquee ticker. Two counter-scrolling bands — film titles in Anton, festival facts in mono. The content is duplicated once inside a width:max-content row and the keyframe translates exactly −50%, so the loop is seamless at any viewport width. CSS animates; JS only contributes the pause button:

.tick-row { display:flex; width:max-content; animation:tick 70s linear infinite; }
.tick-rev { animation-direction:reverse; animation-duration:90s; }
@keyframes tick { to { transform: translateX(-50%); } }
.ticker.paused .tick-row { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .tick-row { animation: none !important; }  /* static list, still readable */
}

4 · The projector. A fixed canvas cycles six pre-rendered noise plates (rendering noise per-frame is wasted work — cycling plates is indistinguishable at 90ms). Every 6–11 seconds the body gets a .flick class for 120ms: the main column jumps a pixel, the grain flares. It reads as a projector slipping a frame. Under prefers-reduced-motion the canvas is display:none and neither loop ever starts.

04 / 05

Details that matter

05 / 05

Ship it on GitHub Pages

Static site, relative paths, no build. The whole deploy is four commands:

git init -b main && git add -A && git commit -m "Undertow Film Festival"
gh repo create bswxyz/undertow-festival --public --source . --push
gh api --method POST /repos/bswxyz/undertow-festival/pages \
  -f 'source[branch]=main' -f 'source[path]=/'
gh repo edit bswxyz/undertow-festival \
  --homepage "https://bswxyz.github.io/undertow-festival/"

The .nojekyll file tells Pages to serve files as-is. Because every URL in the site is relative (./styles.css, ./guide/), it works identically at localhost and under the /undertow-festival/ project path.