Skip to content
Star on GitHub
Sources and outputCode viewer
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Code viewer

Code the agent wrote, with line notes you can ask about. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add code-viewer
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add code-viewer

React

$npx designme add code-viewer --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Code viewer shows the files an agent wrote or changed, with the changed lines marked. Click any line and a note opens under it with the agent's explanation of that line.

  • When people want to understand why the agent wrote a line before they accept the change.
  • When an agent edits more than one file and you want tabs to move between them.
  • When you want changed lines highlighted without showing a full diff.
  • When readers need a quick way to copy the generated file.
03

Usage

Import it and drive it from your own state.

Import

import { CodeViewer } from '@/components/designme/code-viewer/CodeViewer';

Example

The demo above as a React component, 42 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { CodeViewer, type CodeFile, type CodePiece, type CodeToken } from './CodeViewer';
import { useStory } from '../lib/useScript';

/** Turns "<k>return</k> (" into highlight pieces, so the code below reads like code */
const hl = (src: string): CodePiece[] => src.split(/(<(?:k|s|f|cm|tg|at)>.*?<\/(?:k|s|f|cm|tg|at)>)/).filter(Boolean)
  .map(p => { const m = p.match(/^<(\w+)>(.*)<\/\1>$/); return m ? [m[1] as CodeToken, m[2]] : p; });

/* ---------- Code viewer: a changed file, and after a moment the agent explains one of its lines ---------- */
const FILES: CodeFile[] = [
  { name: 'PlanCard.tsx', lines: [
    { code: hl('<k>import</k> { Button } <k>from</k> <s>"@/ui/button"</s>;') },
    { code: '' },
    { code: hl('<k>export function</k> <f>PlanCard</f>({ plan }) {') },
    { code: hl('  <cm>// One CTA per card, annual price first</cm>'), changed: true, note: 'The agent removed the second link here. The design review said two CTAs split attention.' },
    { code: hl('  <k>return</k> (') },
    { code: hl('    <tg><Card</tg> <at>padding</at>=<s>"lg"</s><tg>></tg>') },
    { code: hl('      <tg><Price</tg> <at>value</at>={plan.annual} <at>was</at>={plan.monthly} <tg>/></tg>'), changed: true, note: 'Shows the monthly price crossed out next to the annual one. This is the change behind the 18% lift.' },
    { code: hl('      <tg><Button</tg> <at>variant</at>=<s>"primary"</s><tg>></tg>{plan.cta}<tg></Button></tg>'), changed: true, note: 'Swapped the custom button for the shared one, so it picks up the library tokens.' },
    { code: hl('    <tg></Card></tg>') },
    { code: '  );' },
    { code: '}' }
  ] },
  { name: 'tokens.css', lines: [
    { code: hl('<f>:root</f> {') },
    { code: hl('  --text-muted: <s>#5b6472</s>;'), changed: true, note: 'Was #c9cfd8. Contrast on white goes from 2.1 to 5.9, which passes AA.' },
    { code: hl('  --radius-card: <s>16px</s>;') },
    { code: hl('  --space-card: <s>24px</s>;'), changed: true, note: 'Matched to the 4px grid. Was 18px.' },
    { code: '}' }
  ] }
];

export default function CodeViewerDemo() {
  const [line, setLine] = useState<number | null>(null);
  const ref = useRef<HTMLDivElement>(null);
  // the preview asks about one line on its own, unless you take over first
  useStory(ref, async ({ wait }) => { await wait(1200); setLine(l => l ?? 6); });
  return <div ref={ref} style={{ width: '100%' }}><CodeViewer files={FILES} line={line} onLineChange={setLine} /></div>;
}
04

Props

Everything CodeViewer takes.

