# Pixel horse composer

> A chat message field where a little pixel horse gallops along a strip of dots while you talk or type.

The bottom of a chat on a phone: a frosted message card with a “+”, a round voice button beside it and, under the text, a strip of fine grey dots. While the field listens or keys come in, a pixel horse made of darker dots gallops along the strip, slows when you pause and stops to stand. The card grows a line at a time as the text wraps, the strip folds away when listening stops, and the voice button’s waveform turns into a send arrow.

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

Category: Mobile · Inputs.

## Anatomy

1. **Header**: The chat’s title between two round 40-point buttons: Conversations and New chat.
2. **My message**: A grey bubble on the right for what I sent; each sent message slides in from below.
3. **Reply**: The assistant’s answer as plain text on the page, no bubble. The conversation is a log.
4. **Message card**: A frosted card with a fine rim and a bright top edge. It closes round the text when the strip folds away.
5. **Add button**: The “+” for a photo or file, kept on the line of the last row of text.
6. **Field**: A text area labelled “Message”. It grows a line at a time up to six, then scrolls; Enter sends, Shift+Enter breaks the line.
7. **Dot strip**: A canvas of 12 rows of dots, 2.93 points apart. The pixel horse gallops along it while the field listens or keys come in.
8. **Voice and send button**: Round and slate: six bars while listening or empty (a toggle with aria-pressed), an arrow once there is text to send.

## When to use

- The message field of a chat or assistant app on a phone, where voice input is the main way in.
- A product with a playful, crafted voice, where a small moving mark shows the app is listening.
- A field that should say “I hear you” without a loud waveform or a spinner: the horse runs while input comes in.
- One composer at the foot of a conversation, with a single round button that is voice or send.

## When not to use

- A desktop chat or a wide layout. It is drawn as a 390-point phone screen; use a plain text area with a send button.
- A form field or a search box. Use a text input; the strip and the voice button promise a conversation.
- Real speech recognition feedback. The horse shows that input is coming in, not how loud it is; use a live level meter.
- A sober or clinical product (banking, health records). Use a plain composer with a mic icon.
- Several fields on one screen. The strip only reads well once, at the bottom of a chat.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 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 { PixelHorseComposer } from './pixel-horse-composer';

