Code viewer
NewA file in a small card, line-numbered and coloured, with a Code | Diff switch that shows what changed as a unified diff.
code-viewer/code-viewer.tsxexport async function churnBatch() { const flavor = await getFlavor("pistachio"); const base = await dairy.fetch({ flavor }); await freezer.store(base, { temp: "-16C" }); if (!base.approved) return null; return base.gallons;}The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A short file or snippet shown in a chat, a doc or a review: up to a few dozen lines, read in place.
- An agent’s proposed edit, where the reader wants both the result and what changed: Code shows the file, Diff the change.
- A small change to a familiar file, where the changed value is marked inside its line so
-14C→-16Creads at a glance. - Code meant to be copied as a whole: one Copy in the header, confirmed in green.
When not to use
- Long files or several files at once. Use an editor or a review tool with folding, search and a file tree.
- Code the reader should edit. This is a listing to read; use an editor or a code field.
- Side-by-side reviews or diffs with many hunks: a unified diff of one short file is all it draws.
- Languages far from JavaScript and TypeScript: its small tokenizer knows their keywords only. Use a full highlighter.
- A command or a single line inside a sentence. Inline
codeis enough.
You can add this component with the shadcn CLI or by hand:
Into components/code-viewer of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/code-viewer.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { CodeViewer } from './code-viewer';const before = `export function total(items) { return items.reduce((sum, item) => sum + item.price, 0);}`;const after = `export function total(items) { return items.reduce((sum, item) => sum + item.price * item.quantity, 0);}`;export function EditedFile() { return ( <CodeViewer filename="cart.ts" previous={before} code={after} defaultMode="diff" autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A listing the way an agent shows the file it just edited. The card names the file and copies it in one press; the switch below turns the same file into a unified diff against its previous version: removed lines on red with a hatched edge, added ones on green, the changed value marked inside its line, and the counts in the header. The highlighter and the diff are a few dozen lines of their own, with no dependency.
- Each line is a grid row: a 20 px column of numbers and the code, with a 1 px hairline down the gutter drawn once for the whole listing. Long lines wrap where they must (pre-wrap and break-word), so the card never scrolls sideways, down to a phone.
- The colours come from one regular expression that splits a line into comments, strings, numbers, words, spaces and marks. A word is a keyword if it is in a short list, a call if the next mark after it opens a parenthesis, and plain otherwise: keywords take the accent, calls the strong ink at weight 500, strings and numbers orange, the rest the quiet ink.
- The diff is a longest common subsequence over whole lines, walked back into hunks listed as a unified diff lists them: the removed lines, then the added ones. Removed rows keep their old number, the others take the new one, and the header trades Copy for the counts.
- A removed line followed by an added one is compared token by token from both ends; when most of it stayed the same, the tokens in between sit on a chip of their colour at 18 % (color-mix), so “-14C” to “-16C” reads at a glance. A line that changed throughout is simply removed and added.
- Switching is instant, as a listing should be. Only the switch eases: the chosen button raises in 150 ms (background, colour and shadow on the standard ease), and the Copy button eases its colours in 100 ms. Copy writes the code with the Clipboard API and turns into a green Copied with a tick for 1.5 s.
- The changed rows paint above the gutter’s hairline, so their tints run edge to edge; the removed edge is a 3 px repeating 45° gradient, the added one solid.
- While on screen and untouched, a demo presses Copy, flips to the diff and back, on a loop; it lights each button as a pointer would just before pressing it, never touches the clipboard and is never announced. Any press, key or wheel hands the viewer to the reader, on the view the demo was showing.
- Every colour is a light-dark() token on the root, so the viewer follows the page’s color-scheme or the colorScheme prop. The card’s shadow is written once with all its layers, those a scheme does not use being transparent: a long soft lift by day, a short dark one at night.
| Prop | Type | Default | Description |
|---|---|---|---|
code | string | a sample file | The file as it is now: the listing, and what Copy copies. |
previous | string | null | the sample’s earlier version | The file before the change; the diff compares it with code. null shows the listing alone, without the switch. |
filename | string | 'churn.ts' | The name in the header. |
mode | 'code' | 'diff' | — | The view shown, when the parent owns it (with onModeChange). |
defaultMode | 'code' | 'diff' | 'code' | The view shown first, when the component owns it. |
onModeChange | (mode: 'code' | 'diff') => void | — | Called when the reader switches between the code and the diff. |
onCopy | (code: string) => void | — | Called with the code once Copy has put it on the clipboard. |
copyLabel | string | 'Copy' | The copy button’s words. |
copiedLabel | string | 'Copied' | Its words, and what is announced, once the code is copied. |
codeLabel | string | 'Code' | The switch’s first view. |
diffLabel | string | 'Diff' | The switch’s second view. |
defaultCopied | boolean | false | Opens with Copy reading Copied, held until the reader presses it again. Setting it turns the demo off. |
autoplay | boolean | true | While on screen and untouched, a demo copies the code and flips to the diff and back. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode,} from 'react';import { diff, lines, tokenize, type Token } from './highlight';import { useAutoplay } from './use-autoplay';import styles from './code-viewer.module.css';/* * Code viewer: a file in a small card, with its line numbers and syntax colours, and a Code | Diff * switch below that shows the same file as a unified diff against its previous version. * * - The card has a header (an icon, the file name, a Copy button) and the listing: a grid row per * line, a 20 px column of numbers and the code, with a hairline down the gutter. Long lines wrap./* Code viewer (code-viewer.tsx): a soft grey stage with the card in its middle, 420 px at most, and the Code | Diff switch near its bottom edge; at night a graphite stage, the card a step lighter. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The shadows are one list for both schemes, each layer coloured for one of them and clear in the other. Nothing here moves but colours: buttons ease their colours, the switch its pill. */.root { /* The stage, the card, the switch's track, and a button under the pointer. */ --canvas: oklch(96.1% 0.002 247.84); --surface: oklch(100% 0 0); --field: oklch(96.1% 0.001 286.375); --hover: oklch(97% 0.002 247.839); /* Words: strong, for names and calls; the listing's own; quiet, for numbers and labels. */ --ink: oklch(24.7% 0.006 258.361); --ink-2: oklch(50.6% 0.01 264.477); --ink-3: oklch(69.5% 0.009 264.505); /* Hairlines: the stage's edge, the header's rule and the gutter. */ --line: oklch(94.6% 0.003 264.542); /* Syntax: keywords, and strings and numbers. */// Copyright (c) 2026 Bruno Delorence. MIT License./* * A small highlighter for JavaScript and TypeScript, and a line diff, both written for the code * viewer: no grammar, just a tokenizer good enough for short listings. * * - A line splits into tokens by one regular expression: comments, strings, numbers, words, runs of * spaces and single marks. A word is a keyword if it is in KEYWORDS, a call if the next mark * after it is an opening parenthesis, and plain otherwise. * - The diff is a longest-common-subsequence over whole lines, walked back into removed, added and * unchanged rows. Removed rows keep their old line number, the others take the new one. * - A removed line followed by an added one is a changed line: their tokens are compared from both * ends, and the tokens in between are marked, so a changed value stands out within its line. */export type TokenKind = 'plain' | 'keyword' | 'call' | 'string' | 'number' | 'comment';export type Token = { kind: TokenKind; text: string; changed?: boolean };export type DiffRow = { kind: 'same' | 'removed' | 'added'; number: number; tokens: Token[] };const KEYWORDS = new Set( ( 'abstract as async await break case catch class const continue debugger default delete do ' + 'else enum export extends false finally for from function get if implements import in ' + 'instanceof interface let new null of private protected public readonly return set static ' +'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The switch is a tablist of two tabs controlling the listing, its tabpanel: Tab reaches the chosen one, the left and right arrows (and Home, End) move to the other view and focus it.
- Copy is a real button named “Copy code”; once the code is copied it is named “Copied” and a polite live region says so. The demo’s presses are never announced.
- Each line is a code element; line numbers are hidden from screen readers, and every row of the diff starts with a hidden “Added:” or “Removed:”, so the change does not rest on colour. The header’s counts read “2 added, 1 removed”.
- Focus rings are visible on the copy button and the tabs. Under reduced motion the colours change at once and the demo never plays.