Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Moon dragon

New

A night scene in code that tells a looping story: a dragon rises from the water to meet a swordsman under a full moon.

moon-dragon/moon-dragon.tsx
Live · interactive

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

  • A hero or splash screen that tells a short wordless story before any text: a game, a book, a launch page.
  • An empty or waiting screen with room to breathe, where a slow loop keeps the page alive without asking for anything.
  • A themed moment the reader can play with: tap the moon, step through the chapters, lean the dragon with the pointer.
  • A scene that has to stay sharp at any size: every piece is vector, scaled by the stage’s width.

When not to use

  • Behind text that has to be read: the moon, the dragon and the petals fight the words. Use a flat or blurred backdrop.
  • A small thumbnail or an icon: under about 280 px the swordsman and the chapter bar turn to specks. Use a still illustration.
  • A dense working screen (a dashboard, a form): the story pulls the eye away. Use a static header image.
  • Several scenes on one screen at once: each runs its own story clock. Show one live, the rest as autoplay={false} tableaux.
  • Telling the reader something happened in the app: use a toast or an inline message; the beats are the story’s, not the app’s.

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

Into components/moon-dragon of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/moon-dragon.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

night-hero.tsx
import { MoonDragon } from './moon-dragon';export function NightHero() {  return (    <MoonDragon      speed={0.8}      startAt="moon"      onBeat={(beat) => console.log(beat)}    />  );}

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.

A full moon on still water, a swordsman in a straw hat kneeling in a crescent boat. The moon swells and glows; a dark shape glides under the water towards the boat; a black dragon breaks the surface and rises, water streaming off its long body, to hang its head over the moon and look at him. He stands, bows and holds out his sword, the violet petals turn white in a wave, and the dragon bows its head in answer before sinking back. Tap the moon or press Summon to call it, or step through the chapters. All of it is vector, drawn in SVG and CSS.

  • The story is a set of pure functions of one clock, in seconds from the start of a 39 s loop: Moon (0–4.5), Approach (4.5–11), Rise (11–18.5), Bow (18.5–23.5), Blossom (23.5–30.5) and Return (30.5–39). Every frame can be drawn from the time alone, so a chapter button jumps straight to its beat, and the loop’s last frame is its first. A second, ambient clock keeps the living motion going (the body’s waves, the breath, the moon’s pulse) while the story holds.
  • The dragon is skinned on a spine that follows a path. Every point of its outline (the drawn crest, shoulder and foreleg, then a long body by rule, its spikes and girth thinning to the tail) is stored once as a distance along the spine and an offset from it. The head travels the path: under the water beneath the boat, up through the surface by the bow, over to the drawing’s own neck line, and on down into the water; each point of the body sits on the path behind it, with two waves running down it, so it swims, rises and settles as one serpent.
  • While it rises the top of its path is pressed down and drawn in from the right edge, so the head stays in the picture; as it settles the body lifts into the drawing’s pose. The head is a rigid drawing on the spine’s first point: it leads the rise snout first, turns to the swordsman, nods in answer, and leans towards the pointer.
  • Under the water the same body and head are drawn blurred, dark and foreshortened, clipped below the surface; above it they are clipped at the surface. Where the body crosses the water line, rings spread; before it breaks through, the water swells; then come a splash, streams falling off the body and bubbles over the gliding head. These marks are drawn from the clock with fixed random numbers, so they scrub like the rest.
  • The swordsman is a jointed rig: hips carrying a robe down to the deck, a torso that leans about the hips, a head and hat that tilt about the neck, two two-bone arms solved each frame (inverse kinematics) so the hands land where the sword wants them, and the sword, placed between an upright salute, level before the chest, and level above the head.
  • On Blossom each petal’s white copy and a soft glow fade in over the violet, delayed by its distance from the dragon’s head, so a wave of white spreads through the air; the petals’ CSS animations slow to half speed through their playback rate, without a jump.
  • Everything else moves on the compositor, by transform and opacity: the clouds drift, the reflection shimmers, the boat bobs, the lantern flickers, the petals fall, sway and tumble; the moon’s swell, its halo, the rings leaving it and the light on the water are set each frame by transform and opacity.
  • The pointer slides each layer by its depth (parallax) and eases the risen dragon’s head towards it. Offscreen or in a hidden tab everything pauses. A chapter plays its beat and holds at its end; after ten seconds without input the story plays on, if it autoplays.
PropTypeDefaultDescription
autoplaybooleantrueThe story plays on its own while on screen. Off, it holds the startAt beat’s tableau (Rise by default) until the reader presses play, Summon or a chapter.
speednumber1How fast everything moves: 1 is the story’s own pace (a 39 s loop), 0.5 half as fast.
startAt'moon' | 'approach' | 'rise' | 'bow' | 'blossom'—The beat it opens on: playing, from the beat’s start; held, on its tableau. Without it the story starts at Moon (or, held, shows Rise).
onBeat(beat: 'moon' | 'approach' | 'rise' | 'bow' | 'blossom' | 'return') => void—Called with each beat as the story reaches it, or as the reader jumps to it.
loopbooleantrueStarts over after the dragon sinks back. Off, it plays once and holds on the Blossom tableau, still alive.
petalsnumber26How many petals fall, up to 80. The drawing has 26; more are scattered the same way, with a fixed seed.
palette'violet' | 'ember' | 'tide''violet'The night’s colours: violet, a warm ember dusk, or a sea-green tide.
followPointerbooleantrueThe layers shift a little with the pointer, and the risen dragon’s head follows it.
labelstring—What the picture shows, for screen readers.
colorScheme'light' | 'dark' | 'system'—The frame’s and the buttons’ colours: light, dark, or the reader’s system setting. The picture itself is always a night. Without it the component follows the page’s color-scheme.
classNamestring—Extra class for the root.

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

