Display

Hexdump Viewer

A windowed hex dump whose offset gutter, hex pane and ASCII pane share one selection, with 8/16/32 bytes per row and copy of the selected range as hex or text.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Binary, Check, Copy, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long a copy report or a refusal stays on screen before it clears itself. */
const MESSAGE_MS = 4000
/** How long a copy button keeps its check mark. */
const COPIED_MS = 2000
/** Rows kept mounted above and below the window, so a fast flick never paints blank. */
const OVERSCAN = 4
/** Shared empty buffer for the branches that have nothing to dump. Never mutated. */
const NO_BYTES = new Uint8Array(0)

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/hexdump-viewer.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "HexdumpViewer" component. No new npm
dependencies — plain React state plus four lucide-react icons (Binary, Copy,
Check, TriangleAlert).

Contract
- Export a forwardRef<HTMLDivElement> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "onCopy">; spread the rest onto
  the root and merge className through cn().
- Types: HexdumpBytesPerRow = 8 | 16 | 32; HexdumpCopyFormat = "hex" | "raw";
  HexdumpRange = { start: number; end: number }, both ends INCLUSIVE, absolute
  indices into data.
- Props: data: Uint8Array | string (a string is base64), baseOffset = 0,
  bytesPerRow?: HexdumpBytesPerRow (controlled), defaultBytesPerRow = 16,
  onBytesPerRowChange?, groupSize = 8, visibleRows = 12, rowHeight = 22,
  defaultSelection?: HexdumpRange, onSelectionChange?: (range) => void,
  onCopy?: ({ format, range, text }) => void, showAscii = true,
  showControls = true, hexCase: "upper" | "lower" = "upper",
  nonPrintableChar = "middle dot", label = "Hex dump", emptyText, errorText.
- Selection is uncontrolled: defaultSelection seeds it and onSelectionChange
  reports every accepted change. Bytes per row is the only controlled axis.

Behavior
- Decoding. typeof data === "string" means base64: decode it by hand rather
  than calling atob, which throws, is missing on some server runtimes and
  rejects the URL-safe alphabet. Strip whitespace, map - and _ onto + and /,
  drop trailing =, refuse when length % 4 === 1 (one leftover character cannot
  complete a byte), and refuse on any character outside the alphabet. A refusal
  returns null and renders errorText as a stated panel — never a partial dump
  of whatever happened to decode.
- Geometry. totalRows = ceil(byteLength / bytesPerRow);
  maxTopRow = max(0, totalRows - visibleRows); the topRow state is clamped to
  that on every render; the built window is [topRow - 4, topRow + visibleRows +
  4) clamped into [0, totalRows); offsetDigits = max(8, hex length of
  baseOffset + byteLength - 1).
- Windowing. ONE scroll container for both axes, height (visibleRows + 1) *
  rowHeight — the extra row is the sticky column ruler, which is exactly why
  topRow = floor(scrollTop / rowHeight) still names the first row under it.
  Inside it, a spacer of height totalRows * rowHeight holds a single child
  translated by translateY(startRow * rowHeight). Use a transform, not absolute
  positioning: absolutely positioned rows contribute no width, and a 32-byte
  row has to be able to push the container wider than its viewport. Coalesce
  scroll events into one requestAnimationFrame and cancel it on unmount.
- Selection. State is { anchor, focus }; the painted range is [min, max]
  inclusive and lights the hex cell AND the ASCII cell of every byte in it.
  Mouse: mousedown sets anchor = focus = i and starts a drag — preventDefault
  first, then focus the hex cell by hand, so the browser's own text selection
  never fights the component's; mouseenter during a drag moves focus only;
  Shift+click extends from the existing anchor; a press on the offset gutter
  takes the whole row. A drag ends on window mouseup or window blur, both
  registered by an effect keyed on the dragging flag so they come off with it
  and on unmount. The pointer is never the only path: everything above has a
  keyboard equivalent below.
- Keyboard, roving tabindex over the hex cells (exactly one is tabbable):
  ArrowLeft/Right move one byte, ArrowUp/Down one row, PageUp/PageDown
  bytesPerRow * visibleRows, Home/End the ends of the row, Ctrl/Cmd+Home/End
  the ends of the buffer, Shift with any of them extends from the anchor
  instead of collapsing, Ctrl/Cmd+A selects everything (anchor on the last
  byte, focus on 0, so the reader is taken to the top), Ctrl/Cmd+C copies the
  range as hex, Escape collapses a multi-byte range onto its cursor and is
  then silent, so a second press still closes a surrounding dialog.
  Before anything is picked the tab stop is the first byte of the WINDOW, not
  byte 0 — tabbing into a scrolled dump must not yank it back to the top — and
  the first navigation key selects that byte.
- Focus hand-off. Moving the cursor outside the window has to scroll first, so
  the handler writes the target row into a pendingScroll ref and the target
  byte into a pendingFocus ref, and an effect with NO dependency array (the
  refs are the trigger) applies container.scrollTop = row * rowHeight and then
  focuses the cell — after the commit, when that row exists and the container
  is finally tall enough for the offset. When the cursor's row is already on
  screen, focus moves synchronously instead. If the cell is somehow missing,
  focus falls back to the grid element, never to the document body. Seed
  pendingScroll from defaultSelection so a dump that opens on a selection also
  opens scrolled to it.
- Bytes per row. A radiogroup of 8/16/32: role="radiogroup" wrapping
  role="radio" buttons with aria-checked, a roving tabindex and arrow keys that
  both move and select. Changing it re-centres the window on the byte the
  cursor is on, because the old scroll offset now addresses a different byte;
  focus stays on the pressed control.
