# Code viewer

> A file in a small card, line-numbered and coloured, with a Code | Diff switch that shows what changed as a unified diff.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/code-viewer. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Card**: The file in a card, 420 px at most, centred on a soft stage (white by day, a step above graphite at night). Long lines wrap rather than scroll.
2. **File name**: An icon and the name in mono, cut with an ellipsis when it is too long for the header.
3. **Counts**: In Diff the header trades Copy for the lines added and removed, green and red.
4. **Line number**: A 20 px column in the quiet ink, with a hairline down the gutter. In Diff a removed line keeps its old number.
5. **Removed line**: A red tint with a hatched edge, so the change reads without colour too; a screen reader hears “Removed:”.
6. **Added line**: A green tint with a solid edge, heard as “Added:”. Its number is the line’s place in the new file.
7. **Changed value**: Inside a line that mostly stayed, the tokens that differ sit on a stronger chip of the row’s colour.
8. **View switch**: Code | Diff as tabs: the chosen one raised in the track, 150 ms. Arrow keys, Home and End move between them.

## 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` → `-16C` reads 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 `code` is enough.

## Installation

Install it with the shadcn CLI, into components/code-viewer of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/code-viewer.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```tsx
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}
    />
  );
}
```

## States

- **Code**: The file as it is now, with its numbers and colours, and Copy in the header.
- **Copied**: After Copy has written to the clipboard: a green check and Copied for 1.5 s, announced politely.
- **Diff**: The same file against `previous`, as a unified diff: removed lines first, then the added ones, with the counts in the header.
- **No previous version**: With `previous={null}` there is nothing to compare: no switch, only the listing and Copy.
- **Dark**: Every colour is a `light-dark()` token: a graphite stage, the card a step lighter, tints made translucent.

## How it works

- 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.

## API reference

