Skip to content
All posts

8 min readCase study, Interaction design, Creative coding

Two Libertines: two websites that play a character

Casanova and Don Juan as one site behind a two-door foyer, where the interaction design is the characterization — one runs on your attention, its inversion runs on a clock that ignores you.


Most sites about a character describe him: a portrait, a biography, a quote in large type. These two don't. Casanova and Don Juan now live together as Two Libertines, one site behind a foyer with two doors: /casanova and /donjuan. Each behaves like its man, and the visitor is the person he's behaving toward. The brief for the first was an actor's character study, which the site keeps backstage at its own route. Every note in that study became a rule the page enforces, not a sentence it shows.

The second site was built as the first one's inversion. Casanova needs you: his room is lit by your attention and he leaves before he can be left. Don Juan doesn't need you and barely sees you. His engine is time, and you are a number in his catalogue.

Moods he cycles
4
One night in Seville
4 min
Catalogue entries
1,003
Audio files
0

A room lit by your attention

Casanova's page is dark. A warm pool of light follows your cursor, so you light the room by being in it. Headings tilt toward your pointer, and they lean further the more engaged you are. Stop moving and the light drifts off toward the top-right corner, where the door would be. Push your cursor up toward the browser chrome, where you'd reach to close the tab, and he goes cold early: he saw you reaching. Hold it there and words pile up under the cursor, crowding the exit.

He also reads your tempo. Move fast and erratically and the light widens and the voice turns showy. Slow almost to a stop and the light tightens to a small circle and the voice drops to an intimate register. In the first act he offers a cut of the cards: his honesty against your name. The deck is marked both times. He wins the first cut and keeps your name, then uses it in his asides, at the door when you come back, and in his leaving speech. He loses the second on purpose, because being given a win is part of the seduction. Ask for a third and he refuses.

Then there's the beat the whole site is built around. Go idle for 30 seconds and he leaves first; the candle gutters out. One line lets you call him back, and he acts as if he never left. Each time you make him go, the speech gets shorter and drier. The third time, the mask slips for a moment.

One loop, no re-renders

All of that runs through a single provider component with one requestAnimationFrame loop. Every frame it reads pointer position, speed, idle time, scroll depth and tab focus, then writes the results into CSS custom properties: --warmth, --spot-x/--spot-y, --spot-r, --tempo, --cold, --flame. The light is a CSS radial gradient positioned by those variables, and the text color mixes toward warm as --warmth rises. The fast-changing values live in refs. React state changes only on discrete transitions: a change of mood, a change of register, the cards, his exit. The room breathes at frame rate without React re-rendering.

The mood is a small state machine: arriving → wanted → restless → leaving. Restless kicks in after 6.5 seconds without input, or as soon as the cursor enters the top 5% of the viewport. Leaving latches. Once he's gone, moving the mouse can't bring him back, because the cursor heading for the call-back button would otherwise cancel the very overlay it was reaching for. Only the call-back clears the latch. Most of the craft was in details like that. Chrome fires synthetic pointermoveevents while a page scrolls under a stationary cursor, so only real movement counts as approach. And the intimate register requires a hand that recently moved and a page that isn't scrolling. Without both checks, reading down the page pinned the intimate line to the bottom of the viewport.

The leaning headings had a performance trap of their own. They run inside the frame loop, several per frame, and calling getBoundingClientRect()there would force a synchronous layout every time. So each element's center is measured once, with its own transform removed, and re-measured only on scroll, resize, or a ResizeObserver hit. The per-frame path only writes.

Memory as characterization

Casanova remembers you, and that memory is the cruelest part of the character. A visit counter in localStorage maps to four familiarity tiers, and each tier caps the warmth he'll allow himself. New visitors and early returners get his full attention. By the seventh visit the cap is half: still friendly, but the light never fully settles on you again. A recent fix made this count once per tab session instead of once per page load. Before it, three refreshes were enough to turn a first-time visitor into someone he'd finished with.

Typed words act as cues. A keystroke buffer listens across the whole site. Type one of five vulnerable words and the engine freezes for a breath, then he recovers too brightly. Each word works once per visit, with a word-boundary check so a name that happens to end in one of them doesn't trigger it. One name from his memoirs stops the whole performance and shows the one honest screen on the site. Leave the tab and its title changes to “don't leave before I do —”. The optional soundtrack is generated with Web Audio: a low drone with a slow swell that speeds up when he's cornered and goes fully silent on that honest screen.