PropTypeWhat it does
filesrequiredCodeFile[]
filestringThe open file, by name. Leave it out to let the tabs switch it
defaultFilestring
onFileChange(name: string) => void
linenumber | nullThe line whose note is open (0 is the first line), or null. Leave it out to let a click on a line open it
defaultLinenumber | null
onLineChange(line: number | null) => void
delaynumberHow long a note says "Reading the line" before it answers, in ms
unchangedNotestringThe note for a line the agent didn't change and has nothing to say about
onCopy(text: string) => voidCalled with the file's text when it is copied
classNamestring
05

Code

The full source, as the CLI installs it.

CodeViewer.tsxReact · 119 lines
'use client';
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useCopied, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Tip } from '../lib/motion';
import '../designme.css';
import './code-viewer.css';

/** k keyword, s string, f function, cm comment, tg tag, at attribute */
export type CodeToken = 'k' | 's' | 'f' | 'cm' | 'tg' | 'at';
/** Plain text, or [token, text] for a highlighted piece */
export type CodePiece = string | [CodeToken, string];

export interface CodeLine {
  /** The line as plain text, or as pieces to highlight */
  code: string | CodePiece[];
  /** The agent changed this line: its number turns green and the line gets a wash */
  changed?: boolean;
  /** What the agent says about the line when you click it */
  note?: string;
}

export interface CodeFile {
  /** Shown on the tab, like "PlanCard.tsx" */
  name: string;
  lines: CodeLine[];
}

export interface CodeViewerProps {
  files: CodeFile[];
  /** The open file, by name. Leave it out to let the tabs switch it */
  file?: string;
  defaultFile?: string;
  onFileChange?: (name: string) => void;
  /** The line whose note is open (0 is the first line), or null. Leave it out to let a click on a line open it */
  line?: number | null;
  defaultLine?: number | null;
  onLineChange?: (line: number | null) => void;
  /** How long a note says "Reading the line" before it answers, in ms */
  delay?: number;
  /** The note for a line the agent didn't change and has nothing to say about */
  unchangedNote?: string;
  /** Called with the file's text when it is copied */
  onCopy?: (text: string) => void;
  className?: string;
}

const TOKEN: Record<CodeToken, string> = { k: 'dm-k', s: 'dm-s', f: 'dm-f', cm: 'dm-cm', tg: 'dm-tg', at: 'dm-at' };
const text = (code: CodeLine['code']) => typeof code === 'string' ? code : code.map(p => typeof p === 'string' ? p : p[1]).join('');

/** The note under a clicked line: it reads for a moment, then crossfades to what the agent says */
function Note({ note, delay }: { note: string; delay: number }) {
  const reduce = useReducedMotion();
  const [phase, setPhase] = useState<'reading' | 'out' | 'said'>('reading');
  useEffect(() => {
    const a = setTimeout(() => setPhase('out'), delay);
    const b = setTimeout(() => setPhase('said'), delay + (reduce ? 0 : 170));
    return () => { clearTimeout(a); clearTimeout(b); };
  }, [delay, reduce]);
  // it opens its own height, so the code under it moves down smoothly instead of jumping
  const box = useRef<HTMLDivElement>(null);
  useIsoLayoutEffect(() => {
    const n = box.current; if (!n || reduce || !n.animate) return;
    const cs = getComputedStyle(n), ease = cs.getPropertyValue('--dm-ease').trim() || 'ease';
    n.animate([{ height: '0px', marginTop: '0px', marginBottom: '0px', paddingTop: '0px', paddingBottom: '0px', opacity: 0, overflow: 'hidden' },
      { height: cs.height, marginTop: cs.marginTop, marginBottom: cs.marginBottom, paddingTop: cs.paddingTop, paddingBottom: cs.paddingBottom, opacity: 1, overflow: 'hidden' }], { duration: 420, easing: ease });
  }, []);
  return (
    <div ref={box} className="dm-cvx-note" aria-live="polite">
      <Icon name="spark" size="xs" />
      <span className={cx('dm-sw', phase === 'out' && 'dm-out')}>{phase === 'said' ? note : <span className="dm-shim">Reading the line</span>}</span>
    </div>
  );
}

