DEV Community

SEN LLC
SEN LLC

Posted on

A Browser Hex Viewer With Magic Number Detection and Virtual Scrolling

A Browser Hex Viewer With Magic Number Detection and Virtual Scrolling

Drop any file, see its bytes. The classic hexdump -C format: offset, 16 hex bytes per row, ASCII column with dots for non-printable. Plus: magic number detection (PNG, JPEG, ZIP, PDF, RIFF, gzip), byte-level editing, and virtual scrolling so a 100MB file doesn't kill your browser.

Hex viewers are a category of tool where every developer has a favorite and none of them are in the browser. The terminal has hexdump, xxd, od. IDEs have plugins. But if you want to quickly inspect an unfamiliar file someone sent you, a zero-install web version is handy.

🔗 Live demo: https://sen.ltd/portfolio/hex-viewer/
📦 GitHub: https://github.com/sen-ltd/hex-viewer

Screenshot

Features:

  • Classic hexdump -C format (offset / hex / ASCII)
  • 26 magic number signatures
  • Virtual scrolling (handles large files)
  • Search by hex pattern or ASCII string
  • Byte-level editing with download
  • Click-drag selection, copy as hex/ASCII
  • Japanese / English UI
  • Dark terminal theme
  • Zero dependencies, 66 tests

Magic number detection

export const MAGIC_NUMBERS = [
  { name: 'PNG', mime: 'image/png', signature: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] },
  { name: 'JPEG', mime: 'image/jpeg', signature: [0xFF, 0xD8, 0xFF] },
  { name: 'GIF', mime: 'image/gif', signature: [0x47, 0x49, 0x46, 0x38] },
  { name: 'ZIP', mime: 'application/zip', signature: [0x50, 0x4B, 0x03, 0x04] },
  { name: 'PDF', mime: 'application/pdf', signature: [0x25, 0x50, 0x44, 0x46] },
  { name: 'GZIP', mime: 'application/gzip', signature: [0x1F, 0x8B] },
  { name: 'WAV', mime: 'audio/wav', signature: [0x52, 0x49, 0x46, 0x46], extraCheck: (b) => b[8] === 0x57 && b[9] === 0x41 && b[10] === 0x56 && b[11] === 0x45 },
  // ... 26 total
];

export function detectFileType(bytes) {
  for (const entry of MAGIC_NUMBERS) {
    if (matches(bytes, entry.signature) && (!entry.extraCheck || entry.extraCheck(bytes))) {
      return { name: entry.name, mime: entry.mime };
    }
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Most formats are identified by the first 2-8 bytes. RIFF is special — the first 4 bytes are RIFF but the next 4 bytes (bytes 8-11) distinguish WAV/WebP/AVI. The extraCheck callback handles this:

  • RIFF....WAVE → WAV
  • RIFF....WEBP → WebP
  • RIFF....AVI → AVI

Classic hexdump format

Each row is: 8-char offset, 16 hex bytes (with a gap after byte 8), then 16 ASCII chars:

00000000  48 65 6c 6c 6f 20 57 6f  72 6c 64 21 0a 54 68 69  |Hello World!.Thi|
00000010  73 20 69 73 20 61 20 68  65 78 20 64 75 6d 70 0a  |s is a hex dump.|
Enter fullscreen mode Exit fullscreen mode
export function formatRow(bytes, offset, width = 16) {
  const hexPart = [];
  for (let i = 0; i < width; i++) {
    if (i < bytes.length) {
      hexPart.push(toHex(bytes[i]));
    } else {
      hexPart.push('  '); // padding for short last row
    }
    if (i === 7) hexPart.push(''); // extra gap after byte 8
  }
  const hex = hexPart.join(' ');
  const ascii = bytesToAscii(bytes.slice(0, width));
  return `${toOffset(offset)}  ${hex}  |${ascii}|`;
}
Enter fullscreen mode Exit fullscreen mode

The extra gap after byte 8 is the traditional convention — it makes the row visually easier to scan at 16 bytes wide.

Virtual scrolling

A 100MB file has 6.25 million rows. Rendering all of them kills the browser. The fix: only render the rows currently visible, update on scroll:

function render() {
  const containerHeight = container.clientHeight;
  const rowHeight = 20;
  const startRow = Math.floor(container.scrollTop / rowHeight);
  const visibleRows = Math.ceil(containerHeight / rowHeight) + 5; // buffer

  viewport.innerHTML = '';
  viewport.style.height = `${totalRows * rowHeight}px`; // spacer for scrollbar

  for (let i = startRow; i < Math.min(startRow + visibleRows, totalRows); i++) {
    const row = document.createElement('div');
    row.style.position = 'absolute';
    row.style.top = `${i * rowHeight}px`;
    row.textContent = formatRow(bytes.slice(i * 16, (i + 1) * 16), i * 16);
    viewport.appendChild(row);
  }
}

container.addEventListener('scroll', render);
Enter fullscreen mode Exit fullscreen mode

A ResizeObserver on the container triggers re-render when the window resizes. The total viewport height matches what would be rendered if all rows existed — so the scrollbar is accurate — but only ~50 rows are in the DOM at any moment.

Byte editing

Double-click a byte in the hex column to open an edit modal:

cell.addEventListener('dblclick', () => {
  const offset = parseInt(cell.dataset.offset);
  const input = prompt('New value (0-FF):', toHex(bytes[offset]));
  if (input && /^[0-9a-fA-F]{1,2}$/.test(input)) {
    bytes[offset] = parseInt(input, 16);
    modified = true;
    render();
  }
});
Enter fullscreen mode Exit fullscreen mode

The modified flag enables the "Download modified file" button. The user can patch bytes and get a downloadable file back — useful for quick hex patching without firing up a real hex editor.

Series

This is entry #91 in my 100+ public portfolio series.

Top comments (0)