- Copy. Two buttons. Hex emits the selected bytes two digits at a time and
  breaks lines on the same byte boundaries the grid draws them, so the
  clipboard looks like the block that was highlighted. Raw emits exactly what
  the ASCII pane shows — nonPrintableChar stands in for every byte outside
  0x20-0x7E — so no invisible control byte is ever handed to the clipboard.
  Every outcome speaks: nothing selected, empty buffer, undecodable payload,
  no Clipboard API and a rejected write each get their own sentence in a
  role="status" aria-live="polite" aria-atomic region plus a visible footer
  line, and success reports the byte count and flips that button's icon to a
  check for 2s. A copyBusy ref is read and written inside the same handler so
  a double click cannot start a second write while the first is in flight, and
  a mounted ref keeps the settled promise from setting state after unmount.
- Edge cases: zero bytes render emptyText inside the same frame, with the
  toolbar and footer still in place so nothing jumps; a short final row renders
  filler cells so the ASCII pane's left edge cannot shear; a data prop that
  shrinks under a live selection is handled by clamping the cursor where it is
  read, not by a correcting effect; 0x00 bytes are dimmed so runs of padding
  read as texture.
- Cleanup: the rAF handle, the message timer, the copied-icon timer and both
  drag listeners are all cancelled on unmount, and the drag listeners on every
  change of the dragging flag as well.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the frame and the
  sticky ruler, border for the frame and the pane separators, bg-muted/40 for
  zebra rows, text-muted-foreground for the gutter, the ruler, non-printable
  glyphs and 0x00 bytes, bg-primary/20 for selected cells, bg-primary/30 plus
  ring-primary for the cursor cell, bg-accent / text-accent-foreground for the
  hovered byte, bg-primary / text-primary-foreground for the chosen
  bytes-per-row radio, text-destructive for refusals and the decode error, and
  ring-ring for every focus ring. No hex colours anywhere. font-mono with
  tabular-nums, and cell widths in ch units (w-[2ch] hex, w-[1ch] ASCII) so the
  three columns stay welded together at any font size.
- ARIA: the row area is role="grid" with aria-label, aria-rowcount (the TOTAL
  row count, not the windowed one), aria-colcount, aria-multiselectable and
  aria-readonly; every row is role="row" carrying its absolute aria-rowindex;
  the offset is role="rowheader" at aria-colindex 1; every hex cell is
  role="gridcell" with aria-colindex, aria-selected and a label that reads
  "Offset 0x…, byte 0x…, character …" or "not printable". The ASCII pane and
  the ruler are aria-hidden: both mirror data that every hex cell already
  names, and reading a dump twice is worse than not reading it at all. The grid
  takes a tab stop of its own only while the cursor's cell is scrolled out of
  the window, so a flick of the wheel can never make the dump unreachable, and
  focusing it hands focus straight on to that cell.
- Never the native disabled attribute: the copy buttons go inert through
  aria-disabled plus a guard in the handler that says why, because the
  selection can be cleared out from under a focused button and the browser
  blurs a node the instant it becomes disabled.
- Motion: transitions live only on the buttons and are dropped under
  motion-reduce. The grid animates nothing at all, so reduced motion changes
  nothing about reading, selecting or copying.

Customization levers
- Density: rowHeight and visibleRows are the entire window geometry — raise
  rowHeight for a roomier dump, visibleRows for a taller one; both feed the
  same maths and nothing else has to change.
- Grouping: groupSize sets the extra gap every N hex cells — 8 by default, 4
  reads like a classic word view, 0 removes grouping.
- Panes: showAscii={false} leaves offset plus hex; showControls={false} leaves
  a fixed read-only dump with the whole keyboard model intact; drop the footer
  line for a bare frame, but keep the sr-only live region if you do.
- Colours: the four cell states (selected, cursor, hovered, zero byte) are one
  cn() call inside cellTone — repoint them at var(--chart-1..5) for a per-region
  palette, or flatten them to bg-muted for a quieter dump.
- Structural colouring: to tint magic / header / payload differently, pass an
  array of ranges and OR its class into cellTone by index — the cell renderer
  already has the absolute byte index in hand.
- Copy formats: add base64, a C array or an xxd-style line by writing one more
  builder next to buildHexText / buildRawText and one more button; both take a
  range and read from the same buffer.
- Addressing: baseOffset shifts every printed address (use it when the buffer
  is a slice of a larger file), hexCase switches the whole component between
  upper and lower case, and nonPrintableChar swaps the middle dot for a space
  or a question mark.

Concepts

  • Row window — the dump is addressed, not rendered: totalRows is pure arithmetic on the buffer length, a spacer of totalRows * rowHeight gives the scrollbar its true size, and only the rows around floor(scrollTop / rowHeight) are ever built. 64 KiB and 64 bytes cost the same to paint.
  • One range, three columns — offset, hex and ASCII are three renderings of one {anchor, focus} pair, so a pick made by dragging in the ASCII pane, by Shift+arrow in the hex pane or by pressing the offset gutter lights exactly the same bytes everywhere; the copy buttons read that same range.
  • Pending scroll, pending focus — a cursor that leaves the window points at a cell that does not exist yet, so the handler records the row and the byte in refs and an effect applies the scroll and the focus after the commit — the one moment when the row is mounted and the container is tall enough for the offset.
  • Placeholder in, placeholder out — a byte outside 0x20-0x7E is drawn as a middle dot, and a raw copy emits that same dot: what you read is what reaches the clipboard, and no invisible control byte can ride along into a bug report.
  • Refusal instead of a half-dump — an undecodable base64 payload, an empty buffer and a copy with nothing selected each answer with their own sentence in a polite live region, rather than rendering a plausible-looking dump of nothing or letting a button die quietly.

On This Page