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.
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.
The stack
- Vanilla HTML/CSS/JS — one page, one stylesheet, one script. No build step.
- GSAP 3.12 + ScrollTrigger (CDN, deferred) — the letterpress hero and one scroll-scrubbed drift. Everything else is CSS.
- Google Fonts — Anton (poster display), Inter (body), DM Mono (times, venues, fine print).
- Canvas 2D — film grain, six pre-rendered noise plates cycled at ~11fps.
- GitHub Pages — static, relative paths,
.nojekyll.
Design tokens live at the top of the stylesheet. The whole identity is seven values:
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.
Details that matter
- Day tabs are real tabs.
role="tablist", arrow-key + Home/End navigation,aria-selected, rovingtabindex, focusable panels. Without JS, all five day panels render stacked under their date headings — the tab rail simply hides itself. - Red is budgeted like ink. #e63329 on #0a0a0a measures 4.58:1 — AA for every size we use it at. It appears only on dates, prices, CTAs, premiere tags and the SOLD OUT stamp. Nothing decorative gets it.
- SOLD OUT keeps the row. Like Eventbrite, unavailable things stay listed — dimmed, stamped, honest. The Patron tier’s dead button says why: “Allocation exhausted”.
- Hover inverts the whole film row — background to bone, type to black,
like a poster flashbulb. It’s gated behind
@media (hover:hover)so touch devices never get sticky inversions. - Grid-breaks are deliberate: the retrospective card overlaps the jury column by 64px, stamps hang off card corners, and the giant outlined FERRETTI drifts behind the section on scroll (scrubbed, and skipped under reduced motion).
- Skip link, focus-visible outlines everywhere, mono fine print at AA contrast, and a fictional-festival disclaimer in the footer.
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.