Originally posted on my site.
I've loved Japanese kaomoji for years. The little upright faces like (。•ᴗ•。) and (っ´ω`c), not the sideways :-). They warm up a message just a little, and that always stuck with me.
A while back I went looking for a good place to grab them, and honestly I couldn't find one I liked. There are plenty of sites, but most of them feel old. Cramped pages, tiny text, ads everywhere, a stray link sitting right where you meant to tap. The kaomoji were cute. Using the sites was not.
So I figured I'd just build my own. But a straight copy of what already existed felt pointless. Same list, fewer ads, who cares about that.
Then I thought about the small shops I used to wander into in Japan. The quiet, warm ones, each with its own little personality. You step inside and your shoulders drop, and somehow half an hour goes by. That was the feeling I wanted. Not a database, but a little shop you can walk into, with someone actually in it.
So I put someone in it. A small 看板娘 (kanban-musume), the kind of shop girl who says hi when you come in. I call her Nyamoji. She watches your cursor, blinks when she feels like it, and even sort of breathes.
Here's the thing, though: none of it is hard. It's plain SVG, one pointermove, and a bit of CSS. The tricky part was getting her to stop looking dead. Let me walk you through the bits that actually mattered.
One face, swapped parts
She has sixteen expressions, and they change depending on the page. But her outline and her cheeks never change. So I draw the base once, and only swap out the eyes and the mouth. An expression is really just data:
// Each face only carries eyes + mouth. The base SVG is shared.
const FACES = {
normal: { eyes: DOT, mouth: SMILE },
cry: { eyes: TEARY_EYES, mouth: WAVER },
sing: { eyes: SING_EYES, mouth: NOTE },
// ...16 total
};
Want a new expression? Add an eyes path and a mouth path. I never redraw the whole face. It's the cheapest decision in the whole project, and it kept paying off.
Following the cursor
This is the part I cared about most.
The page listens to a single pointermove. Every time the cursor moves, I work out the direction from the center of her face to the pointer, and nudge just her eyes that way:
function follow(e) {
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const d = Math.hypot(dx, dy) || 1;
eyes.setAttribute(
"transform",
`translate(${(dx / d) * MAX} ${(dy / d) * MAX * 0.85})`
);
}
Two things I got wrong the first time.
First, that 0.85 on the vertical. If her eyes travel up and down as far as they go side to side, she looks like her eyes are rolling back into her head. It's honestly a little creepy. Pulling the vertical in a bit keeps her looking at you instead of through you.
Second, the eyes group has transition: transform .14s ease-out on it. Without that, the eyes jump from spot to spot and it feels robotic. Give them a seventh of a second to catch up and they glide, and suddenly it reads like she's actually watching you.
Blinking, never in sync
Blinking is just CSS. I squash her eyes flat for a single instant:
@keyframes m-blink {
0%, 93%, 100% { transform: scaleY(1); }
96.5% { transform: scaleY(0.1); }
}
Here's the part that surprised me. There's a mascot up in the header, and another one down in the page. The first time I shipped this, every one of them blinked on the exact same frame. The whole page sort of twitched at once. It looked like a bug, not a living thing.
The fix is one line. Give each mascot a random delay when it gets drawn:
blink.style.animationDelay = (Math.random() * 4.8).toFixed(2) + "s";
Now they each blink on their own little schedule, and she feels a lot more alive. Breathing works the same way: a slow scale from 1.0 to 1.025 over five seconds, also offset so they're never in lockstep.
The white dot
There's a tiny white dot in each eye. A catchlight.
That one dot is the whole difference between alive and dead. Take it away and her eyes look like buttons. Put it back and she's looking right at you. If I could keep only one detail from this whole post, I'd keep the dot.
Reduced motion
Not everyone enjoys movement on a page, and that's fair. So when prefers-reduced-motion is set to reduce, I stop all of it. No blinking, no breathing, no cursor following:
@media (prefers-reduced-motion: reduce) {
.m-blink, .mascot[data-breathe] svg { animation: none; }
.m-eyes { transition: none; }
}
The cute movement is a bonus, not the point. I didn't want it to be a tax on anyone, so this went in early rather than as an afterthought.
The bug that cost me the most: an empty span shakes the page
Let me end on the one that really got me.
Her face gets drawn after the JS loads. At first I used an empty <span> as the placeholder. The moment the JS dropped her face in, that span popped open to full size and the whole page jumped (╯°□°)╯︵ ┻━┻. On the header logo it was even worse: flicking between pages, you'd catch a glimpse of the empty shell.
The fix was boring, but it worked. Write her face into the HTML from the start, and make it match what the JS draws, right down to the character. Then when the JS takes over, nothing moves at all.
One warning. If you copy that placeholder by hand, it drifts out of sync sooner or later, and you get a little jump when the JS kicks in. So I just call the same function that builds her face, and paste its output straight into the HTML. Don't try to write it yourself.
That's it
That's really all of it. Plain SVG, pointermove, and some CSS. The 0.85 squash, the offset blinks, that little white dot. That's where she stops being a drawing and starts feeling like she's there.
If you want to say hi, she's over at Kaomojikan. She'll follow you around the page (・ω・). And if it's useful to you, the kaomoji data (readings, tags, and categories, all as JSON) is open under MIT.


Top comments (0)