Teseria
All elements

Peeper

A pair of eyes with irises, lids and lashes. They follow the pointer and converge as it comes close, blink now and then, widen when the pointer moves fast, droop when it goes quiet, and wake with a blink.

Code

1. Install

Terminal
npm i framer-motion

2. Files

Peeper.tsx287 lines
"use client";
// Peeper: a pair of eyes with irises, lids and lashes. They follow the pointer and converge as it comes close, blink now
// and then, widen when the pointer moves fast, droop when it goes quiet, and wake with a blink. Press (or Enter) to wink.
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import { useReducedMotion } from "framer-motion";

export type PeeperCue = "push";

export type PeeperProps = {
  /** Accessible name of the face button, which winks when pressed. */
  label?: string;
  /** Called when the face winks. */
  onWink?: () => void;
  /** Sound hook: "push" on a wink. */
  onSound?: (cue: PeeperCue) => void;
  className?: string;
  style?: CSSProperties;
};

const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n));

/** True while the node is near the viewport and the tab is visible; starts false. */
function useOnScreen(ref: { current: Element | null }) {
  const [near, setNear] = useState(false);
  const [tab, setTab] = useState(true);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") return setNear(true);
    const io = new IntersectionObserver((es) => setNear(es[es.length - 1].isIntersecting), { rootMargin: "160px" });
    io.observe(el);
    return () => io.disconnect();
  }, [ref]);
  useEffect(() => {
    const f = () => setTab(!document.hidden);
    f();
    document.addEventListener("visibilitychange", f);
    return () => document.removeEventListener("visibilitychange", f);
  }, []);
  return near && tab;
}

