Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Dialog box

New

A game’s dialog box: a dark speech bubble with a name tab, typewriter text with teal and red keywords, and a bouncing arrow to continue.

dialog-box/dialog-box.tsx
Live · interactive

Odessa: If you bring me three river pearls, I’ll mend that old lantern for you. Leave them by the forge.

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 character talking to the player one line at a time, with their name on the box and the player setting the pace.
  • Story beats the player must read before playing on: a press shows the rest of the line, the next press continues.
  • Words that carry something to remember, such as an item, a name or a place, marked in teal or red inside the line.
  • A found note or journal page, a narrator over the scene, or the world itself speaking (“Lookout Tower activated.”), in the same box with a different shape.
  • Tutorial and quest text in a game, where a typing line and a bouncing arrow are what players expect.

When not to use

  • A question with answers to pick. Put a choice list next to the box after the line.
  • A short notice that should not stop play, such as an item picked up. Use a game toast.
  • A confirmation that blocks the game until answered, such as quitting without saving. Use a game modal.
  • Help or long text the player reads at their own speed. Use a page or a quest log; typing slows reading down.
  • Chat or messages in a product UI. Use a message list; the typewriter and game styling do not belong there.

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

Into components/dialog-box of any React project, with no setup:

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

talk.tsx
import { DialogBox, type DialogLine } from './dialog-box';const script: DialogLine[] = [  { speaker: 'Odessa', text: 'Bring me three {river pearls} and meet me by the [forge].' },  { variant: 'journal', speaker: 'Field Journal', text: 'The key is under the third stone. {-T}' },  { variant: 'narration', text: 'The wind turns, and the bells go quiet.' },  { variant: 'system', text: 'Lookout Tower activated.' },];export function Talk({ onDone }: { onDone: () => void }) {  return <DialogBox script={script} speed={40} loop={false} autoplay={false} onEnd={onDone} />;}

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.

Where a game talks to the player. A line types out letter by letter in a rounded dark bubble, the speaker’s name riding its top edge, with things to remember in teal and warnings in red. A press lands the rest of the line at once; once it is out, a bouncing arrow says the next press continues. The same box takes four shapes: speech, a square journal page, narration with no box at all, and a system message that lights blue circuits and glows (“Lookout Tower activated.”). It keeps its own dark plate, so it reads on a light page and a dark one.

  • Every size is a multiple of one design unit, --u, set from the component’s width with a container query unit (between 0.78 px and 1.25 px, or --dialog-unit when set), so the box keeps the kit’s proportions: a 481 × 97 pill with a 48.5-unit radius (4-unit corners for a journal), 18-unit bold italic type on 22.7-unit lines inset 80 units from the ends, a 14-unit italic name crossing the top edge 41.5 units in, and a 20 × 12 arrow half over the bottom edge.
  • A line is plain text with {braces} for teal keywords and [brackets] for red ones. It is split into letters once, and every letter is laid out from the start, invisible until its turn, so words never jump to the next line as they arrive. A timer reveals one letter at a time at `speed` letters per second; after a comma it holds five beats, after the end of a sentence nine (an ellipsis only at its last dot). Each letter fades in over 70 ms, and a keyword glows once when its last letter lands.
  • A press while typing shows the whole line; a press when it is out continues. If the next line has the same kind and speaker, only the words fade (130 ms) and the next line types in the same box; otherwise the box shrinks away (190 ms) and the new one pops in, keyed afresh, with a 280 ms overshoot from 0.94 to 1.015, its name sliding in 90 ms later. After the last line the box closes, and a press opens it again on the first.
  • The continue arrow is an SVG: an outlined triangle round a small solid one. It pops in with an overshoot when the line is out, bobs 3 units down and back every second, and dips away on the press.
  • Speech lines have thin arcs inside both ends and a journal has straight marks, each split by a small tick. A system line draws circuit traces into both ends with stroke-dashoffset (staggered over half a second), pops its nodes in and flashes them light blue, and gives its words a blue glow that swells in 700 ms and then breathes.
  • Narration has no box: a soft dark radial halo and a dark glow round each letter keep it readable on any page. On a light page the speaker’s name also gets a small dark tab, so the part that hangs over the page stays legible; on a dark page it is the kit’s bare name with a shadow.
  • Uncontrolled, a demo plays the script while it is on screen and untouched: it reads each finished line for a moment (one second plus 22 ms a letter) and continues, skips once halfway through the second line, and opens the box again after it closes. Any real press hands it to the reader for a few seconds. Typing waits while the box is off screen or the tab hidden.