export function RideChat() {
  return (
    <PixelHorseComposer
      placeholder="Ask anything"
      greeting={[
        { from: 'me', text: 'Did my watch log the ride?' },
        { from: 'assistant', text: 'Not yet: it last synced on Tuesday.' },
      ]}
      onSend={(text) => console.log(text)}
      autoplay={false}
    />
  );
}
```

## States

- **Listening**: Opens empty and listening: the placeholder says Listening, the strip is open and the horse gallops along it.
- **Dictating**: Listening with words in: the button still stops listening; tap it and the text is ready to send.
- **Idle**: Not listening and empty: the strip folds away and the button is the voice button, Talk.
- **Ready to send**: Text and not listening: the bars shrink to dots and the arrow rises. One tap or Enter sends.
- **Long message**: The field grows to six lines, then scrolls; the “+” stays by the last line.
- **New chat**: No conversation above the field yet: the thread is empty, the composer at the foot.
- **Dark**: A near-black page and card, the dots dark grey and the horse a pale grey.

## How it works

- The strip is a canvas of 12 rows of rounded dots, 2.93 points apart and 1.8 points wide; the grid is drawn once into a second canvas and copied in each frame, and only the horse’s dots are drawn over it. Its two colours are read from the canvas’s own color and outline-color, which hold the dot and horse tokens, so it follows light and dark with the rest; a 1 ms transition on them tells the script when the scheme changes.
- The horse is a 22 by 12 sprite (horse.ts): body, neck, head and tail as rows of characters, and four legs drawn per frame as two straight segments each, hip to knee and knee to hoof, rasterised cell by cell. Eight poses make the gallop, the body dropping a row as the legs gather; a ninth is the horse standing.
- Its speed eases towards 30 dots a second while the field listens or for 0.7 s after each key, and back down when that stops: quick to get going, slower to pull up, then it stands. The gallop frame follows the distance run, one every 1.35 dots, so the legs slow with the horse. Past the right end it comes back in from the left, and a dot it leaves fades out over a few hundredths of a second.
- The strip shows while the field listens or has the focus, and folds away otherwise: its row goes from 1fr to 0fr, with its gap inside it, and the card’s bottom padding eases from 13.7 to 17.2 points, so the card closes round the text.
- The text area is as tall as its lines, up to six, and the “+” sits on the line of the last one. The round button is the voice button while listening or empty; with text and not listening its six bars shrink to dots and an arrow rises, and Send is one tap or Enter. A press flashes it white.
- The phone is designed in points: --pt is one point in px (0.82 at most, less on a narrow stage), so the 390 by 844 screen scales as one picture.
- While on screen and untouched, a demo plays: the field listens, a sentence comes in a word or two at a time while the horse runs, the horse runs off the end, the voice button is tapped, then send. Any real input hands it to the reader, and it never starts again over text the reader typed.

## API reference

- **placeholder** (`string`, default `'Ask anything'`): The field’s placeholder when it is not listening.
- **listeningLabel** (`string`, default `'Listening'`): The placeholder while it listens.
- **defaultValue** (`string`, default `''`): Text in the field to begin with. Setting it turns the demo off.
- **defaultListening** (`boolean`): Whether it opens listening, the horse running (by default, when it opens empty). Setting it turns the demo off.
- **greeting** (`{ from: 'me' | 'assistant'; text: string }[]`): The conversation above the field.
- **demoText** (`string`): The sentence the demo dictates.
- **onSend** (`(text: string) => void`): Called with the text when a message is sent.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a sentence is dictated while the horse runs, then sent.
- **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

- The field is a real text area labelled “Message”: Enter sends, Shift+Enter starts a new line.
- The round button says what it does: “Talk”, “Stop listening” (a toggle with aria-pressed) or “Send”. The “+” is a button labelled “Add a photo or file”.
- A polite live region says “Listening”, “Stopped listening” and “Message sent”. The conversation is a log.
- The dot strip and the horse are decoration, hidden from screen readers; so is the demo’s touch.
- Every button shows a focus ring, and the card shows one round the field.
- Under reduced motion the demo does not play, the horse stands still and the card, strip and button change without transitions.

## Source

### pixel-horse-composer/pixel-horse-composer.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type FormEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { GALLOP, HORSE_HEIGHT, HORSE_WIDTH, STAND, type Frame } from './horse';
import { useAutoplay } from './use-autoplay';
import styles from './pixel-horse-composer.module.css';

/*
 * Pixel horse composer: the bottom of a chat on a phone drawn in CSS. The message field sits in a
 * frosted card with a "+" on its left, a round voice button beside it, and under the text a strip
 * of fine grey dots where a little pixel horse gallops while you talk or type.
 *
 * - The strip is a canvas: a grid of rounded dots, 2.93 points apart, drawn once and kept; the
 *   horse is drawn over it every frame, cell by cell, in a darker grey. A cell the horse has just
 *   left fades out over a few hundredths of a second, so the dots seem to light up as it passes.
 * - The horse (horse.ts) is a 22 by 12 sprite with an eight-frame gallop. Its speed eases towards
 *   a canter while the field is listening or keys are coming in, and back to nothing after the
 *   last key: it slows, stops and stands. The gallop frame follows the distance run, not the
 *   clock, so the legs slow with it. Past the right end it comes back in from the left.
 * - The strip shows while the field listens (the voice button) or has the focus, and folds away
 *   otherwise: a grid row from 1fr to 0fr, so the card shrinks around the text. The text area
 *   grows a line at a time as the text wraps, up to six lines, and the "+" stays by the last.
 * - The round button is the voice button while listening or empty, its six bars a waveform; with
 *   text and not listening it is the send button, the bars shrinking to dots as an arrow rises.
 * - While on screen and untouched, a demo plays: the field listens, a sentence comes in a word or
 *   two at a time, the horse runs off the end, the voice button is tapped, then send. Any real
 *   input hands it to the reader. Off screen, in a hidden tab or under reduced motion it stops,
 *   and under reduced motion the horse stands still.
 * - Light or dark: every colour is a light-dark() token in the CSS.
 */

/** The horse's top speed, in dots a second, and how far it runs per gallop frame. */
const RUN = 30;
const STRIDE = 1.35;
/** How long after the last key it keeps running (ms), and how long a lit dot takes to fade (s). */
const COAST = 700;
const TRAIL = 0.022;
/** The strip's geometry, in points: dot pitch, dot size, rows. */
const PITCH = 2.93;
const DOT = 1.8;
const ROWS = HORSE_HEIGHT;
/** The text area grows to this many lines, then scrolls. */
const MAX_LINES = 6;

const DEMO_TEXT =
  'Nice, the little horse runs while I talk. Let’s plan an easy ride out to the lake on Saturday.';

type Message = { id: number; from: 'me' | 'assistant'; text: string };

const THREAD: Message[] = [
  { id: 1, from: 'me', text: 'Did my watch log the ride to the lake?' },
  {
    id: 2,
    from: 'assistant',
    text: 'Not yet. Your watch last synced on Tuesday morning, so I can’t see Sunday’s ride, the route you took or how long you were out.',
  },
];

const wait = (ms: number, signal: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const timer = window.setTimeout(resolve, ms);
    signal.addEventListener('abort', () => {
      window.clearTimeout(timer);
      reject(signal.reason);
    });
  });

/** The horse's state, kept outside React: it changes every frame. */
type Sim = {
  x: number;
  speed: number;
  distance: number;
  last: number;
  activeUntil: number;
  hold: boolean;
  cols: number;
  /** How lit each dot is, 0 to 1, column by column. */
  glow: Float32Array;
  frame: number;
};

export function PixelHorseComposer({
  placeholder = 'Ask anything',
  listeningLabel = 'Listening',
  defaultValue = '',
  defaultListening,
  greeting = THREAD,
  demoText = DEMO_TEXT,
  onSend,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The field's placeholder when it is not listening. */
  placeholder?: string;
  /** The placeholder while it listens. */
  listeningLabel?: string;
  /** Text in the field to begin with. */
  defaultValue?: string;
  /** Whether it opens listening (by default, when it opens empty). Setting it turns the demo off. */
  defaultListening?: boolean;
  /** The conversation above the field. */
  greeting?: { from: 'me' | 'assistant'; text: string }[];
  /** The sentence the demo dictates. */
  demoText?: string;
  /** Called with the text when a message is sent. */
  onSend?: (text: string) => void;
  /** While on screen and untouched, a sentence is dictated, then sent. */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, take, visible, reduced, playing } = useAutoplay<HTMLDivElement>(6000);
  const demo = autoplay && playing && !defaultValue && defaultListening === undefined;

  const [value, setValue] = useState(defaultValue);
  const [listening, setListening] = useState(defaultListening ?? !defaultValue);
  const [focused, setFocused] = useState(false);
  const [thread, setThread] = useState<Message[]>(() =>
    greeting.map((message, index) => ({ ...message, id: index + 1 })),
  );
  const [flash, setFlash] = useState(false);
  const [touch, setTouch] = useState(false);
  const [announce, setAnnounce] = useState('');

  const field = useRef<HTMLTextAreaElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const threadRef = useRef<HTMLDivElement>(null);
  const valueRef = useRef(value);
  valueRef.current = value;
  const typed = useRef(false);
  const nextId = useRef(greeting.length + 1);
  const frameRequest = useRef(0);
  const sim = useRef<Sim>({
    x: 3,
    speed: 0,
    distance: 0,
    last: 0,
    activeUntil: 0,
    hold: false,
    cols: 0,
    glow: new Float32Array(0),
    frame: -1,
  });
  /** The grid without the horse, and the colours it was drawn in. */
  const paper = useRef<{
    grid: HTMLCanvasElement;
    horse: string;
    pitch: number;
    dot: number;
    left: number;
  } | null>(null);
  const greetingRef = useRef(greeting);
  greetingRef.current = greeting;
  const visibleRef = useRef(visible);
  visibleRef.current = visible;
  const reducedRef = useRef(reduced);
  reducedRef.current = reduced;

  const strip = listening || focused;
  const mode = listening ? 'listening' : value.trim() ? 'send' : 'voice';

  /* ---------- The strip ---------- */

  /** Draws the grid, then every lit dot over it. */
  const paint = useCallback(() => {
    const element = canvas.current;
    const sheet = paper.current;
    if (!element || !sheet) return;
    const context = element.getContext('2d');
    if (!context) return;
    const { cols, glow } = sim.current;
    const scale = element.width / element.clientWidth || 1;
    context.setTransform(1, 0, 0, 1, 0, 0);
    context.clearRect(0, 0, element.width, element.height);
    context.drawImage(sheet.grid, 0, 0);
    context.setTransform(scale, 0, 0, scale, 0, 0);
    context.fillStyle = sheet.horse;
    const radius = sheet.dot * 0.24;
    for (let column = 0; column < cols; column++) {
      for (let row = 0; row < ROWS; row++) {
        const light = glow[column * ROWS + row];
        if (light < 0.03) continue;
        context.globalAlpha = light;
        context.beginPath();
        context.roundRect(
          sheet.left + column * sheet.pitch,
          row * sheet.pitch,
          sheet.dot,
          sheet.dot,
          radius,
        );
        context.fill();
      }
    }
    context.globalAlpha = 1;
  }, []);

  /** Lights the cells of `frame` with the horse's left edge at column `x`; fades the others. */
  const light = useCallback((frame: Frame, x: number, fade: number) => {
    const { cols, glow } = sim.current;
    for (let index = 0; index < glow.length; index++) glow[index] *= fade;
    const left = Math.round(x);
    for (const [column, row] of frame) {
      const at = left + column;
      if (at >= 0 && at < cols) glow[at * ROWS + row] = 1;
    }
  }, []);

  /** Sizes the canvas to its box and redraws the grid in the current colours. */
  const layout = useCallback(() => {
    const element = canvas.current;
    if (!element) return;
    const width = element.clientWidth;
    const height = element.clientHeight;
    if (!width || !height) return;
    const scale = Math.min(3, window.devicePixelRatio || 1);
    element.width = Math.round(width * scale);
    element.height = Math.round(height * scale);
    // Points to px: the strip is ROWS dots tall.
    const pitch = height / (ROWS - 1 + DOT / PITCH);
    const dot = pitch * (DOT / PITCH);
    const cols = Math.max(HORSE_WIDTH, Math.floor((width - dot) / pitch) + 1);
    const left = (width - ((cols - 1) * pitch + dot)) / 2;
    const style = getComputedStyle(element);
    const grid = document.createElement('canvas');
    grid.width = element.width;
    grid.height = element.height;
    const context = grid.getContext('2d');
    if (context) {
      context.scale(scale, scale);
      context.fillStyle = style.outlineColor;
      for (let column = 0; column < cols; column++) {
        for (let row = 0; row < ROWS; row++) {
          context.beginPath();
          context.roundRect(left + column * pitch, row * pitch, dot, dot, dot * 0.24);
          context.fill();
        }
      }
    }
    paper.current = { grid, horse: style.color, pitch, dot, left };
    const state = sim.current;
    if (state.cols !== cols) {
      state.cols = cols;
      state.glow = new Float32Array(cols * ROWS);
    }
    light(state.speed > 0 ? (GALLOP[state.frame] ?? STAND) : STAND, state.x, 1);
    paint();
  }, [light, paint]);

  /** One frame of the run. */
  const tick = useCallback(
    (now: number) => {
      frameRequest.current = 0;
      const state = sim.current;
      const seconds = Math.min(0.05, (now - (state.last || now)) / 1000);
      state.last = now;
      const running = state.hold || now < state.activeUntil;
      const target = running ? RUN : 0;
      // Quick to get going, slower to pull up.
      const rate = target > state.speed ? 4.5 : 2.6;
      state.speed += (target - state.speed) * (1 - Math.exp(-rate * seconds));
      if (!running && state.speed < 2.5) state.speed = 0;
      state.x += state.speed * seconds;
      state.distance += state.speed * seconds;
      if (state.x > state.cols) state.x = -HORSE_WIDTH;
      const frame = state.speed > 0 ? Math.floor(state.distance / STRIDE) % GALLOP.length : -1;
      state.frame = frame;
      light(frame < 0 ? STAND : GALLOP[frame], state.x, Math.exp(-seconds / TRAIL));
      paint();
      let fading = false;
      for (const glow of state.glow) {
        if (glow > 0.03 && glow < 1) {
          fading = true;
          break;
        }
      }
      if ((state.speed > 0 || running || fading) && visibleRef.current && !reducedRef.current)
        frameRequest.current = requestAnimationFrame(tick);
      else state.last = 0;
    },
    [light, paint],
  );

  /** Starts the frames if they are not running. */
  const kick = useCallback(() => {
    if (frameRequest.current || !visibleRef.current || reducedRef.current) return;
    frameRequest.current = requestAnimationFrame(tick);
  }, [tick]);

  useEffect(() => {
    const element = canvas.current;
    if (!element) return;
    const observer = new ResizeObserver(layout);
    observer.observe(element);
    // Its two colours carry a one-millisecond transition: when the scheme changes, from the
    // reader's settings, the page or a wrapper, the transition's end redraws the grid in them.
    element.addEventListener('transitionend', layout);
    return () => {
      observer.disconnect();
      element.removeEventListener('transitionend', layout);
    };
  }, [layout]);
  useEffect(layout, [colorScheme, layout]);

  // The horse runs for as long as the field listens.
  useEffect(() => {
    sim.current.hold = listening;
    kick();
  }, [listening, kick]);

  // Off screen or in a hidden tab, no frames; back on, pick up where it was.
  useEffect(() => {
    if (visible) kick();
    else {
      cancelAnimationFrame(frameRequest.current);
      frameRequest.current = 0;
      sim.current.last = 0;
    }
  }, [visible, kick]);
  useEffect(() => () => cancelAnimationFrame(frameRequest.current), []);

  /* ---------- The field ---------- */

  // The text area as tall as its lines, up to MAX_LINES.
  useLayoutEffect(() => {
    const element = field.current;
    if (!element) return;
    element.style.height = 'auto';
    const line = parseFloat(getComputedStyle(element).lineHeight) || 22;
    element.style.height = `${Math.min(element.scrollHeight, line * MAX_LINES)}px`;
  }, [value]);

  useEffect(() => {
    const element = threadRef.current;
    if (element) element.scrollTop = element.scrollHeight;
  }, [thread]);

  const press = useCallback(() => {
    setFlash(true);
    window.setTimeout(() => setFlash(false), 260);
  }, []);

  const send = useCallback(() => {
    const text = valueRef.current.trim();
    if (!text) return;
    setThread((list) => [...list, { id: nextId.current++, from: 'me', text }]);
    setValue('');
    setListening(false);
    setAnnounce('Message sent');
    onSend?.(text);
  }, [onSend]);

  const act = () => {
    press();
    if (mode === 'listening') {
      setListening(false);
      setAnnounce('Stopped listening');
    } else if (mode === 'send') send();
    else {
      setListening(true);
      setAnnounce('Listening');
    }
  };

  const onInput = (text: string) => {
    typed.current = true;
    setValue(text);
    sim.current.activeUntil = performance.now() + COAST;
    kick();
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
    if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
      event.preventDefault();
      send();
    }
  };

  const onSubmit = (event: FormEvent) => {
    event.preventDefault();
    send();
  };

  /* ---------- The demo ---------- */

  useEffect(() => {
    if (!demo) return;
    // Never over what the reader wrote.
    if (typed.current && valueRef.current) return;
    const controller = new AbortController();
    const { signal } = controller;
    const tap = async () => {
      setTouch(true);
      await wait(120, signal);
      press();
      await wait(140, signal);
    };
    const run = async () => {
      for (;;) {
        const greeting = greetingRef.current;
        setThread(greeting.map((message, index) => ({ ...message, id: index + 1 })));
        nextId.current = greeting.length + 1;
        setValue('');
        setListening(true);
        sim.current.x = -HORSE_WIDTH + 6;
        await wait(1100, signal);
        // Dictation: a word or two at a time, as speech comes in.
        const words = demoText.split(' ');
        let text = '';
        for (let index = 0; index < words.length;) {
          const count = Math.random() < 0.4 ? 2 : 1;
          text += (text ? ' ' : '') + words.slice(index, index + count).join(' ');
          index += count;
          setValue(text);
          await wait(200 + count * 110 + Math.random() * 160, signal);
        }
        // Let the horse run off the end, then stop listening.
        await wait(900, signal);
        for (let spent = 0; spent < 5000; spent += 80) {
          const { x, cols } = sim.current;
          if (x > cols - 2 || x < -HORSE_WIDTH + 2) break;
          await wait(80, signal);
        }
        await tap();
        setListening(false);
        await wait(300, signal);
        setTouch(false);
        await wait(2000, signal);
        await tap();
        send();
        await wait(300, signal);
        setTouch(false);
        await wait(2600, signal);
      }
    };
    run().catch(() => {});
    return () => {
      controller.abort();
      setTouch(false);
    };
  }, [demo, demoText, press, send]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-reduced={reduced || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.phone} data-part="phone">
        <div className={styles.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="Chat">
            <header className={styles.header} data-part="header">
              <button type="button" className={styles.round} aria-label="Conversations">
                <svg viewBox="0 0 20 20" aria-hidden="true">
                  <path d="M3.5 7h13M3.5 13h8" />
                </svg>
              </button>
              <h3 className={styles.title}>Riding log</h3>
              <button type="button" className={styles.round} aria-label="New chat">
                <svg viewBox="0 0 20 20" aria-hidden="true">
                  <path d="M10 4.5v11M4.5 10h11" />
                </svg>
              </button>
            </header>

            <div
              ref={threadRef}
              className={styles.thread}
              data-part="thread"
              role="log"
              aria-label="Conversation"
            >
              {thread.map((message) =>
                message.from === 'me' ? (
                  <p key={message.id} className={styles.mine} data-part="mine">
                    {message.text}
                  </p>
                ) : (
                  <p key={message.id} className={styles.reply} data-part="reply">
                    {message.text}
                  </p>
                ),
              )}
            </div>

            <form className={styles.composer} data-part="composer" onSubmit={onSubmit}>
              <div className={styles.card} data-part="card" data-strip={strip || undefined}>
                <div className={styles.line}>
                  <button
                    type="button"
                    className={styles.attach}
                    data-part="attach"
                    aria-label="Add a photo or file"
                  >
                    <svg viewBox="0 0 16 16" aria-hidden="true">
                      <path d="M8 1v14M1 8h14" />
                    </svg>
                  </button>
                  <textarea
                    ref={field}
                    className={styles.field}
                    data-part="field"
                    rows={1}
                    value={value}
                    placeholder={listening ? listeningLabel : placeholder}
                    aria-label="Message"
                    onChange={(event) => onInput(event.target.value)}
                    onKeyDown={onKeyDown}
                    onFocus={() => setFocused(true)}
                    onBlur={() => setFocused(false)}
                  />
                </div>
                <div className={styles.fold} aria-hidden="true">
                  <div className={styles.strip} data-part="strip">
                    <canvas ref={canvas} className={styles.dots} />
                  </div>
                </div>
              </div>

              <button
                type="button"
                className={styles.action}
                data-part="action"
                data-mode={mode}
                data-flash={flash || undefined}
                aria-label={
                  mode === 'listening' ? 'Stop listening' : mode === 'send' ? 'Send' : 'Talk'
                }
                aria-pressed={mode === 'send' ? undefined : listening}
                onClick={act}
              >
                <svg className={styles.wave} viewBox="0 0 20 24" aria-hidden="true">
                  {[6, 14, 21.5, 10.8, 17, 7.7].map((height, index) => (
                    <rect
                      key={index}
                      x={0.85 + index * 3.35 - 0.8}
                      y={12 - height / 2}
                      width={1.6}
                      height={height}
                      rx={0.8}
                      style={{ transitionDelay: `${index * 18}ms` }}
                    />
                  ))}
                </svg>
                <svg className={styles.arrow} viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M12 20V4.5M5.5 11 12 4.5l6.5 6.5" />
                </svg>
                <span className={styles.touch} data-on={touch || undefined} aria-hidden="true" />
              </button>
            </form>
          </section>

          <span className={styles.home} aria-hidden="true" />
        </div>
      </div>
      <p className={styles.srOnly} aria-live="polite">
        {announce}
      </p>
    </div>
  );
}

function StatusIcons() {
  return (
    <svg className={styles.statusIcons} viewBox="0 0 80 13" aria-hidden="true">
      <g fill="currentColor">
        <rect x="0" y="8" width="3" height="4" rx="0.8" />
        <rect x="4.6" y="5.6" width="3" height="6.4" rx="0.8" />
        <rect x="9.2" y="3.2" width="3" height="8.8" rx="0.8" />
        <rect x="13.8" y="0.8" width="3" height="11.2" rx="0.8" />
        <path d="M29.5 2.6c2.6 0 5 1 6.8 2.7l1.2-1.2A11.5 11.5 0 0 0 29.5 1a11.5 11.5 0 0 0-8 3.1l1.2 1.2c1.8-1.7 4.2-2.7 6.8-2.7Zm0 3.5c1.6 0 3.1.6 4.3 1.7l1.2-1.2a7.8 7.8 0 0 0-11 0l1.2 1.2c1.2-1.1 2.7-1.7 4.3-1.7Zm0 3.5c.7 0 1.3.3 1.8.7l-1.8 1.8-1.8-1.8c.5-.4 1.1-.7 1.8-.7Z" />
        <rect
          x="52"
          y="1.2"
          width="23.5"
          height="11"
          rx="3.4"
          fill="none"
          stroke="currentColor"
          strokeOpacity="0.38"
        />
        <rect x="54" y="3.2" width="19.5" height="7" rx="1.8" />
        <path d="M77 5v3.4c.8-.3 1.3-1 1.3-1.7S77.8 5.3 77 5Z" fillOpacity="0.4" />
      </g>
    </svg>
  );
}
```

