A Browser Hex Viewer With Magic Number Detection and Virtual Scrolling
Drop any file, see its bytes. The classic
hexdump -Cformat: 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
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;
}
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.|
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}|`;
}
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);
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();
}
});
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.
- 📦 Repo: https://github.com/sen-ltd/hex-viewer
- 🌐 Live: https://sen.ltd/portfolio/hex-viewer/
- 🏢 Company: https://sen.ltd/

Top comments (0)