# Pixel pedometer

> A step counter with a pixel cat to look after: big pixel digits, a goal bar, hearts and a walk button.

A phone screen in a pixel-art style. Today’s steps in large pixel digits fill a lime bar towards the goal, and a little cat sits on a dotted line below. Tap or rub the cat and it shuts its eyes and smiles, a heart rises and it earns experience; press Walk together and it steps along while the count runs up. Every letter is drawn on a pixel grid, with no font files.

A React component by Bruno Delorence: https://brunodelorence.com/components/pixel-pedometer. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Mobile · Indicators.

## Anatomy

1. **Goal**: The day’s target in pixel digits, with a lock and its deadline in small print under it.
2. **Count**: Today’s steps in big block digits, one SVG path redrawn on every step, with the real number beside it for screen readers.
3. **Progress bar**: A progressbar with an ink edge; its lime fill grows by scaleX to the share of the goal, 500 ms.
4. **Pet**: The cat is a button. A tap or a rub shuts its eyes, sends a heart up, fills an empty heart and earns 5 XP.
5. **Name and stage**: The pet’s name in pixel capitals, and its stage of life and day under it.
6. **Hearts**: Three hearts, full or outlined: how much attention the pet has had. Read as one image.
7. **Walk button**: A toggle on a 3-point ledge it presses into. While walking, steps count fast and the XP earns its bonus.
8. **Experience**: XP over the next stage’s goal, what is left, and a track of 29 slots: blocks up to the marker, dots after.

## When to use

- A playful daily-goal screen on a phone: steps, water, minutes of practice, with one big number.
- A habit app where a pet or mascot gives the goal a reason, and petting it is a small reward.
- A retro or game-like brand, where pixel type is the voice and no font files should load.
- One goal and one action on the screen: today’s count, its bar and a single “do it” button.

## When not to use

- Health data people read closely (heart rate, sleep stages). Use a plain chart with real type and axes.
- Several metrics side by side. Use a dashboard of cards, each with its own number.
- Text that changes language or length freely: the pixel fonts cover A–Z, digits and a few signs.
- A sober or clinical product. The pet and its hearts read as a game; use a progress ring and a list.
- A desktop page. It is drawn as a 390-point phone screen; on a wide layout use a stat card.

## Installation

Install it with the shadcn CLI, into components/pixel-pedometer of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/pixel-pedometer.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { PixelPedometer } from './pixel-pedometer';

