The internet used to feel like other people were in the room with you. You’d load a page and there’d be a hit counter, a guestbook, a shoutbox, a chat that was mostly bots and one guy named Dave. It was janky and it was alive. Somewhere along the way most of my browsing turned into single-player. I read a page, the page doesn’t know I’m here (outside of the 40 tracking scripts logging every action I take), I leave.

I’ve been trying to put a little of that back into my own site. So I rebuilt my homepage header with playhtml, and now when you drag one of the stickers around, it moves for everyone else looking at the page too.

playhtml is Spencer Chang’s, and I’ve been a little in awe of him. So much of what he builds points at the same idea: that the internet is alive, that it’s something we make together instead of a stack of pages we each read alone. I love the internet. Spencer clearly loves it too, and you can feel it in everything he makes, like We Were Online, another of his playhtml pieces I keep thinking about. Getting to build on his library felt like the right kind of borrowing.

What playhtml actually does

playhtml is a small library that makes plain HTML elements collaborative. You add an attribute to an element and its state syncs to everyone else on the same page in real time. There’s a server behind it holding the shared state (it runs on partykit, and the sync is Yjs under the hood), but from where I sit it’s mostly: annotate the element, get the collaboration for free.

The two attributes I lean on:

  • can-move makes an element draggable, and the drag offset syncs to everyone.
  • can-play is the escape hatch: you give the element a defaultData object and an updateElement handler, and you own the shared state. Anything you can put in an object, you can sync.

There’s also an events channel for one-shot broadcasts (a wave, a “hey I turned this on”) that don’t need to persist. That’s it. That’s the whole mental model I needed to start.

The stickers

The header is a bomb of die-cut emoji stickers scattered around my name. They render on the server, so they’re there before any JavaScript runs, and they look like stickers whether or not the collaboration ever loads.

The emoji are GT-Maru’s, the only font I’ve ever paid for with my own money. It’s the keith.is vibe compressed into a typeface, round and a little silly, and I plan to get every dollar’s worth by putting these guys on everything I can.

Each one is a can-move element:

html
<span class="sticker" can-move loading-behavior="none">
  <span class="sticker__glyph">👋</span>
</span>

Drag one and playhtml syncs its {x, y} offset to the room. Reload and it’s where you left it, because the room remembers. Open the page in a second window and drag: it slides in the first one too. The first time that worked I moved the pig back and forth for way too long.

I added one bit of my own logic on top: the stickers spring back if you fling them out of the header box, so nobody can drag the avocado off into the footer and strand it there.

The wave

The yellow hand is tappable. Tap it and it waves. But it doesn’t just wave on your screen: it waves on the screen of everyone who’s on the page right now.

That one runs through the events channel instead of synced state, because a wave isn’t a thing you store, it’s a thing that happens:

javascript
playhtml.dispatchPlayEvent({ type: 'wave' });

Everyone’s client listens for wave and runs the same little animation on their own hand. There’s a short cooldown so you can’t strobe the whole room by mashing it. Waving at a stranger you’ll never meet and knowing the hand moved on their screen too is exactly the feeling I’m after.

The listening party

Tap the music note and a small player opens in the corner. Pick a track and it starts playing for everyone who’s here and hits play. Whoever picks the song is the DJ; everyone else drops into the same spot in the same song.

The player is a can-play element, so I own the shared state: which video, playing or paused, and where in the track we are. New arrivals extrapolate the current position from when the room last changed state, so you join a song already in progress at roughly the right second instead of starting it over.

The part I didn’t expect to be hard was knowing whether anyone is actually DJing right now. A song someone started an hour ago and walked away from shouldn’t still show as “playing” forever. My first instinct was to use presence/awareness for that, and it synced unreliably enough that I gave up on it. What works: the DJ’s client stamps a heartbeat onto the shared data every few seconds. If the heartbeat’s gone stale, the room goes quiet on its own.

Rainbow mode

There’s a rainbow sticker that flips the whole site into a rainbow gradient haze. When you turn it on, everyone else on the page gets a little toast in the corner: someone activated rainbow mode, you too? They can join you or dismiss it. It’s a stranger tapping you on the shoulder to point at something silly, and you can always wave it off.

What got me

A few things I’d tell myself before starting:

  1. Testing collaboration means you’re your own worst neighbor. playhtml rooms key off the URL, so every localhost tab I had open was in the same room as the version I was testing against. My debugging sessions were leaking DJ changes into my own other window and I couldn’t figure out why the state kept jumping. The fix was a ?phroom=<name> query param that drops you into an isolated room.

  2. Presence is harder than it looks. See the DJ heartbeat above. If you want “who’s here right now,” don’t assume the awareness layer will be reliable for it. A heartbeat on the channel you already trust is boring and it works.

  3. The library dims your elements while it connects, and that bit me the morning I wrote this. playhtml adds a loading state to can-move elements until the socket connects, and its CSS quietly pulses them at reduced opacity as a “hang on, connecting” cue. Nice idea, until the backend has a slow morning like it did today, and my whole sticker header sat there breathing and half-faded while it waited. The fix was loading-behavior="none" on the stickers. They’re server-rendered and they drag locally even with no connection, so there was never a reason to gate how they look on whether the server picked up. Now they’re solid and interactive instantly, and the sync just quietly catches up whenever it connects.

That last one is the whole trick to doing this without making your site fragile. The page should be good on its own and better when other people are around. It should never be the thing that sits broken while a socket connects. Everything renders on the server. Dragging works offline. If playhtml can’t reach its backend, the onError handler shrugs and you get a normal static header that works fine. The collaboration is the topping, not the cake.

Why I keep doing this

None of this is useful. Nobody needs to co-drag a sticker of a snail. But every one of these is a tiny argument that the internet can still feel inhabited, that a webpage can know you’re not the only person looking at it. I miss that. I’m building the small version of it I can host myself.

I’ve got more planned: the music player is going to get better, and I want every one of those emojis to do something fun and make you feel like you’re experiencing the site with someone else. If you’re reading this and the hand waved at you, that was probably me.

Find me on Bluesky and let me know which emoji / experience I should tackle next 💗