- **code** (`string`, default `a sample file`): The file as it is now: the listing, and what Copy copies.
- **previous** (`string | null`, default `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`, default `'churn.ts'`): The name in the header.
- **mode** (`'code' | 'diff'`): The view shown, when the parent owns it (with onModeChange).
- **defaultMode** (`'code' | 'diff'`, default `'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`, default `'Copy'`): The copy button’s words.
- **copiedLabel** (`string`, default `'Copied'`): Its words, and what is announced, once the code is copied.
- **codeLabel** (`string`, default `'Code'`): The switch’s first view.
- **diffLabel** (`string`, default `'Diff'`): The switch’s second view.
- **defaultCopied** (`boolean`, default `false`): Opens with Copy reading Copied, held until the reader presses it again. Setting it turns the demo off.
- **autoplay** (`boolean`, default `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.

## Accessibility

- 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.

## Source

### code-viewer/code-viewer.tsx

```tsx
// 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.
 * - The colours come from a small tokenizer in highlight.ts: keywords in the accent, calls in the
 *   strong ink and a little bolder, strings and numbers in orange, the rest in the quiet ink.
 * - In Diff, the header trades Copy for the counts (+2 −1) and the rows follow the unified diff:
 *   removed lines on a red tint with a hatched edge, added ones on a green tint with a solid edge,
 *   and inside a changed line the changed value on a stronger chip of its colour.
 * - Switching is instant, as a listing should be; only the switch's pill eases (150 ms) between
 *   its two buttons. Copy writes the code to the clipboard and turns into a green Copied for 1.5 s.
 * - While on screen and untouched, a demo flips to the diff and back and presses Copy (without
 *   touching the clipboard); any input hands it to the reader.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type CodeViewerMode = 'code' | 'diff';

const SAMPLE_BEFORE = `export async function churnBatch() {
  const flavor = await getFlavor("pistachio");
  const base = await dairy.fetch({ flavor });
  await freezer.store(base, { temp: "-14C" });
  return base.gallons;
}
`;

const SAMPLE = `export 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;
}
`;

/** How long Copy reads Copied. */
const COPIED_MS = 1500;

/**
 * The demo's script: each step waits `wait` ms, then does its thing. A hover lights a button as
 * the pointer would, just before it is pressed.
 */
type Step = {
  wait: number;
  hover?: 'code' | 'diff' | 'copy' | null;
  mode?: CodeViewerMode;
  copy?: true;
};
const SCRIPT: Step[] = [
  { wait: 2200, hover: 'copy' },
  { wait: 450, copy: true },
  { wait: 650, hover: null },
  { wait: 1700, hover: 'diff' },
  { wait: 450, mode: 'diff', hover: null },
  { wait: 3600, hover: 'code' },
  { wait: 450, mode: 'code', hover: null },
];

export type CodeViewerProps = {
  /** The file as it is now: the listing, and what Copy copies. */
  code?: string;
  /** The file before the change; the diff compares it with `code`. null: no diff, no switch. */
  previous?: string | null;
  /** The name in the header. */
  filename?: string;
  /** The view shown, when the parent owns it. */
  mode?: CodeViewerMode;
  /** The view shown first, when the component owns it. */
  defaultMode?: CodeViewerMode;
  /** Called when the reader switches between the code and the diff. */
  onModeChange?: (mode: CodeViewerMode) => void;
  /** Called with the code once Copy has put it on the clipboard. */
  onCopy?: (code: string) => void;
  copyLabel?: string;
  copiedLabel?: string;
  codeLabel?: string;
  diffLabel?: string;
  /** Opens with Copy reading Copied, held until the reader presses it again; turns the demo off. */
  defaultCopied?: boolean;
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

export function CodeViewer({
  code = SAMPLE,
  previous = SAMPLE_BEFORE,
  filename = 'churn.ts',
  mode: controlledMode,
  defaultMode = 'code',
  onModeChange,
  onCopy,
  copyLabel = 'Copy',
  copiedLabel = 'Copied',
  codeLabel = 'Code',
  diffLabel = 'Diff',
  defaultCopied = false,
  autoplay = true,
  colorScheme,
  className,
}: CodeViewerProps) {
  const id = useId();
  const { ref, take, playing } = useAutoplay<HTMLDivElement>();
  const demo = autoplay && !defaultCopied && playing;
  const [ownMode, setOwnMode] = useState<CodeViewerMode>(defaultMode);
  const [demoMode, setDemoMode] = useState<CodeViewerMode | null>(null);
  const hasDiff = previous != null;
  const mode: CodeViewerMode = hasDiff ? (demoMode ?? controlledMode ?? ownMode) : 'code';
  const [copied, setCopied] = useState(defaultCopied);
  const [announce, setAnnounce] = useState('');
  const [hover, setHover] = useState<Step['hover']>(null);
  const copiedTimer = useRef(0);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);

  const listing = useMemo(() => lines(code).map((line) => tokenize(line)), [code]);
  const changes = useMemo(() => (previous != null ? diff(previous, code) : null), [previous, code]);

  const flashCopied = (say: boolean) => {
    setCopied(true);
    if (say) setAnnounce(copiedLabel);
    window.clearTimeout(copiedTimer.current);
    copiedTimer.current = window.setTimeout(() => {
      setCopied(false);
      setAnnounce('');
    }, COPIED_MS);
  };
  useEffect(() => () => window.clearTimeout(copiedTimer.current), []);

  const choose = (next: CodeViewerMode) => {
    setDemoMode(null);
    if (next === mode) return;
    setOwnMode(next);
    onModeChange?.(next);
  };

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(code);
    } catch {
      return;
    }
    flashCopied(true);
    onCopy?.(code);
  };

  // The demo: walks its script on a timer while it plays. Its view lives apart from the
  // reader's, and is dropped as soon as the reader takes over.
  const step = useEffectEvent((index: number) => {
    const at = SCRIPT[index];
    if (at.hover !== undefined) setHover(at.hover);
    if (at.mode) setDemoMode(at.mode);
    if (at.copy) flashCopied(false);
  });
  useEffect(() => {
    if (!demo || !hasDiff) {
      setHover(null);
      return;
    }
    let index = 0;
    let timer = 0;
    const next = () => {
      timer = window.setTimeout(() => {
        step(index);
        index = (index + 1) % SCRIPT.length;
        next();
      }, SCRIPT[index].wait);
    };
    next();
    return () => window.clearTimeout(timer);
  }, [demo, hasDiff]);

  // Any real input hands the demo to the reader, who keeps the view it was showing.
  const handOver = () => {
    take();
    if (demoMode) {
      setOwnMode(demoMode);
      setDemoMode(null);
    }
    setHover(null);
  };

  const onTabKey = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
    const order: CodeViewerMode[] = ['code', 'diff'];
    const at = order.indexOf(mode);
    let to = -1;
    if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') to = 1 - at;
    else if (event.key === 'Home') to = 0;
    else if (event.key === 'End') to = 1;
    if (to < 0) return;
    event.preventDefault();
    choose(order[to]);
    tabs.current[to]?.focus();
  };

  const rows =
    mode === 'diff' && changes
      ? changes.rows
      : listing.map((tokens, index) => ({ kind: 'same' as const, number: index + 1, tokens }));

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={handOver}
      onKeyDownCapture={handOver}
      onWheel={handOver}
    >
      <div className={styles.card} data-part="card">
        <div className={styles.header} data-part="header">
          <span className={styles.file} data-part="file">
            <svg
              className={styles.fileIcon}
              aria-hidden="true"
              width="15"
              height="15"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.8"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <path d="M6.6 6.9 1.6 12l5 5.1M17.4 6.9l5 5.1-5 5.1M14.3 3.6 9.7 20.4" />
            </svg>
            <span className={styles.name} data-part="filename">
              {filename}
            </span>
          </span>
          {mode === 'diff' && changes ? (
            <span
              className={styles.counts}
              data-part="counts"
              role="img"
              aria-label={`${changes.added} added, ${changes.removed} removed`}
            >
              <span className={styles.plus}>+{changes.added}</span>
              <span className={styles.minus}>-{changes.removed}</span>
            </span>
          ) : (
            <button
              type="button"
              className={styles.copy}
              data-part="copy"
              aria-label={copied ? copiedLabel : `${copyLabel} code`}
              data-copied={copied || undefined}
              data-hover={hover === 'copy' || undefined}
              onClick={copy}
            >
              {copied ? (
                <svg
                  aria-hidden="true"
                  width="11"
                  height="11"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="3"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="m4.5 12.5 4.8 4.6L19.5 6.8" />
                </svg>
              ) : (
                <svg
                  aria-hidden="true"
                  width="11"
                  height="11"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M11.5 9h7a2.5 2.5 0 0 1 2.5 2.5v7a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 9 18.5v-7A2.5 2.5 0 0 1 11.5 9Z" />
                  <path d="M15 5V4.4A2.4 2.4 0 0 0 12.6 2H4.4A2.4 2.4 0 0 0 2 4.4v8.2A2.4 2.4 0 0 0 4.4 15H5" />
                </svg>
              )}
              {copied ? copiedLabel : copyLabel}
            </button>
          )}
        </div>
        <div
          className={styles.body}
          data-part="body"
          id={`${id}-panel`}
          role={hasDiff ? 'tabpanel' : undefined}
          aria-labelledby={hasDiff ? `${id}-${mode}` : undefined}
          aria-label={hasDiff ? undefined : filename}
        >
          <div className={styles.lines} data-mode={mode}>
            <span className={styles.gutter} data-part="gutter" aria-hidden="true" />
            {rows.map((row, index) => (
              <div
                key={`${mode}-${index}`}
                className={styles.row}
                data-part="row"
                data-kind={row.kind}
              >
                {row.kind !== 'same' && (
                  <span className={styles.edge} data-part="edge" aria-hidden="true" />
                )}
                <span className={styles.number} data-part="number" aria-hidden="true">
                  {row.number}
                </span>
                <code className={styles.code} data-part="code">
                  {row.kind !== 'same' && (
                    <span className={styles.hidden}>
                      {row.kind === 'added' ? 'Added: ' : 'Removed: '}
                    </span>
                  )}
                  <Line tokens={row.tokens} />
                </code>
              </div>
            ))}
          </div>
        </div>
      </div>

      {hasDiff && (
        <div className={styles.switch} data-part="switch" role="tablist" aria-label="View">
          {(['code', 'diff'] as const).map((value, index) => (
            <button
              key={value}
              ref={(element) => {
                tabs.current[index] = element;
              }}
              id={`${id}-${value}`}
              type="button"
              role="tab"
              aria-selected={mode === value}
              aria-controls={`${id}-panel`}
              tabIndex={mode === value ? 0 : -1}
              className={styles.tab}
              data-part="tab"
              data-hover={hover === value || undefined}
              onClick={() => choose(value)}
              onKeyDown={onTabKey}
            >
              {value === 'code' ? codeLabel : diffLabel}
            </button>
          ))}
        </div>
      )}
      <span className={styles.hidden} aria-live="polite">
        {announce}
      </span>
    </div>
  );
}