The inversion: a clock you can only hurry

Don Juan uses the same architecture, one frame loop writing CSS variables, but the input is different. The variable that matters, --reckoning, rises from 0 to 1 over a four-minute night and never goes back down. Watching doesn't slow it, and neither does looking away. Scrolling deeper into the night sets a floor under it: reach the graveyard and the clock jumps to at least 0.6, reach the dinner and it jumps to 0.86. In code the rule is one line, a Math.max over the current value, the elapsed time and the scroll floor. You can hurry the reckoning but you can never delay it.

His spotlight wanders on a pair of slow sine waves and ignores your cursor, with one exception. During the wedding scene it finds your pointer and holds it, then drops you as soon as your entry is filed. The choice in that scene is rigged: you're filed the moment your pointer or keyboard focus lands on his hand, whichever button you end up pressing. Go idle and he doesn't notice, but his servant does, in lowercase asides. The knocks at the door arrive on schedule, and a minimum gap keeps them from stacking. A scroll jump, or coming back to a tab that kept time while hidden, once landed the second knock 13 ms after the first, so nobody ever saw its line.

You are entry number 1,003 plus your visit count. The catalogue page lists the other 1,003 entries, generated with a seeded mulberry32PRNG keyed on each entry's index, so the book reads the same every night. It's a ledger, not a slot machine. The list renders 120 entries at a time through an IntersectionObserver, opens on your own line, and can jump to the last page. Its scroll handler is scoped to the main night, so reading the catalogue doesn't hurry the clock.

Two rooms, one house: the foyer

The two sites started as separate projects, and they share almost nothing at runtime: different fonts, different global styles, different engines writing different CSS variables onto the same root element. Merging them into one app couldn't mean one layout. Instead each man gets his own root layout in a Next.js route group, and the homepage gets a third. Moving between root layouts is a full page load, which is usually listed as a caveat. Here it's the point: walking from one man's room to the other resets the room, so neither engine's state or styling can leak into the other. With no single layout spanning all three, the 404 page stands alone, through Next 16's global-not-found.

The foyer had a brief: show the difference between the two men before you open either door. Casanova's half of the page answers you. His light follows your cursor, his name leans toward it, and his door widens when you come near, which is pure CSS via :has(). Drift over to the other door and his light presses against the seam after you while he asks “…over there? With him?”Don Juan's half answers nothing. His door has no hover state at all, his light wanders on its own, his night's fuse was already burning before you arrived, and his link has numbered you before you're through the door. The foyer also reads what each room has stored about you: Casanova notices which door you chose last time, and the servant keeps count of your nights in the list.

The Two Libertines foyer split in two: on the left, Casanova's warm half, with his light pressed against the center seam and two whispered lines; on the right, Don Juan's dark half, unchanged, with a lowercase aside from his servant.
The cursor is resting on Don Juan's door. Casanova's light has followed it to the seam and he's talking. Don Juan's half hasn't moved, and the only response there is from his servant.

Tested like a play

Both sites expose development-only hooks (never shipped to production) for driving the engine in tests. Headless browser tabs pause requestAnimationFrame, so a step()hook advances one frame by hand, and Don Juan adds a hook that spends the night's time early. The latest round of fixes came from a full live playthrough at desktop and 375px phone widths. That pass capped the size of the final swelling word so it fits a phone screen, made overlays opaque enough that the headline behind them stopped showing through, and moved the scroll hint out from under the text. Both sites honor prefers-reduced-motion: the light sits where you are instead of gliding, the shudder stops, and story beats land instantly.

What we'd tell a client

  • If a brand has a personality, let the interface act it out. Timing, attention and memory say more than copy.
  • Run continuous effects through CSS variables written from one animation loop, and save React state for real transitions.
  • State that should stick (a latch, a visit counter) needs a precise definition. 'One visit' meaning 'one page load' was a real bug.
  • Headless tests and rAF don't mix by default. Build a frame-step hook in from day one.
  • When two experiences share a domain but nothing else, give each its own root layout. A full page load between them is isolation you get for free.
Casanova runs on your attention and Don Juan runs on a clock. The characterization in both is the engine choice, and the foyer only has to let you feel the difference before you choose.