moon-dragon.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,} from 'react';import {  BACK_CLOUDS,  CRATERS,  DEPTH,  EYE,  EYE_CENTRE,  FRONT_LEFT,  FRONT_RIGHT,  HEAD,  HORIZON,  HULL,
story.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.import {  MARKS,  SNOUT,  SURFACE,  headPoint,  pose,  seen,  smooth,  spinePoint,  type DragonState,  type Point,} from './scene';import type { Stance } from './rig';/* * The story, as pure functions of its clock (seconds from the start of the loop): every frame of * it can be drawn from the time alone, so the reader can jump between beats and the loop closes * on itself (the last frame is the first). Slow, living motion that never stops (the waves, the * breath, the moon's pulse) runs on a second, ambient clock instead. */export type Beat = 'moon' | 'approach' | 'rise' | 'bow' | 'blossom' | 'return';
scene.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The scene's drawing, in the units of a 1600 × 1200 canvas, and the dragon's motion. * * The dragon is two silhouettes. The head (with its antlers) is rigid; the body, from the neck to * past the right edge, is skinned on a spine: every point of its outline is stored once as a * distance along the spine and an offset from it, and each frame the spine bends (waves travel * down it from the head to the tail) and the outline is rebuilt around the bent spine. The head * rides the spine's first point and turns with it, so the neck never breaks. */export type Point = { x: number; y: number };const parse = (points: string): Point[] =>  points    .trim()    .split(/\s+/)    .map((pair) => {      const [x, y] = pair.split(',').map(Number);      return { x, y };    });/** The body's outline, crest spikes, shoulder and foreleg included, at rest. */
rig.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { Point } from './scene';/* * The swordsman as a jointed rig, in the canvas's units, facing right. Hips carry a robe that * reaches the deck; the torso turns about the hips, the head (with its hat) about the neck; each * arm is two bones, solved each frame so the hands land where the sword wants them (two-bone * inverse kinematics); the sword is placed between two holds: raised upright in salute, or lying * level on both palms, before the chest or raised above the head to the dragon. * * Kneeling, the figure is the drawing's own: hips at (806, 712), its hat's tip at y 645, the sword * upright at x 851. */export type Stance = {  /** 0 kneeling, 1 standing. */  rise: number;  /** The torso's lean forward, in degrees. */  lean: number;  /** The head's tilt from the torso, in degrees: positive bows, negative looks up. */  nod: number;  /** 0 the sword raised upright, 1 held level on both palms. */  offer: number;
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. */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(
moon-dragon.module.css
/* Moon dragon (moon-dragon.tsx): a 4:3 night scene in layers. Every layer is the same canvas   (1600 × 1200 units, drawn 6% larger so it can slide), and --u is one canvas unit in px of the   stage (a container query unit), so HTML pieces, motion and blur all scale with the stage.   The scene's colours are the palette's (data-palette); they are the same on light and dark   pages, since the picture is a night. Only the frame and the pause button follow the colour   scheme: light-dark() tokens, from the page's color-scheme or the colorScheme prop.   The story's moving parts are set by script each frame, by transform and opacity where they   can be (the moon, its halo and rings, the light on the water, the sparks); the dragon, the   water's marks and the swordsman's rig are small SVG attributes.   Memory. The stage is a layout boundary sized from outside; each layer is its own compositor   layer, moved by transform and opacity only. Only the dragon's SVG repaints while it plays. */.root {  /* The palette: violet, the default. */  --sky: #210f38;  --halo-1: #5a4b69;  --halo-2: #3f2e52;  --halo-3: #36224a;  --halo-4: #2c1843;  --pulse: rgb(206 186 236 / 0.22);  --moon: #fbf2e1;
  • The scene is one image (role="img") with a label describing it; every drawn piece inside is hidden from assistive technology.
  • Under the picture sit real buttons: play or pause (aria-pressed, labelled Play the story or Pause the story), a toolbar of five chapter buttons with the current one marked aria-current="step", and Summon. All have a visible focus ring.
  • In the chapter toolbar one button takes Tab; the arrow keys, Home and End move between chapters and jump to them. Tapping the moon does what Summon does; it is a pointer shortcut, so it is left out of the tab order and the accessibility tree.
  • When the reader jumps to a chapter or summons the dragon, a polite live region reads out what the beat shows; the autoplaying story does not announce itself.
  • Under prefers-reduced-motion nothing moves: no animations, transitions, parallax or story clock. It opens on the Blossom tableau, with the petals white, and the chapter buttons step through each beat’s tableau; the play button is not shown.
  • Any real input (a chapter, Summon, play or pause) hands control to the reader; the story pauses whenever it is offscreen or the tab is hidden.