← Playground

Logo face

A mascot whose eyes and nose change mood

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".

Download SKILL.md

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>

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

Options

Every option is optional. These are the defaults.

OptionDefaultWhat it does
size120width in px
colorA / colorB'#6fb0ff' / '#3f86f0'top and bottom colour of the body
moods13 moodswhich moods may appear at random (remove the ones you do not want)
hoverMood'happy'mood while hovered, ” for none
followtrueeyes look toward the mouse
idle / hold[6,10] / [3.8,5.2]seconds resting / seconds each mood stays
speed1how fast the face changes

Tips

Nature does not hurry, yet everything is accomplished.
Lao Tzu