### pixel-horse-composer/horse.ts

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

/*
 * The horse: a 22 by 12 sprite on the strip's dot grid, facing right. The body, neck, head and tail
 * are one picture drawn as rows of characters; the legs are drawn per frame, each as two straight
 * segments (hip or shoulder to knee, knee to hoof) rasterised cell by cell, so a gallop is a list
 * of knee and hoof positions rather than eight hand-drawn pictures.
 *
 * GALLOP is the eight frames of the stride, in order: stretched out in the air, the hind hooves
 * landing, gathered under the body (the body a cell lower), pushing off, and stretched again.
 * STAND is the pose it settles into when it stops.
 */

export const HORSE_WIDTH = 22;
export const HORSE_HEIGHT = 12;

/** Body, neck, head and tail; '#' is a lit cell. Row 6 is the belly the legs hang from. */
const BODY = [
  '.................##...',
  '................####..',
  '...............#######',
  '...##############...##',
  '####.############.....',
  '#...#############.....',
  '.....###########......',
];
const BELLY = 6;

/** One leg: the column it hangs from, its knee and its hoof (columns and rows of the sprite). */
type Leg = [hip: number, kneeX: number, kneeY: number, hoofX: number, hoofY: number];
/** A frame: how far the body drops (rows) and the four legs, hind pair first. */
type Pose = { drop: number; legs: [Leg, Leg, Leg, Leg] };