/** One line's tokens, a changed run of them wrapped in a single chip. */
function Line({ tokens }: { tokens: Token[] }) {
  const out: ReactNode[] = [];
  for (let index = 0; index < tokens.length;) {
    if (tokens[index].changed) {
      const run: Token[] = [];
      // A run of changed tokens, with any spaces between them.
      let end = index;
      while (
        end < tokens.length &&
        (tokens[end].changed ||
          (/^\s+$/.test(tokens[end].text) && tokens.slice(end + 1).some((t) => t.changed)))
      )
        run.push(tokens[end++]);
      out.push(
        <span key={index} className={styles.chip} data-part="chip">
          {run.map((token, at) => (
            <TokenSpan key={at} token={token} />
          ))}
        </span>,
      );
      index = end;
    } else {
      out.push(<TokenSpan key={index} token={tokens[index]} />);
      index++;
    }
  }
  // An empty line still keeps its height.
  return out.length ? out : '\n';
}

function TokenSpan({ token }: { token: Token }) {
  if (token.kind === 'plain') return <span>{token.text}</span>;
  // Numbers share the strings' colour; their class is named apart from the line numbers'.
  const name = token.kind === 'number' ? styles['number-literal'] : styles[token.kind];
  return <span className={name}>{token.text}</span>;
}
```

### code-viewer/code-viewer.module.css

```css
/* 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. */
  --keyword: oklch(55.6% 0.187 255.617);
  --string: oklch(68.9% 0.179 49.902);
  /* The diff: added and removed, their row tints. */
  --green: oklch(60.3% 0.155 150.883);
  --green-tint: oklch(95.8% 0.017 159.118);
  --red: oklch(62.1% 0.192 23.042);
  --red-tint: oklch(95.6% 0.017 17.462);
  /* The card's ring and lift, and the pill's: the day's long soft layers, the night's short
     dark ones. */
  --card-ring: oklch(94.6% 0.003 264.542);
  --btn-ring: oklch(91.2% 0.005 258.326);
  --day-10: rgb(0 0 0 / 0.04);
  --day-8: rgb(0 0 0 / 0.03);
  --day-5: rgb(0 0 0 / 0.02);
  --day-3: rgb(0 0 0 / 0.012);
  --night-20: transparent;
  --night-30: transparent;
  /* The same on both schemes: the focus ring. */
  --focus: oklch(62.6% 0.205 254.947);
  position: relative;
  container-type: inline-size;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 272px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --keyword: light-dark(oklch(55.6% 0.187 255.617), oklch(78.8% 0.113 248.33));
    --string: light-dark(oklch(68.9% 0.179 49.902), oklch(74.6% 0.156 55.642));
    --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
    --green-tint: light-dark(oklch(95.8% 0.017 159.118), oklch(70.5% 0.154 153.814 / 0.14));
    --red: light-dark(oklch(62.1% 0.192 23.042), oklch(66.6% 0.18 21.433));
    --red-tint: light-dark(oklch(95.6% 0.017 17.462), oklch(66.6% 0.18 21.433 / 0.14));
    --card-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
    --btn-ring: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --day-10: light-dark(rgb(0 0 0 / 0.04), transparent);
    --day-8: light-dark(rgb(0 0 0 / 0.03), transparent);
    --day-5: light-dark(rgb(0 0 0 / 0.02), transparent);
    --day-3: light-dark(rgb(0 0 0 / 0.012), transparent);
    --night-20: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --night-30: light-dark(transparent, oklch(0% 0 0 / 0.3));
  }
}

