Two dots
Colours, motion and personality. Changes show on the agent as you make them.
No body and no mascot, just two dots. They follow your cursor, read line by line while the agent works, look up and away while it thinks, smile when it's done and go flat when something breaks.
Patience, confidence and chattiness change everything about how it behaves. A patient agent blinks slowly and settles, a confident one snaps to where it looks, an unsure one glances away, and a chatty one leans in while you type and looks around when it's bored.
Install with AI
Paste this into Claude Code, Cursor or any AI coding tool.
Add the Two dots component from https://baked.design/interfaces/two-dots.md to this project. Follow the install steps on that page and use the code exactly as written.
Install by hand
Copy this file into your project.
// Two dots: an AI agent that is only a pair of eyes, with lasers, by Baked Design Studio
// (baked.design/interfaces/two-dots). The eyes follow you, and fire solid red laser beams, exactly as wide as
// the eyes, that meet on your cursor in a red dot. While it works the beams sweep the page like a scanner,
// burning lines as it reads. While it thinks the eyes bounce in turn inside a spinning ring of dots, sound ripples pulse out while it listens, it smiles and lets off
// fireworks when it's done, and it glitches when something breaks. Personality (patience, confidence,
// chattiness) changes how it moves.
//
// import { mount } from "./two-dots.js";
// const eyes = mount(document.querySelector("#agent"), { lasers: "auto", laserColor: "#ff2d55" });
// eyes.set("working"); // idle · listening · thinking · working · done · error
// eyes.hear(); // call on each keystroke while listening
// eyes.tune({ chattiness: 1, laserWidth: 0.8 });
//
// Options (all can change later with tune): eyeColor (defaults to your text colour), size, gap, lasers
// ("auto" | "always" | "off"), laserColor, laserWidth (beam thickness as a share of the eye: 1 = as wide as
// the eye), glow (0 to 1), sparks, ripples, glitch, patience,
// confidence, chattiness (0 to 1), and gaze: a spring { visualDuration, bounce }. No dependencies.
const STATES = ["idle", "listening", "thinking", "working", "done", "error"];
const NSV = "http://www.w3.org/2000/svg";
const mk = (tag, attrs = {}, parent) => { const e = document.createElementNS(NSV, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); parent?.append(e); return e; };
const f = (n) => n.toFixed(2);
let uid = 0;
// what each state wants from the eyes: size, where they look (null = at you), how open
const POSE = {
idle: { w: 20, h: 32, gaze: null }, listening: { w: 22, h: 38, gaze: null }, thinking: { w: 20, h: 26, gaze: [-0.55, -0.7] },
working: { w: 24, h: 15, gaze: null }, done: { w: 22, h: 30, gaze: [0, -0.1] }, error: { w: 24, h: 4, gaze: [0, 0.35] },
};
export function mount(el, options = {}) {
const p = {
eyeColor: "", size: 1, gap: 30, lasers: "auto", laserColor: "#ff2d55", laserWidth: 1, glow: 0.7,
sparks: true, ripples: true, glitch: true, patience: 0.5, confidence: 0.5, chattiness: 0.5,
gaze: { visualDuration: 0.3, bounce: 0.15 }, ...options,
};
const still = matchMedia("(prefers-reduced-motion: reduce)");
const id = `td${++uid}`;
const svg = mk("svg", { viewBox: "-100 -60 200 120", "aria-hidden": "true" });
Object.assign(svg.style, { width: "100%", height: "100%", display: "block", overflow: "visible" });
const defs = mk("defs", {}, svg);
const glowF = mk("filter", { id: `${id}g`, x: "-50%", y: "-50%", width: "200%", height: "200%" }, defs);
const blur = mk("feGaussianBlur", { stdDeviation: 3, result: "b" }, glowF);
const merge = mk("feMerge", {}, glowF); mk("feMergeNode", { in: "b" }, merge); mk("feMergeNode", { in: "SourceGraphic" }, merge);
const trails = mk("g", {}, svg); // burnt scan lines
const beams = mk("g", {}, svg); // solid beams: no glow, no outline
const ripples = mk("g", { fill: "none" }, svg);
const hit = mk("g", {}, svg);
const hitDot = mk("circle", { r: 2.4 }, hit);
const sparkG = mk("g", { filter: `url(#${id}g)` }, svg);
// thinking: a ring of dots spinning round the eyes, like a loader, with a fading tail
const spinG = mk("g", {}, svg);
const spin = Array.from({ length: 14 }, () => mk("circle", { r: 2 }, spinG));
const eyesG = mk("g", {}, svg);
const eyes = [-1, 1].map((side) => {
const g = mk("g", {}, eyesG);
const ghostR = mk("rect", { fill: "#ff2d55", opacity: 0 }, g), ghostC = mk("rect", { fill: "#00e5ff", opacity: 0 }, g);
const pill = mk("rect", {}, g);
const hot = mk("circle", { opacity: 0 }, g); // the eye's white-hot middle while it fires, where the beam starts
const arc = mk("path", { fill: "none", "stroke-width": 5, "stroke-linecap": "round", d: "M-11,5 Q0,-9 11,5" }, g);
// each beam: one solid shape wrapped around the eye and the dot where it lands (see hull below)
const beam = { body: mk("polygon", { opacity: 0 }, beams) };
return { side, g, pill, hot, arc, ghostR, ghostC, beam, x: 0, y: 0 };
});
el.append(svg);
let state = STATES.includes(options.state) ? options.state : "idle";
let t = 0, last = 0, raf = 0, seen = true, heard = 0, since = 9, nextBlink = 2, blink = 0, wander = [0, 0], nextWander = 3;
let aim = [0, 0], aimPt = null, lastMove = -1e9, over = false;
const cur = { w: 20, h: 32, gx: 0, gy: 0, smile: 0, lean: 0, laser: 0, think: 0 }, vel = { gx: 0, gy: 0 };
const parts = []; // sparks and fireworks: { x, y, vx, vy, life, max, r, c, orbit? }
const scars = []; // scan lines the lasers have burnt: { y, x0, x1, life }
const rings = []; // listening ripples: { x, y, r, life }
const ink = () => p.eyeColor || getComputedStyle(el).color;
function spark(x, y, n, speed, colors, life = 1.1) {
for (let i = 0; i < n && parts.length < 160; i++) {
const a = Math.random() * Math.PI * 2, v = speed * (0.4 + Math.random() * 0.8);
parts.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.3, life, max: life, r: 0.8 + Math.random() * 1.6, c: colors[i % colors.length], node: null });
}
}
function laserWanted() {
if (p.lasers === "off" || state === "thinking" || state === "done") return 0;
if (p.lasers === "always") return 1;
if (state === "working" || state === "listening") return 1;
if (state === "error") return 0; // an error glitches the eyes; the lasers go out
return over && performance.now() - lastMove < 1800 ? 1 : 0; // idle: only while you're pointing at it
}
function target(now) {
const pose = POSE[state];
let gaze = pose.gaze ?? aim;
const idleFor = (now - lastMove) / 1000;
if ((state === "idle" || state === "listening") && idleFor > 2.5 + 8 * (1 - p.chattiness)) gaze = wander;
if (state === "thinking") { const s = 1.5 + (1 - p.patience) * 1.2; gaze = [0.5 * Math.cos(t * s), -0.3 + 0.28 * Math.sin(t * s)]; } // a slow look round
if (state === "working") { const k = (t * (0.55 + (1 - p.patience) * 0.4)) % 1; gaze = [-0.8 + 1.6 * k, -0.5]; } // eyes up, beams angled down onto the page
const size = (0.85 + p.confidence * 0.25) * p.size;
return { w: pose.w * size, h: pose.h * size * (1 + 0.15 * heard), gx: gaze[0], gy: gaze[1], smile: state === "done" ? 1 : 0,
lean: state === "listening" ? aim[0] * (0.4 + p.chattiness * 0.6) : 0, laser: laserWanted() };
}
// where the beams meet: your cursor, or the scanner's sweep while working
function laserPoint() {
if (state === "working") { const k = (t * (0.55 + (1 - p.patience) * 0.4)) % 1, row = Math.floor(t * (0.55 + (1 - p.patience) * 0.4)) % 3; return [-90 + 180 * k, 32 + row * 11]; }
if (aimPt) return aimPt;
return [cur.gx * 60, 40 + cur.gy * 20];
}
function draw(dt) {
const color = ink(), lc = p.laserColor;
blur.setAttribute("stdDeviation", f(0.5 + p.glow * 3.5));
const glitching = state === "error" && p.glitch;
const shakeX = state === "error" ? Math.sin(t * 38) * 6 * Math.max(0, 1 - since * 1.6) : 0;
const open = 1 - blink;
eyes.forEach((E) => {
const { side, g, pill, hot, arc, ghostR, ghostC } = E;
// thinking: the eyes bounce in turn, stretching as they rise and squashing as they land
const b = Math.sin(t * (6 + (1 - p.patience) * 3) + (side > 0 ? Math.PI : 0)) * cur.think;
E.x = side * p.gap + cur.gx * 22 + cur.lean * 10 + shakeX; E.y = cur.gy * 14 - cur.lean * cur.lean * 4 - Math.max(0, b) * 8;
const h = Math.max(3, cur.h * open * (1 - cur.smile) * (1 + 0.16 * b)), w = cur.w * (1 - 0.08 * b);
for (const r of [pill, ghostR, ghostC]) { r.setAttribute("x", f(-w / 2)); r.setAttribute("y", f(-h / 2)); r.setAttribute("width", f(w)); r.setAttribute("height", f(h)); r.setAttribute("rx", f(Math.min(w, h) / 2)); }
// the eyes stay their own colour (black on a light page); only the beams are red
pill.setAttribute("fill", color); arc.setAttribute("stroke", color); hot.style.opacity = 0;
pill.style.opacity = 1 - cur.smile; arc.style.opacity = cur.smile;
// the glitch: red and cyan copies of each eye, jumping about
const gx = glitching && Math.random() < 0.6 ? (Math.random() - 0.5) * 8 : 0;
ghostR.setAttribute("transform", `translate(${f(gx)},0)`); ghostC.setAttribute("transform", `translate(${f(-gx)},${f(gx * 0.3)})`);
ghostR.style.opacity = ghostC.style.opacity = glitching ? 0.85 : 0;
E.w = w; E.h = h; E.rot = state === "error" ? side * -6 : cur.lean * side * 4;
g.setAttribute("transform", `translate(${f(E.x)},${f(E.y)}) rotate(${f(E.rot)})`);
});
// the lasers: a solid red beam from each eye to the same point
const [tx, ty] = laserPoint(), on = cur.laser;
// Each beam is the convex hull of the eye's outline and the dot where it lands: like a rubber band
// stretched round both. Its sides touch the eye exactly where the eye's edge turns away, from any angle and
// for any eye shape, so the beam and the eye read as one piece with nothing sticking out.
let w1 = 0;
eyes.forEach((E) => {
// pulled in a hair (0.3 units) so the eye's own smoothed edge never shows a red fringe from the beam behind it
const k = p.laserWidth, inset = 0.3, r = Math.max(0.5, (Math.min(E.w, E.h) / 2) * k - inset), hw = Math.max(0, (E.w / 2) * k - inset - r), hh = Math.max(0, (E.h / 2) * k - inset - r);
const a = (E.rot * Math.PI) / 180, ca = Math.cos(a), sa = Math.sin(a), pts = [];
// the pill (a stadium): two half-circles joined by straight sides, sampled finely, in the eye's own frame
for (let i = 0; i < 72; i++) {
const th = (i / 72) * Math.PI * 2, cx = Math.cos(th), cy = Math.sin(th);
const lx = cx * r + (cx >= 0 ? hw : -hw), ly = cy * r + (cy >= 0 ? hh : -hh);
pts.push([E.x + lx * ca - ly * sa, E.y + lx * sa + ly * ca]);
}
const end = r * 0.55; w1 = Math.max(w1, end);
for (let i = 0; i < 36; i++) { const th = (i / 36) * Math.PI * 2; pts.push([tx + Math.cos(th) * end, ty + Math.sin(th) * end]); }
E.beam.body.setAttribute("points", hull(pts).map(([x, y]) => `${f(x)},${f(y)}`).join(" "));
E.beam.body.setAttribute("fill", lc); E.beam.body.style.opacity = f(on);
});
// where they land: a solid red disc a little wider than the beams
hit.style.opacity = f(on);
hitDot.setAttribute("cx", f(tx)); hitDot.setAttribute("cy", f(ty)); hitDot.setAttribute("r", f(w1 * 1.35)); hitDot.setAttribute("fill", lc);
// burnt scan lines while working, and embers where the beams land
if (state === "working" && on > 0.5 && !still.matches) {
const s = scars[scars.length - 1];
if (s && Math.abs(s.y - ty) < 0.5 && tx > s.x1 - 1) s.x1 = tx; else scars.push({ y: ty, x0: tx, x1: tx, life: 1, node: null });
if (p.sparks && Math.random() < 0.5) spark(tx, ty, 1, 30, [lc, "#ffd60a"], 0.5);
} else if (on > 0.5 && p.sparks && Math.random() < 0.15 && !still.matches) spark(tx, ty, 1, 24, [lc], 0.4);
for (let i = scars.length - 1; i >= 0; i--) {
const s = scars[i]; s.life -= dt * 0.35;
if (!s.node) s.node = mk("line", { "stroke-linecap": "round" }, trails);
s.node.setAttribute("x1", f(s.x0)); s.node.setAttribute("x2", f(s.x1)); s.node.setAttribute("y1", f(s.y)); s.node.setAttribute("y2", f(s.y));
s.node.setAttribute("stroke-linecap", "round"); s.node.setAttribute("stroke", lc); s.node.setAttribute("stroke-width", f(Math.max(1.2, cur.w * p.laserWidth * 0.28))); s.node.style.opacity = f(Math.max(0, s.life) * 0.7);
if (s.life <= 0) { s.node.remove(); scars.splice(i, 1); }
}
// the spinner ring: brightest at its head, fading behind it
const head = (t * 0.55) % 1, cy0 = (eyes[0].y + eyes[1].y) / 2 - 2, rx = p.gap + 30, ry = 30;
spin.forEach((c, i) => {
const u = i / spin.length, a = (u + head) * Math.PI * 2, tail = (1 - u) ** 2;
c.setAttribute("cx", f(Math.cos(a) * rx)); c.setAttribute("cy", f(cy0 + Math.sin(a) * ry)); c.setAttribute("r", f(1 + 1.6 * tail));
c.setAttribute("fill", i % 3 ? color : lc); c.style.opacity = f(cur.think * tail);
});
for (let i = parts.length - 1; i >= 0; i--) {
const q = parts[i]; q.life -= dt;
if (q.orbit) { const E = eyes[q.orbit.e]; q.orbit.a += q.orbit.s * dt; q.x = E.x + Math.cos(q.orbit.a) * q.orbit.r; q.y = E.y + Math.sin(q.orbit.a) * q.orbit.r * 0.7; if (state !== "thinking") q.life = Math.min(q.life, 0.3); }
else { q.vy += 90 * dt; q.x += q.vx * dt; q.y += q.vy * dt; }
if (!q.node) q.node = mk("circle", {}, sparkG);
q.node.setAttribute("cx", f(q.x)); q.node.setAttribute("cy", f(q.y)); q.node.setAttribute("r", f(q.r)); q.node.setAttribute("fill", q.c);
q.node.style.opacity = f(Math.max(0, q.life / q.max));
if (q.life <= 0) { q.node.remove(); parts.splice(i, 1); }
}
// listening: ripples out from the eyes
for (let i = rings.length - 1; i >= 0; i--) {
const r = rings[i]; r.life -= dt * 1.4; r.r += dt * 60;
if (!r.node) r.node = mk("circle", { "stroke-width": 1.2 }, ripples);
r.node.setAttribute("cx", f(r.x)); r.node.setAttribute("cy", f(r.y)); r.node.setAttribute("r", f(r.r)); r.node.setAttribute("stroke", color);
r.node.style.opacity = f(Math.max(0, r.life) * 0.5);
if (r.life <= 0) { r.node.remove(); rings.splice(i, 1); }
}
svg.style.transform = glitching && Math.random() < 0.3 ? `translate(${f((Math.random() - 0.5) * 4)}px,0)` : "";
}
// convex hull (Andrew's monotone chain)
function hull(pts) {
pts = pts.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lo = [], up = [];
for (const q of pts) { while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], q) <= 0) lo.pop(); lo.push(q); }
for (let i = pts.length - 1; i >= 0; i--) { const q = pts[i]; while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], q) <= 0) up.pop(); up.push(q); }
return lo.slice(0, -1).concat(up.slice(0, -1));
}
function frame(now) {
raf = 0;
const dt = Math.min(0.05, (now - (last || now)) / 1000); last = now;
t += dt; since += dt; heard = Math.max(0, heard - dt * 3);
nextBlink -= dt;
if (nextBlink <= 0 && state !== "error" && state !== "done") { blink = 1; nextBlink = 1.4 + p.patience * 4 + Math.random() * 2; }
blink = Math.max(0, blink - dt * (6 + (1 - p.patience) * 6));
nextWander -= dt;
if (nextWander <= 0) { wander = [Math.random() * 1.6 - 0.8, Math.random() * 1 - 0.5]; nextWander = 0.8 + (1 - p.chattiness) * 2.5 + Math.random(); }
// gaze rides a spring you can tune; everything else eases
const goal = target(now), vd = Math.max(0.05, p.gaze?.visualDuration ?? 0.3), z = Math.max(0.05, 1 - (p.gaze?.bounce ?? 0.15)), w = (2 * Math.PI) / vd;
for (const k of ["gx", "gy"]) { const a = w * w * (goal[k] - cur[k]) - 2 * z * w * vel[k]; vel[k] += a * dt; cur[k] += vel[k] * dt; }
const e = 1 - Math.exp(-dt * (4 + p.confidence * 16 - p.patience * 2));
for (const k of ["w", "h", "lean"]) cur[k] += (goal[k] - cur[k]) * e;
cur.smile += (goal.smile - cur.smile) * (1 - Math.exp(-dt * 10));
cur.laser += (goal.laser - cur.laser) * (1 - Math.exp(-dt * (state === "error" ? 40 : 12)));
cur.think += ((state === "thinking" ? 1 : 0) - cur.think) * (1 - Math.exp(-dt * 6));
draw(dt);
const busy = parts.length || scars.length || rings.length;
if ((seen || busy) && !document.hidden && !still.matches) raf = requestAnimationFrame(frame);
}
const wake = () => { if (!raf) { last = 0; raf = requestAnimationFrame(frame); } };
const settle = () => { const g = target(performance.now()); Object.assign(cur, g); blink = 0; draw(0); };
const onMove = (e) => {
const r = el.getBoundingClientRect(); if (!r.width) return;
aim = [Math.max(-1, Math.min(1, (e.clientX - (r.left + r.width / 2)) / (r.width * 1.2))), Math.max(-1, Math.min(1, (e.clientY - (r.top + r.height / 2)) / (r.height * 1.5)))];
const stage = el.closest(".kstage") || el, sr = stage.getBoundingClientRect();
over = e.clientX >= sr.left && e.clientX <= sr.right && e.clientY >= sr.top && e.clientY <= sr.bottom;
const m = svg.getScreenCTM(); if (m) { const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse()); aimPt = [pt.x, pt.y]; }
lastMove = performance.now();
if (still.matches) settle(); else wake();
};
addEventListener("pointermove", onMove, { passive: true });
const io = new IntersectionObserver(([en]) => { seen = en.isIntersecting; if (seen) wake(); });
io.observe(el);
const onVis = () => !document.hidden && wake();
document.addEventListener("visibilitychange", onVis);
settle(); wake();
return {
get state() { return state; },
set(next) {
if (!STATES.includes(next) || next === state) return;
state = next; since = 0;
if (next === "done" && p.sparks && !still.matches) eyes.forEach((E) => spark(E.x, E.y - 6, 26, 70, [p.laserColor, "#ffd60a", "#34c759", "#0a84ff", "#fff"], 1.4));
if (still.matches) settle(); else wake();
},
hear() {
heard = Math.min(1, heard + 0.6); lastMove = performance.now();
if (p.ripples && !still.matches && rings.length < 8) eyes.forEach((E) => rings.push({ x: E.x, y: E.y, r: 8, life: 1, node: null }));
if (p.chattiness > 0.6) blink = 0.6;
wake();
},
tune(next) { Object.assign(p, next); if (still.matches) settle(); else wake(); },
destroy() { cancelAnimationFrame(raf); io.disconnect(); removeEventListener("pointermove", onMove); document.removeEventListener("visibilitychange", onVis); svg.remove(); },
};
}
Use
import { mount } from "./two-dots.js";
const eyes = mount(document.querySelector("#agent"), { patience: 0.3, confidence: 0.9 });
input.addEventListener("input", () => { eyes.set("listening"); eyes.hear(); });Notes
- States: idle, listening, thinking, working, done and error. Call set() whenever your agent changes what it's doing; it moves between states on its own.
- Call hear() on each keystroke while someone types to your agent, so it reacts as they type.
- Small enough to sit in a chat header or next to a send button. The eyes use currentColor.
- tune({ patience, confidence, chattiness }) takes values from 0 to 1, any time.
- Respects reduced motion: with it on, the character holds a still pose for each state.
- It pauses when it's off screen or the tab is hidden. destroy() removes it.
- No dependencies. One plain JavaScript file.