PokémonCn

Dialogue Box

Preview

A wild PIKACHU appeared!

PROF. ELM: I have a Pokémon for you!

Surge surge surge — pulled out a leaf.

Looker: I sense a strong presence here.

Team Rocket boss: We meet again.

It's super effective! HP dropped sharply.

frame=0

frame=1

frame=2

frame=3

frame=4

frame=5

frame=6

frame=7

frame=8

frame=9

frame=10

frame=11

frame=12

frame=13

frame=14

frame=15

frame=16

frame=17

frame=18

frame=19

Installation

$pnpm dlx shadcn@latest add https://pokemoncn.dev/r/dialogue-box.json

Usage

<DialogueBox frame={0} cursor>
  <p className="font-pixel text-base text-foreground">
    A wild PIKACHU appeared!
  </p>
</DialogueBox>

Component code

Same source `shadcn add` drops into your project. Multi-file components ship every file separately — auto-generated data files are collapsed by default.

ui/dialogue-box.tsx132 lines · 4.9 KB
import * as React from "react"

import { cn } from "@/lib/utils"

/**
 * DialogueBox — the HG/SS speech window.
 *
 * The 20 frame styles the player can choose under Options → Frame, sampled
 * directly from `pret/pokeheartgold:files/a/0/3/8` — NCGRs at members 2..21
 * paired with NCLRs at 26..45, decoded against the corresponding palette.
 * Each frame contributes three colours (background, border, accent) and the
 * component re-synthesizes the chassis as a single CSS box: 2px border, a
 * 1px accent stripe just inside it, body fill, slight pixel-rounded corners.
 *
 * Same pattern as `<AreaBanner>` and `<HpBar>` — the chrome is ours, the
 * children are yours: drop in any pixel-font text, link, list, anything.
 * The blinking ▼ continue cursor is opt-in via the `cursor` prop.
 */

// 20 frame styles. Each entry: [background, border, accent]. Indices match
// `Options_GetFrame()` so frame={0} is the default cream-and-grey speech box.
const FRAMES = [
  ["#F8F8F8", "#484040", "#606068"], // 0  — default cream / grey
  ["#F8F8F8", "#484040", "#606068"], // 1  — same, alt accent
  ["#F8F8F8", "#484040", "#F84868"], // 2  — pink accent
  ["#F8F8F8", "#484040", "#30C0F0"], // 3  — sky-blue accent
  ["#F8F8F8", "#484040", "#90E068"], // 4  — green accent
  ["#F8F8F8", "#484040", "#F89830"], // 5  — orange accent
  ["#F8F8F8", "#484040", "#B080D8"], // 6  — purple accent
  ["#F8B808", "#F8E860", "#F8C800"], // 7  — gold/yellow chassis
  ["#B8C8C8", "#E0E0E8", "#D0D8D8"], // 8  — pale stone
  ["#404050", "#282838", "#9898A8"], // 9  — dark slate
  ["#2088A0", "#282838", "#106070"], // 10 — deep teal
  ["#E84068", "#282838", "#B82848"], // 11 — crimson
  ["#388898", "#182858", "#F8F8F8"], // 12 — sea
  ["#C8C890", "#382820", "#C8C8E8"], // 13 — sand
  ["#F8F8F8", "#507880", "#C8F8F8"], // 14 — ice
  ["#F88000", "#484040", "#F8F8F8"], // 15 — orange chassis
  ["#F8F8F8", "#986880", "#C8B8F0"], // 16 — mauve
  ["#E0F8F8", "#2878B0", "#A8D8F0"], // 17 — pale sea
  ["#F8F8F8", "#602800", "#884010"], // 18 — wood
  ["#B0D8E8", "#70B8D8", "#281860"], // 19 — twilight
] as const

type FrameId =
  | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
  | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19

interface DialogueBoxProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Which of the 20 in-game speech frames to render. Default 0 (cream). */
  frame?: FrameId
  /** Show the blinking ▼ "press A to continue" cursor in the bottom-right. */
  cursor?: boolean
}

/**
 * Relative luminance of a `#RRGGBB` colour, sRGB-ish. Used to pick an ink
 * colour that always reads against the frame's background — so children
 * inherit a sensible default without having to know which frame is in play.
 */
function isDarkBg(hex: string): boolean {
  const r = parseInt(hex.slice(1, 3), 16) / 255
  const g = parseInt(hex.slice(3, 5), 16) / 255
  const b = parseInt(hex.slice(5, 7), 16) / 255
  // Quick perceptual luminance — accurate enough to pick text colour.
  return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.55
}

function DialogueBox({
  className,
  frame = 0,
  cursor = false,
  children,
  style,
  ...props
}: DialogueBoxProps) {
  const [bg, border, accent] = FRAMES[frame]
  // Auto-contrast ink so default-styled children stay legible on light
  // *and* dark frames. Consumer can still override with an explicit text
  // colour on the children — `currentColor` propagates as usual.
  const ink = isDarkBg(bg) ? "#FFFFFF" : "#1F1B2E"
  return (
    <div
      data-slot="dialogue-box"
      data-frame={frame}
      className={cn(
        // Sprite-era chassis. Hard offset shadow + crisp corner radius keep
        // the box reading as pixel art even when the consumer scales the
        // type or plops it on a flat background.
        "relative inline-block max-w-full rounded-[4px] px-5 py-4",
        className,
      )}
      style={{
        background: bg,
        color: ink,
        border: `2px solid ${border}`,
        // 1px accent stripe just inside the border, then a 1px bg buffer to
        // the content. Mirrors the inner outline drawn by the game's
        // user-frame tiles (LoadUserFrameGfx2 + matching NCLR).
        boxShadow: `inset 0 0 0 1px ${accent}, 3px 3px 0 0 ${border}`,
        ...style,
      }}
      {...props}
    >
      {children}
      {cursor ? <ContinueCursor color={ink} /> : null}
    </div>
  )
}

/** Pixel-art ▼ that ticks at the canonical Pokémon dialog cadence. */
function ContinueCursor({ color }: { color: string }) {
  return (
    <svg
      width="10"
      height="6"
      viewBox="0 0 10 6"
      fill={color}
      shapeRendering="crispEdges"
      aria-hidden="true"
      className="pointer-events-none absolute bottom-2.5 right-3 [animation:var(--animate-dialog-blink,_dialog-blink_1.1s_step-end_infinite)]"
    >
      <rect x="0" y="0" width="10" height="2" />
      <rect x="2" y="2" width="6" height="2" />
      <rect x="4" y="4" width="2" height="2" />
    </svg>
  )
}

export { DialogueBox, FRAMES as DIALOGUE_FRAMES }
export type { DialogueBoxProps, FrameId }