My Head following the visitor

The idea

The hero on my site is my own head, and it turns to look at your cursor wherever you move it. There’s no 3D model and no face tracking behind it: just one short video of me turning my head, cut into 67 still frames.

The trick is simple. Every frame is tagged with the direction my face points in it. When the cursor moves, the page works out which direction it’s in, relative to my eyes, and shows the frame that matches best. Swap frames fast enough and it reads as a head following you.

The whole thing is one HTML snippet: a bit of CSS, an empty div, and about 60 lines of JavaScript. Here’s how I built it

Recording the video

The result depends almost entirely on the recording. My first try was a random six-second clip, and it barely worked: some directions had one frame, others none. The second take followed a script and took about 55 seconds.

Setup:

  • Phone on a tripod, so the camera never moves.
  • Focus and exposure locked, so brightness doesn’t flicker as I turn.
  • Light from the front, and a plain wall behind me.
  • Seating, moving only neck, be sure there is room above my head.

The movement script:

  1. Look straight hold for 3 seconds.
  2. For each of the 8 general directions (up, down, left, right, up left, up right, down left, down right) turn to it (1 s), hold (1 s), come back to center (1 s).
  3. Make one big, slow circle through all 8 notes.
  4. Make a smaller circle, about halfway out, for the in-between positions.
  5. Look into the lens again and hold for 3 seconds.

The holds give sharp frames at each direction. The circles fill the gaps between them, so the head never has to jump far.

From video to frames

The 4K video has over 3,000 frames at 60 fps, but most are near-duplicates. I picked 67 that cover every direction: the held positions from the star, plus steps along both circles.

Each frame gets two numbers / coordiantes, basicly showing where my face points on screen:

X and Y from -1 to 1, where straight look is 0,0. Simmilar to what values are in basic trangular functions.

Then the background goes. The wall was is smooth, so each frame was cut out by comparing every pixel with the wall color.
We should take extra care with gaps between glasses and face in extreeme positions.

The frames are saved as transparent WebP images, 1920 px wide, about 5 MB for all 67. They live in a /frames/ folder at the root of the site. (This part is what could be better and its discussed in conclusion)

The HTML and CSS

The markup is almost nothing. The script fills #head with the frames:

<div id="hero">
  <div id="head"></div>
</div>

The CSS revolves around one variable, --head, the width of the head. Everything that needs a size reads from it, so the media queries only change that one value:

#hero {
  position: relative;
  min-height: 100vh;
  overflow: hidden;
  --head: min(800px, 100vw, 100vh);
}

/* Phone, portrait: the head fills the width */
@media (pointer: coarse) and (orientation: portrait) {
  #hero { --head: 100vw; }
}

/* Phone, landscape: 70% of the width */
@media (pointer: coarse) and (orientation: landscape) {
  #hero { --head: 70vw; }
}

On desktop the head is up to 800 px wide, but min() never lets it grow past the window. pointer: coarse targets touch screens, and orientation tells portrait from landscape.

The head sits at the bottom center of the hero. All 67 frames are stacked on top of each other and hidden; only the one with the class on is visible:

#head {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: var(--head);
  aspect-ratio: 1;
  overflow: hidden;
}
#head img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  visibility: hidden;
  display: block;
  filter: grayscale(100%) blur(3px);
}
#head img.on { visibility: visible; }

The filter turns the photo gray and blur softens the edges, this hides small edge flaws and makes the frame changes less noticeable.

Raw image with no filters applied

Image with greyscale filter

Image with greyscale + blur filter 3px

The JavaScript

The script does four things: lists the frames, creates the images, tracks where the cursor wants the head to look, and shows the nearest frame on every screen refresh.

1. The frame list. Each entry is a direction plus a file. Here are the first few of 67:

const FRAMES = [
  {"x": 0.0,  "y": 0.0,  "src": "/frames/0000.webp"},  // straight ahead
  {"x": 0.0,  "y": -0.8, "src": "/frames/0120.webp"},  // up
  {"x": -0.7, "y": 0.0,  "src": "/frames/0296.webp"},  // left
  {"x": 0.6,  "y": 0.6,  "src": "/frames/0520.webp"},  // down-right
  // ...
];

2. Creating the images. One img per frame goes into #head. They all load at once, so switching later is instant:

const head = document.getElementById('head');
const imgs = FRAMES.map(f => {
  const img = new Image();
  img.src = f.src;
  img.alt = '';
  head.appendChild(img);
  return img;
});