/** A file the agent changed, with its changed lines marked. Click a line and the agent explains it. */
export function CodeViewer({ files, file, defaultFile, onFileChange, line, defaultLine = null, onLineChange, delay = 600, unchangedNote = 'Unchanged. The agent kept this line as it was.', onCopy, className }: CodeViewerProps) {
  const [name, setName] = useControllable(file, defaultFile ?? files[0]?.name ?? '', onFileChange);
  const [open, setOpen] = useControllable(line, defaultLine, onLineChange);
  const [copied, copy] = useCopied();
  const cur = files.find(f => f.name === name) ?? files[0];
  const changed = cur?.lines.filter(l => l.changed).length ?? 0;

  const pick = (n: string) => { if (n === name) return; setName(n); setOpen(null); };
  const ask = (k: number) => setOpen(open === k ? null : k);
  const key = (e: KeyboardEvent, k: number) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ask(k); } };
  const copyFile = () => { const t = cur.lines.map(l => text(l.code)).join('\n'); copy(t); onCopy?.(t); };

  return (
    <div data-dm="" className={cx('dm-cvx dm-panel', className)}>
      <div className="dm-cvx-h">
        <div className="dm-cvx-tabs" role="tablist">
          {files.map(f => <button key={f.name} type="button" role="tab" aria-selected={f === cur} className={cx('dm-cvx-tab', f === cur && 'dm-on')} onClick={() => pick(f.name)}>{f.name}</button>)}
        </div>
        <span className="dm-cvx-meta dm-num"><b>+{changed}</b> changed</span>
        <Tip label={copied ? 'Copied to clipboard' : 'Copy'} open={copied}>
          <button type="button" className={cx('dm-ib', copied && 'dm-copied')} aria-label="Copy file" onClick={copyFile}><Icon name={copied ? 'check' : 'copy'} /></button>
        </Tip>
      </div>
      <div className="dm-cvx-body">
        <pre className="dm-cvx-code">
          {cur?.lines.map((l, k) => {
            const e = enter(k * 18), on = open === k;
            return [
              <div key={cur.name + k} className={cx('dm-cvx-l', l.changed && 'dm-new', on && 'dm-on', e.className)} style={e.style}
                role="button" tabIndex={0} aria-expanded={on} onClick={() => ask(k)} onKeyDown={ev => key(ev, k)}>
                <span className="dm-n">{k + 1}</span>
                <span className="dm-c">{typeof l.code === 'string' ? l.code || ' ' : l.code.length ? l.code.map((p, j) => typeof p === 'string' ? p : <span key={j} className={TOKEN[p[0]]}>{p[1]}</span>) : ' '}</span>
              </div>,
              on && <Note key={cur.name + k + 'n'} note={l.note ?? unchangedNote} delay={delay} />
            ];
          })}
        </pre>
      </div>
    </div>
  );
}
code-viewer.cssReact · 29 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Code viewer ---------- */
.dm-cvx{overflow:hidden;display:flex;flex-direction:column}
.dm-cvx-h{display:flex;align-items:center;gap:2px;height:44px;padding:0 var(--dm-s-2);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-cvx-tabs{display:flex;gap:2px;flex:1;min-width:0;overflow-x:auto}
.dm-cvx-tab{display:flex;align-items:center;height:28px;padding:0 10px;border-radius:var(--dm-r-sm);font-size:12px;font-family:var(--dm-mono);color:var(--dm-muted);transition:color .2s,background .2s;white-space:nowrap}
@media (hover:hover) and (pointer:fine){.dm-cvx-tab:hover{color:var(--dm-fg)}}
/* the tabs scroll sideways, which would cut an outside ring: theirs is drawn inside */
.dm-cvx-tab:focus-visible{outline-offset:-2px}
.dm-cvx-tab.dm-on{background:var(--dm-surface);color:var(--dm-fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-on) 50%,transparent)}
.dm-cvx-meta{margin-left:auto;padding-right:2px;font-size:12px;color:var(--dm-muted);white-space:nowrap}
.dm-cvx-meta b{font-weight:500;color:var(--dm-ok)}
.dm-cvx-body{overflow-x:auto;min-height:232px}
.dm-cvx-code{margin:0;padding:var(--dm-s-2) 0;font-family:var(--dm-mono);font-size:12px;line-height:1.8}
.dm-cvx-l{display:flex;padding-right:14px;cursor:pointer;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-cvx-l:hover{background:var(--dm-row)}}
/* a line that takes the focus fills the scrolling code, which would cut an outside ring: its ring is drawn inside */
.dm-cvx-l:focus-visible{outline-offset:-2px}
.dm-cvx-l.dm-new{background:color-mix(in srgb,var(--dm-ok) 6%,transparent)}
.dm-cvx-l.dm-on{background:var(--dm-sel)}
@media (hover:hover) and (pointer:fine){.dm-cvx-l.dm-on:hover{background:var(--dm-sel)}}
.dm-cvx-l .dm-n{width:42px;text-align:right;padding-right:var(--dm-s-4);color:var(--dm-muted);user-select:none;flex:none;transition:color .2s}
.dm-cvx-l.dm-new .dm-n{color:var(--dm-ok)}
.dm-cvx-l.dm-on .dm-n{color:var(--dm-accent-fg,var(--dm-accent))}
.dm-cvx-l .dm-c{white-space:pre}
.dm-cvx-code .dm-k{color:var(--dm-pp)}.dm-cvx-code .dm-s{color:var(--dm-pm)}.dm-cvx-code .dm-f{color:var(--dm-pb)}.dm-cvx-code .dm-cm{color:var(--dm-muted);font-style:italic}.dm-cvx-code .dm-tg{color:var(--dm-pr)}.dm-cvx-code .dm-at{color:var(--dm-py)}
.dm-cvx-note{margin:var(--dm-s-1) 14px var(--dm-s-2) 42px;padding:var(--dm-s-2) 10px;border-radius:10px;background:var(--dm-chip);font-family:var(--dm-sans);font-size:12.5px;line-height:1.5;white-space:normal;color:var(--dm-fg2);display:flex;gap:var(--dm-s-2)}
.dm-cvx-note>svg{color:var(--dm-accent-fg,var(--dm-accent));flex:none;margin-top:3px}
code-viewer.htmlHTML · 9 lines
<div class="card" data-c="code-viewer">
  <div class="stage-in">
    <div class="cvx panel" data-reveal="flat">
      <div class="cvx-h"><div class="cvx-tabs" role="tablist"></div><span class="cvx-meta num"></span><button class="ib cvx-cp" aria-label="Copy file" data-tip="Copy"><svg class="i s"><use href="#i-copy"/></svg></button></div>
      <div class="cvx-body"><pre class="cvx-code"></pre></div>
    </div>
  </div>
