DEV Community

Cover image for More than a smiley face...creating expressions
Andy Schelb
Andy Schelb

Posted on AI-assisted

More than a smiley face...creating expressions

When you first start drawing you learn a smiley face. It's easy, it's simple, it's universally understood to mean happy.

I made Disco Doodle as a prompt generator for drawing. The natural outgrowth of that was thinking about characters and expressions. If I wanted to improve my subjects, they can't just all be the classic happy or sad. They could use some real emotions!

I wanted a prompt that forced a little more range: spin a wheel, land on a feeling, draw that face on your character.

That became Expressions — a spinning wheel of emoji "faces" that lands on top of a hand-drawn character body, shows a plain-language definition of the feeling, and keeps a little history so you can see what you've drawn so far.

Here's roughly where it landed:

Starting with a spec, not a sketch

I didn't have art yet, so the first version was built against a full written spec: a three-tier taxonomy of feelings (Surface Moods, Deep Waters, Complex Blends), a spinning wheel behind a circular "head" viewport, sound effects, and a rolling history of the last five spins. The plan going in was: build the whole interaction against a placeholder, then swap in real art once it existed — as opposed to designing around a fixed drawing from day one.

That turned out to matter, because the real art changed the layout more than once (more on that below).

The taxonomy itself went through a few passes of quality control. Early on it included feelings like "Hopeful" and "Nostalgic" that don't really have a dedicated face emoji — you end up reaching for something like 🙏 or 🌇 that doesn't actually read as a face, which defeats the point of a drawing prompt. Anything without a genuine face emoji got cut. It also went through a couple of rounds of "does this word actually mean what the emoji looks like" — 😬 ended up as "Nervous" rather than "Conflicted," 😐 became "Meh" instead of "Calm," and 🫠 landed on "Overwhelmed" with a definition that nods at embarrassment too, since that melting-face emoji covers a specific mix of feelings that no single word quite captures on its own.

Final lineup: 25 feelings across the three tiers — 8 surface, 10 deep, 7 complex.

const EMOTIONS = [
  // surface
  { tier:'surface', emoji:'😊', feeling:'Happy', definition:"Happy is that warm, bright feeling you get when something good happens..." },
  { tier:'surface', emoji:'😐', feeling:'Meh', definition:"Meh is that shrug feeling when you don't feel excited or upset about something..." },
  // deep
  { tier:'deep', emoji:'😬', feeling:'Nervous', definition:"Nervous is that jittery, jumpy feeling in your stomach right before something big..." },
  // complex
  { tier:'complex', emoji:'🫠', feeling:'Overwhelmed', definition:"Overwhelmed is when so much is happening at once — maybe you're embarrassed too — that you just smile and think 'this is fine'..." },
  // ...25 total
];
Enter fullscreen mode Exit fullscreen mode

Getting real art into a responsive layout

Once I had a hand-drawn body — t-shirt, pants, shoes, no head, on plain white paper — the job was turning a photo of paper into something that could sit believably underneath a spinning emoji.

That meant:

  1. Alpha-keying the drawing. Instead of a white background, the ink line work needed to become a transparent PNG so it could layer under the spinning head. I inverted the luminance of the scan and used that as the alpha channel, then recolored the remaining ink to match the site's actual brand color, so it doesn't look like a pasted-in scan.
  2. Cropping tight. Bounding-box crop down to the art itself, plus a small safety margin, so there's no dead space around the body.
  3. Compressing it. Ran the result through pngquant to bring the file size down before embedding it as a base64 data: URI directly in the page — no separate image request, no loading flash.
129KB scanned PNG → alpha-keyed, recolored, cropped → ~26KB compressed
Enter fullscreen mode Exit fullscreen mode

The trickier part wasn't the image processing — it was getting the emoji to sit exactly where a head should sit, responsively, across every screen width. The first pass used fixed pixel values for the head size and wheel geometry while the body image itself scaled fluidly with width: 100%. That's a bug waiting to happen: on a narrow phone screen, the art scales down but the head window doesn't, and suddenly the emoji is floating in the wrong spot.

The fix was to stop hardcoding pixels entirely and make everything a percentage of the containing stage:

.stage {
  position: relative;
  width: 100%;
  aspect-ratio: 1000 / 784;
}

.wheel-viewport {
  position: absolute;
  top: var(--head-top);
  left: 50%;
  transform: translateX(-50%);
  width: var(--head-size);
  aspect-ratio: 1;
  border-radius: 50%;
  overflow: hidden;
}
Enter fullscreen mode Exit fullscreen mode

Then, since the wheel's tile height still needs to be an actual pixel number for the animation math, I measure the rendered viewport at runtime instead of assuming a value:

function measureItemH() {
  const rect = wheelViewport.getBoundingClientRect();
  ITEM_H = rect.height;
}
Enter fullscreen mode Exit fullscreen mode

...with a debounced resize listener calling it again whenever the layout changes. That combination — percentage-based CSS plus a JS layer that measures rather than assumes — is the part of this build I'd reach for again on basically any "art that has to line up with an animated element" problem.

