Code Block
A line-numbered listing and a unified diff.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/foundation.css";
import { useCallback, useState, type ReactNode } from "react";
/* ─────────────────────────────────────────────────────────
* CODE BLOCK
* A light editor panel with two versions (switch in the card):
* · Code — a line-numbered listing
* · Diff — a unified diff: old/new gutters, a green/red accent
* bar and row tint, plus word-level add/del highlights.
* Both share syntax coloring, insets, and wrapping behavior.
* ───────────────────────────────────────────────────────── */
const FILE = "plan.ts";
const CODE_LINES = [
"export async function planBatch() {",
' const project = await getProject("pistachio");',
" const base = await dairy.fetch({ project });",
' await freezer.store(base, { temp: "-16C" });',
" if (!base.approved) return null;",
" return base.gallons;",
"}",
];
/* A single run of code within a diff row; `change` tints it as an add/del. */
export type CodePiece = { text: string; change?: "add" | "del" };
/* One row of a unified diff: old/new line numbers, its kind, and its pieces. */
export type DiffRow = {
old: number | null;
cur: number | null;
type: "ctx" | "add" | "del";
pieces: CodePiece[];
};
/* Prominent copy strings on the code block. */
export type CodeBlockLabels = { copy: string; copied: string };
// Back-compat internal aliases for the local component signatures.
type Piece = CodePiece;
type Row = DiffRow;
const DIFF: Row[] = [
{ old: 1, cur: 1, type: "ctx", pieces: [{ text: "export async function planBatch() {" }] },
{ old: 2, cur: 2, type: "ctx", pieces: [{ text: ' const project = await getProject("pistachio");' }] },
{ old: 3, cur: 3, type: "ctx", pieces: [{ text: " const base = await dairy.fetch({ project });" }] },
{ old: 4, cur: null, type: "del", pieces: [{ text: " await freezer.store(base, { temp: " }, { text: '"-14C"', change: "del" }, { text: " });" }] },
{ old: null, cur: 4, type: "add", pieces: [{ text: " await freezer.store(base, { temp: " }, { text: '"-16C"', change: "add" }, { text: " });" }] },
{ old: null, cur: 5, type: "add", pieces: [{ text: " if (!base.approved) return null;" }] },
{ old: 5, cur: 6, type: "ctx", pieces: [{ text: " return base.gallons;" }] },
{ old: 6, cur: 7, type: "ctx", pieces: [{ text: "}" }] },
];
const HATCH = "repeating-linear-gradient(45deg, var(--red) 0, var(--red) 1.5px, transparent 1.5px, transparent 3px)";
/* light syntax coloring — keywords/imports/conditionals, functions, strings & numbers */
const KEYWORDS = new Set(["import", "from", "export", "default", "async", "function", "const", "let", "var", "await", "return", "if", "else", "for", "while", "new", "throw", "try", "catch", "null", "true", "false", "undefined"]);
const TOKEN = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|\b\d+(?:\.\d+)?\b|\b(?:import|from|export|default|async|function|const|let|var|await|return|if|else|for|while|new|throw|try|catch|null|true|false|undefined)\b|[A-Za-z_$][\w$]*(?=\s*\())/g;
function highlight(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
let last = 0;
let k = 0;
for (const m of text.matchAll(TOKEN)) {
const idx = m.index ?? 0;
const t = m[0];
if (idx > last) nodes.push(<span key={k++}>{text.slice(last, idx)}</span>);
let color: string;
let weight: number | undefined;
if (/^["'`]/.test(t) || /^\d/.test(t)) color = "var(--orange)"; // string / number
else if (KEYWORDS.has(t)) color = "var(--highlight-ink)"; // keyword / import / conditional
else { color = "var(--ink)"; weight = 500; } // function call
nodes.push(<span key={k++} style={{ color, fontWeight: weight }}>{t}</span>);
last = idx + t.length;
}
if (last < text.length) nodes.push(<span key={k++}>{text.slice(last)}</span>);
return nodes;
}
function Pieces({ pieces }: { pieces: Piece[] }) {
return (
<>
{pieces.map((p, i) => {
if (p.change) {
const add = p.change === "add";
return (
<span
key={i}
className="rounded-[3px]"
style={{
background: `color-mix(in srgb, var(--${add ? "green" : "red"}) 18%, transparent)`,
padding: "0 2px",
margin: "0 -1px",
boxDecorationBreak: "clone",
WebkitBoxDecorationBreak: "clone",
}}
>
{highlight(p.text)}
</span>
);
}
return <span key={i}>{highlight(p.text)}</span>;
})}
</>
);
}
function FileIcon() {
return (
<svg aria-hidden width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 text-ink-3">
<path d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
);
}
const DEFAULT_LABELS: CodeBlockLabels = { copy: "Copy", copied: "Copied" };
export type CodeBlockProps = {
/** Which view to render — "Code" (line-numbered listing) or "Diff". */
variant?: string;
/** The lines shown in the Code view. */
lines?: string[];
/** Raw text placed on the clipboard by Copy. Defaults to `lines` joined. */
code?: string;
/** The unified-diff rows shown in the Diff view. */
diff?: DiffRow[];
/** Filename shown in the header. */
filename?: string;
/** Prominent copy strings. */
labels?: Partial<CodeBlockLabels>;
/** Called with the copied text after a successful copy. */
onCopy?: (text: string) => void;
};
export default function CodeBlock({
variant = "Code",
lines = CODE_LINES,
code,
diff = DIFF,
filename = FILE,
labels,
onCopy,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const isDiff = variant === "Diff";
const text = { ...DEFAULT_LABELS, ...labels };
const raw = code ?? lines.join("\n");
const copy = useCallback(() => {
navigator.clipboard.writeText(raw).then(() => {
setCopied(true);
onCopy?.(raw);
setTimeout(() => setCopied(false), 1500);
});
}, [raw, onCopy]);
const added = diff.filter((r) => r.type === "add").length;
const removed = diff.filter((r) => r.type === "del").length;
return (
<div className="w-full max-w-105 overflow-hidden rounded-card bg-card shadow-card">
{/* header — file · (diff stat | copy) */}
<div className="flex h-11 items-center gap-2 border-b border-line px-4 text-[12.5px]">
<span className="inline-flex min-w-0 items-center gap-[7px]">
<FileIcon />
<span className="truncate font-mono leading-none text-ink">{filename}</span>
</span>
{isDiff ? (
<span className="ml-auto inline-flex items-center gap-2 font-mono text-[12px] leading-none tabular-nums">
<span className="text-green">+{added}</span>
<span className="text-red">-{removed}</span>
</span>
) : (
<button
type="button"
aria-label="Copy code"
onClick={copy}
className={`-mr-1 ml-auto flex h-6 items-center gap-1 rounded-[6px] px-1.5 text-[12px]
font-medium transition-colors duration-100 hover:bg-hover
${copied ? "text-green" : "text-ink-3 hover:text-ink"}`}
>
{copied ? (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
) : (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
)}
{copied ? text.copied : text.copy}
</button>
)}
</div>
{/* body — equal 12px inset on top / left / right; lines wrap */}
<div className="py-3 font-mono text-[12.5px] leading-[1.65] text-ink-2">
{isDiff ? (
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-5 w-px bg-line" />
{diff.map((r, i) => {
const add = r.type === "add";
const del = r.type === "del";
// one gutter column: removals keep the old number, additions/context show the new one
const num = del ? r.old : r.cur;
return (
<div
key={i}
className={`relative grid grid-cols-[20px_minmax(0,1fr)] items-start
${add ? "bg-green-tint" : del ? "bg-red-tint" : ""}`}
>
{(add || del) && (
<span className="absolute inset-y-0 left-0 w-[3px]" style={{ background: add ? "var(--green)" : HATCH }} />
)}
<span className={`select-none text-center text-[11px] ${add ? "text-green" : del ? "text-red" : "text-ink-3"}`}>{num ?? ""}</span>
<code className="pr-3 pl-1 break-words whitespace-pre-wrap">
<Pieces pieces={r.pieces} />
</code>
</div>
);
})}
</div>
) : (
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-5 w-px bg-line" />
{lines.map((line, i) => (
<div key={i} className="grid grid-cols-[20px_minmax(0,1fr)] items-start">
<span className="select-none text-center text-[11px] text-ink-3">{i + 1}</span>
<code className="pr-3 pl-1 break-words whitespace-pre-wrap">{highlight(line)}</code>
</div>
))}
</div>
)}
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ward/code-blocknpx shadcn@latest add @ward/code-blockyarn dlx shadcn@latest add @ward/code-blockbunx --bun shadcn@latest add @ward/code-blockAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/code-block.json
Add the Ward items it builds on.
npx shadcn@latest add @ward/foundationCopy each file from the Code tab into:
components/ward/CodeBlock.tsx
Update the import paths to match your project.
Usage
import CodeBlock from "@/components/ward/CodeBlock";<CodeBlock
variant="Code"
filename="greet.ts"
lines={[
"export function greet(name: string) {",
' return "Hello, " + name;',
"}",
]}
labels={{ copy: "Copy", copied: "Copied" }}
onCopy={(text) => console.log(text)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "Code" | "Diff" | "Code" | Shows either a line-numbered listing or a unified diff. |
lines | string[] | CODE_LINES | Lines of code shown in the Code view. |
code | string | - | Text copied to the clipboard by the Copy button, which falls back to the lines joined with new lines. |
diff | DiffRow[] | DIFF | Unified diff rows shown in the Diff view, each with old and new line numbers, a row type and its text pieces. |
filename | string | "plan.ts" | File name shown in the header. |
labels | Partial<CodeBlockLabels> | - | Overrides for the Copy button text before and after copying. |
onCopy | (text: string) => void | - | Called with the copied text after a successful copy. |
Ward · Version 1.1.0 · Registry manifest