const POSES: Pose[] = [
  {
    drop: 0,
    legs: [
      [6, 4, 8, 1, 10],
      [8, 6, 9, 4, 11],
      [13, 16, 8, 19, 9],
      [14, 17, 9, 20, 10],
    ],
  },
  {
    drop: 0,
    legs: [
      [6, 5, 9, 4, 11],
      [8, 7, 9, 6, 11],
      [13, 15, 9, 17, 11],
      [14, 16, 8, 19, 8],
    ],
  },
  {
    drop: 1,
    legs: [
      [6, 7, 9, 8, 11],
      [8, 9, 9, 10, 11],
      [13, 14, 9, 14, 11],
      [14, 15, 9, 17, 10],
    ],
  },
  {
    drop: 1,
    legs: [
      [6, 8, 9, 10, 11],
      [8, 9, 10, 11, 11],
      [13, 12, 9, 13, 11],
      [14, 16, 9, 14, 10],
    ],
  },
  {
    drop: 1,
    legs: [
      [6, 8, 9, 11, 10],
      [8, 10, 10, 12, 11],
      [13, 12, 9, 11, 11],
      [14, 15, 9, 16, 11],
    ],
  },
  {
    drop: 0,
    legs: [
      [6, 7, 8, 9, 9],
      [8, 8, 9, 10, 11],
      [13, 12, 8, 11, 11],
      [14, 15, 8, 18, 9],
    ],
  },
  {
    drop: 0,
    legs: [
      [6, 5, 9, 5, 11],
      [8, 7, 9, 8, 11],
      [13, 12, 9, 10, 10],
      [14, 16, 8, 19, 9],
    ],
  },
  {
    drop: 0,
    legs: [
      [6, 4, 9, 2, 10],
      [8, 6, 9, 5, 11],
      [13, 14, 9, 16, 10],
      [14, 17, 8, 20, 9],
    ],
  },
];