const PEEP_EYES = [40, 110] as const;
const PEEP_Y = 56;
const PEEP_ALMOND = "M-25 0 Q0 -30 25 0 Q0 30 -25 0Z";
const PEEP_STRIAE = Array.from({ length: 30 }, (_, i) => {
  const a = (i / 30) * Math.PI * 2, r0 = 3.6 + (i % 3) * 0.5, r1 = 8.4;
  return `M${(Math.cos(a) * r0).toFixed(2)} ${(Math.sin(a) * r0).toFixed(2)}L${(Math.cos(a) * r1).toFixed(2)} ${(Math.sin(a) * r1).toFixed(2)}`;
}).join("");
const PEEP_FLECKS = Array.from({ length: 9 }, (_, i) => {
  const a = i * 2.4 + 0.5, r = 4.6 + (i % 3) * 1.3;
  return [Math.cos(a) * r, Math.sin(a) * r] as const;
});
export function Peeper({ label = "Wink", onWink, onSound, className, style }: PeeperProps) {
  const uid = useId().replace(/:/g, "");
  const face = useRef<HTMLButtonElement>(null);
  const iris = useRef<(SVGGElement | null)[]>([]);
  const pupil = useRef<(SVGCircleElement | null)[]>([]);
  const lidUp = useRef<(SVGPathElement | null)[]>([]);
  const lash = useRef<(SVGPathElement | null)[]>([]);
  const wing = useRef<(SVGPathElement | null)[]>([]);
  const lidLo = useRef<(SVGPathElement | null)[]>([]);
  const winkAt = useRef(0);
  const still = !!useReducedMotion();
  const vis = useOnScreen(face);
  useEffect(() => {
    if (!vis) return;
    const P = { x: 0, y: 0, seen: performance.now(), inside: true, wake: false, widen: 0 };
    let px = 0, py = 0, pt = 0;
    const onMove = (e: PointerEvent) => {
      const t = performance.now();
      if (pt && t > pt) {
        const v = Math.hypot(e.clientX - px, e.clientY - py) / (t - pt);
        if (v > 1.8 && !still) P.widen = t + 450;
      }
      px = e.clientX; py = e.clientY; pt = t;
      if (!P.inside || t - P.seen > 4000) P.wake = true;
      P.x = e.clientX; P.y = e.clientY; P.seen = t; P.inside = true;
    };
    const onOut = (e: MouseEvent) => {
      if (!e.relatedTarget) P.inside = false;
    };
    const off = () => (P.inside = false);
    window.addEventListener("pointermove", onMove, { passive: true });
    window.addEventListener("mouseout", onOut);
    window.addEventListener("blur", off);
    // per eye: gaze x, y (+velocity); lids u (upper close 0..1); pupil radius
    const G = [[0, 0, 0, 0], [0, 0, 0, 0]], U = [0.04, 0.04], R = [4.4, 4.4], RV = [0, 0];
    let blink = { start: -1e9, hold: 0, mask: [true, true] };
    let nextBlink = performance.now() + 2500;
    let queued = 0;
    let frames = 0, dark = false;
    let raf = 0, last = 0;
    const closeness = (t: number, m: boolean) => {
      if (!m) return 0;
      const s = t - blink.start;
      if (s < 0) return 0;
      const e = (x: number) => x * x * (3 - 2 * x);
      if (s < 140) return e(s / 140);
      if (s < 140 + blink.hold) return 1;
      const u = (s - 140 - blink.hold) / 180;
      return u < 1 ? 1 - e(u) : 0;
    };
    const frame = (t: number) => {
      raf = requestAnimationFrame(frame);
      const dt = last ? clamp((t - last) / 16.67, 0, 2.5) : 1;
      last = t;
      const f = face.current;
      if (!f) return;
      const r = f.getBoundingClientRect();
      const sleepy = !still && (!P.inside || t - P.seen > 4000);
      if (frames++ % 30 === 0) {
        const th = f.closest("[data-theme]")?.getAttribute("data-theme");
        dark = th ? th === "dark" : window.matchMedia("(prefers-color-scheme: dark)").matches;
      }
      if (winkAt.current) {
        blink = { start: t, hold: 260, mask: [false, true] };
        winkAt.current = 0;
        nextBlink = t + 3000;
      }
      if (P.wake) {
        P.wake = false;
        if (!still) blink = { start: t, hold: 0, mask: [true, true] };
      }
      if (!still && t > nextBlink) {
        blink = { start: t, hold: 0, mask: [true, true] };
        nextBlink = t + 2500 + Math.random() * 3500;
        queued = Math.random() < 0.25 ? t + 330 : 0;
      }
      if (queued && t > queued) {
        blink = { start: t, hold: 0, mask: [true, true] };
        queued = 0;
      }
      const mx = r.left + r.width / 2, my = r.top + r.height / 2;
      const dMid = Math.hypot(P.x - mx, P.y - my) || 0;
      const tr = P.inside && t - P.seen < 4000 ? clamp(0.8 + (dMid - 70) / 260, 0, 1.15) * 1 : 0.5;
      const baseR = clamp(3.1 + 2.3 * clamp(tr, 0, 1) + (dark ? 0.7 : 0), 2.6, 6.4);
      for (let i = 0; i < 2; i++) {
        const cx = r.left + PEEP_EYES[i], cy = r.top + PEEP_Y;
        let tx = 0, ty = 0;
        if (P.inside && t - P.seen < 4000) {
          const dx = P.x - cx, dy = P.y - cy, d = Math.hypot(dx, dy);
          const a = Math.atan2(dy, dx), k = 1 - Math.exp(-d / 110);
          // soft ellipse limit: scale into rx 14 / ry 6 ellipse
          const ux = Math.cos(a) * k, uy = Math.sin(a) * k;
          const m = Math.hypot(ux, uy) || 1, lim = Math.tanh(m * 1.4) / m;
          tx = ux * lim * 14;
          ty = uy * lim * 6;
          tx += (1 - clamp(d / 220, 0, 1)) * 2.2 * (i === 0 ? 1 : -1);
        }
        if (!Number.isFinite(tx + ty)) (tx = 0), (ty = 0);
        const g = G[i];
        if (!Number.isFinite(g[0] + g[1] + g[2] + g[3] + R[i] + RV[i] + U[i])) (g[0] = g[1] = g[2] = g[3] = 0), (R[i] = baseR), (RV[i] = 0), (U[i] = 0.1);
        if (still) (g[0] = tx), (g[1] = ty);
        else {
          g[2] += (tx - g[0]) * 0.18 * dt; g[2] *= Math.pow(0.72, dt); g[0] += g[2] * dt;
          g[3] += (ty - g[1]) * 0.18 * dt; g[3] *= Math.pow(0.72, dt); g[1] += g[3] * dt;
        }
        if (still) R[i] = baseR;
        else {
          RV[i] += (baseR - R[i]) * 0.12 * dt; RV[i] *= Math.pow(0.78, dt); R[i] += RV[i] * dt;
        }
        const b = closeness(t, blink.mask[i]);
        const base = sleepy ? 0.5 : t < P.widen ? 0 : 0.04;
        U[i] += (base - U[i]) * (still ? 1 : 1 - Math.pow(0.8, dt));
        const u = clamp(U[i] + (1 - U[i]) * b, 0, 1);
        const cu = -30 + 60 * u, cl = 25 + 5 * b;
        iris.current[i]?.setAttribute("transform", `translate(${g[0].toFixed(2)} ${g[1].toFixed(2)})`);
        pupil.current[i]?.setAttribute("r", R[i].toFixed(2));
        lidUp.current[i]?.setAttribute("d", `M-30 -32H30V0H25Q0 ${cu.toFixed(2)} -25 0H-30Z`);
        lash.current[i]?.setAttribute("d", `M-25 0Q0 ${cu.toFixed(2)} 25 0Q0 ${(cu - 2.6).toFixed(2)} -25 0Z`);
        const o = i === 0 ? 0.16 : 0.84, qx = (1 - o) * (1 - o) * -25 + o * o * 25, qy = 2 * o * (1 - o) * cu;
        const ex = i === 0 ? -25 : 25;
        wing.current[i]?.setAttribute("d", `M${qx.toFixed(2)} ${qy.toFixed(2)}L${ex} 0`);
        lidLo.current[i]?.setAttribute("d", `M-30 32H30V0H25Q0 ${cl.toFixed(2)} -25 0H-30Z`);
      }
    };
    raf = requestAnimationFrame(frame);
    return () => {
      cancelAnimationFrame(raf);
      raf = 0;
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("mouseout", onOut);
      window.removeEventListener("blur", off);
    };
  }, [still, vis]);
  const wink = () => {
    winkAt.current = 1;
    onWink?.();
    onSound?.("push");
  };
  const id = (s: string) => `pee-${s}-${uid}`;
  return (
    <button
      type="button"
      className={className ? `pee-root ${className}` : "pee-root"}
      style={style}
      ref={face}
      aria-label={label}
      onPointerDown={() => wink()}
      onClick={(e) => e.detail === 0 && wink()}
    >
      <svg width={150} height={120} viewBox="0 0 150 120" aria-hidden>
          <defs>
            <clipPath id={id("clip")}>
              <path d={PEEP_ALMOND} />
            </clipPath>
            <linearGradient id={id("scl")} x1="0" y1="-1" x2="0" y2="1">
              <stop offset="0" className="pee-s0" />
              <stop offset="1" className="pee-s1" />
            </linearGradient>
            <linearGradient id={id("lid")} x1="0" y1="-16" x2="0" y2="2" gradientUnits="userSpaceOnUse">
              <stop offset="0" stopColor="#000" stopOpacity=".34" />
              <stop offset="1" stopColor="#000" stopOpacity="0" />
            </linearGradient>
            <radialGradient id={id("iris")} cx=".5" cy=".5" r=".5">
              <stop offset="0" stopColor="#e0a23c" />
              <stop offset=".34" stopColor="#b8782a" />
              <stop offset=".62" stopColor="#1f8a86" />
              <stop offset=".9" stopColor="#0c4d58" />
              <stop offset="1" stopColor="#05222b" />
            </radialGradient>
            <radialGradient id={id("hl")} cx=".5" cy=".5" r=".5">
              <stop offset="0" stopColor="#fff" stopOpacity=".95" />
              <stop offset="1" stopColor="#fff" stopOpacity=".1" />
            </radialGradient>
          </defs>
          {PEEP_EYES.map((x, i) => (
            <g key={x}>
              <g transform={`translate(${x} ${PEEP_Y})`}>
                <g clipPath={`url(#${id("clip")})`}>
                  <path d={PEEP_ALMOND} fill={`url(#${id("scl")})`} />
                  <g
                    ref={(n) => {
                      iris.current[i] = n;
                    }}
                  >
                    <circle r={9.2} fill={`url(#${id("iris")})`} />
                    {PEEP_FLECKS.map(([fx, fy], k) => (
                      <circle key={k} cx={fx} cy={fy} r={0.7} fill="#f2c15e" opacity={0.55} />
                    ))}
                    <path d={PEEP_STRIAE} stroke="#fff" strokeOpacity=".16" strokeWidth=".5" fill="none" />
                    <circle r={8.7} fill="none" stroke="#02161b" strokeOpacity=".6" strokeWidth="1.3" />
                    <circle
                      r={4.4}
                      fill="#06090b"
                      ref={(n) => {
                        pupil.current[i] = n;
                      }}
                    />
                  </g>
                  <rect x={-26} y={-16} width={52} height={18} fill={`url(#${id("lid")})`} />
                  <ellipse cx={-5.5} cy={-5.5} rx={3.6} ry={3} fill={`url(#${id("hl")})`} />
                  <circle cx={4.5} cy={3} r={1.3} fill="#fff" opacity={0.9} />
                  <path
                    className="pee-lid"
                    ref={(n) => {
                      lidUp.current[i] = n;
                    }}
                  />
                  <path
                    className="pee-lid"
                    ref={(n) => {
                      lidLo.current[i] = n;
                    }}
                  />
                </g>
                <path d={PEEP_ALMOND} className="pee-rim" />
                <path
                  className="pee-lash"
                  ref={(n) => {
                    lash.current[i] = n;
                  }}
                />
                <path
                  className="pee-wing"
                  ref={(n) => {
                    wing.current[i] = n;
                  }}
                />
              </g>
            </g>
          ))}
      </svg>
    </button>
  );
}