3. Aiming. On every mouse move, the script measures the distance from my eyes, about 40% down the head picture, to the cursor. It turns that into the same -1 to 1 scale the frames use. The cursor has to travel 40% of the window width, or half its height, before the head is fully turned:

function aim(cx, cy) {
  const r = head.getBoundingClientRect();
  const dx = cx - (r.left + r.width / 2);
  const dy = cy - (r.top + r.height * 0.4);   // eye level
  const reachX = innerWidth * 0.4;
  const reachY = innerHeight * 0.5;
  targetX = Math.max(-1, Math.min(1, dx / reachX));
  targetY = Math.max(-1, Math.min(1, dy / reachY));
}
addEventListener('mousemove', e => aim(e.clientX, e.clientY));
addEventListener('touchmove', e => aim(e.touches[0].clientX, e.touches[0].clientY), { passive: true });

getBoundingClientRect() is measured fresh every time, so aiming still works after scrolling or resizing.

eyes
frame x / y
Move your cursor inside the frame

4. Easing and picking the frame. The head doesn’t jump straight to the target. On each frame of the animation loop, it moves 8% of the remaining distance (ease = 0.08). That makes the turn slow down as it arrives, and it passes through the in-between frames instead of skipping them. Then it picks the frame whose direction is closest:

const ease = 0.08;   // lower = slower, smoother turning

function nearest(x, y) {
  let best = 0, bestD = Infinity;
  FRAMES.forEach((f, i) => {
    const d = (f.x - x) ** 2 + (f.y - y) ** 2;
    if (d < bestD) { bestD = d; best = i; }
  });
  return best;
}

function tick() {
  lookX += (targetX - lookX) * ease;
  lookY += (targetY - lookY) * ease;
  const i = nearest(lookX, lookY);
  if (i !== current) {
    imgs[i].classList.add('on');
    if (current >= 0) imgs[current].classList.remove('on');
    current = i;
  }
  requestAnimationFrame(tick);
}
tick();

“Closest” is plain distance between two points. With only 67 frames, checking all of them 60 times a second costs nothing. When the cursor leaves the window, the target resets to 0, 0, and the head turns back to look straight ahead.

Tweaking it

A handful of values control how it feels:

SettingWhereWhat it does
--head#heroHead size; 800px is the desktop maximum
easescriptTurning speed; lower is lazier and smoother
reachX, reachYaim()How far the cursor travels before the head is fully turned
blur(3px)#head imgSoftness; more blur hides frame changes
aspect-ratio#headShape of the head box; 1 is square

Conclusion

The effect works, but there’s room to make it lighter. Right now every visitor downloads about 5 MB of 1920 px frames, and the browser then grays out and blurs each one with a CSS filter. Both steps do work that could be done once, ahead of time.

Smaller images. The head is never shown wider than 800 px, and the blur hides fine detail anyway. Frames at 960 px would look the same on screen and weigh roughly a quarter as much. That cuts the first load to around 1–1.5 MB, so the head is ready almost as soon as the page appears, even on a phone connection.

Bake the filters into the images. Instead of filter: grayscale(100%) blur(3px) in CSS, the frames can be exported already gray and blurred. That helps in three ways:

  • The browser doesn’t have to apply a filter every time a frame switches, which makes the turning smoother on slower phones and laptops.
  • Gray, soft images compress much better than sharp color ones, so the files get smaller again.
  • The look is identical in every browser, because it’s part of the picture, not something each browser renders its own way.

The trade-off is flexibility. With the filter in CSS, I can try a different blur or bring back color by changing one line. Once it’s baked in, every change means exporting all the frames again. Since I’m happy with the look, that’s a fair price.

The savings also open the door to the bigger upgrade: more frames. Smaller, pre-processed frames would let me use three or four times as many positions for the same download as today, and the head would turn even more smoothly.

One short video, 67 pictures and some easing turned into a hero that looks back at you. Next up: making it lighter.

Other posts

Sometimes I draw

Sometimes I make art. I can’t really say I vividly remember my first steps in drawing or making art. I know I was drawing “better” than others in primary school.

Read More »

Coach Aleks “Mišek”

Sometimes life works in mysterious ways. American football was a big part of my life for almost 10 years. The love for the sport started while watching broadcasts on sports

Read More »
Scroll to Top