.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The card. */

.card {
  width: 100%;
  max-width: 420px;
  overflow: hidden;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-ring),
    0 18px 47px var(--day-8),
    0 7.5px 19px var(--day-5),
    0 4px 10.5px var(--day-5),
    0 2.3px 5.8px var(--day-3),
    0 1.2px 3.1px var(--day-3),
    0 0.5px 1.3px var(--day-3),
    0 1px 2px var(--night-20),
    0 2px 6px var(--night-20);
}

.header {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 44px;
  padding: 0 16px;
  border-bottom: 1px solid var(--line);
  font-size: 12.5px;
}

.file {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
}

.fileIcon {
  flex-shrink: 0;
  width: 15px;
  height: 15px;
  color: var(--ink-3);
}

.name,
.counts,
.body {
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, Menlo, monospace;
}

.name {
  overflow: hidden;
  color: var(--ink);
  line-height: 1;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.copy {
  display: flex;
  align-items: center;
  gap: 4px;
  height: 24px;
  margin: 0 -4px 0 auto;
  padding: 0 6px;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--ink-3);
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  line-height: 1.5;
  letter-spacing: inherit;
  white-space: nowrap;
  cursor: pointer;
  transition:
    color 100ms cubic-bezier(0.4, 0, 0.2, 1),
    background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.copy[data-hover] {
  background: var(--hover);
  color: var(--ink);
}
@media (hover: hover) {
  .copy:hover {
    background: var(--hover);
    color: var(--ink);
  }
}
.copy[data-copied],
.copy[data-copied]:hover {
  color: var(--green);
}
.copy svg {
  flex-shrink: 0;
  width: 11px;
  height: 11px;
}

.counts {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-left: auto;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  white-space: nowrap;
}
.plus {
  color: var(--green);
}
.minus {
  color: var(--red);
}

/* The listing: a row per line, the numbers in a 20 px column, a hairline down the gutter. */

.body {
  padding: 12px 0;
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 1.65;
}

.lines {
  position: relative;
}

.gutter {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 20px;
  width: 1px;
  background: var(--line);
  pointer-events: none;
}

.row {
  display: grid;
  grid-template-columns: 20px minmax(0, 1fr);
  align-items: start;
}
/* In the diff the rows stand above the gutter's hairline, so their tints cover it. */
.lines[data-mode='diff'] .row {
  position: relative;
}
.row[data-kind='added'] {
  background: var(--green-tint);
}
.row[data-kind='removed'] {
  background: var(--red-tint);
}

/* The edge of a changed row: solid for an added line, hatched for a removed one. */
.edge {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 3px;
  background: var(--green);
}
.row[data-kind='removed'] .edge {
  background: repeating-linear-gradient(
    45deg,
    var(--red) 0,
    var(--red) 1.5px,
    transparent 1.5px,
    transparent 3px
  );
}

.number {
  color: var(--ink-3);
  font-size: 11px;
  text-align: center;
  user-select: none;
}
.row[data-kind='added'] .number {
  color: var(--green);
}
.row[data-kind='removed'] .number {
  color: var(--red);
}

.code {
  padding: 0 12px 0 4px;
  font-family:
    ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
    monospace;
  font-size: 1em;
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

.keyword {
  color: var(--keyword);
}
.call {
  color: var(--ink);
  font-weight: 500;
}
.string,
.number-literal {
  color: var(--string);
}
.comment {
  color: var(--ink-3);
  font-style: italic;
}

/* The changed value inside a changed line. */
.chip {
  margin: 0 -1px;
  padding: 0 2px;
  border-radius: 3px;
  background: color-mix(in srgb, var(--green) 18%, transparent);
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.row[data-kind='removed'] .chip {
  background: color-mix(in srgb, var(--red) 18%, transparent);
}

/* The Code | Diff switch: a pill track with the chosen view raised in it. */

.switch {
  position: absolute;
  bottom: 10px;
  left: 50%;
  display: flex;
  padding: 2px;
  border-radius: 999px;
  background: var(--field);
  transform: translateX(-50%);
}

.tab {
  padding: 2px 8px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  box-shadow:
    0 0 0 1px transparent,
    0 0 4px transparent,
    0 1px 2px transparent;
  color: var(--ink-3);
  font: inherit;
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1.5;
  letter-spacing: inherit;
  cursor: pointer;
  transition:
    background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.tab[data-hover] {
  color: var(--ink-2);
}
@media (hover: hover) {
  .tab:hover {
    color: var(--ink-2);
  }
}
.tab[aria-selected='true'] {
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--btn-ring),
    0 0 4px var(--day-10),
    0 1px 2px var(--night-30);
  color: var(--ink);
}

.copy:focus-visible,
.tab:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* On a narrow stage the lines wrap and the card grows; it keeps clear of the switch. */
@container (max-width: 479px) {
  .card {
    margin-bottom: 28px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .copy,
  .tab {
    transition: none;
  }
}
```

### code-viewer/highlight.ts

```tsx
// 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 ' +
    'super switch this throw true try type typeof undefined var void while with yield'
  ).split(' '),
);

const TOKEN =
  /\/\/.*|\/\*.*?\*\/|"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|`(?:\\.|[^`\\])*`?|\d[\d_.]*[a-z]*|[A-Za-z_$][\w$]*|\s+|./g;

/** The tokens of one line, neighbouring plain ones merged. */
export function tokenize(line: string): Token[] {
  const tokens: Token[] = [];
  const parts = line.match(TOKEN) ?? [];
  parts.forEach((text, index) => {
    let kind: TokenKind = 'plain';
    const first = text[0];
    if (text.startsWith('//') || text.startsWith('/*')) kind = 'comment';
    else if (first === '"' || first === "'" || first === '`') kind = 'string';
    else if (/\d/.test(first)) kind = 'number';
    else if (/[A-Za-z_$]/.test(first)) {
      if (KEYWORDS.has(text)) kind = 'keyword';
      else {
        // A call: the first mark after the word, past any spaces, opens a parenthesis.
        let next = index + 1;
        while (next < parts.length && /^\s+$/.test(parts[next])) next++;
        if (parts[next] === '(') kind = 'call';
      }
    }
    const last = tokens[tokens.length - 1];
    if (kind === 'plain' && last?.kind === 'plain') last.text += text;
    else tokens.push({ kind, text });
  });
  return tokens;
}

/** Splits text into lines, without the empty one a final line break leaves. */
export function lines(text: string): string[] {
  const all = text.replace(/\r\n?/g, '\n').split('\n');
  if (all.length > 1 && all[all.length - 1] === '') all.pop();
  return all;
}

/** Marks the tokens that differ between a removed line and the added line that replaces it. */
function markChange(before: Token[], after: Token[]) {
  // Compare unmerged tokens, so a value inside a run of marks is found on its own.
  const split = (tokens: Token[]) =>
    tokens.flatMap((token) =>
      token.kind === 'plain'
        ? (token.text.match(TOKEN) ?? []).map((text) => ({ kind: token.kind, text }) as Token)
        : [token],
    );
  const a = split(before);
  const b = split(after);
  let start = 0;
  while (start < a.length && start < b.length && a[start].text === b[start].text) start++;
  let end = 0;
  while (
    end < a.length - start &&
    end < b.length - start &&
    a[a.length - 1 - end].text === b[b.length - 1 - end].text
  )
    end++;
  // Only a line that mostly stayed the same reads as changed; otherwise it is simply replaced.
  const kept = start + end;
  if (kept === 0 || kept * 2 < Math.max(a.length, b.length)) return [before, after];
  const mark = (tokens: Token[]) =>
    tokens.map((token, index) =>
      index >= start && index < tokens.length - end && !/^\s+$/.test(token.text)
        ? { ...token, changed: true }
        : token,
    );
  return [mark(a), mark(b)];
}

/** The unified diff of two texts, as rows ready to draw, and how many lines it adds and removes. */
export function diff(before: string, after: string) {
  const a = lines(before);
  const b = lines(after);
  // lcs[i][j]: the longest common run of lines from a[i] and b[j] on.
  const lcs = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
  for (let i = a.length - 1; i >= 0; i--)
    for (let j = b.length - 1; j >= 0; j--)
      lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);

  const rows: DiffRow[] = [];
  let i = 0;
  let j = 0;
  let added = 0;
  let removed = 0;
  while (i < a.length || j < b.length) {
    if (i < a.length && j < b.length && a[i] === b[j]) {
      rows.push({ kind: 'same', number: j + 1, tokens: tokenize(b[j]) });
      i++;
      j++;
      continue;
    }
    // A hunk: every removed line, then every added one, as a unified diff lists them.
    const gone: DiffRow[] = [];
    const come: DiffRow[] = [];
    while (i < a.length || j < b.length) {
      if (i < a.length && j < b.length && a[i] === b[j]) break;
      if (j >= b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1])) {
        gone.push({ kind: 'removed', number: i + 1, tokens: tokenize(a[i]) });
        i++;
      } else {
        come.push({ kind: 'added', number: j + 1, tokens: tokenize(b[j]) });
        j++;
      }
    }
    for (let k = 0; k < Math.min(gone.length, come.length); k++)
      [gone[k].tokens, come[k].tokens] = markChange(gone[k].tokens, come[k].tokens);
    rows.push(...gone, ...come);
    added += come.length;
    removed += gone.length;
  }
  return { rows, added, removed };
}
```

### code-viewer/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```