const STANDING: Pose = {
  drop: 0,
  legs: [
    [6, 6, 9, 6, 11],
    [8, 8, 9, 8, 11],
    [13, 13, 9, 13, 11],
    [15, 15, 9, 15, 11],
  ],
};

/** A frame as the list of its lit cells, [column, row] from the sprite's top left. */
export type Frame = [x: number, y: number][];

/** Lights every cell on the straight line between two cells. */
function line(grid: boolean[][], x0: number, y0: number, x1: number, y1: number) {
  const dx = Math.abs(x1 - x0);
  const dy = -Math.abs(y1 - y0);
  const sx = x0 < x1 ? 1 : -1;
  const sy = y0 < y1 ? 1 : -1;
  let error = dx + dy;
  for (;;) {
    if (y0 >= 0 && y0 < HORSE_HEIGHT && x0 >= 0 && x0 < HORSE_WIDTH) grid[y0][x0] = true;
    if (x0 === x1 && y0 === y1) return;
    const twice = 2 * error;
    if (twice >= dy) {
      error += dy;
      x0 += sx;
    }
    if (twice <= dx) {
      error += dx;
      y0 += sy;
    }
  }
}

function draw({ drop, legs }: Pose): Frame {
  const grid = Array.from({ length: HORSE_HEIGHT }, () => Array<boolean>(HORSE_WIDTH).fill(false));
  BODY.forEach((row, y) => {
    for (let x = 0; x < row.length; x++) if (row[x] === '#') grid[y + drop][x] = true;
  });
  const belly = BELLY + drop;
  for (const [hip, kneeX, kneeY, hoofX, hoofY] of legs) {
    line(grid, hip, belly, kneeX, kneeY);
    line(grid, kneeX, kneeY, hoofX, hoofY);
  }
  const cells: Frame = [];
  grid.forEach((row, y) => row.forEach((lit, x) => lit && cells.push([x, y])));
  return cells;
}