export function TodayScreen() {
  return (
    <PixelPedometer
      steps={1320}
      goal={8000}
      deadline="By tmrw 19:30"
      petName="Bean"
      xp={16273}
      xpGoal={50000}
      autoplay={false}
    />
  );
}
```

## States

- **Default**: Part way to the goal, all three hearts full, the cat breathing a pixel up and down.
- **Petted**: Eyes shut and mouth up while it is petted; it settles 1.1 s after the last touch.
- **Walking**: The button says Walking and its bonus blinks; steps tick in every 260 ms and the cat steps along.
- **Missed attention**: One heart full, two outlined. Each pet fills one back.
- **Goal reached**: The bar is full at 100%; the count keeps going past the goal.
- **Nearly the next stage**: The track is all blocks up to its marker, a few hundred XP left.
- **Dark**: A near-black page with light type; the lime stays the lime.

## How it works

- Every word and number is a pixel font written as rows of characters: four of them, a block face for the count (6 by 10), a lighter one for the goal and the percentage, a heading face and a 5 by 7 monospace for the small print. A line of text becomes one SVG path, a rectangle per run of lit pixels, scaled by its viewBox to its size in points; the real text sits beside it for screen readers.
- The phone is designed in points, as a phone screen is: --pt is one point in px, the largest that fits the stage (0.82 px at most), so the 390 by 844 screen scales down as one picture on narrow stages.
- The cat is a 48 by 52 sprite in two-pixel lines. Its head is a group of its own that drops a pixel and back every 450 ms, on steps(), so it breathes over a still body. Petted, the open eyes and the little w of a mouth swap for shut eyes and a mouth turned up, and the head nuzzles faster.
- A tap pets it once; a press that rubs keeps it pleased and counts another pet every 650 ms. Each pet adds 5 XP, fills an empty heart and sends a small heart up in nine steps.
- The walk button stands on a 3-point ledge of its own edge colour and presses down into it. Walking, the steps come in every 260 ms, the front legs lift in turn, the shoe stomps and the bonus blinks.
- The experience track is two repeating gradients in 11.7-point slots: blocks up to a marker and dots after it, with the stage’s name under the marker.
- While on screen and untouched, a demo plays: the day’s steps count up from zero and the bar fills, steps keep ticking in, a heart empties, then a finger pets and rubs the cat, starts a walk and stops it. Any real input hands it to the reader; it stops off screen and under reduced motion.

## API reference

- **steps** (`number`, default `1320`): Today’s steps so far.
- **goal** (`number`, default `8000`): The day’s goal, in steps.
- **deadline** (`string`, default `'By tmrw 19:30'`): When the goal is due, written under it.
- **petName** (`string`, default `'Bean'`): The pet’s name.
- **stage** (`string`, default `'Kid'`): The pet’s stage of life, under its name and under the marker.
- **day** (`number`, default `1`): The day of that stage.
- **nextStage** (`string`, default `'Adult'`): The stage the experience leads to.
- **xp** (`number`, default `16273`): The pet’s experience.
- **xpGoal** (`number`, default `50000`): The experience the next stage needs.
- **hearts** (`number`, default `3`): Full hearts, out of three.
- **onPet** (`(xp: number) => void`): Called on each pet, with the experience the pet now has.
- **onWalkChange** (`(walking: boolean) => void`): Called when a walk starts or stops.
- **defaultWalking** (`boolean`): Opens walking, the steps ticking in fast. Setting it turns the demo off.
- **defaultMood** (`'calm' | 'happy'`): Opens with the cat calm or pleased, held until it is petted. Setting it turns the demo off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, the steps tick in and a finger pets the cat and goes for a walk.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): A class for the stage.

## Accessibility

- Each pixel line is an SVG hidden from screen readers, with its real text beside it: “1,320 steps”, “Goal 8,000 steps, by tmrw 19:30”, the pet’s name, stage and experience.
- The bar is a progressbar with its percentage. The hearts are an image labelled “3 of 3 hearts”.
- The cat is a button (“Pet Bean”): Enter or Space pets it as a tap does. The walk button is a toggle with aria-pressed.
- A polite live region says when the pet purrs and the XP it earned, and when a walk starts or pauses; the ticking count is not announced.
- Both buttons show a focus ring. The demo finger is hidden from screen readers and stops on any key or pointer.
- Under reduced motion the demo does not play, the cat holds still, no heart rises and the bar and button change without transitions.

## Source

### pixel-pedometer/pixel-pedometer.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import {
  BLOCK,
  CAT,
  HEADING,
  HEART,
  HEART_OUTLINE,
  LOCK,
  MEDIUM,
  MONO,
  SHOE,
  layout,
  toPath,
  type PixelFont,
} from './pixel-font';
import { useAutoplay } from './use-autoplay';
import styles from './pixel-pedometer.module.css';

/*
 * Pixel pedometer: a step counter with a pet, on a phone drawn in CSS. Today's steps in big pixel
 * digits, the goal and its deadline, a progress bar, a pixel cat that likes to be petted, its
 * hearts, a "walk together" button and the pet's experience on its way to the next stage.
 *
 * - Every word and number is drawn on a pixel grid (pixel-font.ts): one SVG path per line of text,
 *   scaled by its viewBox, with the real text beside it for screen readers. No font files.
 * - The phone is designed in points, as a phone's screen is: --pt is one point in px, the largest
 *   that fits the stage's width, so the 390-point-wide screen scales as one picture.
 * - The cat is two layers over one sprite: the head bobs a pixel over the body as it breathes.
 *   Tapping or rubbing it shuts its eyes and turns its mouth up, a heart rises from it, a heart
 *   fills if one was empty and it earns 5 XP.
 * - The walk button presses into its shadow. While walking, steps count up fast, the cat steps
 *   along, and the experience earns its bonus.
 * - While on screen and untouched, a demo plays: the day's steps count up and the bar fills, the
 *   steps keep ticking, a finger pets the cat, then starts and stops a walk. Any real input hands
 *   it over to the reader.
 * - Light or dark: every colour is a light-dark() token in the CSS.
 */

/** XP for each pet, and how long the cat stays pleased after the last one. */
const PET_XP = 5;
const PLEASED = 1100;
/** A rub counts as another pet every RUB ms. */
const RUB = 650;
/** How often a step comes in: idly (the phone in a pocket) and walking. */
const IDLE_STEP = 900;
const WALK_STEP = 260;
/** The day's steps count up over INTRO ms the first time the demo plays. */
const INTRO = 1500;
/** The experience bar's slots. */
const SLOTS = 29;

const format = (value: number) => Math.round(value).toLocaleString('en-US');

type Size = [x: number, y: number];

/** A line of pixel text: an SVG drawn `size` points per pixel; screen readers skip it. */
function Pixels({
  font,
  text,
  size,
  className,
}: {
  font: PixelFont;
  text: string;
  size: Size;
  className?: string;
}) {
  const { d, width, height } = useMemo(() => layout(font, text), [font, text]);
  return (
    <svg
      className={`${styles.pixels} ${className ?? ''}`}
      viewBox={`0 0 ${width} ${height}`}
      preserveAspectRatio="none"
      aria-hidden="true"
      style={{
        width: `calc(var(--pt) * ${(width * size[0]).toFixed(2)})`,
        height: `calc(var(--pt) * ${(height * size[1]).toFixed(2)})`,
      }}
    >
      <path d={d} />
    </svg>
  );
}

/** A pixel drawing (rows of '#'), `size` points per pixel. */
function Sprite({ rows, size, className }: { rows: string[]; size: Size; className?: string }) {
  const d = useMemo(() => toPath(rows), [rows]);
  const width = rows[0].length;
  return (
    <svg
      className={`${styles.pixels} ${className ?? ''}`}
      viewBox={`0 0 ${width} ${rows.length}`}
      preserveAspectRatio="none"
      aria-hidden="true"
      style={{
        width: `calc(var(--pt) * ${(width * size[0]).toFixed(2)})`,
        height: `calc(var(--pt) * ${(rows.length * size[1]).toFixed(2)})`,
      }}
    >
      <path d={d} />
    </svg>
  );
}

function StatusIcons() {
  return (
    <svg className={styles.statusIcons} viewBox="0 0 82 13" aria-hidden="true">
      <g fill="currentColor">
        <rect x="0" y="8.4" width="3.2" height="3.6" rx="0.7" />
        <rect x="4.7" y="6" width="3.2" height="6" rx="0.7" />
        <rect x="9.4" y="3.5" width="3.2" height="8.5" rx="0.7" />
        <rect x="14.1" y="1" width="3.2" height="11" rx="0.7" />
        <path d="M30.5 3.1a9.6 9.6 0 0 1 6.7 2.7l1.2-1.3a11.4 11.4 0 0 0-15.8 0l1.2 1.3a9.6 9.6 0 0 1 6.7-2.7zm0 3.5c1.6 0 3 .6 4.2 1.6l1.2-1.3a7.9 7.9 0 0 0-10.8 0l1.2 1.3a6.3 6.3 0 0 1 4.2-1.6zm0 3.4c.7 0 1.3.2 1.8.6l-1.8 1.9-1.8-1.9c.5-.4 1.1-.6 1.8-.6z" />
        <rect
          x="53.5"
          y="1.5"
          width="23"
          height="10.5"
          rx="3.2"
          fill="none"
          stroke="currentColor"
          strokeOpacity="0.4"
        />
        <rect x="55.3" y="3.3" width="19.4" height="6.9" rx="1.7" />
        <path d="M78 5v3.4c.7-.3 1.2-.9 1.2-1.7S78.7 5.3 78 5z" fillOpacity="0.4" />
      </g>
    </svg>
  );
}

type Float = { id: number; x: number };

export function PixelPedometer({
  steps: initialSteps = 1320,
  goal = 8000,
  deadline = 'By tmrw 19:30',
  petName = 'Bean',
  stage = 'Kid',
  day = 1,
  nextStage = 'Adult',
  xp: initialXp = 16273,
  xpGoal = 50000,
  hearts: initialHearts = 3,
  onPet,
  onWalkChange,
  defaultWalking,
  defaultMood,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** Today's steps so far. */
  steps?: number;
  /** The day's goal, in steps. */
  goal?: number;
  /** When the goal is due, under it. */
  deadline?: string;
  /** The pet's name. */
  petName?: string;
  /** The pet's stage of life, and the day of it. */
  stage?: string;
  day?: number;
  /** The stage the experience leads to. */
  nextStage?: string;
  /** The pet's experience, and the experience the next stage needs. */
  xp?: number;
  xpGoal?: number;
  /** Full hearts, out of three. */
  hearts?: number;
  /** Called on each pet, with the experience it now has. */
  onPet?: (xp: number) => void;
  /** Called when a walk starts or stops. */
  onWalkChange?: (walking: boolean) => void;
  /** Opens walking (the steps tick in fast); setting it turns the demo off. */
  defaultWalking?: boolean;
  /** Opens with the cat calm or pleased (held until it is petted); setting it turns the demo off. */
  defaultMood?: 'calm' | 'happy';
  /** While on screen and untouched, the steps tick and a finger pets the cat and goes walking. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(6000);
  const [steps, setSteps] = useState(initialSteps);
  const [xp, setXp] = useState(initialXp);
  const [hearts, setHearts] = useState(Math.max(0, Math.min(3, initialHearts)));
  const [walking, setWalking] = useState(defaultWalking ?? false);
  const [pleased, setPleased] = useState(defaultMood === 'happy');
  const [floats, setFloats] = useState<Float[]>([]);
  const [message, setMessage] = useState('');
  /** The demo's finger: where it is (in points on the screen), shown, pressing. */
  const [touch, setTouch] = useState({ x: 195, y: 520, shown: false, down: false });
  /** The walk button pressed by the demo's finger. */
  const [demoPress, setDemoPress] = useState(false);

  const screen = useRef<HTMLDivElement>(null);
  const petButton = useRef<HTMLButtonElement>(null);
  const walkButton = useRef<HTMLButtonElement>(null);
  const pleasedTimer = useRef(0);
  const lastPet = useRef(0);
  const floatId = useRef(0);
  const introDone = useRef(false);
  const xpRef = useRef(xp);
  useEffect(() => {
    xpRef.current = xp;
  }, [xp]);

  /** An opening state asked for holds the demo off. */
  const demo = autoplay && defaultWalking === undefined && defaultMood === undefined && playing;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  const pet = useEffectEvent((silent = false) => {
    setPleased(true);
    window.clearTimeout(pleasedTimer.current);
    pleasedTimer.current = window.setTimeout(() => setPleased(false), PLEASED);
    const now = performance.now();
    if (now - lastPet.current < 350) return;
    lastPet.current = now;
    const next = xpRef.current + PET_XP;
    setXp((value) => value + PET_XP);
    setHearts((count) => Math.min(3, count + 1));
    const id = ++floatId.current;
    setFloats((list) => [...list.slice(-3), { id, x: (id % 3) - 1 }]);
    window.setTimeout(() => setFloats((list) => list.filter((float) => float.id !== id)), 1200);
    onPet?.(next);
    if (!silent) announce(`${petName} purrs. Plus ${PET_XP} XP.`);
  });

  const setWalk = useEffectEvent((on: boolean, silent = false) => {
    setWalking(on);
    onWalkChange?.(on);
    if (!silent) announce(on ? `Walking together with ${petName}.` : 'Walk paused.');
  });

  useEffect(() => () => window.clearTimeout(pleasedTimer.current), []);

  // Steps come in: slowly while the demo plays (the phone in a pocket), fast while walking.
  const ticking = visible && (walking || demo);
  useEffect(() => {
    if (!ticking) return;
    const timer = window.setInterval(
      () => {
        setSteps((count) => count + (walking ? 1 + Math.round(Math.random()) : 1));
        if (walking) setXp((value) => value + 1);
      },
      walking ? WALK_STEP : IDLE_STEP,
    );
    return () => window.clearInterval(timer);
  }, [ticking, walking]);

  // The demo: a finger pets the cat, then starts a walk and stops it, over and over.
  const runDemo = useEffectEvent(async (signal: AbortSignal) => {
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        const timer = window.setTimeout(resolve, ms);
        signal.addEventListener('abort', () => {
          window.clearTimeout(timer);
          reject(signal.reason);
        });
      });
    /** The middle of an element, in points on the screen, nudged by dx, dy points. */
    const at = (element: Element | null, dx = 0, dy = 0) => {
      const frame = screen.current?.getBoundingClientRect();
      const box = element?.getBoundingClientRect();
      if (!frame || !box) return { x: 195, y: 420 };
      const scale = frame.width / 390 || 1;
      return {
        x: (box.left + box.width / 2 - frame.left) / scale + dx,
        y: (box.top + box.height / 2 - frame.top) / scale + dy,
      };
    };
    const move = (point: { x: number; y: number }, down = false) =>
      setTouch({ ...point, shown: true, down });

    if (!introDone.current) {
      introDone.current = true;
      const target = steps;
      const start = performance.now();
      setSteps(0);
      await new Promise<void>((resolve) => {
        const frame = (now: number) => {
          if (signal.aborted) {
            setSteps(target);
            return resolve();
          }
          const t = Math.min(1, (now - start) / INTRO);
          setSteps(Math.round(target * (1 - (1 - t) ** 3)));
          if (t < 1) requestAnimationFrame(frame);
          else resolve();
        };
        requestAnimationFrame(frame);
      });
      if (signal.aborted) return;
    }

    for (;;) {
      await wait(1600);
      // The cat has missed some attention: a heart empties.
      setHearts((count) => (count === 3 ? 2 : count));
      await wait(1200);
      const cat = petButton.current;
      setTouch((now) => ({ ...now, ...at(cat, 34, -10), shown: false, down: false }));
      await wait(60);
      move(at(cat, 26, -6));
      await wait(420);
      move(at(cat, 22, -4), true);
      pet(true);
      await wait(160);
      move(at(cat, 22, -4));
      await wait(700);
      // A rub, back and forth over its head.
      move(at(cat, 8, -26), true);
      pet(true);
      for (let pass = 0; pass < 4; pass++) {
        await wait(260);
        move(at(cat, pass % 2 ? 8 : -14, -26), true);
        if (pass === 2) pet(true);
      }
      await wait(260);
      move(at(cat, -2, -20));
      await wait(900);
      // Off for a walk.
      const walk = walkButton.current;
      move(at(walk, 40, 0));
      await wait(620);
      move(at(walk, 40, 0), true);
      setDemoPress(true);
      await wait(150);
      setDemoPress(false);
      setWalk(true, true);
      move(at(walk, 40, 0));
      await wait(380);
      setTouch((now) => ({ ...now, shown: false }));
      await wait(4200);
      move(at(walk, 40, 0));
      await wait(420);
      move(at(walk, 40, 0), true);
      setDemoPress(true);
      await wait(150);
      setDemoPress(false);
      setWalk(false, true);
      move(at(walk, 40, 0));
      await wait(380);
      setTouch((now) => ({ ...now, shown: false }));
    }
  });

  useEffect(() => {
    if (!demo) return;
    const controller = new AbortController();
    runDemo(controller.signal).catch(() => {});
    return () => {
      controller.abort();
      setTouch((now) => ({ ...now, shown: false, down: false }));
      setDemoPress(false);
    };
  }, [demo]);

  // The reader's hands on the cat: a tap pets it, a rub keeps petting it.
  const rubbing = useRef<{ id: number; last: number } | null>(null);
  const onPetDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0) return;
    take();
    event.currentTarget.setPointerCapture(event.pointerId);
    rubbing.current = { id: event.pointerId, last: performance.now() };
    pet();
  };
  const onPetMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
    const rub = rubbing.current;
    if (!rub || rub.id !== event.pointerId) return;
    setPleased(true);
    window.clearTimeout(pleasedTimer.current);
    pleasedTimer.current = window.setTimeout(() => setPleased(false), PLEASED);
    const now = performance.now();
    if (now - rub.last > RUB) {
      rub.last = now;
      pet();
    }
  };
  const onPetUp = () => {
    rubbing.current = null;
  };

  const ratio = Math.min(1, steps / Math.max(1, goal));
  const percent = Math.floor(ratio * 100);
  const xpRatio = Math.min(1, xp / Math.max(1, xpGoal));
  const filled = Math.max(1, Math.min(SLOTS, Math.floor(xpRatio * SLOTS)));
  const remaining = Math.max(0, xpGoal - xp);
  const heartList = [0, 1, 2].map((index) => index < hearts);

  const stopDemo = useCallback(() => take(), [take]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-reduced={reduced || undefined}
      onPointerDownCapture={stopDemo}
      onKeyDownCapture={stopDemo}
    >
      <div className={styles.phone} data-part="phone">
        <div className={styles.screen} ref={screen}>
          <div className={styles.status} aria-hidden="true">
            <span className={styles.clock}>9:41</span>
            <span className={styles.island} />
            <StatusIcons />
          </div>

          <section className={styles.app} aria-label="Step counter">
            <h3 className={styles.today} data-part="title">
              <Pixels font={HEADING} text="TODAY" size={[3.15, 3.23]} />
              <span className={styles.srOnly}>Today</span>
            </h3>

            <div className={styles.goal} data-part="goal">
              <span className={styles.goalLabel}>
                <Sprite rows={LOCK} size={[2, 2]} />
                <Pixels font={MONO} text="GOAL" size={[1.22, 1.4]} />
              </span>
              <Pixels
                className={styles.goalValue}
                font={MEDIUM}
                text={format(goal)}
                size={[1.42, 1.52]}
              />
              <Pixels
                className={styles.goalBy}
                font={MONO}
                text={deadline.toUpperCase()}
                size={[1.12, 1.33]}
              />
              <span className={styles.srOnly}>
                Goal {format(goal)} steps, {deadline.toLowerCase()}
              </span>
            </div>

            <p className={styles.count} data-part="count">
              <Pixels font={BLOCK} text={format(steps)} size={[8, 8]} />
              <span className={styles.srOnly}>{format(steps)} steps</span>
            </p>

            <div
              className={styles.bar}
              data-part="progress"
              role="progressbar"
              aria-label="Steps toward the goal"
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={percent}
              aria-valuetext={`${percent}%`}
            >
              <span className={styles.fill} style={{ '--ratio': ratio } as CSSProperties} />
            </div>
            <Pixels
              className={styles.percent}
              font={MEDIUM}
              text={`${percent}%`}
              size={[1.56, 1.5]}
            />

            <button
              ref={petButton}
              type="button"
              className={styles.pet}
              data-part="pet"
              data-mood={pleased ? 'happy' : 'calm'}
              data-walking={walking || undefined}
              aria-label={`Pet ${petName}`}
              onPointerDown={onPetDown}
              onPointerMove={onPetMove}
              onPointerUp={onPetUp}
              onPointerCancel={onPetUp}
              onClick={(event) => {
                // Pointers pet on press; a click from the keyboard pets here.
                if (event.detail === 0) pet();
              }}
            >
              <svg
                className={styles.cat}
                viewBox={`0 0 ${CAT.width} ${CAT.height}`}
                preserveAspectRatio="none"
                aria-hidden="true"
              >
                <g className={styles.catHead}>
                  <path d={CAT.head} />
                  <path className={styles.calm} d={CAT.calm} />
                  <path className={styles.happy} d={CAT.happy} />
                </g>
                <path d={CAT.body} />
                <path className={styles.legs} d={CAT.legs} />
                <path className={styles.stepLeft} d={CAT.stepLeft} />
                <path className={styles.stepRight} d={CAT.stepRight} />
              </svg>
              {floats.map((float) => (
                <span
                  key={float.id}
                  className={styles.float}
                  style={{ '--drift': float.x } as CSSProperties}
                  aria-hidden="true"
                >
                  <Sprite rows={HEART} size={[1.1, 1.1]} />
                </span>
              ))}
            </button>

            <span className={styles.rule} aria-hidden="true" />

            <div className={styles.profile} data-part="profile">
              <Pixels
                className={styles.name}
                font={MONO}
                text={petName.toUpperCase()}
                size={[2, 2]}
              />
              <Pixels
                className={styles.stage}
                font={MONO}
                text={`${stage} · DAY ${day}`.toUpperCase()}
                size={[1.12, 1.36]}
              />
              <span className={styles.srOnly}>
                {petName}, {stage.toLowerCase()}, day {day}.
              </span>
            </div>

            <div
              className={styles.hearts}
              data-part="hearts"
              role="img"
              aria-label={`${hearts} of 3 hearts`}
            >
              {heartList.map((full, index) => (
                <span
                  key={index}
                  className={styles.heart}
                  data-part="heart"
                  data-full={full || undefined}
                >
                  <Sprite rows={full ? HEART : HEART_OUTLINE} size={[1.96, 2.06]} />
                </span>
              ))}
            </div>

            <button
              ref={walkButton}
              type="button"
              className={styles.walk}
              data-part="walk"
              data-pressed={demoPress || undefined}
              aria-pressed={walking}
              onClick={() => setWalk(!walking)}
            >
              <span className={styles.shoe} aria-hidden="true">
                <Sprite rows={SHOE} size={[2, 1.95]} />
              </span>
              <Pixels
                className={styles.walkLabel}
                font={MONO}
                text={walking ? 'WALKING' : 'WALK TOGETHER'}
                size={[2.2, 2.54]}
              />
              <Pixels className={styles.bonus} font={MONO} text="XP +5%" size={[1.28, 1.53]} />
              <span className={styles.srOnly}>Walk together, XP plus 5%</span>
            </button>

            <div className={styles.xp} data-part="xp">
              <Pixels className={styles.xpTag} font={MONO} text="XP" size={[1.12, 1.23]} />
              <Pixels
                className={styles.xpValue}
                font={MONO}
                text={format(xp)}
                size={[1.38, 1.55]}
              />
              <Pixels
                className={styles.xpGoal}
                font={MONO}
                text={`/ ${format(xpGoal)}`}
                size={[1.12, 1.23]}
              />
              <Pixels
                className={styles.xpLeft}
                font={MONO}
                text={`${format(remaining)} TO ${nextStage.toUpperCase()}`}
                size={[1.12, 1.23]}
              />
              <span
                className={styles.track}
                data-part="track"
                style={{ '--filled': filled } as CSSProperties}
                aria-hidden="true"
              >
                <span className={styles.blocks} />
                <span className={styles.marker} />
                <span className={styles.dots} />
                <Pixels
                  className={styles.markerLabel}
                  font={MONO}
                  text={stage.toUpperCase()}
                  size={[1.12, 1.23]}
                />
              </span>
              <span className={styles.srOnly}>
                {format(xp)} of {format(xpGoal)} XP, {format(remaining)} to{' '}
                {nextStage.toLowerCase()}.
              </span>
            </div>
          </section>

          <span className={styles.home} aria-hidden="true" />
          <span
            className={styles.touch}
            data-shown={touch.shown || undefined}
            data-down={touch.down || undefined}
            style={{ '--x': touch.x, '--y': touch.y } as CSSProperties}
            aria-hidden="true"
          />
        </div>
      </div>
      <p className={styles.srOnly} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### pixel-pedometer/pixel-font.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The pedometer's type and art, drawn on pixel grids: each glyph or sprite is a list of rows, '#'
 * for a lit pixel. `toPath` turns rows into one SVG path (a rectangle per run of lit pixels), so
 * a whole line of text is a single path in a single SVG, scaled to any size by its viewBox.
 *
 * - Block: the step count's face, 6 by 10 with strokes two pixels thick and corners cut by one.
 * - Medium: the goal and the percentage, 8 by 13, lighter, with a narrow one.
 * - Heading: the day's title, 7 by 10.
 * - Mono: the small print, a monospaced 5 by 7 with one-pixel strokes.
 */

export type PixelFont = {
  /** Rows above the baseline; glyphs may hang below it (a comma's tail). */
  height: number;
  /** Pixels between two glyphs (fractions allowed). */
  gap: number;
  /** Each glyph's width in pixels, when the font is monospaced. */
  mono?: number;
  glyphs: Record<string, string[]>;
};

/** One SVG path for a grid of rows, offset by `x`, `y` pixels. */
export function toPath(rows: string[], x = 0, y = 0): string {
  let d = '';
  rows.forEach((row, r) => {
    let start = -1;
    for (let c = 0; c <= row.length; c++) {
      const lit = row[c] === '#';
      if (lit && start < 0) start = c;
      if (!lit && start >= 0) {
        d += `M${x + start} ${y + r}h${c - start}v1h${start - c}z`;
        start = -1;
      }
    }
  });
  return d;
}

/** The width, in pixels, of a glyph. */
const widthOf = (font: PixelFont, glyph: string[]) =>
  font.mono ?? Math.max(0, ...glyph.map((row) => row.length));

/**
 * Lays a line of text out: its path, its width and its height in pixels (the height counts what
 * hangs below the baseline). Characters the font lacks are left blank.
 */
export function layout(font: PixelFont, text: string) {
  let x = 0;
  let d = '';
  let depth = font.height;
  [...text].forEach((char, index) => {
    const glyph = font.glyphs[char] ?? font.glyphs[char.toUpperCase()] ?? font.glyphs[' '] ?? [];
    if (index > 0) x += font.gap;
    d += toPath(glyph, x, 0);
    depth = Math.max(depth, glyph.length);
    x += widthOf(font, glyph) || font.mono || 3;
  });
  return { d, width: x, height: depth };
}

export const BLOCK: PixelFont = {
  height: 10,
  gap: 1,
  glyphs: {
    '0': [
      '.####.',
      '######',
      '##..##',
      '##..##',
      '##..##',
      '##..##',
      '##..##',
      '##..##',
      '######',
      '.####.',
    ],
    '1': [
      '.###..',
      '.###..',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
      '######',
      '######',
    ],
    '2': [
      '.####.',
      '######',
      '##..##',
      '...###',
      '..###.',
      '.###..',
      '###...',
      '##....',
      '######',
      '######',
    ],
    '3': [
      '.####.',
      '######',
      '##..##',
      '....##',
      '..###.',
      '..###.',
      '....##',
      '##..##',
      '######',
      '.####.',
    ],
    '4': [
      '...###',
      '..####',
      '.##.##',
      '##..##',
      '##..##',
      '######',
      '######',
      '....##',
      '....##',
      '....##',
    ],
    '5': [
      '######',
      '######',
      '##....',
      '#####.',
      '######',
      '....##',
      '....##',
      '##..##',
      '######',
      '.####.',
    ],
    '6': [
      '.####.',
      '######',
      '##..##',
      '##....',
      '#####.',
      '######',
      '##..##',
      '##..##',
      '######',
      '.####.',
    ],
    '7': [
      '######',
      '######',
      '##..##',
      '....##',
      '...###',
      '..###.',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
    ],
    '8': [
      '.####.',
      '######',
      '##..##',
      '##..##',
      '.####.',
      '######',
      '##..##',
      '##..##',
      '######',
      '.####.',
    ],
    '9': [
      '.####.',
      '######',
      '##..##',
      '##..##',
      '######',
      '.#####',
      '....##',
      '##..##',
      '######',
      '.####.',
    ],
    ',': ['..', '..', '..', '..', '..', '..', '..', '..', '##', '##', '.#', '#.'],
  },
};

export const MEDIUM: PixelFont = {
  height: 13,
  gap: 2,
  glyphs: {
    '0': [
      '.######.',
      '########',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '########',
      '.######.',
    ],
    '1': [
      '###',
      '###',
      '.##',
      '.##',
      '.##',
      '.##',
      '.##',
      '.##',
      '.##',
      '.##',
      '.##',
      '.##',
      '.##',
    ],
    '2': [
      '.######.',
      '########',
      '##....##',
      '......##',
      '.....###',
      '....###.',
      '...###..',
      '..###...',
      '.###....',
      '###.....',
      '##......',
      '########',
      '########',
    ],
    '3': [
      '.######.',
      '########',
      '##....##',
      '......##',
      '......##',
      '..#####.',
      '..#####.',
      '......##',
      '......##',
      '......##',
      '##....##',
      '########',
      '.######.',
    ],
    '4': [
      '....###.',
      '...####.',
      '..##.##.',
      '.##..##.',
      '##...##.',
      '##...##.',
      '########',
      '########',
      '.....##.',
      '.....##.',
      '.....##.',
      '.....##.',
      '.....##.',
    ],
    '5': [
      '########',
      '########',
      '##......',
      '##......',
      '#######.',
      '########',
      '......##',
      '......##',
      '......##',
      '......##',
      '##....##',
      '########',
      '.######.',
    ],
    '6': [
      '.######.',
      '########',
      '##....##',
      '##......',
      '##......',
      '#######.',
      '########',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '########',
      '.######.',
    ],
    '7': [
      '########',
      '########',
      '##....##',
      '......##',
      '.....##.',
      '.....##.',
      '....##..',
      '....##..',
      '...##...',
      '...##...',
      '...##...',
      '...##...',
      '...##...',
    ],
    '8': [
      '.######.',
      '########',
      '##....##',
      '##....##',
      '##....##',
      '.######.',
      '.######.',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '########',
      '.######.',
    ],
    '9': [
      '.######.',
      '########',
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '########',
      '.#######',
      '......##',
      '......##',
      '##....##',
      '########',
      '.######.',
    ],
    ',': ['..', '..', '..', '..', '..', '..', '..', '..', '..', '..', '..', '##', '##', '#.'],
    '%': [
      '............#',
      '.##........#.',
      '#..#......#..',
      '#..#.....#...',
      '.##.....#....',
      '.......#.....',
      '......#......',
      '.....#.......',
      '....#........',
      '...#.....##..',
      '..#.....#..#.',
      '.#......#..#.',
      '#........##..',
    ],
  },
};

export const HEADING: PixelFont = {
  height: 10,
  gap: 1.35,
  glyphs: {
    T: [
      '######',
      '######',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
      '..##..',
    ],
    O: [
      '.#####.',
      '#######',
      '###.###',
      '##...##',
      '##...##',
      '##...##',
      '##...##',
      '###.###',
      '#######',
      '.#####.',
    ],
    D: [
      '######.',
      '#######',
      '##..###',
      '##...##',
      '##...##',
      '##...##',
      '##...##',
      '##..###',
      '#######',
      '######.',
    ],
    A: [
      '...#...',
      '..###..',
      '.#####.',
      '###.###',
      '##...##',
      '##...##',
      '#######',
      '#######',
      '##...##',
      '##...##',
    ],
    Y: [
      '##....##',
      '##....##',
      '##....##',
      '##....##',
      '###..###',
      '.######.',
      '..####..',
      '...##...',
      '...##...',
      '...##...',
    ],
    ' ': ['....'],
  },
};

export const MONO: PixelFont = {
  height: 7,
  gap: 1,
  mono: 5,
  glyphs: {
    A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
    B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'],
    C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'],
    D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
    E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
    F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'],
    G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.####'],
    H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
    I: ['.###.', '..#..', '..#..', '..#..', '..#..', '..#..', '.###.'],
    J: ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'],
    K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
    L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
    M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'],
    N: ['#...#', '#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#'],
    O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
    P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
    Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'],
    R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
    S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
    T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
    U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
    V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
    W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '#.#.#', '.#.#.'],
    X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'],
    Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
    Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'],
    '0': ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
    '1': ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'],
    '2': ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
    '3': ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'],
    '4': ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
    '5': ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
    '6': ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'],
    '7': ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
    '8': ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
    '9': ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'],
    ',': ['.....', '.....', '.....', '.....', '.....', '..#..', '..#..', '.#...'],
    '.': ['.....', '.....', '.....', '.....', '.....', '.....', '..#..'],
    ':': ['.....', '..#..', '.....', '.....', '.....', '..#..', '.....'],
    '·': ['.....', '.....', '.....', '..#..', '.....', '.....', '.....'],
    '/': ['....#', '....#', '...#.', '..#..', '.#...', '#....', '#....'],
    '%': ['##..#', '##..#', '...#.', '..#..', '.#...', '#..##', '#..##'],
    '+': ['.....', '..#..', '..#..', '#####', '..#..', '..#..', '.....'],
    '-': ['.....', '.....', '.....', '#####', '.....', '.....', '.....'],
    "'": ['..#..', '..#..', '.....', '.....', '.....', '.....', '.....'],
    '!': ['..#..', '..#..', '..#..', '..#..', '..#..', '.....', '..#..'],
    '?': ['.###.', '#...#', '....#', '...#.', '..#..', '.....', '..#..'],
    ' ': ['.....'],
  },
};

/*
 * The art. The pet is a cat drawn in two-pixel lines on a 48 by 52 grid: its head (rows 0 to 30)
 * is a group of its own, so it can bob over the body; its eyes and mouth come in two moods.
 */

type Rect = [x: number, y: number, w: number, h: number];

/** The head is symmetric about its middle: half of it, mirrored. */
const HEAD_WIDTH = 38;
const mirror = (rects: Rect[]): Rect[] =>
  rects.flatMap(([x, y, w, h]): Rect[] => {
    const twin: Rect = [HEAD_WIDTH - x - w, y, w, h];
    return twin[0] === x ? [[x, y, w, h]] : [[x, y, w, h], twin];
  });

const rectsPath = (rects: Rect[]) =>
  rects.map(([x, y, w, h]) => `M${x} ${y}h${w}v${h}h${-w}z`).join('');

export const CAT = {
  width: 48,
  height: 52,
  /** The outline of the head: ears, sides, the top and the chin. */
  head: rectsPath(
    mirror([
      [2, 0, 2, 6],
      [4, 2, 2, 2],
      [6, 4, 2, 2],
      [8, 6, 2, 2],
      [10, 8, 18, 2],
      [0, 6, 2, 21],
      [2, 27, 2, 2],
      [4, 29, 30, 2],
    ]),
  ),
  /** Open eyes and the little "w" of a mouth. */
  calm: rectsPath(
    mirror([
      [8, 17, 2, 4],
      [13, 23, 2, 2],
      [17, 23, 4, 2],
      [15, 25, 2, 2],
    ]),
  ),
  /** Shut, smiling eyes and the mouth turned up: being petted. */
  happy: rectsPath(
    mirror([
      [7, 19, 4, 2],
      [15, 23, 2, 2],
      [13, 25, 2, 2],
      [17, 25, 4, 2],
    ]),
  ),
  /** The body, the base it sits on and the curled tail. */
  body: rectsPath([
    [6, 31, 2, 5],
    [4, 36, 2, 4],
    [2, 40, 2, 10],
    [30, 31, 2, 5],
    [32, 36, 2, 4],
    [34, 40, 2, 10],
    [4, 50, 40, 2],
    [38, 31, 8, 2],
    [36, 33, 2, 3],
    [46, 33, 2, 15],
    [44, 48, 2, 2],
    [38, 36, 6, 2],
    [42, 38, 2, 8],
    [34, 46, 8, 2],
  ]),
  /** The front legs, standing, and each one lifted in turn as it walks. */
  legs: rectsPath([
    [13, 42, 2, 8],
    [23, 42, 2, 8],
  ]),
  stepLeft: rectsPath([
    [13, 40, 2, 7],
    [23, 42, 2, 8],
  ]),
  stepRight: rectsPath([
    [13, 42, 2, 8],
    [23, 40, 2, 7],
  ]),
};

export const HEART = [
  '..###...###..',
  '.#####.#####.',
  '#############',
  '#############',
  '#############',
  '#############',
  '.###########.',
  '..#########..',
  '...#######...',
  '....#####....',
  '.....###.....',
  '......#......',
];

/** The heart's outline: its lit pixels that touch an unlit one or the edge. */
export const HEART_OUTLINE = HEART.map((row, r) =>
  [...row]
    .map((cell, c) => {
      if (cell !== '#') return '.';
      const lit = (rr: number, cc: number) => HEART[rr]?.[cc] === '#';
      const inner = lit(r - 1, c) && lit(r + 1, c) && lit(r, c - 1) && lit(r, c + 1);
      return inner ? '.' : '#';
    })
    .join(''),
);

export const LOCK = ['.###.', '#...#', '#...#', '#####', '##.##', '#####'];

/** A high-top sneaker, laces and all, on a 13 by 10 grid; the line under it is drawn apart. */
export const SHOE = [
  '###..........',
  '####.........',
  '#####........',
  '##.##........',
  '###..#.......',
  '####..#.##...',
  '###########..',
  '############.',
  '############.',
  '.###########.',
];
```