The mockup pivot: emoji in front, body behind


This was how Claude saw the idea orginally.


This was my first sketch mockup where I knew we were close.

I initially had the drawing layered above the wheel, with the emoji peeking out through a circular cutout — basically a porthole. It looked fine. Then I got a mockup with a big, expressive emoji head and a small, simple body underneath, with a note that the emoji should be the visual focus and the body should read as secondary — layered under the head, even mid-spin, not equal to it.

That was a real "redo the compositing" moment, not a tweak: it meant flipping the z-index relationship between the two layers and resizing the head window to match the proportions in the mockup rather than the proportions I'd guessed at originally.

.wheel-viewport { z-index: 2; } /* head: on top now */
.body-image     { z-index: 1; } /* body: underneath, always */
Enter fullscreen mode Exit fullscreen mode

I matched the mockup's proportions by overlaying a measurement grid on the reference image and then checking the live implementation against those same ratios with a headless browser, reading back the actual rendered bounding boxes rather than eyeballing it. It's a small technique but a reliable one whenever someone hands you a rough mockup and says "make it look like this."

I was so happy when it finally looked like I wanted. We'd come a long way to get Claude to understand exactly what I was asking for. Sometimes it even felt like I was just in an hours long argument, trying to restate my case so that Claude could do exactly what I wanted.

Sound design, and a rejected experiment

The spin needed sound — something in the spirit of a game-show wheel, not a system beep. This went through more iterations than anything else in the build.

First pass: a simple square-wave tick on every tile that scrolls past, plus a soft chime on landing, all synthesized with the Web Audio API (no audio files, just oscillators and gain envelopes) — no strong opinion here initially, just "does it make noise."

Second pass: slowing the whole animation down and giving it a two-phase feel — fast at first, then easing hard into a slow, dramatic settle, like it's building anticipation for which face you're about to get. This also got a filtered noise-burst "clack" instead of a plain tone, to feel more like a physical wheel clicker.

That second version got a clean "I don't like this one, go back" — which is a completely normal thing to happen partway through a build, and honestly a useful signal: the two-phase timing sounded more sophisticated in my head than it felt in practice. So it got fully reverted — the phase-split logic, the extra easing curve, the noise-burst clack, all of it — back to the original single continuous ease with a plain, simple tick:

function easeOutQuint(t){ return 1 - Math.pow(1 - t, 5); }

function playTick(){
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = 'sine';
  osc.frequency.value = 700;
  gain.gain.linearRampToValueAtTime(0.12, t0 + 0.004);
  gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.05);
  osc.start(t0);
  osc.stop(t0 + 0.06);
}
Enter fullscreen mode Exit fullscreen mode

And on landing, a short three-note ascending flourish (G5 → B5 → E6) instead of a single chime — enough to feel like a small "ta-da" without turning into a jingle:

function playChime(){
  const notes = [784, 987.77, 1318.51]; // G5, B5, E6
  notes.forEach((freq, i) => {
    // staggered oscillators, each with its own gain envelope
  });
}
Enter fullscreen mode Exit fullscreen mode

Keeping the wheel, the card, and the history honest with each other

There's a rolling history of your last five spins, and each entry is clickable — tap an old one and it brings back that feeling's definition card. The bug that showed up here was subtle: clicking a history item updated the definition card, but not the actual face sitting on the drawing. So you could end up looking at a card that says "Bittersweet" while the drawing still shows the surprised face from two spins ago — visually incoherent in a way that undermines the whole "practice drawing this specific expression" point of the feature.

The fix ties both together through one function, so there's no path where the card updates without the wheel (or vice versa):

function selectHistoryEntry(entry) {
  const emotionIdx = EMOTIONS.indexOf(entry);
  const pos = ORDER.indexOf(emotionIdx);
  if (pos !== -1) {
    setTrackY(-pos * ITEM_H); // scroll the wheel to match
  } else {
    // the tier filter changed since this was spun — just paint the tile directly
    tileEls[centerIndex].textContent = entry.emoji;
  }
  renderCard(entry);
  renderHistory(entry);
}
Enter fullscreen mode Exit fullscreen mode

That "tier filter changed since this was spun" branch matters because of the other feature in here: a slider that lets you filter the wheel down to just Surface Moods, just Deep Waters, just Complex Blends, or all of them combined. If you spin something from "all," then narrow the filter to "surface" only, a history click on that older entry can't just scroll the now-filtered wheel to it — it has to paint the tile directly instead.

What resets, and what doesn't

Worth calling out directly: nothing here is saved to the browser. No localStorage, no cookies — that's a deliberate, site-wide choice on Disco Doodle, not an oversight. So the history, the mute setting, and whatever face is currently showing all live in plain JavaScript memory for that page session. Reload the page, close the tab, come back later — it's a clean slate every time.

It was really important to me that it's all browser based. I want a site that feels zippy to use and loads quickly. Any moment waiting and I'm putting my phone down or scrolling on to something else.

We'll see where this takes me. I'm not sure what should be added next. Do you have any ideas? Post them in the comments!

Top comments (0)