export const GALLOP: Frame[] = POSES.map(draw);
export const STAND: Frame = draw(STANDING);
```

### pixel-horse-composer/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-horse-composer/pixel-horse-composer.module.css

```css
/* Pixel horse composer (pixel-horse-composer.tsx): a silver phone on a quiet stage, its screen the
   bottom of a chat in soft off-white. The message field is a frosted card with a fine rim, a
   bright top edge and a soft shadow; beside it a round slate button. At night the page and the
   card go near-black, the dots dark grey and the horse a pale grey.

   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. The
   strip's dots are drawn on a canvas, in the colours of two tokens read from it (its color and
   its outline-color), so they follow the scheme with the rest.

   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 and
   opacity (the button's bars and arrow, the demo's touch), the card's height through one grid
   row; the horse redraws on the canvas only while it runs or its trail fades. */

.root {
  /* The stage and its edge. */
  --surface: #f2f2f2;
  --line: rgb(0 0 0 / 5%);
  /* The phone: its silver frame (two stops and its edge), the buttons, its drop shadow. */
  --frame-light: #f4f4f5;
  --frame-dark: #c4c5c8;
  --frame-edge: rgb(0 0 0 / 14%);
  --frame-button: #c9cacd;
  --shadow: rgb(20 20 30 / 18%);
  --shadow-near: rgb(20 20 30 / 8%);
  /* The screen's page, its type, the muted placeholder and the bubble of my messages. */
  --page: #f0f0ed;
  --ink: #111111;
  --ink-2: #262626;
  --muted: #959691;
  --bubble: #e3e3df;
  --chrome: #e6e6e2;
  /* The card: its face (the bright top and bottom edges and the middle), rim and shadow. */
  --card: #eeeeea;
  --card-shine: #fbfbf9;
  --card-rim: rgb(0 0 0 / 12%);
  --card-shadow: rgb(0 0 0 / 7%);
  /* The strip: its dots, and the horse. */
  --dot: #dfe0dc;
  --horse: #434951;
  /* The round button: its slate (top, bottom), rim, sheen and glyph, and its flash when pressed. */
  --button-top: #9ba6ad;
  --button-bottom: #a1adb4;
  --button-rim: rgb(40 50 60 / 22%);
  --button-shine: rgb(255 255 255 / 32%);
  --glyph: #1e2226;
  --flash: #fbfdfd;
  --flash-glow: rgb(150 215 232 / 55%);
  /* The same in both schemes: the focus ring, and the demo's touch. */
  --focus: #2f7bff;
  --touch: rgb(30 30 30 / 18%);
  --touch-ring: rgb(30 30 30 / 28%);
  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(#f2f2f2, #0e0e0f);
    --line: light-dark(rgb(0 0 0 / 5%), rgb(255 255 255 / 7%));
    --frame-light: light-dark(#f4f4f5, #4a4a4e);
    --frame-dark: light-dark(#c4c5c8, #1f1f22);
    --frame-edge: light-dark(rgb(0 0 0 / 14%), rgb(255 255 255 / 12%));
    --frame-button: light-dark(#c9cacd, #333336);
    --shadow: light-dark(rgb(20 20 30 / 18%), rgb(0 0 0 / 70%));
    --shadow-near: light-dark(rgb(20 20 30 / 8%), rgb(0 0 0 / 40%));
    --page: light-dark(#f0f0ed, #101011);
    --ink: light-dark(#111111, #f1f1ee);
    --ink-2: light-dark(#262626, #dcdcd8);
    --muted: light-dark(#959691, #77787b);
    --bubble: light-dark(#e3e3df, #26272a);
    --chrome: light-dark(#e6e6e2, #1d1d20);
    --card: light-dark(#eeeeea, #1a1a1c);
    --card-shine: light-dark(#fbfbf9, #2a2a2d);
    --card-rim: light-dark(rgb(0 0 0 / 12%), rgb(255 255 255 / 9%));
    --card-shadow: light-dark(rgb(0 0 0 / 7%), rgb(0 0 0 / 45%));
    --dot: light-dark(#dfe0dc, #2a2b2e);
    --horse: light-dark(#434951, #c3c9d0);
    --button-top: light-dark(#9ba6ad, #6d7882);
    --button-bottom: light-dark(#a1adb4, #5f6973);
    --button-rim: light-dark(rgb(40 50 60 / 22%), rgb(255 255 255 / 12%));
    --button-shine: light-dark(rgb(255 255 255 / 32%), rgb(255 255 255 / 16%));
    --glyph: light-dark(#1e2226, #0d1013);
    --flash: light-dark(#fbfdfd, #e9f4f7);
    --flash-glow: light-dark(rgb(150 215 232 / 55%), rgb(150 215 232 / 35%));
    --touch: light-dark(rgb(30 30 30 / 18%), rgb(255 255 255 / 26%));
    --touch-ring: light-dark(rgb(30 30 30 / 28%), rgb(255 255 255 / 50%));
  }
}
.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;
}
.root :where(svg) {
  display: block;
  overflow: visible;
}
.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The phone: a silver frame, a black bezel and the 390 by 844 screen. */
.phone {
  --pt: min(0.82px, calc((100cqw - 32px) / 414));
  --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
  position: relative;
  flex: none;
  width: calc(var(--pt) * 414);
  height: calc(var(--pt) * 868);
  border-radius: calc(var(--pt) * 64);
  background: linear-gradient(
    150deg,
    var(--frame-light),
    var(--frame-dark) 45%,
    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) * 52);
  background: var(--page);
  box-shadow: 0 0 0 calc(var(--pt) * 8) #050505;
  color: var(--ink);
  font-family: var(--sans);
}

/* The status bar. */
.status {
  position: absolute;
  inset: 0 0 auto;
  z-index: 1;
  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 var(--sans);
  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) * 80);
  height: calc(var(--pt) * 13);
}

/* The app: a header, the conversation, and the composer at the bottom. */
.app {
  position: absolute;
  inset: calc(var(--pt) * 54) 0 0;
  display: flex;
  flex-direction: column;
}
.header {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  height: calc(var(--pt) * 52);
  padding: 0 calc(var(--pt) * 16);
}
.title {
  font-size: calc(var(--pt) * 17);
  font-weight: 600;
  letter-spacing: calc(var(--pt) * -0.3);
}
.round {
  display: grid;
  place-items: center;
  width: calc(var(--pt) * 40);
  height: calc(var(--pt) * 40);
  border-radius: 50%;
  background: var(--chrome);
}
.round svg {
  width: calc(var(--pt) * 20);
  height: calc(var(--pt) * 20);
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}

.thread {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-height: 0;
  padding: calc(var(--pt) * 12) calc(var(--pt) * 25) calc(var(--pt) * 41.7);
  overflow-y: auto;
  scrollbar-width: none;
}
.thread::before {
  flex: 1;
  content: '';
}
.mine {
  align-self: flex-end;
  max-width: 78%;
  margin: 0 calc(var(--pt) * -8) calc(var(--pt) * 26) 0;
  padding: calc(var(--pt) * 10) calc(var(--pt) * 15);
  border-radius: calc(var(--pt) * 21);
  background: var(--bubble);
  color: var(--ink);
  font-size: calc(var(--pt) * 17);
  line-height: calc(var(--pt) * 22);
  letter-spacing: calc(var(--pt) * -0.25);
  overflow-wrap: anywhere;
  animation: arrive 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.mine:not(:last-child) {
  animation: none;
}
@keyframes arrive {
  from {
    opacity: 0;
    transform: translateY(calc(var(--pt) * 24)) scale(0.96);
  }
}
.reply {
  color: var(--ink-2);
  font-size: calc(var(--pt) * 18.5);
  line-height: calc(var(--pt) * 26);
  letter-spacing: calc(var(--pt) * -0.3);
}
.reply + .mine {
  margin-top: calc(var(--pt) * 26);
}

/* The composer: the card and, beside it, the round button, both on the same bottom line. */
.composer {
  display: flex;
  flex: none;
  align-items: flex-end;
  gap: calc(var(--pt) * 9);
  margin: 0;
  padding: 0 calc(var(--pt) * 17) calc(var(--pt) * 38.6);
}
.card {
  flex: 1;
  min-width: 0;
  padding-bottom: calc(var(--pt) * 17.2);
  border-radius: calc(var(--pt) * 30);
  background: linear-gradient(
    to bottom,
    var(--card-shine),
    var(--card) calc(var(--pt) * 6),
    var(--card) calc(100% - calc(var(--pt) * 4)),
    var(--card-shine)
  );
  box-shadow:
    0 0 0 calc(var(--pt) * 0.75) var(--card-rim),
    0 calc(var(--pt) * 3) calc(var(--pt) * 12) var(--card-shadow),
    0 calc(var(--pt) * 1) calc(var(--pt) * 2) var(--card-shadow);
  -webkit-backdrop-filter: blur(16px) saturate(1.2);
  backdrop-filter: blur(16px) saturate(1.2);
  transition: padding-bottom 0.34s cubic-bezier(0.3, 0.7, 0.2, 1);
}
.card[data-strip] {
  padding-bottom: calc(var(--pt) * 13.7);
}
.line {
  display: flex;
  align-items: flex-end;
  padding: calc(var(--pt) * 16.8) calc(var(--pt) * 18) 0 0;
}
.attach {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: calc(var(--pt) * 44);
  height: calc(var(--pt) * 22);
  margin: 0 calc(var(--pt) * 3.9) 0 calc(var(--pt) * 5.6);
  border-radius: calc(var(--pt) * 11);
  color: var(--ink);
  transition: transform 0.15s ease;
}
.attach::before {
  position: absolute;
  inset: calc(var(--pt) * -11) 0;
  content: '';
}
.attach:active {
  transform: scale(0.86);
}
.attach svg {
  width: calc(var(--pt) * 16);
  height: calc(var(--pt) * 16);
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}
.field {
  display: block;
  flex: 1;
  min-width: 0;
  height: calc(var(--pt) * 22);
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  outline: none;
  background: transparent;
  color: var(--ink);
  caret-color: var(--ink);
  position: relative;
  top: calc(var(--pt) * -1.5);
  font: 400 calc(var(--pt) * 16.5) / calc(var(--pt) * 22) var(--sans);
  letter-spacing: calc(var(--pt) * -0.25);
  resize: none;
  scrollbar-width: none;
}
.field::placeholder {
  color: var(--muted);
  opacity: 1;
}

/* The strip folds away with its gap: a grid row from 1fr to 0fr. */
.fold {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows 0.34s cubic-bezier(0.3, 0.7, 0.2, 1),
    opacity 0.2s ease;
}
.card[data-strip] .fold {
  grid-template-rows: 1fr;
  opacity: 1;
  transition:
    grid-template-rows 0.34s cubic-bezier(0.3, 0.7, 0.2, 1),
    opacity 0.25s ease 0.08s;
}
.strip {
  min-height: 0;
  overflow: hidden;
}
/* Its gap is the canvas's margin, inside the row, so it folds away too. */
.dots {
  display: block;
  width: calc(100% - calc(var(--pt) * 37.6));
  height: calc(var(--pt) * 34);
  margin: calc(var(--pt) * 17.7) calc(var(--pt) * 18.8) 0;
  color: var(--horse);
  outline: 0 none var(--dot);
  /* Not to animate: their end tells the script the scheme changed (see the component). */
  transition:
    color 1ms,
    outline-color 1ms;
}

/* The round button: voice (six bars) or send (an arrow). */
.action {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: calc(var(--pt) * 56);
  height: calc(var(--pt) * 56);
  border-radius: 50%;
  background: linear-gradient(to bottom, var(--button-top), var(--button-bottom));
  box-shadow:
    inset 0 0 0 calc(var(--pt) * 0.9) var(--button-rim),
    inset 0 calc(var(--pt) * -1.2) calc(var(--pt) * 1) var(--button-shine),
    0 calc(var(--pt) * 2) calc(var(--pt) * 6) var(--card-shadow);
  color: var(--glyph);
  transition:
    background-color 0.3s ease,
    box-shadow 0.3s ease,
    transform 0.2s cubic-bezier(0.3, 0.7, 0.2, 1);
}
.action::before {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--flash);
  box-shadow: 0 0 calc(var(--pt) * 14) calc(var(--pt) * 2) var(--flash-glow);
  opacity: 0;
  transition: opacity 0.35s ease;
  content: '';
}
.action[data-flash] {
  transform: scale(0.95);
}
.action[data-flash]::before {
  opacity: 1;
  transition-duration: 0.06s;
}
.action:active {
  transform: scale(0.94);
}
.wave,
.arrow {
  position: relative;
  grid-area: 1 / 1;
}
.wave {
  width: calc(var(--pt) * 20);
  height: calc(var(--pt) * 24);
  fill: currentColor;
}
.wave rect {
  transform-box: fill-box;
  transform-origin: center;
  transition:
    transform 0.26s cubic-bezier(0.3, 0.7, 0.2, 1),
    opacity 0.2s ease;
}
.arrow {
  width: calc(var(--pt) * 24);
  height: calc(var(--pt) * 24);
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  transform: translateY(calc(var(--pt) * 6)) scale(0.6);
  transition:
    transform 0.3s cubic-bezier(0.3, 0.7, 0.2, 1),
    opacity 0.2s ease;
}
.action[data-mode='send'] .wave rect {
  opacity: 0;
  transform: scaleY(0.08);
}
.action[data-mode='send'] .arrow {
  opacity: 1;
  transform: none;
  transition-delay: 0.12s;
}

/* The demo's touch: a soft disc with a ring, hidden from screen readers. */
.touch {
  position: absolute;
  inset: calc(var(--pt) * 6);
  border-radius: 50%;
  background: var(--touch);
  box-shadow: 0 0 0 calc(var(--pt) * 1.5) var(--touch-ring);
  opacity: 0;
  transform: scale(1.4);
  transition:
    opacity 0.2s ease,
    transform 0.2s ease;
  pointer-events: none;
}
.touch[data-on] {
  opacity: 1;
  transform: scale(1);
}

.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);
  pointer-events: none;
}

/* Focus. */
.round:focus-visible,
.attach:focus-visible,
.action:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.card:has(.field:focus-visible) {
  box-shadow:
    0 0 0 2px var(--focus),
    0 calc(var(--pt) * 3) calc(var(--pt) * 12) var(--card-shadow);
}

/* Reduced motion: no transitions; the horse stands where it is. */
.root[data-reduced] :not(.dots),
.root[data-reduced] *::before {
  transition: none !important;
  animation: none !important;
}
```