# Commander: a pointer-following mascot hero from 90 video frames, and eight traps

> How Commander scrubs 90 video frames on a canvas so a cat follows the pointer, and eight traps: pill borders, a canvas benchmark, a spotlight, focus rings and a hero that broke in a preview.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Commander — Pet Shop Hero Section with Cursor-Tracking Cat (React + Next.js)](https://upfork.dev/products/pet-shop-hero) (Component, comes with the Pro plan). Published 2026-10-26. 7 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Commander is a hero section for a premium pet shop, in React and Next.js, whose mascot follows the visitor's attention. The Commander, a Maine Coon in a beret, comes from a ten-second AI-generated video turned into 90 frames that the page scrubs live on a canvas: move the pointer up and he looks up, move it down to the product cards and he looks down at them, focus the search field and he watches you type. Around him: a pet-type control, a search with suggestions, "inspected" product picks with an add-to-cart that flies to the cart, and a trust ticker. Dark by default, or framed on paper in a light theme.

[Live preview of Commander — Pet Shop Hero Section with Cursor-Tracking Cat (React + Next.js)](https://upfork.dev/products/pet-shop-hero)

This article is for anyone, person or AI, building a hero around a filmed character or any frame sequence that reacts to the pointer: how the frames are stored and drawn, and eight traps we hit, each with its fix. Two of them only showed up once the hero was listed on UpFork.

## Ninety frames, decoded on a budget

The video's frames are cut into two parts each: a blurred 160 px "plate" (all 90 in one sprite) for the softly lit background, and a sharp crop of the cat feathered into it, so no seam shows where the light changes. The pointer's height picks the frame: the top of the hero maps to the first frame (looking up), the eyes' height to the level-gaze frame, the bottom to the last.

Frames load coarse to fine, so scrubbing works long before the last one arrives:

`components/hero/engine/frame-store.ts`, lines 31–34:

```ts
/**
 * Coarse to fine: the frames in `first`, every 8th frame and the last one, then every 4th, every 2nd, the rest.
 * Scrubbing works from the end of the first pass on, with the nearest frame standing in for the missing ones.
 */
```

Decoded frames are big (about 4.3 MB each on desktop), so how many stay decoded depends on the device's memory:

`components/hero/hero-config.ts`, lines 142–148:

```ts
  /**
   * Memory for decoded frames, by the device's RAM (8 GB+, 4 GB+, less). Every second frame stays decoded plus a
   * window round the head, so the frame drawn is never more than one away from the exact one; raising `high` to
   * about 400 keeps all 90 desktop frames decoded (smoothest, heavy on integrated GPUs). One desktop frame of this
   * video is about 4.3 MB decoded, a mobile one 1.1 MB (mobile always fits all 90).
   */
  frameMemoryMB: { high: 300, mid: 220, low: 120 },
```

`components/hero/engine/frame-store.ts`, lines 120–129:

```ts
export function frameBudget(o: {
  count: number;
  frameBytes: number;
  deviceMemory?: number;
  budgetsMB: { high: number; mid: number; low: number };
}): number {
  const ram = o.deviceMemory ?? 4;
  const mb = ram >= 8 ? o.budgetsMB.high : ram >= 4 ? o.budgetsMB.mid : o.budgetsMB.low;
  return Math.max(16, Math.min(o.count, Math.floor((mb * 1024 * 1024) / Math.max(1, o.frameBytes))));
}
```

Between two frames the renderer cross-fades, which is where the first trap was.

> **Lesson: Blending a scrubbed frame with a stand-in until the right one decodes reads as blur: draw the nearest frame alone** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** when the frame after the current position wasn't decoded yet, blending with the nearest decoded one instead would still look like motion.
> **What happened:** that stand-in could be up to 8 frames away, and blending two such different poses made a double image. The review said it plainly: some frames look blurry.
> **The fix:** the renderer blends only when both frames are the exact neighbours of the position; otherwise it draws the single nearest frame, sharp, until the right ones are decoded.
> **Evidence:** renderer.ts lines 109–121; commit d4ec4b9.

`components/hero/engine/renderer.ts`, lines 104–121:

```ts
    const pos = Math.min(count - 1, Math.max(0, p));
    const i0 = Math.floor(pos);
    const i1 = Math.min(count - 1, i0 + 1);
    const f = pos - i0;

    // Blend only two exact neighbours. A stand-in frame a few positions away would blend into a double image, so
    // until the right frames are decoded, the single nearest frame is drawn, sharp.
    let a = source.getDrawable(Math.round(pos));
    let b: typeof a = null;
    let t = 0;
    if (this.opts.crossfade && f > 0.004 && i1 !== i0) {
      const d0 = source.getDrawable(i0);
      const d1 = source.getDrawable(i1);
      if (d0 && d1 && d0.index === i0 && d1.index === i1) {
        a = d0;
        b = d1;
        t = f;
      }
```

## Measuring smoothness without fooling yourself

The second complaint in that review was that the head felt "sticky" rather than smooth. Before changing anything, we measured, and the first measurement was wrong.

> **Lesson: Reading a canvas back with getImageData moves it off the GPU: it poisoned a whole frame-rate benchmark** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** calling `getImageData` after each draw was a harmless way to make the benchmark wait until the canvas had really been drawn.
> **What happened:** reading pixels back pushes Chrome's canvas off the GPU, which skewed every measurement taken after it in that page. The first benchmark timed the canvas at a median of 86.8 ms a frame and the CSS-effect variants at 83 to 133 ms. Done again with a fresh page for each variant and no readbacks, every variant ran at 16.7 ms.
> **The fix:** never read pixels back while measuring a canvas, and start each variant in a fresh page so one can't change the next.
> **Evidence:** the two benchmark runs in the build notes.

With honest numbers, the cost wasn't the canvas at all. It was what moved with the pointer.

> **Lesson: A pointer spotlight painted from CSS variables repaints the whole stage on every move: move a fixed disc instead** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** a soft light following the pointer was cheap as a full-stage radial gradient whose centre came from CSS variables set on the hero.
> **What happened:** measured with real pointer events on an Intel Iris Plus laptop, every pointer move repainted that full-stage gradient, and the frosted (`backdrop-filter`) panels in the copy layer, which shifts slightly with the pointer, blurred their backdrop again every frame.
> **The fix:** the spotlight is a fixed soft disc that the stage moves with `transform`, so following the pointer neither restyles nor repaints; and nothing over the canvas uses a backdrop blur: borders are gradient rings instead. The head then ran at a steady 60 fps, p95 16.8 ms, with real pointer events.
> **Evidence:** HeroStage.tsx lines 241–242; commander-hero.css lines 187–196 and 321–325; commit d4ec4b9.

`components/hero/HeroStage.tsx`, lines 236–243:

```tsx
      const key = `${shift}|${mx.toFixed(3)}|${my.toFixed(3)}`;
      if (key === written) return;
      written = key;
      inner.style.transform = `translate3d(${shift}px,0,0)`;
      if (content) content.style.transform = `translate3d(${((-px * motion.parallaxPx) / 2).toFixed(2)}px,0,0)`;
      // The spotlight is a fixed disc moved by transform: no repaint, no restyle.
      if (spot) spot.style.transform = `translate3d(${(mx * stageWidth).toFixed(1)}px,${(my * stageHeight).toFixed(1)}px,0)`;
    },
```

`components/hero/commander-hero.css`, lines 187–196:

```css
.cmd-spot {
  /* A fixed soft disc moved with transform (the stage writes it), so following the pointer never repaints. */
  position: absolute;
  left: 0;
  top: 0;
  width: 1400px;
  height: 1400px;
  margin: -700px 0 0 -700px;
  background: radial-gradient(closest-side, rgb(255 236 214 / 0.075), rgb(255 236 214 / 0.03) 42%, rgb(255 236 214 / 0) 100%);
  mix-blend-mode: screen;
```

`components/hero/commander-hero.css`, lines 321–325:

```css
/*
 * Gradient rings: a 1px border painted by a masked pseudo-element, light at the top and fading down, so edges read
 * as catching the light rather than as drawn lines (an inset box-shadow thins out on the curves). No backdrop blur
 * over the canvas: it would be recomputed on every frame of the head's motion.
 */
```

## Borders that keep their width

> **Lesson: A 1px border drawn by a masked ring thins out on a pill's round ends: draw pill borders as an SVG stroke** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** the 1 px gradient ring that looked right on the cards (a pseudo-element masked down to its border) would look right on pill-shaped buttons and fields too.
> **What happened:** on the round ends of a pill, anti-aliasing spreads that 1 px ring over several pixels and it looks thinner than on the straight sides. The review caught it on the ghost button; side by side, an SVG stroke on a rounded rect kept one width all the way round.
> **The fix:** pill-shaped controls (the eyebrow, the pet-type control, the search field, the ghost button, the sign-up field) draw their border with a small SVG: a `rect` with `ry: 50%` set in CSS and a gradient stroke. Cards and tiles, with small corner radii, keep the masked ring.
> **Evidence:** PillRing.tsx lines 8–26; commander-hero.css lines 357–362; commit 7012bfa.

`components/hero/PillRing.tsx`, lines 8–26:

```tsx
/**
 * The border of a pill-shaped control, drawn as an SVG stroke so it keeps one width all the way round (a masked 1px
 * ring thins out on the round ends). Optionally a comet of light that runs round it: dashes that share one leading
 * edge and get longer layer by layer, so the tail fades smoothly. CSS decides when the comet shows (hover, focus).
 *
 * `radius` is only the fallback for browsers without CSS geometry properties; the stylesheet sets ry: 50%.
 */
export function PillRing({ id, radius, comet = false }: { id: string; radius: number; comet?: boolean }) {
  const geom = { width: "100%", height: "100%", rx: radius, ry: radius };
  return (
    <svg className="cmd-ring" aria-hidden="true" focusable="false">
      <defs>
        <linearGradient id={`${id}-rest`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" className="cmd-ring-top" />
          <stop offset="0.52" className="cmd-ring-mid" />
          <stop offset="1" className="cmd-ring-bot" />
        </linearGradient>
      </defs>
      <rect className="cmd-ring-rest" stroke={`url(#${id}-rest)`} {...geom} />
```

`components/hero/commander-hero.css`, lines 357–362:

```css
.cmd-ring rect {
  rx: auto;
  ry: 50%;
  fill: none;
  stroke-width: 1.25px;
}
```

The same SVG carries the ghost button's comet, a light that runs round the border on hover and focus: fourteen dashes that share one leading edge and get longer layer by layer, so the tail fades smoothly.

## Focus: rings and the mascot's attention

> **Lesson: A container's :focus-visible rule outranks a control's outline: none: undo the ring at the same specificity** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** `.cmd-input { outline: none }` kept a ring off the bare input inside the search pill, which shows focus with its own glow.
> **What happened:** the hero's general rule, `.cmd-hero :focus-visible` (specificity 0,2,0), beats `.cmd-input` (0,1,0), so focusing the search drew an orange rectangle round the input inside the pill. The newsletter field at the bottom of the page had the same bug.
> **The fix:** turn the ring off with a selector at least as specific, `.cmd-hero .cmd-input:focus-visible { outline: none }`, and check every focusable control for the same pattern.
> **Evidence:** commander-hero.css lines 93–98 and 706–711; commit 0cf54ed.

`components/hero/commander-hero.css`, lines 93–98:

```css
/* A visible focus ring for the hero's own controls (the page's own rule may be anything). */
.cmd-hero :focus-visible,
.cmd-rail :focus-visible {
  outline: 2px solid var(--cmd-accent);
  outline-offset: 2px;
}
```

`components/hero/commander-hero.css`, lines 706–711:

```css
/* The pill shows the field's focus (its glow and the orbiting light): no ring round the bare input inside it, from the
   hero's own focus rule or the page's (shadcn's `outline-ring/50`, a browser's default). */
.cmd-hero .cmd-input:focus,
.cmd-hero .cmd-input:focus-visible {
  outline: none;
}
```

> **Lesson: A mouse click leaves focus on the button it pressed: let only :focus-visible hold the mascot's gaze** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** looking at whatever has focus was the right rule for the cat's attention, since keyboard users move focus to show where they are.
> **What happened:** a button keeps focus after a mouse click, so the cat kept watching a button the visitor had already moved away from.
> **The fix:** focus holds his attention only when it matches `:focus-visible`: keyboard focus, and typing in a field. A button that was merely clicked lets go when the pointer leaves.
> **Evidence:** useGazeTarget.ts lines 39–44.

`components/hero/useGazeTarget.ts`, lines 39–44:

```ts
    // Keyboard focus (and typing in a field) holds his attention; a button that was merely clicked does not.
    const onFocusIn = (e: FocusEvent) => {
      if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
      focused = true;
      update();
    };
```

## On UpFork: what a listing changed

> **Lesson: UpFork's importer leaves out files named like hero.config.ts as project setup: give component files other names** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** naming the hero's settings `hero.config.ts` and the page's copy `site.config.ts` was a tidy convention.
> **What happened:** UpFork's importer treats any file ending in `.config.ts` (or `.config.js`, `.mjs`, `.cjs`) as the project's own configuration, which buyers keep for themselves, and leaves it out of the install. Both files, which the component needs, would have been missing.
> **The fix:** they became `hero-config.ts` and `site-content.ts` before the ZIP was made.
> **Evidence:** the importer's rule ("your project's configuration: buyers keep their own"); commit 2cf81f2.

> **Lesson: A hero laid out by window height broke inside a preview frame and on 1366×768 laptops: lay out by width alone** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** switching to the side-by-side desktop layout only when the window was at least 640 px tall protected short screens.
> **What happened:** on UpFork's full-page demo view, the site's two toolbars and Windows' 125% scaling left the frame about 1519 × 629 px, so the hero fell back to its stacked tablet layout: the cat small at the top, the copy below. A buyer on a 1366 × 768 laptop would have seen the same. The demos had also been saved with the "centered" layout, so the site drew them at tablet width in the catalogue card and with a margin in the product window.
> **The fix:** the desktop layout depends on width alone (1180 px and up). The vertical rhythm follows the hero's own height, through a `--cmd-vh` unit computed from it, not the window's, and below 722 px the hero shrinks to as little as 600 px and tightens so the product picks stay above the fold. Each demo now starts with `// @upfork layout=full-page`, so the importer sets its layout on every upload.
> **Evidence:** commander-hero.css lines 55–59 and 1361–1366; commit 0cf54ed.

`components/hero/commander-hero.css`, lines 55–60:

```css
  /* The picks overlap the next section; the hero leaves room for them above the fold. */
  --cmd-h: clamp(680px, calc(100svh - var(--cmd-overlap) - 18px), 980px);
  /* The vertical rhythm follows the hero's own height, not the window's: in a window shorter than the hero's least
     height (a laptop with toolbars, a preview frame) it's laid out as in the shortest window it fits, never squeezed. */
  --cmd-vh: calc((var(--cmd-h) + var(--cmd-overlap) + 18px) / 100);
  height: var(--cmd-h);
```

`components/hero/commander-hero.css`, lines 1361–1366:

```css
/* Shorter still (a laptop's window under its toolbars, a preview frame under the site's): the hero follows the window
   down to 600px instead of stopping at 680px, and tightens so the picks stay above the fold. From 722px both agree. */
@media (min-width: 1180px) and (max-height: 721.98px) {
  .cmd-hero {
    --cmd-h: clamp(600px, calc(100svh - var(--cmd-overlap) - 18px), 980px);
  }
```

## Checked

36 unit tests, TypeScript and ESLint clean. A steady 60 fps (p95 16.8 ms) with real pointer events on an Intel Iris Plus laptop, with every animation running. Checked again at 1519 × 629 after the layout fix, with the product cards whole above the fold. On UpFork: the importer (a component, 40 files, two demos, 183 assets, no warnings) and the preview builder.

## Make it yours

`<CommanderHero />` takes your `products` (the picks and the search draw from them; pass them in from the server), a `theme` (dark, light or auto) and a `config` that overrides any part of `hero-config.ts`: the copy, navigation, categories, trust row, colours, motion, the frame memory budget and the gaze calibration. `manifest` takes another frame set's manifest; `hero-manifest.ts` describes the one it comes with.

```bash
npx shadcn@latest add @upfork/pet-shop-hero
```

---

On UpFork: https://upfork.dev/articles/commander-a-pointer-following-mascot-hero-from-90-video-frames-and-eight-tra