</div>
code-viewer.cssHTML · 28 lines
/* ---------- Code viewer ---------- */
.cvx{overflow:hidden;display:flex;flex-direction:column}
.cvx-h{display:flex;align-items:center;gap:2px;height:44px;padding:0 var(--s-2);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.cvx-tabs{display:flex;gap:2px;flex:1;min-width:0;overflow-x:auto}
.cvx-tab{display:flex;align-items:center;height:28px;padding:0 10px;border-radius:var(--r-sm);font-size:12px;font-family:var(--mono);color:var(--muted);transition:color .2s,background .2s;white-space:nowrap}
@media (hover:hover) and (pointer:fine){.cvx-tab:hover{color:var(--fg)}}
/* the tabs scroll sideways, which would cut an outside ring: theirs is drawn inside */
.cvx-tab:focus-visible{outline-offset:-2px}
.cvx-tab.on{background:var(--surface);color:var(--fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-on) 50%,transparent)}
.cvx-meta{margin-left:auto;padding-right:2px;font-size:12px;color:var(--muted);white-space:nowrap}
.cvx-meta b{font-weight:500;color:var(--ok)}
.cvx-body{overflow-x:auto;min-height:232px}
.cvx-code{margin:0;padding:var(--s-2) 0;font-family:var(--mono);font-size:12px;line-height:1.8}
.cvx-l{display:flex;padding-right:14px;cursor:pointer;transition:background .2s}
@media (hover:hover) and (pointer:fine){.cvx-l:hover{background:var(--row)}}
/* a line that takes the focus fills the scrolling code, which would cut an outside ring: its ring is drawn inside */
.cvx-l:focus-visible{outline-offset:-2px}
.cvx-l.new{background:color-mix(in srgb,var(--ok) 6%,transparent)}
.cvx-l.on{background:var(--sel)}
@media (hover:hover) and (pointer:fine){.cvx-l.on:hover{background:var(--sel)}}
.cvx-l .n{width:42px;text-align:right;padding-right:var(--s-4);color:var(--muted);user-select:none;flex:none;transition:color .2s}
.cvx-l.new .n{color:var(--ok)}
.cvx-l.on .n{color:var(--accent-fg,var(--accent))}
.cvx-l .c{white-space:pre}
.cvx-code .k{color:var(--pp)}.cvx-code .s{color:var(--pm)}.cvx-code .f{color:var(--pb)}.cvx-code .cm{color:var(--muted);font-style:italic}.cvx-code .tg{color:var(--pr)}.cvx-code .at{color:var(--py)}
.cvx-note{margin:var(--s-1) 14px var(--s-2) 42px;padding:var(--s-2) 10px;border-radius:10px;background:var(--chip);font-family:var(--sans);font-size:12.5px;line-height:1.5;white-space:normal;color:var(--fg2);display:flex;gap:var(--s-2)}
.cvx-note>svg{color:var(--accent-fg,var(--accent));flex:none;margin-top:3px}
code-viewer.jsHTML · 55 lines
DM.def('code-viewer', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{copy}', I('copy'));
  const F = [
    { n:'PlanCard.tsx', t:'re', src:[
      ['<k>import</k> { Button } <k>from</k> <s>"@/ui/button"</s>;',0],
      ['',0],
      ['<k>export function</k> <f>PlanCard</f>({ plan }) {',0],
      ['  <cm>// One CTA per card, annual price first</cm>',1,'The agent removed the second link here. The design review said two CTAs split attention.'],
      ['  <k>return</k> (',0],
      ['    <tg>&lt;Card</tg> <at>padding</at>=<s>"lg"</s><tg>&gt;</tg>',0],
      ['      <tg>&lt;Price</tg> <at>value</at>={plan.annual} <at>was</at>={plan.monthly} <tg>/&gt;</tg>',1,'Shows the monthly price crossed out next to the annual one. This is the change behind the 18% lift.'],
      ['      <tg>&lt;Button</tg> <at>variant</at>=<s>"primary"</s><tg>&gt;</tg>{plan.cta}<tg>&lt;/Button&gt;</tg>',1,'Swapped the custom button for the shared one, so it picks up the library tokens.'],
      ['    <tg>&lt;/Card&gt;</tg>',0],
      ['  );',0],
      ['}',0]
    ]},
    { n:'tokens.css', t:'', src:[
      ['<f>:root</f> {',0],
      ['  --text-muted: <s>#5b6472</s>;',1,'Was #c9cfd8. Contrast on white goes from 2.1 to 5.9, which passes AA.'],
      ['  --radius-card: <s>16px</s>;',0],
      ['  --space-card: <s>24px</s>;',1,'Matched to the 4px grid. Was 18px.'],
      ['}',0]
    ]}
  ];
  const tabs = $('.cvx-tabs', r), code = $('.cvx-code', r), meta = $('.cvx-meta', r);
  tabs.innerHTML = F.map((f, i) => `<button class="cvx-tab" role="tab" data-i="${i}">${f.n}</button>`).join('');
  const hl = s => s.replace(/<(k|s|f|cm|tg|at)>/g, '<span class="$1">').replace(/<\/(k|s|f|cm|tg|at)>/g, '</span>');
  let cur = 0;
  function show(i){
    cur = i; $$('.cvx-tab', tabs).forEach((t, j) => { t.classList.toggle('on', j === i); t.setAttribute('aria-selected', j === i); });
    code.innerHTML = F[i].src.map((l, k) => `<div class="cvx-l${l[1] ? ' new' : ''}" data-k="${k}"><span class="n">${k + 1}</span><span class="c">${hl(l[0]) || ' '}</span></div>`).join('');
    stagger($$('.cvx-l', code), 18); meta.innerHTML = `<b>+${F[i].src.filter(l => l[1]).length}</b> changed`;
    $$('.cvx-l', code).forEach(el => el.onclick = () => ask(el));
  }
  // a note opens and closes its own height, so the code around it moves smoothly
  const fold = (n, open) => { const cs = getComputedStyle(n), ease = getComputedStyle(r).getPropertyValue('--ease').trim() || 'ease';
    const full = { height:cs.height, marginTop:cs.marginTop, marginBottom:cs.marginBottom, paddingTop:cs.paddingTop, paddingBottom:cs.paddingBottom, opacity:1, overflow:'hidden' };
    const none = { height:'0px', marginTop:'0px', marginBottom:'0px', paddingTop:'0px', paddingBottom:'0px', opacity:0, overflow:'hidden' };
    return n.animate(open ? [none, full] : [full, none], { duration:REDUCE ? 1 : 420, easing:ease }); };
  function ask(el){
    const k = +el.dataset.k, l = F[cur].src[k], open = el.classList.contains('on');
    $$('.cvx-note:not(.going)', code).forEach(n => { n.classList.add('going'); fold(n, false).onfinish = () => n.remove(); }); $$('.cvx-l', code).forEach(x => x.classList.remove('on'));
    if (open) return;
    el.classList.add('on');
    const note = html(`<div class="cvx-note">${I('spark','xs')}<span><span class="shim">Reading the line</span></span></div>`); el.after(note); fold(note, true);
    c.later(600, () => swap($('span', note), l[2] || 'Unchanged. The agent kept this line as it was.'));
  }
  $$('.cvx-tab', tabs).forEach(t => t.onclick = () => show(+t.dataset.i));
  const cp = $('.cvx-cp', r);
  cp.onclick = () => { const txt = F[cur].src.map(l => l[0].replace(/<[^>]+>/g, '').replace(/&lt;/g,'<').replace(/&gt;/g,'>')).join('\n'); copied(cp, txt); };
  show(0);
  // the preview asks about one line on its own, unless you take over first
  c.auto(1200, () => { if (!$('.cvx-note', code)) ask($$('.cvx-l', code)[6]); });
});
06

Questions

What people ask about it.

Does it highlight syntax automatically?
No. It renders the tokens you give it. Pass code as an array of plain strings and [token, text] pairs, using k, s, f, cm, tg or at.
Can the agent open a note on a line by itself?
Yes. Control line with the index of the line, starting at 0, and update it from onLineChange. The demo opens line 7's note this way after a short wait.
What does the "Reading the line" pause do?
It shows for delay milliseconds, 600 by default, before the note text appears. Set delay to 0 if you want the note to show right away.
07

More in Sources and output

What the agent read, what it produced and where each answer came from.

Terminal streamShell commands the agent runs, with their output as it streams in.
Citation hover cardHover a citation to see the exact passage it came from.
Source stackEverything the agent read, folded into one quiet pill.
Field extractionThe agent pulls fields out of a document and shows where each came from.
Metric insightA number, its trend, and the agent's read on why it moved.
Details panelProperties of a run you can change in place.
Task tableAgent tasks in a sortable table with bulk actions.