PropTypeDefaultDescription
scriptDialogLine[]an invented exchangeThe lines, in order: { variant?, speaker?, text }. variant is speech (default), journal, narration or system; speaker is the name or the journal’s title on the tab; in text, {braces} mark a teal keyword and [brackets] a red one, and line breaks are kept.
speednumber38Letters per second while typing (system lines go 1.5 times faster). Commas and sentence ends add their own pauses.
loopbooleantrueAfter the last line closes the box, a press opens it again on the first line.
onLineChange(index: number) => void—Called when a line starts typing, with its index in the script.
onEnd() => void—Called when the box closes after the last line.
defaultLinenumber0Opens on this line of the script instead of the first, with the demo off.
defaultState'typing' | 'done' | 'closed'—Opens still in this state, with the demo off: typing holds part of the line out until a press, done has the whole line out with the arrow bobbing, closed has the box closed after the end.
defaultTypednumber0.5With defaultState="typing": the share of the line already out, from 0 to 1.
autoplaybooleantrueWhile on screen and untouched, plays the script on its own: reads, continues, skips once, starts again. Any press stops it for a while.
colorScheme'light' | 'dark' | 'system'—Light or dark, or the reader’s; without it, the page’s color-scheme. The box keeps its colours on both; focus, key chips, the name tab and the narration halo follow it. Set --dialog-unit on the root to fix the size (1px is the kit’s 481 × 97).
classNamestring—Added to the root.

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

dialog-box.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  Fragment,  useEffect,  useEffectEvent,  useMemo,  useRef,  useState,  type KeyboardEvent as ReactKeyboardEvent,  type ReactNode,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './dialog-box.module.css';/* * Dialog box: where a game talks to the player. A line of a script types out in a dark box; a * press lands the rest of the line at once, and once the line is out a bouncing arrow says the * next press continues. * * - Four kinds of line, told apart by shape: speech (a rounded pill with thin side brackets and *   the speaker's name on a tab riding its top edge), journal (a square-cornered plate with
dialog-box.module.css
/* Dialog box (dialog-box.tsx): one line of a game's script in its box, alone on the page's stage,   with the keys under it in small chips.   Every size is a multiple of --u, one design unit (1 px at the kit's size): the root sets it from   its own width (a container query unit), between 0.78 px and 1.25 px; --dialog-unit fixes it. At   the kit's size the box is 481 × 97: a pill (radius 48.5) for speech and system lines, a plate   with 4-unit corners for a journal, 18-unit bold italic type on 22.7-unit lines, a 14-unit name   tab riding the top edge and a 20 × 12 continue arrow half over the bottom edge.   Colours are the tokens below. The box keeps the kit's colours on any page, as a game's does: a   dark plate, warm light type, teal and red keywords, a blue glow for system lines; narration,   which has no box, brings a soft dark halo, and on a light page the name gets a small dark tab.   Only what sits straight on the page (focus, the key chips, a faint edge round the plate on a   dark page) follows the scheme.   Motion: the box pops in with an overshoot and its name slides in after; letters fade in as they   are typed; a finished keyword brightens once; the arrow pops in and bobs; leaving, the words fade   and the box shrinks away. A system box draws its circuit traces in (stroke-dashoffset), lights   its nodes, and its words' glow swells and breathes. */.root {  /* The box. */  --plate: #33322e;  --ink: #e9e1d1;
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` alone says whether it is on screen * in a visible tab, for clocks that should stop when nobody can see them. */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);
  • The box is one real button, named by what a press does now: “Skip” while the line types, “Continue” once it is out, “Open the dialog again” after the end. Space and Enter press it, and so does the A key while it has focus; it shows a :focus-visible ring.
  • The drawn box is aria-hidden; a polite live region reads each whole line once when it starts, with its speaker (“Odessa: If you bring me…”), never letter by letter, and says when the dialog closes.
  • Keywords are marked by colour, as in the kit, and add no meaning a screen reader would miss: the live region reads the line as plain text.
  • Reduced motion: whole lines appear at once, with no pop, bob, glow swell, circuit draw or letter fades. The demo never plays.