### pixel-pedometer/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input. `visible` and `reduced` are there for what
 * runs on its own whoever is in charge.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```

### pixel-pedometer/pixel-pedometer.module.css

```css
/* Pixel pedometer (pixel-pedometer.tsx): a graphite phone on a quiet stage, its screen an
   off-white page of black pixel type with a lime bar and a lime button; at night the page is
   near-black and the type light, the lime the same. The phone is drawn in points, as a phone's
   screen is designed: --pt is one point in px (0.82 at most, less on a stage too narrow for it),
   so the 390 by 844 screen scales as one picture. Every word is an SVG path (pixel-font.ts),
   sized in points here or inline.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme).

   Memory. The phone is one layout boundary of a fixed size. What moves moves by transforms: the
   cat's head (a group in its SVG, a pixel up or down), the bar's fill (scaleX), the button (its
   press), the hearts that rise and the demo's finger. The numbers redraw as paths. */

.root {
  /* The stage and its edge. */
  --surface: #ebeae6;
  --line: rgb(0 0 0 / 6%);
  /* The phone: its frame (two stops of its sheen and its edge), the buttons, its drop shadow. */
  --frame-light: #4a4a4e;
  --frame-dark: #1f1f22;
  --frame-edge: rgb(255 255 255 / 14%);
  --frame-button: #333336;
  --shadow: rgb(20 20 30 / 28%);
  --shadow-near: rgb(20 20 30 / 10%);
  /* The screen's page, its type (black, the dark thin type, the grey small print) and lines. */
  --page: #f5f5f2;
  --ink: #121212;
  --ink-2: #2b2b2b;
  --muted: #92928d;
  /* The lime of the bar and the button, the type on it, and the button's edge and depth. */
  --lime: #dcf65a;
  --lime-ink: #526700;
  --edge: #121212;
  /* The same in both schemes: the focus ring, and the demo's finger. */
  --focus: #2f7bff;
  --touch: rgb(30 30 30 / 22%);
  --touch-ring: rgb(30 30 30 / 30%);
  container-type: inline-size;
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  min-width: 0;
  padding: 24px 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#ebeae6, #0e0e0f);
    --line: light-dark(rgb(0 0 0 / 6%), rgb(255 255 255 / 7%));
    --frame-light: light-dark(#4a4a4e, #3b3b3f);
    --frame-dark: light-dark(#1f1f22, #18181a);
    --frame-edge: light-dark(rgb(255 255 255 / 14%), rgb(255 255 255 / 10%));
    --frame-button: light-dark(#333336, #2a2a2d);
    --shadow: light-dark(rgb(20 20 30 / 28%), rgb(0 0 0 / 70%));
    --shadow-near: light-dark(rgb(20 20 30 / 10%), rgb(0 0 0 / 40%));
    --page: light-dark(#f5f5f2, #111210);
    --ink: light-dark(#121212, #efefe9);
    --ink-2: light-dark(#2b2b2b, #d9d9d2);
    --muted: light-dark(#92928d, #7b7b75);
    --lime: light-dark(#dcf65a, #d5f052);
    --lime-ink: light-dark(#526700, #3c4c00);
    --edge: light-dark(#121212, #000);
    --touch: light-dark(rgb(30 30 30 / 22%), rgb(255 255 255 / 30%));
    --touch-ring: light-dark(rgb(30 30 30 / 30%), rgb(255 255 255 / 55%));
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root :where(button) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  touch-action: manipulation;
}
.root :where(h3, p) {
  margin: 0;
  font: inherit;
}
.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The phone: a graphite frame, a black bezel and the 390 by 844 screen. */
.phone {
  --pt: min(0.82px, calc((100cqw - 32px) / 414));
  position: relative;
  flex: none;
  width: calc(var(--pt) * 414);
  height: calc(var(--pt) * 868);
  border-radius: calc(var(--pt) * 62);
  background: linear-gradient(
    150deg,
    var(--frame-light),
    var(--frame-dark) 40%,
    var(--frame-light)
  );
  box-shadow:
    inset 0 0 0 calc(var(--pt) * 1.2) var(--frame-edge),
    0 26px 50px -22px var(--shadow),
    0 3px 8px var(--shadow-near);
  contain: layout paint;
}
.phone::before,
.phone::after {
  position: absolute;
  width: calc(var(--pt) * 3.4);
  background: var(--frame-button);
  content: '';
}
.phone::before {
  top: calc(var(--pt) * 170);
  left: calc(var(--pt) * -3);
  height: calc(var(--pt) * 62);
  border-radius: calc(var(--pt) * 2) 0 0 calc(var(--pt) * 2);
  box-shadow: 0 calc(var(--pt) * 80) 0 var(--frame-button);
}
.phone::after {
  top: calc(var(--pt) * 240);
  right: calc(var(--pt) * -3);
  height: calc(var(--pt) * 100);
  border-radius: 0 calc(var(--pt) * 2) calc(var(--pt) * 2) 0;
}
.screen {
  position: absolute;
  inset: calc(var(--pt) * 12);
  overflow: hidden;
  border-radius: calc(var(--pt) * 50);
  background: var(--page);
  box-shadow: 0 0 0 calc(var(--pt) * 9) #050505;
  color: var(--ink);
  user-select: none;
  -webkit-user-select: none;
}

/* The status bar. */
.status {
  position: absolute;
  inset: 0 0 auto;
  height: calc(var(--pt) * 54);
  color: var(--ink);
  pointer-events: none;
}
.clock {
  position: absolute;
  top: calc(var(--pt) * 33);
  left: calc(var(--pt) * 70);
  font:
    600 calc(var(--pt) * 16.5) / 1 -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Inter,
    sans-serif;
  letter-spacing: calc(var(--pt) * -0.2);
  transform: translate(-50%, -50%);
}
.island {
  position: absolute;
  top: calc(var(--pt) * 11);
  left: 50%;
  width: calc(var(--pt) * 125);
  height: calc(var(--pt) * 37);
  margin-left: calc(var(--pt) * -62.5);
  border-radius: calc(var(--pt) * 19);
  background: #050505;
}
.statusIcons {
  position: absolute;
  top: calc(var(--pt) * 25.5);
  right: calc(var(--pt) * 27);
  width: calc(var(--pt) * 82);
  height: calc(var(--pt) * 13);
}

/* The app: everything placed in points from the screen's top left corner. */
.app {
  position: absolute;
  inset: 0;
}
.pixels {
  display: block;
  flex: none;
  overflow: visible;
  fill: currentColor;
}
.today {
  position: absolute;
  top: calc(var(--pt) * 88.6);
  left: calc(var(--pt) * 22.3);
}

.goal {
  position: absolute;
  top: calc(var(--pt) * 84.5);
  right: calc(var(--pt) * 24);
  display: flex;
  flex-direction: column;
  align-items: flex-end;
}
.goalLabel {
  display: flex;
  align-items: flex-end;
  gap: calc(var(--pt) * 6.5);
  color: var(--ink-2);
}
.goalValue {
  margin-top: calc(var(--pt) * 11.3);
}
.goalBy {
  position: absolute;
  top: calc(var(--pt) * 53.7);
  right: 0;
  color: var(--ink-2);
}

.count {
  position: absolute;
  top: calc(var(--pt) * 170.5);
  left: calc(var(--pt) * 23.3);
}

.bar {
  position: absolute;
  top: calc(var(--pt) * 281.7);
  left: calc(var(--pt) * 24);
  width: calc(var(--pt) * 270.6);
  height: calc(var(--pt) * 29);
  padding: calc(var(--pt) * 3.4);
  border: calc(var(--pt) * 2.5) solid var(--ink);
}
.fill {
  display: block;
  height: 100%;
  background: var(--lime);
  transform: scaleX(var(--ratio));
  transform-origin: 0 50%;
  transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.percent {
  position: absolute;
  top: calc(var(--pt) * 286.9);
  right: calc(var(--pt) * 24);
}

/* The pet: a button the size of the cat. */
.pet {
  position: absolute;
  top: calc(var(--pt) * 350.8);
  left: calc(var(--pt) * 132.3);
  width: calc(var(--pt) * 133.4);
  height: calc(var(--pt) * 144.6);
  border-radius: calc(var(--pt) * 6);
  touch-action: none;
}
.cat {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  fill: var(--ink);
}
/* It breathes: the head a pixel down and back, on two frames. Petted, it nuzzles: a pixel or two
   lower, faster. */
.catHead {
  animation: breathe 0.9s steps(1) infinite;
}
.pet[data-mood='happy'] .catHead {
  animation: nuzzle 0.36s steps(1) infinite;
}
@keyframes breathe {
  50% {
    transform: translateY(1px);
  }
}
@keyframes nuzzle {
  0% {
    transform: translateY(1px);
  }
  50% {
    transform: translateY(2px);
  }
}
.happy,
.pet[data-mood='happy'] .calm {
  display: none;
}
.pet[data-mood='happy'] .happy {
  display: inline;
}
/* Walking, the front legs lift in turn and the cat hops a pixel. */
.stepLeft,
.stepRight,
.pet[data-walking] .legs {
  display: none;
}
.pet[data-walking] .stepLeft {
  display: inline;
  animation: step-a 0.44s steps(1) infinite;
}
.pet[data-walking] .stepRight {
  display: inline;
  animation: step-b 0.44s steps(1) infinite;
}
.pet[data-walking] .cat {
  animation: hop 0.22s steps(1) infinite;
}
@keyframes step-a {
  50% {
    opacity: 0;
  }
}
@keyframes step-b {
  0% {
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
}
@keyframes hop {
  50% {
    transform: translateY(calc(var(--pt) * -2.78));
  }
}
/* A heart rises from the head for each pet. */
.float {
  position: absolute;
  top: calc(var(--pt) * 6);
  left: 50%;
  color: var(--ink);
  pointer-events: none;
  animation: rise 1.1s steps(9, end) forwards;
}
@keyframes rise {
  from {
    opacity: 1;
    transform: translate(calc(var(--pt) * (var(--drift) * 10 - 7)), 0);
  }
  70% {
    opacity: 1;
  }
  to {
    opacity: 0;
    transform: translate(calc(var(--pt) * (var(--drift) * 22 - 7)), calc(var(--pt) * -48));
  }
}
.pet:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}

/* The dotted rule the cat sits on. */
.rule {
  position: absolute;
  top: calc(var(--pt) * 498.8);
  left: calc(var(--pt) * 25);
  width: calc(var(--pt) * 342.6);
  height: calc(var(--pt) * 1.8);
  background: linear-gradient(90deg, var(--ink) calc(var(--pt) * 1.8), transparent 0) 0 0 /
    calc(var(--pt) * 8.74) 100% repeat-x;
}

.profile {
  position: absolute;
  top: calc(var(--pt) * 518.5);
  left: calc(var(--pt) * 26);
  color: var(--ink-2);
}
.stage {
  margin: calc(var(--pt) * 9.5) 0 0 calc(var(--pt) * 0.5);
  color: var(--ink-2);
}

.hearts {
  position: absolute;
  top: calc(var(--pt) * 521.6);
  right: calc(var(--pt) * 24);
  display: flex;
  gap: calc(var(--pt) * 11.7);
}
.heart {
  display: block;
  color: var(--ink);
}
.heart[data-full] {
  animation: fill 0.3s steps(3);
}
@keyframes fill {
  from {
    transform: scale(0.6);
  }
}

/* The walk button: lime with an ink edge, standing on a 3-point ledge it presses into. */
.walk {
  position: absolute;
  top: calc(var(--pt) * 566.5);
  left: calc(var(--pt) * 24);
  width: calc(var(--pt) * 342);
  height: calc(var(--pt) * 54);
  border: calc(var(--pt) * 3) solid var(--edge) !important;
  background: var(--lime) !important;
  box-shadow: 0 calc(var(--pt) * 3) 0 var(--edge);
  color: var(--lime-ink);
  transition:
    transform 70ms ease-out,
    box-shadow 70ms ease-out;
}
.walk:active,
.walk[data-pressed] {
  box-shadow: 0 0 0 var(--edge);
  transform: translateY(calc(var(--pt) * 3));
}
.walk:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(var(--pt) * 6);
}
.shoe {
  position: absolute;
  top: calc(var(--pt) * 14.3);
  left: calc(var(--pt) * 16.8);
  padding-bottom: calc(var(--pt) * 3.4);
  color: var(--edge);
  background: linear-gradient(var(--edge), var(--edge)) 0 100% / calc(var(--pt) * 24)
    calc(var(--pt) * 1.6) no-repeat;
}
.walk[aria-pressed='true'] .shoe > svg {
  animation: stomp 0.44s steps(1) infinite;
}
@keyframes stomp {
  50% {
    transform: translate(calc(var(--pt) * 2), calc(var(--pt) * -2));
  }
}
.walkLabel {
  position: absolute;
  top: calc(var(--pt) * 16.2);
  left: calc(var(--pt) * 54);
}
.bonus {
  position: absolute;
  top: calc(var(--pt) * 18.8);
  right: calc(var(--pt) * 11);
}
.walk[aria-pressed='true'] .bonus {
  animation: blink 0.88s steps(1) infinite;
}
@keyframes blink {
  50% {
    opacity: 0.35;
  }
}

/* The experience: the numbers, then a track of blocks up to a marker, dots after it. */
.xp {
  position: absolute;
  top: calc(var(--pt) * 645);
  left: calc(var(--pt) * 24);
  width: calc(var(--pt) * 342);
  height: calc(var(--pt) * 62);
  color: var(--muted);
}
.xpTag {
  position: absolute;
  top: calc(var(--pt) * 3);
  left: 0;
}
.xpValue {
  position: absolute;
  top: 0;
  left: calc(var(--pt) * 24.5);
  color: var(--ink);
}
.xpGoal {
  position: absolute;
  top: calc(var(--pt) * 3);
  left: calc(var(--pt) * 81.5);
}
.xpLeft {
  position: absolute;
  top: calc(var(--pt) * 3);
  right: 0;
}
.track {
  --slot: calc(var(--pt) * 11.7);
  position: absolute;
  top: calc(var(--pt) * 25);
  left: 0;
  width: calc(var(--slot) * 29);
  height: calc(var(--pt) * 19);
}
.blocks,
.dots,
.marker {
  position: absolute;
  display: block;
}
.blocks {
  top: calc(var(--pt) * 5.5);
  left: 0;
  width: calc(var(--slot) * var(--filled));
  height: calc(var(--pt) * 8);
  background: linear-gradient(
      90deg,
      transparent calc(var(--pt) * 2.3),
      var(--ink) 0 calc(var(--pt) * 9.5),
      transparent 0
    )
    0 0 / var(--slot) 100% repeat-x;
}
.marker {
  top: calc(var(--pt) * 1);
  left: calc(var(--slot) * var(--filled) - var(--pt) * 2.2);
  width: calc(var(--pt) * 2.5);
  height: calc(var(--pt) * 18);
  background: var(--ink);
}
.dots {
  top: calc(var(--pt) * 8.8);
  right: 0;
  left: calc(var(--slot) * var(--filled));
  height: calc(var(--pt) * 2);
  background: linear-gradient(
      90deg,
      transparent calc(var(--pt) * 4.85),
      var(--ink) 0 calc(var(--pt) * 6.85),
      transparent 0
    )
    0 0 / var(--slot) 100% repeat-x;
}
.markerLabel {
  position: absolute;
  top: calc(var(--pt) * 27);
  left: calc(var(--slot) * var(--filled) - var(--pt) * 0.95);
  transform: translateX(-50%);
}

.home {
  position: absolute;
  bottom: calc(var(--pt) * 8);
  left: 50%;
  width: calc(var(--pt) * 134);
  height: calc(var(--pt) * 5);
  margin-left: calc(var(--pt) * -67);
  border-radius: calc(var(--pt) * 3);
  background: var(--ink);
}

/* The demo's finger: a soft disc that presses in. */
.touch {
  position: absolute;
  top: 0;
  left: 0;
  width: calc(var(--pt) * 44);
  height: calc(var(--pt) * 44);
  margin: calc(var(--pt) * -22) 0 0 calc(var(--pt) * -22);
  border-radius: 50%;
  background: var(--touch);
  box-shadow: inset 0 0 0 calc(var(--pt) * 1.5) var(--touch-ring);
  opacity: 0;
  pointer-events: none;
  transform: translate(calc(var(--pt) * var(--x)), calc(var(--pt) * var(--y)));
  transition:
    transform 0.42s cubic-bezier(0.3, 0.7, 0.2, 1),
    opacity 0.2s ease,
    scale 0.12s ease;
}
.touch[data-shown] {
  opacity: 1;
}
.touch[data-down] {
  scale: 0.84;
  transition:
    transform 0.24s cubic-bezier(0.4, 0.2, 0.3, 1),
    opacity 0.2s ease,
    scale 0.12s ease;
}

@media (prefers-reduced-motion: reduce) {
  .catHead,
  .pet[data-mood='happy'] .catHead,
  .pet[data-walking] .cat,
  .pet[data-walking] .stepLeft,
  .pet[data-walking] .stepRight,
  .walk[aria-pressed='true'] .shoe > svg,
  .walk[aria-pressed='true'] .bonus,
  .heart[data-full] {
    animation: none;
  }
  .float {
    display: none;
  }
  .fill,
  .walk {
    transition: none;
  }
}
```