Interface gallery
Copy-ready samples of every element a Claude Code mod can draw, from Text and Markdown to buttons, inputs, diffs, rasters and SVG, with what each looks like.
This page is a catalogue of the elements a mod can draw, each with a sample you can paste and a description of how it renders. Pick an element by what you want on screen. For how drawing works, read /docs/plugins/mods/interface first; for every prop, check the elements reference and your generated types.
A harness for trying samples
The samples are fragments, not whole mods. This tiny mod adds /sample, opens a pane, and draws whatever you put after return. Create sample-pane/ with the usual three files.
sample-pane/.claude-plugin/plugin.json:
{ "name": "sample-pane", "version": "0.1.0", "description": "Draws one sample in a pane" }
sample-pane/hooks/hooks.json:
{ "modules": ["./register.js"] }
sample-pane/hooks/register.js:
const noop = () => {} // stand-in callback for samples that need one
let format = 'json' // used by the Select sample
const BLANK = 0x01000000 // terminal default colour, used by the Raster sample
const pack = (rows) =>
new Uint8Array(Uint32Array.from(rows.flat().flatMap(([ch, fg]) => [ch.codePointAt(0), fg, BLANK])).buffer).toBase64()
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'sample', description: 'Open the sample pane' })
return next(e)
})
on('command.run', { command: 'sample' }, async ($) => {
await $.ui.open({ id: 'sample', focus: true, closeOnEscape: true })
return {}
})
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== 'sample') return next(e)
const { Box, Text, Button, Input, Select, Link, Markdown, Code, Raster, Svg } = $.ui.resolve(e)
// Paste a sample over the next line
return Text({ children: ['Replace me with a sample'] })
})
}
Run claude --plugin-dir ./sample-pane, then /sample. Each time you paste a new sample and save, the module reloads; run /sample again to see it.
Text
Text
Styled strings. One line per style:
Box({
flexDirection: 'column',
children: [
Text({ children: ['Build #482'] }),
Text({ bold: true, children: ['bold: status passed'] }),
Text({ italic: true, children: ['italic: queued 2 min ago'] }),
Text({ underline: true, children: ['underline: main'] }),
Text({ strikethrough: true, children: ['strikethrough: flaky test skipped'] }),
Text({ dimColor: true, children: ['dimColor: 14 s'] }),
Text({ inverse: true, children: [' inverse: LIVE '] }),
Text({ color: 'green', children: ["color: 'green'"] }),
Text({ backgroundColor: 'magenta', children: ["backgroundColor: 'magenta'"] }),
],
})
dimColor renders grey. backgroundColor only fills the width of the text itself, so pad with spaces if you want a wider chip.
Markdown
Formats text the way Claude's replies look. Content goes in text, not children:
Markdown({
text: '### Deploy checklist\n\n1. Run `pnpm test`\n2. Bump **version**\n3. Tag the release\n\n> Fridays need a second reviewer.',
})
Headings render bold without their # marks, inline code renders coloured without backticks, numbered lists keep their numbers, and quotes render italic with a bar down the left.
Link
A label followed by its URL:
Link({ href: 'https://github.com/acme/api/pull/812', label: 'PR #812' })
The terminal prints the URL after the label as text; whether it is clickable depends on the user's terminal.
Code and diffs
Code
Syntax-highlighted source in the user's theme colours. Give language, or a path to infer it; startLine numbers the lines:
Code({
path: 'src/lib/vat.ts',
startLine: 18,
source: 'export function addVat(net: number, rate = 0.2) {\n return Math.round(net * (1 + rate) * 100) / 100\n}',
})
Code as a diff
Set format: 'diff' and pass unified diff hunks:
Code({
format: 'diff',
source: '@@ -18,3 +18,3 @@\n export function addVat(net: number, rate = 0.2) {\n- return net * (1 + rate)\n+ return Math.round(net * (1 + rate) * 100) / 100\n }',
})
Line numbers replace the @@ header. Removed lines are shaded red, added lines green, and where a removed and added line are similar, the changed words get a stronger shade. Combine this with a tool.call hook on Edit to build your own change reviewer.
Layout
Box
Rows, columns, gaps, padding and borders:
Box({
flexDirection: 'column',
gap: 1,
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [Text({ bold: true, children: ['api'] }), Text({ color: 'green', children: ['healthy'] }), Text({ dimColor: true, children: ['p95 120 ms'] })],
}),
Box({
borderStyle: 'single',
paddingX: 1,
children: [Text({ children: ['Last deploy: 09:42 by cameron'] })],
}),
],
})
A bordered box stretches to the pane's width. Border names are listed in the reference; anything unknown (such as 'rounded') silently draws no border.
Input controls
Users Tab between controls and act on the focused one. Opening the pane with focus: true gives it the keyboard; add autoFocus: true to the field that should receive typing immediately. Keys are covered in /docs/plugins/mods/interface.
Button
Box({
flexDirection: 'column',
children: [
Button({ key: 'approve', label: 'Approve', onPress: noop }),
Button({ key: 'retry', label: 'Retry', hotkey: 'r', plain: true, onPress: noop }),
Button({ key: 'later', label: 'Later', dimColor: true, onPress: noop }),
],
})
The default style draws [ Approve ] in brackets. plain: true drops the brackets and shows the hotkey as r: Retry, with the key coloured. dimColor greys the button. The focused button draws in inverse video.
Input
A one-line field that calls onSubmit on Enter:
Input({
key: 'branch',
label: 'Branch',
placeholder: 'feature/...',
value: '',
submitLabel: 'create',
onSubmit: noop,
})
Unfocused, it shows Branch: feature/... with the placeholder dim. Focused, the label turns bold, a cursor appears and ⏎ create shows after the field. Typing replaces the placeholder.
Select
Pick one option; onSelect receives its value:
Select({
key: 'export',
label: 'Export as',
value: format,
options: [
{ value: 'json', label: 'JSON' },
{ value: 'csv', label: 'CSV' },
{ value: 'xlsx', label: 'Excel' },
],
onSelect: (v) => {
format = v
},
})
Closed, it shows the label, the current option's label and a small down arrow. Open, it lists the options with the highlighted one in inverse video; picking closes it. Remember to call $.ui.invalidate('ui.render') in real code so the new value is drawn.
Pictures
Raster (terminal only)
A grid of coloured character cells, packed with the pack helper from the harness. A tiny traffic-light status board:
Raster({
key: 'lights',
columns: 4,
rows: 2,
cells: pack([
[['●', 0x43a047], ['●', 0x43a047], ['●', 0xfdd835], ['●', 0xe53935]],
[['▁', 0x90caf9], ['▃', 0x64b5f6], ['▅', 0x42a5f5], ['▇', 0x1e88e5]],
]),
})
Colours are rounded to a smaller palette, so the exact hex you pass may render slightly differently.
Svg (Desktop only)
Svg({
alt: 'Donut chart: 72 percent of context used',
width: 80,
height: 80,
source:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle cx="18" cy="18" r="15.9" fill="none" stroke="#e0e0e0" stroke-width="4"/><circle cx="18" cy="18" r="15.9" fill="none" stroke="#1e88e5" stroke-width="4" stroke-dasharray="72 28" transform="rotate(-90 18 18)"/></svg>',
})
In the terminal a pane returning only an Svg is empty, so branch on e.surface and return something else there.
Image and Client
Image draws a PNG or raw RGBA pixels in the terminal. Client is a region drawn by a second file of yours, for animation and pointer input. Props for both are in the reference.
An Image only appears as a picture when Claude Code detects at startup that the terminal draws kitty graphics protocol images with Unicode placeholders. That works in kitty 0.28+ and Ghostty once they answer Claude Code's graphics query. It does not work:
- in any other terminal, or one that does not answer the query;
- inside tmux or screen, in any terminal including kitty and Ghostty;
- in background sessions, whatever terminal they are attached from.
Otherwise the user sees your alt text, dimmed, so make it meaningful on its own. Users whose terminal does support placeholder images can set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 to skip detection; inside tmux or screen that does not help, because the image is sent without passthrough wrapping.
Places beyond panes
All samples here draw in a pane, but a mod can also draw in:
- the band above the prompt and panes: /docs/plugins/mods/interface;
- Claude Code's own rows such as the spinner: /docs/plugins/mods/interface;
- toasts, the status line and log lines: /docs/plugins/mods/api;
- the question dialog, via
$.ui.ask: /docs/plugins/mods/events.