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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/hexdump-viewer.jsonPrompt
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:
totalRowsis pure arithmetic on the buffer length, a spacer oftotalRows * rowHeightgives the scrollbar its true size, and only the rows aroundfloor(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.
JWT Decoder
A paste-a-token JWT inspector: header and payload as labelled claims, registered claims explained, exp/nbf/iat judged against an injected instant, and a permanent notice that the signature is never verified.
Spin Wheel
A prize wheel that reveals an outcome drawn somewhere else — weighted SVG slices, a flick or a button to start, ticking deceleration onto a pre-computed angle, and a one-shot settle.