Hand it to your AI assistant
A ready-made skill: a Markdown file with instructions, the options, the full source and the wrappers for every framework. Save it as SKILL.md inside a folder named nikuma-logo-face in your assistant's skills folder (for Claude Code: .claude/skills/), or paste it into any chat, then say "add a mascot whose eyes and nose change mood to my site".
SKILL.mdpreview
---
name: nikuma-logo-face
description: Add a live logo/mascot whose eyes blink, follow the mouse and change mood (happy, wink, sleepy, sad...). Use when the user asks for this effect or component in any framework (vanilla JS, Astro, React, Vue, Svelte, Solid).
---
# Logo face
The sloth in the top left corner of this site, standalone. It blinks, looks at your mouse, and every few seconds changes mood: happy, wink, sleepy, side-eye, wow, smug, bored, sad, suspicious, nervous, angry. Hover it and it smiles.
One dependency-free file. `mountLogoFace(element, options)` builds everything inside `element` and **returns a `stop()` function** that removes it and cleans up all listeners. The framework wrappers below only call it in the right lifecycle hook.
## When to use
The user wants: a mascot whose eyes and nose change mood. Works in plain HTML, Astro, React (incl. Next.js), Vue (incl. Nuxt), Svelte (incl. SvelteKit) and Solid.
## How to add it
1. Save the file(s) under **Source** into the project (for example `src/lib/logo-face/`), keeping their names. They need no installation.
2. Vanilla / Astro / plain HTML: import the function in a `<script type="module">` and call it with the container element. Other frameworks: create the wrapper from **Framework wrappers**, then render it.
3. Give the container a size where the rules below say so, then check it in both light and dark theme.
```js
import { mountLogoFace } from './logo-face.js';
const stop = mountLogoFace(document.getElementById('stage'), { size: 140, hoverMood: "happy" });
// stop(); // when the element is removed
```
## Options (all optional)
| Option | Default | What it does |
|---|---|---|
| `size` | `120` | width in px |
| `colorA / colorB` | `'#6fb0ff' / '#3f86f0'` | top and bottom colour of the body |
| `moods` | `13 moods` | which moods may appear at random (remove the ones you do not want) |
| `hoverMood` | `'happy'` | mood while hovered, '' for none |
| `follow` | `true` | eyes look toward the mouse |
| `idle / hold` | `[6,10] / [3.8,5.2]` | seconds resting / seconds each mood stays |
| `speed` | `1` | how fast the face changes |
## Rules
- Both files must sit in the same folder (`logo-face.js` imports `./logo-face-data.js`).
- The body is a filled SVG: colour it with `colorA` and `colorB`.
- Call `stop()` before mounting again on the same element (the framework wrappers do this on unmount).
- Do not edit the engine to change behaviour; use options. Respect `prefers-reduced-motion` (the engine does).
## Source
### `logo-face.js`
```js
// Live logo: the eyes and nose of an SVG mascot change shape. It rests in "idle" with slow blinks, now and then picks a random mood,
// holds it, and goes back to idle. The eyes follow the mouse. Hovering makes it happy.
// mountLogoFace(el, options) -> stop() Needs ./logo-face-data.js (the sloth's face shapes) next to this file.
//
// options (all optional):
// size width in px default 120 (height follows)
// colorA/colorB top / bottom colour of the body default '#6fb0ff' / '#3f86f0'
// moods which moods may appear at random default all; available: happy wink sleepy sideeye wow smug bored sad suspicious nervous angry shocked
// hoverMood mood while hovered ('' = none) default 'happy'
// follow eyes look toward the mouse default true
// blink slow blinks while idle default true
// idle [min, max] seconds between moods default [6, 10]
// hold [min, max] seconds a mood stays default [3.8, 5.2]
// speed how fast the face changes (.5 slower, 2 quicker) default 1
import { EDGE_A as A, EDGE_B as B, MOODS as X, NOSE, NOSE_Y as NY, BODY } from './logo-face-data.js';
const M = A.length, C = A.map((a, i) => [(a[0] + B[i][0]) / 2, (a[1] + B[i][1]) / 2]), H = A.map((a, i) => [(a[0] - B[i][0]) / 2, (a[1] - B[i][1]) / 2]);
const lerp = (arr, t) => { const x = Math.min(Math.max(t, 0), 1) * (M - 1), k = Math.min(Math.floor(x), M - 2), f = x - k; return [arr[k][0] * (1 - f) + arr[k + 1][0] * f, arr[k][1] * (1 - f) + arr[k + 1][1] * f]; };
const rnd = (a, b) => a + Math.random() * (b - a);
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };
let uid = 0;
export function mountLogoFace(el, o = {}) {
css('lfx-css', `.lfx{display:inline-block;line-height:0;transition:filter .5s}.lfx:hover{filter:drop-shadow(0 0 7px rgba(190,225,255,.9))}.lfx svg{display:block;overflow:visible}`);
const id = 'lfx' + ++uid, size = o.size || 120, g = { x: 0, y: 0, blink: 1 }; // g = eye look offset and blink amount, shared by the shape functions
const eyePath = (p, mirror) => {
const th = p[0] * Math.PI / 180, co = Math.cos(th), si = Math.sin(th), L = p[1], m = p[6], sq = p[7] * (g.blink * m + 1 - m), T = p[2] * (g.blink + (1 - g.blink) * m), sc = p[5], P = [], a = [], b = [];
for (let k = 0; k < M; k++) {
const u = k / (M - 1), c = lerp(C, u * L), h = lerp(H, u * L), gg = L < 1 ? (1 - u) / (1 - u * L + 1e-9) : 1;
a.push([c[0] + T * h[0] * gg, c[1] + T * h[1] * gg]); b.push([c[0] - T * h[0] * gg, c[1] - T * h[1] * gg]);
}
for (let k = M - 1; k >= 0; k--) P.push(a[k]); for (let k = 1; k < M; k++) P.push(b[k]);
const n = P.length, kx = 140 - 5 * m, ky = 300 + 35 * m, ox = p[3] + (mirror ? -g.x : g.x) * .8, oy = p[4] + g.y * .8;
for (let k = 0; k < n; k++) {
const ang = (135 + 360 * k / (n - 1)) * Math.PI / 180, px = P[k][0] + (135 + 62 * Math.cos(ang) - P[k][0]) * m, py = P[k][1] + (335 + 62 * Math.sin(ang) - P[k][1]) * m;
const x = px - kx, y = (py - ky) * sq, rx = (x * co - y * si) * sc + kx + ox, ry = (x * si + y * co) * sc + ky + oy; P[k] = [mirror ? 500 - rx : rx, ry];
}
let d = 'M' + P[0][0].toFixed(1) + ' ' + P[0][1].toFixed(1); // smooth curve through the points (Catmull-Rom -> Bezier)
for (let k = 0; k < n - 1; k++) {
const p0 = P[k - 1] || P[k], p1 = P[k], p2 = P[k + 1], p3 = P[k + 2] || P[k + 1];
d += 'C' + (p1[0] + (p2[0] - p0[0]) / 6).toFixed(1) + ' ' + (p1[1] + (p2[1] - p0[1]) / 6).toFixed(1) + ' ' + (p2[0] - (p3[0] - p1[0]) / 6).toFixed(1) + ' ' + (p2[1] - (p3[1] - p1[1]) / 6).toFixed(1) + ' ' + p2[0].toFixed(1) + ' ' + p2[1].toFixed(1);
}
return d + 'Z';
};
const lid = (p, mirror) => { // the eyelid: a half-plane that cuts the top of the eye
const ox = (mirror ? -g.x : g.x) * .8, ly = p[8] + g.y * .8, s = p[9], y = (x) => ly + s * (140 + ox - x);
return [[-20, y(-20)], [246, y(246)], [246, -60], [-20, -60]].map((q, k) => (k ? 'L' : 'M') + (mirror ? 500 - q[0] : q[0]).toFixed(1) + ' ' + q[1].toFixed(1)).join('') + 'Z';
};
el.innerHTML = `<span class="lfx" role="img" aria-label="${o.label || 'Logo'}"><svg viewBox="0 0 500 600" width="${size}" height="${Math.round(size * 1.2)}" fill="none">
<defs><linearGradient id="${id}g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${o.colorA || '#6fb0ff'}"/><stop offset="1" stop-color="${o.colorB || '#3f86f0'}"/></linearGradient>
<mask id="${id}m" maskUnits="userSpaceOnUse" x="-20" y="-20" width="540" height="640"><rect x="-20" y="-20" width="540" height="640" fill="#fff"/>
<path class="eL" fill="#000"/><path class="eR" fill="#000"/><path class="lL" fill="#fff"/><path class="lR" fill="#fff"/>
<circle class="cL" r="78" cx="135" fill="#fff"/><circle class="cR" r="78" cx="365" fill="#fff"/><ellipse class="n" cx="251.5" fill="#000"/></mask></defs>
<g class="h"><path fill="url(#${id}g)" d="${BODY}" mask="url(#${id}m)"/></g></svg></span>`;
const box = el.firstElementChild, q = (c) => box.querySelector('.' + c);
const eL = q('eL'), eR = q('eR'), lL = q('lL'), lR = q('lR'), cL = q('cL'), cR = q('cR'), nose = q('n'), head = q('h');
const allowed = (o.moods || ['happy', 'happy', 'wink', 'sleepy', 'sleepy', 'sideeye', 'wow', 'smug', 'bored', 'sad', 'suspicious', 'nervous', 'angry']).filter((m) => X[m] && m !== 'idle');
const speed = Math.max(.2, o.speed || 1), [idleMin, idleMax] = o.idle || [6, 10], [holdMin, holdMax] = o.hold || [3.8, 5.2], hoverMood = o.hoverMood ?? 'happy';
let cur = X.idle.map((p) => p.slice()), vel = X.idle.map((p) => p.map(() => 0)), tgt = X.idle, nc = NOSE.idle.slice(), nv = [0, 0], nt = NOSE.idle;
let tx = 0, ty = 0, blinkT = 1, mood = 'idle', hover = false, last = '', raf = 0; const timers = [];
const draw = () => {
eL.setAttribute('d', eyePath(cur[0], false)); eR.setAttribute('d', eyePath(cur[1], true));
lL.setAttribute('d', lid(cur[0], false)); lR.setAttribute('d', lid(cur[1], true));
cL.setAttribute('cx', (135 + g.x * .8).toFixed(1)); cL.setAttribute('cy', (cur[0][10] + g.y * .8).toFixed(1));
cR.setAttribute('cx', (365 + g.x * .8).toFixed(1)); cR.setAttribute('cy', (cur[1][10] + g.y * .8).toFixed(1));
nose.setAttribute('rx', nc[0].toFixed(1)); nose.setAttribute('ry', nc[1].toFixed(1)); nose.setAttribute('cx', (251.5 + g.x * .9).toFixed(1)); nose.setAttribute('cy', (NY + g.y * .9).toFixed(1));
head.setAttribute('transform', `translate(${(g.x * .4).toFixed(2)} ${(g.y * .3).toFixed(2)}) rotate(${(g.x * .18).toFixed(2)} 250 320)`);
};
const step = () => { // springs: every eye number chases its target with a little overshoot
const k = .025 * speed * speed, damp = 1 - .22 * speed; let mv = 0;
for (let e = 0; e < 2; e++) for (let j = 0; j < 11; j++) { vel[e][j] = vel[e][j] * damp + (tgt[e][j] - cur[e][j]) * k; cur[e][j] += vel[e][j]; mv += Math.abs(vel[e][j]) + Math.abs(tgt[e][j] - cur[e][j]); }
for (let j = 0; j < 2; j++) { nv[j] = nv[j] * damp + (nt[j] - nc[j]) * k; nc[j] += nv[j]; mv += Math.abs(nv[j]) + Math.abs(nt[j] - nc[j]); }
g.x += (tx - g.x) * .06; g.y += (ty - g.y) * .06; g.blink += (blinkT - g.blink) * .22;
mv += Math.abs(tx - g.x) + Math.abs(ty - g.y) + Math.abs(blinkT - g.blink);
draw(); return mv > .05;
};
const loop = () => { raf = step() ? requestAnimationFrame(loop) : 0; }, wake = () => { if (!raf) raf = requestAnimationFrame(loop); };
const set = (n) => { mood = n; tgt = X[n]; nt = NOSE[n]; wake(); }, later = (fn, ms) => timers.push(setTimeout(fn, ms));
draw();
const ac = new AbortController(), sig = { signal: ac.signal };
const stop = () => { ac.abort(); timers.forEach(clearTimeout); cancelAnimationFrame(raf); el.innerHTML = ''; };
if (!o.ignoreMotionPref && matchMedia('(prefers-reduced-motion: reduce)').matches) return stop; // reduce motion: the calm still face
if (o.follow !== false && matchMedia('(hover: hover)').matches) addEventListener('pointermove', (e) => {
const r = box.getBoundingClientRect(); tx = Math.max(-1, Math.min(1, (e.clientX - r.left - r.width / 2) / 450)) * 13; ty = Math.max(-1, Math.min(1, (e.clientY - r.top - r.height / 2) / 300)) * 8; wake();
}, { passive: true, signal: ac.signal });
if (hoverMood && X[hoverMood]) { box.addEventListener('pointerenter', () => { hover = true; set(hoverMood); }, sig); box.addEventListener('pointerleave', () => { hover = false; set('idle'); }, sig); }
if (o.blink !== false) (function blinkLoop() { if (!document.hidden) { blinkT = .08; wake(); later(() => { blinkT = 1; wake(); }, 260); } later(blinkLoop, rnd(3.2, 7) * 1000); })();
if (allowed.length) (function moodLoop() {
let wait;
if (document.hidden || hover) wait = 1500;
else if (mood !== 'idle') { set('idle'); wait = rnd(idleMin, idleMax) * 1000; }
else { let n; do { n = allowed[Math.floor(Math.random() * allowed.length)]; } while (n === last && allowed.length > 1); last = n; set(n); wait = rnd(holdMin, holdMax) * 1000; }
later(moodLoop, wait);
})();
return stop;
}
```
### `logo-face-data.js`
```js
// Face data for the nikuma sloth (the logo). Generated from the logo shape; you do not need to edit it.
// To use YOUR OWN mascot you need the same kind of data for it (see the "Your own logo" note in the post).
export const BODY = "M217.0 591.8 195.0 587.7 168.0 579.7 152.0 573.2 131.0 562.7 109.0 548.7 95.0 538.0 73.0 517.0 65.0 508.0 38.8 468.0 28.3 446.0 18.0 415.5 15.3 411.0 10.2 385.0 9.1 368.0 8.2 365.0 8.2 341.0 11.2 314.0 15.3 294.0 21.3 274.0 29.3 254.0 40.3 232.0 57.0 207.0 65.0 197.0 79.0 182.2 80.0 182.0 80.7 183.0 82.9 201.0 87.2 215.0 94.3 229.0 103.0 240.7 104.0 240.1 105.1 236.0 106.2 217.0 109.3 198.0 115.3 175.0 123.3 153.0 136.3 127.0 142.3 117.0 158.0 95.0 170.0 81.0 185.0 66.0 198.0 55.0 214.0 43.3 234.0 31.3 262.0 18.3 290.0 9.3 303.0 6.9 303.8 10.0 301.9 23.0 301.9 35.0 302.8 38.0 302.9 46.0 305.9 61.0 313.0 81.6 319.0 93.6 328.3 108.0 339.1 121.0 349.1 130.0 358.0 136.7 382.0 151.3 397.0 162.0 408.0 171.0 431.0 194.0 441.0 206.0 451.7 221.0 460.7 236.0 470.2 255.0 480.7 283.0 484.7 298.0 488.8 320.0 490.9 342.0 490.8 368.0 488.8 387.0 484.7 409.0 477.7 433.0 468.2 456.0 458.7 474.0 445.0 495.0 429.0 515.0 412.0 532.0 400.0 542.0 376.0 558.7 365.0 565.2 343.0 575.7 314.0 585.7 290.0 590.8 265.0 593.8 236.0 593.8Z";
export const EDGE_A = [[137.0,269.8],[129.3,268.5],[121.7,268.5],[114.1,269.8],[106.9,272.8],[100.0,276.5],[93.1,280.4],[86.7,284.8],[80.4,289.5],[74.2,294.3],[68.7,299.8],[63.1,305.4],[57.5,311.0],[52.5,317.0],[48.1,323.4],[43.6,329.9],[39.5,336.5],[36.0,343.5],[32.5,350.6],[29.4,357.8],[26.5,365.0],[24.3,372.6],[22.2,380.1],[20.5,387.7],[19.4,395.4],[18.5,403.1],[17.5,410.8],[19.3,418.4],[21.5,425.9],[23.9,433.3],[26.7,440.7],[29.7,447.9],[32.8,455.1],[36.3,462.1],[39.9,469.1],[44.1,475.7],[48.1,482.5],[52.5,489.0],[56.9,495.4],[62.0,501.3]];
export const EDGE_B = [[137.0,269.8],[143.6,273.1],[149.6,277.2],[154.3,282.8],[158.3,289.0],[160.7,295.9],[162.2,303.0],[162.1,310.3],[160.7,317.4],[158.1,324.3],[154.2,330.4],[149.4,336.0],[143.3,340.1],[136.8,343.4],[130.2,346.7],[123.8,350.4],[117.7,354.4],[111.7,358.7],[106.0,363.2],[100.5,368.0],[95.3,373.2],[90.1,378.4],[85.4,384.1],[80.9,389.9],[76.8,396.0],[73.1,402.3],[69.8,408.9],[66.9,415.6],[64.0,422.4],[61.7,429.3],[59.7,436.4],[58.5,443.6],[57.5,450.8],[56.5,458.0],[56.5,465.4],[57.1,472.7],[57.8,479.9],[59.2,487.1],[60.6,494.2],[62.0,501.3]];
export const NOSE_Y = 378;
// Eye params: [tilt, length, thickness, dx, dy, scale, round, squash, lidY, lidSlope, cheekY]
export const MOODS = {"idle":[[0,1,1,0,0,1,0,1,120,0,520],[0,1,1,0,0,1,0,1,120,0,520]],"happy":[[0,1,1,0,-4,1,1,1,120,0,432],[0,1,1,0,-4,1,1,1,120,0,432]],"sad":[[0,1,1,0,10,0.92,1,1,322,0.6,520],[0,1,1,0,10,0.92,1,1,322,0.6,520]],"angry":[[0,1,1,0,2,0.95,1,1,306,-0.6,520],[0,1,1,0,2,0.95,1,1,306,-0.6,520]],"shocked":[[0,1,1,0,-6,1.08,1,1,120,0,520],[0,1,1,0,-6,1.08,1,1,120,0,520]],"suspicious":[[0,1,1,0,4,0.95,1,1,322,0.08,520],[0,1,1,0,-8,1.1,1,1,120,0,520]],"wink":[[0,1,1,0,0,1,1,1,120,0,520],[0,1,1,0,-4,1,1,1,120,0,432]],"sleepy":[[4,0.9,0.4,0,8,1,0,1,120,0,520],[4,0.9,0.4,0,8,1,0,1,120,0,520]],"nervous":[[0,1,1,0,6,0.55,1,1,120,0,520],[0,1,1,0,6,0.55,1,1,120,0,520]],"wow":[[0,1,1,0,-8,1.08,1,1,120,0,520],[0,1,1,0,-8,1.08,1,1,120,0,520]],"bored":[[0,1,1,0,6,1,1,1,338,0,520],[0,1,1,0,6,1,1,1,338,0,520]],"sideeye":[[0,1,1,-14,0,0.9,1,1,120,0,520],[0,1,1,14,0,0.9,1,1,120,0,520]],"smug":[[0,1,1,0,4,1,1,1,312,-0.3,446],[0,1,1,0,4,1,1,1,312,-0.3,446]]};
export const NOSE = {"idle":[44,25.5],"happy":[50,22],"sad":[38,22],"angry":[46,21],"shocked":[30,34],"suspicious":[42,22],"wink":[48,24],"sleepy":[40,20],"nervous":[34,20],"wow":[40,30],"bored":[44,18],"sideeye":[44,22],"smug":[50,22]};
```
## Framework wrappers
### React: `LogoFace.jsx`
```jsx
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountLogoFace } from './logo-face.js';
export default function LogoFace({ options = {}, className, style }) {
const ref = useRef(null);
useEffect(() => mountLogoFace(ref.current, options), []); // the engine returns its own cleanup
return <div ref={ref} className={className} style={style} />;
}
```
### Vue: `LogoFace.vue`
```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountLogoFace } from './logo-face.js';
const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountLogoFace(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>
<template><div ref="root"></div></template>
```
### Svelte: `LogoFace.svelte`
```svelte
<script>
import { onMount } from 'svelte';
import { mountLogoFace } from './logo-face.js';
export let options = {};
export let style = '';
let klass = '';
export { klass as class };
let root;
onMount(() => mountLogoFace(root, options)); // returning the cleanup from onMount stops it on destroy
</script>
<div bind:this={root} class={klass} {style}></div>
```
### Solid: `LogoFace.jsx`
```jsx
import { onMount, onCleanup } from 'solid-js';
import { mountLogoFace } from './logo-face.js';
export default function LogoFace(props) {
let root;
onMount(() => onCleanup(mountLogoFace(root, props.options || {})));
return <div ref={root} class={props.class} style={props.style} />;
}
```Get the code
One small file does all the work and has no dependencies. React, Vue, Svelte and Solid only add a few lines around it. Pick your tool below.
1. Copy the engine (same for every framework)
logo-face.js9 KB
// Live logo: the eyes and nose of an SVG mascot change shape. It rests in "idle" with slow blinks, now and then picks a random mood,
// holds it, and goes back to idle. The eyes follow the mouse. Hovering makes it happy.
// mountLogoFace(el, options) -> stop() Needs ./logo-face-data.js (the sloth's face shapes) next to this file.
//
// options (all optional):
// size width in px default 120 (height follows)
// colorA/colorB top / bottom colour of the body default '#6fb0ff' / '#3f86f0'
// moods which moods may appear at random default all; available: happy wink sleepy sideeye wow smug bored sad suspicious nervous angry shocked
// hoverMood mood while hovered ('' = none) default 'happy'
// follow eyes look toward the mouse default true
// blink slow blinks while idle default true
// idle [min, max] seconds between moods default [6, 10]
// hold [min, max] seconds a mood stays default [3.8, 5.2]
// speed how fast the face changes (.5 slower, 2 quicker) default 1
import { EDGE_A as A, EDGE_B as B, MOODS as X, NOSE, NOSE_Y as NY, BODY } from './logo-face-data.js';
const M = A.length, C = A.map((a, i) => [(a[0] + B[i][0]) / 2, (a[1] + B[i][1]) / 2]), H = A.map((a, i) => [(a[0] - B[i][0]) / 2, (a[1] - B[i][1]) / 2]);
const lerp = (arr, t) => { const x = Math.min(Math.max(t, 0), 1) * (M - 1), k = Math.min(Math.floor(x), M - 2), f = x - k; return [arr[k][0] * (1 - f) + arr[k + 1][0] * f, arr[k][1] * (1 - f) + arr[k + 1][1] * f]; };
const rnd = (a, b) => a + Math.random() * (b - a);
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };
let uid = 0;
export function mountLogoFace(el, o = {}) {
css('lfx-css', `.lfx{display:inline-block;line-height:0;transition:filter .5s}.lfx:hover{filter:drop-shadow(0 0 7px rgba(190,225,255,.9))}.lfx svg{display:block;overflow:visible}`);
const id = 'lfx' + ++uid, size = o.size || 120, g = { x: 0, y: 0, blink: 1 }; // g = eye look offset and blink amount, shared by the shape functions
const eyePath = (p, mirror) => {
const th = p[0] * Math.PI / 180, co = Math.cos(th), si = Math.sin(th), L = p[1], m = p[6], sq = p[7] * (g.blink * m + 1 - m), T = p[2] * (g.blink + (1 - g.blink) * m), sc = p[5], P = [], a = [], b = [];
for (let k = 0; k < M; k++) {
const u = k / (M - 1), c = lerp(C, u * L), h = lerp(H, u * L), gg = L < 1 ? (1 - u) / (1 - u * L + 1e-9) : 1;
a.push([c[0] + T * h[0] * gg, c[1] + T * h[1] * gg]); b.push([c[0] - T * h[0] * gg, c[1] - T * h[1] * gg]);
}
for (let k = M - 1; k >= 0; k--) P.push(a[k]); for (let k = 1; k < M; k++) P.push(b[k]);
const n = P.length, kx = 140 - 5 * m, ky = 300 + 35 * m, ox = p[3] + (mirror ? -g.x : g.x) * .8, oy = p[4] + g.y * .8;
for (let k = 0; k < n; k++) {
const ang = (135 + 360 * k / (n - 1)) * Math.PI / 180, px = P[k][0] + (135 + 62 * Math.cos(ang) - P[k][0]) * m, py = P[k][1] + (335 + 62 * Math.sin(ang) - P[k][1]) * m;
const x = px - kx, y = (py - ky) * sq, rx = (x * co - y * si) * sc + kx + ox, ry = (x * si + y * co) * sc + ky + oy; P[k] = [mirror ? 500 - rx : rx, ry];
}
let d = 'M' + P[0][0].toFixed(1) + ' ' + P[0][1].toFixed(1); // smooth curve through the points (Catmull-Rom -> Bezier)
for (let k = 0; k < n - 1; k++) {
const p0 = P[k - 1] || P[k], p1 = P[k], p2 = P[k + 1], p3 = P[k + 2] || P[k + 1];
d += 'C' + (p1[0] + (p2[0] - p0[0]) / 6).toFixed(1) + ' ' + (p1[1] + (p2[1] - p0[1]) / 6).toFixed(1) + ' ' + (p2[0] - (p3[0] - p1[0]) / 6).toFixed(1) + ' ' + (p2[1] - (p3[1] - p1[1]) / 6).toFixed(1) + ' ' + p2[0].toFixed(1) + ' ' + p2[1].toFixed(1);
}
return d + 'Z';
};
const lid = (p, mirror) => { // the eyelid: a half-plane that cuts the top of the eye
const ox = (mirror ? -g.x : g.x) * .8, ly = p[8] + g.y * .8, s = p[9], y = (x) => ly + s * (140 + ox - x);
return [[-20, y(-20)], [246, y(246)], [246, -60], [-20, -60]].map((q, k) => (k ? 'L' : 'M') + (mirror ? 500 - q[0] : q[0]).toFixed(1) + ' ' + q[1].toFixed(1)).join('') + 'Z';
};
el.innerHTML = `<span class="lfx" role="img" aria-label="${o.label || 'Logo'}"><svg viewBox="0 0 500 600" width="${size}" height="${Math.round(size * 1.2)}" fill="none">
<defs><linearGradient id="${id}g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${o.colorA || '#6fb0ff'}"/><stop offset="1" stop-color="${o.colorB || '#3f86f0'}"/></linearGradient>
<mask id="${id}m" maskUnits="userSpaceOnUse" x="-20" y="-20" width="540" height="640"><rect x="-20" y="-20" width="540" height="640" fill="#fff"/>
<path class="eL" fill="#000"/><path class="eR" fill="#000"/><path class="lL" fill="#fff"/><path class="lR" fill="#fff"/>
<circle class="cL" r="78" cx="135" fill="#fff"/><circle class="cR" r="78" cx="365" fill="#fff"/><ellipse class="n" cx="251.5" fill="#000"/></mask></defs>
<g class="h"><path fill="url(#${id}g)" d="${BODY}" mask="url(#${id}m)"/></g></svg></span>`;
const box = el.firstElementChild, q = (c) => box.querySelector('.' + c);
const eL = q('eL'), eR = q('eR'), lL = q('lL'), lR = q('lR'), cL = q('cL'), cR = q('cR'), nose = q('n'), head = q('h');
const allowed = (o.moods || ['happy', 'happy', 'wink', 'sleepy', 'sleepy', 'sideeye', 'wow', 'smug', 'bored', 'sad', 'suspicious', 'nervous', 'angry']).filter((m) => X[m] && m !== 'idle');
const speed = Math.max(.2, o.speed || 1), [idleMin, idleMax] = o.idle || [6, 10], [holdMin, holdMax] = o.hold || [3.8, 5.2], hoverMood = o.hoverMood ?? 'happy';
let cur = X.idle.map((p) => p.slice()), vel = X.idle.map((p) => p.map(() => 0)), tgt = X.idle, nc = NOSE.idle.slice(), nv = [0, 0], nt = NOSE.idle;
let tx = 0, ty = 0, blinkT = 1, mood = 'idle', hover = false, last = '', raf = 0; const timers = [];
const draw = () => {
eL.setAttribute('d', eyePath(cur[0], false)); eR.setAttribute('d', eyePath(cur[1], true));
lL.setAttribute('d', lid(cur[0], false)); lR.setAttribute('d', lid(cur[1], true));
cL.setAttribute('cx', (135 + g.x * .8).toFixed(1)); cL.setAttribute('cy', (cur[0][10] + g.y * .8).toFixed(1));
cR.setAttribute('cx', (365 + g.x * .8).toFixed(1)); cR.setAttribute('cy', (cur[1][10] + g.y * .8).toFixed(1));
nose.setAttribute('rx', nc[0].toFixed(1)); nose.setAttribute('ry', nc[1].toFixed(1)); nose.setAttribute('cx', (251.5 + g.x * .9).toFixed(1)); nose.setAttribute('cy', (NY + g.y * .9).toFixed(1));
head.setAttribute('transform', `translate(${(g.x * .4).toFixed(2)} ${(g.y * .3).toFixed(2)}) rotate(${(g.x * .18).toFixed(2)} 250 320)`);
};
const step = () => { // springs: every eye number chases its target with a little overshoot
const k = .025 * speed * speed, damp = 1 - .22 * speed; let mv = 0;
for (let e = 0; e < 2; e++) for (let j = 0; j < 11; j++) { vel[e][j] = vel[e][j] * damp + (tgt[e][j] - cur[e][j]) * k; cur[e][j] += vel[e][j]; mv += Math.abs(vel[e][j]) + Math.abs(tgt[e][j] - cur[e][j]); }
for (let j = 0; j < 2; j++) { nv[j] = nv[j] * damp + (nt[j] - nc[j]) * k; nc[j] += nv[j]; mv += Math.abs(nv[j]) + Math.abs(nt[j] - nc[j]); }
g.x += (tx - g.x) * .06; g.y += (ty - g.y) * .06; g.blink += (blinkT - g.blink) * .22;
mv += Math.abs(tx - g.x) + Math.abs(ty - g.y) + Math.abs(blinkT - g.blink);
draw(); return mv > .05;
};
const loop = () => { raf = step() ? requestAnimationFrame(loop) : 0; }, wake = () => { if (!raf) raf = requestAnimationFrame(loop); };
const set = (n) => { mood = n; tgt = X[n]; nt = NOSE[n]; wake(); }, later = (fn, ms) => timers.push(setTimeout(fn, ms));
draw();
const ac = new AbortController(), sig = { signal: ac.signal };
const stop = () => { ac.abort(); timers.forEach(clearTimeout); cancelAnimationFrame(raf); el.innerHTML = ''; };
if (!o.ignoreMotionPref && matchMedia('(prefers-reduced-motion: reduce)').matches) return stop; // reduce motion: the calm still face
if (o.follow !== false && matchMedia('(hover: hover)').matches) addEventListener('pointermove', (e) => {
const r = box.getBoundingClientRect(); tx = Math.max(-1, Math.min(1, (e.clientX - r.left - r.width / 2) / 450)) * 13; ty = Math.max(-1, Math.min(1, (e.clientY - r.top - r.height / 2) / 300)) * 8; wake();
}, { passive: true, signal: ac.signal });
if (hoverMood && X[hoverMood]) { box.addEventListener('pointerenter', () => { hover = true; set(hoverMood); }, sig); box.addEventListener('pointerleave', () => { hover = false; set('idle'); }, sig); }
if (o.blink !== false) (function blinkLoop() { if (!document.hidden) { blinkT = .08; wake(); later(() => { blinkT = 1; wake(); }, 260); } later(blinkLoop, rnd(3.2, 7) * 1000); })();
if (allowed.length) (function moodLoop() {
let wait;
if (document.hidden || hover) wait = 1500;
else if (mood !== 'idle') { set('idle'); wait = rnd(idleMin, idleMax) * 1000; }
else { let n; do { n = allowed[Math.floor(Math.random() * allowed.length)]; } while (n === last && allowed.length > 1); last = n; set(n); wait = rnd(holdMin, holdMax) * 1000; }
later(moodLoop, wait);
})();
return stop;
}logo-face-data.js4 KB
// Face data for the nikuma sloth (the logo). Generated from the logo shape; you do not need to edit it.
// To use YOUR OWN mascot you need the same kind of data for it (see the "Your own logo" note in the post).
export const BODY = "M217.0 591.8 195.0 587.7 168.0 579.7 152.0 573.2 131.0 562.7 109.0 548.7 95.0 538.0 73.0 517.0 65.0 508.0 38.8 468.0 28.3 446.0 18.0 415.5 15.3 411.0 10.2 385.0 9.1 368.0 8.2 365.0 8.2 341.0 11.2 314.0 15.3 294.0 21.3 274.0 29.3 254.0 40.3 232.0 57.0 207.0 65.0 197.0 79.0 182.2 80.0 182.0 80.7 183.0 82.9 201.0 87.2 215.0 94.3 229.0 103.0 240.7 104.0 240.1 105.1 236.0 106.2 217.0 109.3 198.0 115.3 175.0 123.3 153.0 136.3 127.0 142.3 117.0 158.0 95.0 170.0 81.0 185.0 66.0 198.0 55.0 214.0 43.3 234.0 31.3 262.0 18.3 290.0 9.3 303.0 6.9 303.8 10.0 301.9 23.0 301.9 35.0 302.8 38.0 302.9 46.0 305.9 61.0 313.0 81.6 319.0 93.6 328.3 108.0 339.1 121.0 349.1 130.0 358.0 136.7 382.0 151.3 397.0 162.0 408.0 171.0 431.0 194.0 441.0 206.0 451.7 221.0 460.7 236.0 470.2 255.0 480.7 283.0 484.7 298.0 488.8 320.0 490.9 342.0 490.8 368.0 488.8 387.0 484.7 409.0 477.7 433.0 468.2 456.0 458.7 474.0 445.0 495.0 429.0 515.0 412.0 532.0 400.0 542.0 376.0 558.7 365.0 565.2 343.0 575.7 314.0 585.7 290.0 590.8 265.0 593.8 236.0 593.8Z";
export const EDGE_A = [[137.0,269.8],[129.3,268.5],[121.7,268.5],[114.1,269.8],[106.9,272.8],[100.0,276.5],[93.1,280.4],[86.7,284.8],[80.4,289.5],[74.2,294.3],[68.7,299.8],[63.1,305.4],[57.5,311.0],[52.5,317.0],[48.1,323.4],[43.6,329.9],[39.5,336.5],[36.0,343.5],[32.5,350.6],[29.4,357.8],[26.5,365.0],[24.3,372.6],[22.2,380.1],[20.5,387.7],[19.4,395.4],[18.5,403.1],[17.5,410.8],[19.3,418.4],[21.5,425.9],[23.9,433.3],[26.7,440.7],[29.7,447.9],[32.8,455.1],[36.3,462.1],[39.9,469.1],[44.1,475.7],[48.1,482.5],[52.5,489.0],[56.9,495.4],[62.0,501.3]];
export const EDGE_B = [[137.0,269.8],[143.6,273.1],[149.6,277.2],[154.3,282.8],[158.3,289.0],[160.7,295.9],[162.2,303.0],[162.1,310.3],[160.7,317.4],[158.1,324.3],[154.2,330.4],[149.4,336.0],[143.3,340.1],[136.8,343.4],[130.2,346.7],[123.8,350.4],[117.7,354.4],[111.7,358.7],[106.0,363.2],[100.5,368.0],[95.3,373.2],[90.1,378.4],[85.4,384.1],[80.9,389.9],[76.8,396.0],[73.1,402.3],[69.8,408.9],[66.9,415.6],[64.0,422.4],[61.7,429.3],[59.7,436.4],[58.5,443.6],[57.5,450.8],[56.5,458.0],[56.5,465.4],[57.1,472.7],[57.8,479.9],[59.2,487.1],[60.6,494.2],[62.0,501.3]];
export const NOSE_Y = 378;
// Eye params: [tilt, length, thickness, dx, dy, scale, round, squash, lidY, lidSlope, cheekY]
export const MOODS = {"idle":[[0,1,1,0,0,1,0,1,120,0,520],[0,1,1,0,0,1,0,1,120,0,520]],"happy":[[0,1,1,0,-4,1,1,1,120,0,432],[0,1,1,0,-4,1,1,1,120,0,432]],"sad":[[0,1,1,0,10,0.92,1,1,322,0.6,520],[0,1,1,0,10,0.92,1,1,322,0.6,520]],"angry":[[0,1,1,0,2,0.95,1,1,306,-0.6,520],[0,1,1,0,2,0.95,1,1,306,-0.6,520]],"shocked":[[0,1,1,0,-6,1.08,1,1,120,0,520],[0,1,1,0,-6,1.08,1,1,120,0,520]],"suspicious":[[0,1,1,0,4,0.95,1,1,322,0.08,520],[0,1,1,0,-8,1.1,1,1,120,0,520]],"wink":[[0,1,1,0,0,1,1,1,120,0,520],[0,1,1,0,-4,1,1,1,120,0,432]],"sleepy":[[4,0.9,0.4,0,8,1,0,1,120,0,520],[4,0.9,0.4,0,8,1,0,1,120,0,520]],"nervous":[[0,1,1,0,6,0.55,1,1,120,0,520],[0,1,1,0,6,0.55,1,1,120,0,520]],"wow":[[0,1,1,0,-8,1.08,1,1,120,0,520],[0,1,1,0,-8,1.08,1,1,120,0,520]],"bored":[[0,1,1,0,6,1,1,1,338,0,520],[0,1,1,0,6,1,1,1,338,0,520]],"sideeye":[[0,1,1,-14,0,0.9,1,1,120,0,520],[0,1,1,14,0,0.9,1,1,120,0,520]],"smug":[[0,1,1,0,4,1,1,1,312,-0.3,446],[0,1,1,0,4,1,1,1,312,-0.3,446]]};
export const NOSE = {"idle":[44,25.5],"happy":[50,22],"sad":[38,22],"angry":[46,21],"shocked":[30,34],"suspicious":[42,22],"wink":[48,24],"sleepy":[40,20],"nervous":[34,20],"wow":[40,30],"bored":[44,18],"sideeye":[44,22],"smug":[50,22]};Save these files in your project (for example src/logo-face/). You never need to edit them: everything is an option.
2. Use it in your project
Plain HTML, Astro, Eleventy, WordPress, anything. No build step needed: a `<script type="module">` is enough.
Use it:
<div id="stage"></div>
<script type="module">
import { mountLogoFace } from './logo-face.js';
const stop = mountLogoFace(document.getElementById('stage'), { size: 140, hoverMood: "happy" });
// when you are done (for example when a page closes): stop();
</script>Works in Vite, Create React App, Next.js (App Router needs the `'use client'` line, already included), Remix. The engine only touches the page inside `useEffect`, so server rendering is safe.
Create LogoFace.jsx next to the engine:
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountLogoFace } from './logo-face.js';
export default function LogoFace({ options = {}, className, style }) {
const ref = useRef(null);
useEffect(() => mountLogoFace(ref.current, options), []); // the engine returns its own cleanup
return <div ref={ref} className={className} style={style} />;
}Then use it:
import LogoFace from './LogoFace';
export default function Page() {
return <LogoFace options={{ size: 140, hoverMood: "happy" }} />;
}Works in Vite + Vue 3 and Nuxt (wrap it in `<ClientOnly>` if you want to be extra safe; it only touches the page in `onMounted`).
Create LogoFace.vue next to the engine:
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountLogoFace } from './logo-face.js';
const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountLogoFace(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>
<template><div ref="root"></div></template>Then use it:
<script setup>
import LogoFace from './LogoFace.vue';
</script>
<template>
<LogoFace :options="{ size: 140, hoverMood: 'happy' }" />
</template>Works in Svelte 4 and 5 (the `export let` style still works in 5) and SvelteKit. Starts in `onMount`, so it never runs on the server.
Create LogoFace.svelte next to the engine:
<script>
import { onMount } from 'svelte';
import { mountLogoFace } from './logo-face.js';
export let options = {};
export let style = '';
let klass = '';
export { klass as class };
let root;
onMount(() => mountLogoFace(root, options)); // returning the cleanup from onMount stops it on destroy
</script>
<div bind:this={root} class={klass} {style}></div>Then use it:
<script>
import LogoFace from './LogoFace.svelte';
</script>
<LogoFace options={{ size: 140, hoverMood: "happy" }} />Works in Solid and SolidStart. Starts in `onMount`, so it never runs on the server.
Create LogoFace.jsx next to the engine:
import { onMount, onCleanup } from 'solid-js';
import { mountLogoFace } from './logo-face.js';
export default function LogoFace(props) {
let root;
onMount(() => onCleanup(mountLogoFace(root, props.options || {})));
return <div ref={root} class={props.class} style={props.style} />;
}Then use it:
import LogoFace from './LogoFace';
export default function Page() {
return <LogoFace options={{ size: 140, hoverMood: "happy" }} />;
}The sloth in the top left corner of this site, standalone. It blinks, looks at your mouse, and every few seconds changes mood: happy, wink, sleepy, side-eye, wow, smug, bored, sad, suspicious, nervous, angry. Hover it and it smiles.
How it works
- The eyes and nose are holes cut out of the body with an SVG mask, so whatever is behind the logo shines through them.
- Eye shapes are computed from 11 numbers each (tilt, length, thickness, roundness …). Every number is a spring chasing its target, which gives the little overshoot that feels alive.
logo-face-data.jsholds the sloth’s outline and the numbers for each mood.- Blinks and moods run on timers; the pointer follow only runs with a mouse.
prefers-reduced-motion= a calm still face.
Options
Every option is optional. These are the defaults.
| Option | Default | What it does |
|---|---|---|
size | 120 | width in px |
colorA / colorB | '#6fb0ff' / '#3f86f0' | top and bottom colour of the body |
moods | 13 moods | which moods may appear at random (remove the ones you do not want) |
hoverMood | 'happy' | mood while hovered, ” for none |
follow | true | eyes look toward the mouse |
idle / hold | [6,10] / [3.8,5.2] | seconds resting / seconds each mood stays |
speed | 1 | how fast the face changes |
Tips
- Want it as the logo link? Wrap the container in an
<a href="/">. - Calmer:
moods: ["happy", "sleepy"],idle: [12, 20]. - Your own mascot needs the same kind of data as
logo-face-data.js: an outline of the body and the edge points of one eye. That is a design job more than a coding one; the nikuma sloth is included so you can try everything.