Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Pixel horse composer

New

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

pixel-horse-composer/pixel-horse-composer.tsx
Live · interactive

Riding log

Did my watch log the ride to the lake?

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.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/pixel-horse-composer of any React project, with no setup:

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

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

ride-chat.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}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
placeholderstring'Ask anything'The field’s placeholder when it is not listening.
listeningLabelstring'Listening'The placeholder while it listens.
defaultValuestring''Text in the field to begin with. Setting it turns the demo off.
defaultListeningboolean—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.
demoTextstring—The sentence the demo dictates.
onSend(text: string) => void—Called with the text when a message is sent.
autoplaybooleantrueWhile 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.
classNamestring—A class for the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

pixel-horse-composer.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
horse.ts
// 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 = [  '.................##...',  '................####..',  '...............#######',  '...##############...##',  '####.############.....',  '#...#############.....',
use-autoplay.ts
// 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);
pixel-horse-composer.module.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;
  • 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.