Peeper.css55 lines
/* Peeper. Prefix pee-. */
.pee-root, .pee-root * { box-sizing: border-box; }
.pee-root {
  font: inherit;
  color: var(--ts-fg, #14161b);
  border: 0;
  padding: 0;
  display: block;
  position: relative;
  width: 150px;
  height: 120px;
  margin: 0 auto;
  border-radius: 38px;
  background: var(--ts-raised, #fff);
  box-shadow: var(--ts-edge), 0 12px 24px -10px rgba(var(--ts-fg-rgb), 0.22);
  overflow: hidden;
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
}
.pee-root:focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
:where(.pee-root) svg { display: block; }
.pee-s0 { stop-color: #f8f4ea; }
.pee-s1 { stop-color: #e9e2d3; }
.pee-lid { fill: var(--ts-raised, #fff); }
.pee-rim {
  fill: none;
  stroke: rgba(var(--ts-fg-rgb), 0.18);
  stroke-width: 0.6;
}
.pee-lash {
  fill: #15171a;
  stroke: #15171a;
  stroke-width: 1;
  stroke-linejoin: round;
}
.pee-wing {
  fill: none;
  stroke: #15171a;
  stroke-width: 2.4;
  stroke-linecap: round;
}
/* dark: a deeper drop, darker lashes */
[data-theme="dark"] .pee-root { box-shadow: 0 12px 24px -10px rgba(0, 0, 0, 0.6); }
[data-theme="dark"] .pee-s0 { stop-color: #ece7da; }
[data-theme="dark"] .pee-s1 { stop-color: #d4cdbc; }
[data-theme="dark"] .pee-lash { fill: #020305; stroke: #020305; }
[data-theme="dark"] .pee-wing { stroke: #020305; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pee-root { box-shadow: 0 12px 24px -10px rgba(0, 0, 0, 0.6); }
  :root:not([data-theme="light"]) .pee-s0 { stop-color: #ece7da; }
  :root:not([data-theme="light"]) .pee-s1 { stop-color: #d4cdbc; }
  :root:not([data-theme="light"]) .pee-lash { fill: #020305; stroke: #020305; }
  :root:not([data-theme="light"]) .pee-wing { stroke: #020305; }
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { Peeper } from "./Peeper";
import "./Peeper.css";

export default function Demo() {
  return <Peeper />;
}