Developer

Code block

Frames a block of code with a filename, line numbers, highlighted or diff lines, copy and an optional fold.

Built on
<figure><pre>Async Clipboard APIinterpolate-size
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.3 kB
Built from
Button
deploy.tsts
const res = await fetch(
  "/v1/deploys",
  { method: "POST" },
);
Code
import { CodeBlock } from "@/components/equip/code-block";

const code = `const res = await fetch(
  "/v1/deploys",
  { method: "POST" },
);`;

export default function Demo() {
  return (
    <div style={{ width: 224 }}>
      <CodeBlock code={code} filename="deploy.ts" language="ts" highlight={[3]} />
    </div>
  );
}

Examples

Diff with line numbers

Added and removed lines carry a plus or a minus as well as a tint, and screen readers hear which is which.

equip.config.tsts
export const config = {
  region: "eu-west-1",
  region: "eu-central-1",
  retries: 3,
  timeoutMs: 10_000,
};
Code
import { CodeBlock } from "@/components/equip/code-block";

const code = `export const config = {
  region: "eu-west-1",
  region: "eu-central-1",
  retries: 3,
  timeoutMs: 10_000,
};`;

export default function Example() {
  return (
    <CodeBlock
      code={code}
      filename="equip.config.ts"
      language="ts"
      lineNumbers
      removed={[2]}
      added={[3, 5]}
      style={{ maxWidth: 420 }}
    />
  );
}

Collapsed long file

Copy always takes the whole file, even while most of it is folded away.

.github/workflows/deploy.ymlyaml
name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test
      - run: pnpm build

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: pnpm dlx equip deploy --prod
Code
import { CodeBlock } from "@/components/equip/code-block";

const code = `name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

concurrency:
  group: deploy-\${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test
      - run: pnpm build

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: pnpm dlx equip deploy --prod`;

export default function Example() {
  return (
    <CodeBlock code={code} filename=".github/workflows/deploy.yml" language="yaml" lineNumbers maxHeight={180} style={{ maxWidth: 420 }} />
  );
}

Soft wrap without a header

With no filename or language the copy button sits over the code, and long lines wrap instead of scrolling.

curl -X POST https://api.example.com/v1/deploys -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" -d '{"project":"web","ref":"main"}'
Code
import { CodeBlock } from "@/components/equip/code-block";

const code = `curl -X POST https://api.example.com/v1/deploys -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" -d '{"project":"web","ref":"main"}'`;

export default function Example() {
  return <CodeBlock code={code} wrap style={{ maxWidth: 380 }} />;
}

Props

PropTypeDefaultWhat it does
codestringnoneThe source text. Always what the copy button copies.
filenamestringnoneShown in the header and used to name the scroll region.
languagestringnoneA quiet label in the header. Nothing is parsed or coloured.
lineNumbersbooleanfalseShows line numbers, which are never selected or copied.
highlightnumber[][]1-based lines to emphasise with a tint and a start-edge bar.
addednumber[][]1-based lines marked with a plus and an ok tint.
removednumber[][]1-based lines marked with a minus and a danger tint.
wrapbooleanfalseSoft-wraps long lines instead of scrolling sideways.
maxHeightnumbernoneFolds taller code to this many pixels, with a button that shows every line.
copybooleantrueShows a copy button.
childrenReactNode[]nonePre-highlighted nodes, one per line, drawn instead of the plain lines of code.
...restComponentProps<"figure">noneEvery native figure attribute.

Anatomy

root.eq-code-blockA figure.
header.eq-code-block-headerThe figcaption, present with a filename or language.
filename.eq-code-block-filename
language.eq-code-block-language
copy.eq-code-block-copyFloats over the code when there is no header.
copy label.eq-code-block-copy-labelCopy, then Copied with a drawn tick.
scroll.eq-code-block-scrollThe focusable, labelled region that scrolls.
pre.eq-code-block-pre
code.eq-code-block-code
line.eq-code-block-lineIts line number is generated content from data-line.
diff mark.eq-code-block-markPlus or minus, drawn by CSS.
line text.eq-code-block-text
footer.eq-code-block-footerHolds the show all lines button when folded.
status.eq-code-block-srAnnounces Copied.

Keyboard

TabMoves to the copy button, the code region and the show all lines button.
Arrow keysScroll the focused code region.
EnterSpaceCopies the code, or folds and unfolds it.

Notes

  • Line numbers and diff marks are generated content, so selecting the code never picks them up.
  • Added and removed lines carry a plus or minus as well as a tint, and screen readers hear Added or Removed before the line.
  • The copy button always copies code in full, even while the block is folded or children draw the lines.
  • It parses nothing: pass highlighted nodes as children if you run a highlighter yourself.
  • The server cannot measure, so a block with maxHeight renders folded and drops the button after mount if everything fits.
  • The file is a client component only for copying and folding; everything else renders on the server.
  • Unfolding animates its height where interpolate-size is supported (Chromium); elsewhere it opens at once. Copy falls back to a temporary textarea and execCommand where the async clipboard is unavailable.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/code-block.tsx212 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, ReactNode } from "react";
import { Button } from "./button";
import "./code-block.css";

export type CodeBlockProps = Omit<ComponentProps<"figure">, "children"> & {
  /** The source text. Always what the copy button copies, even when `children` draws the lines. */
  code: string;
  /** Shown in the header and used to name the scroll region. */
  filename?: string;
  /** A quiet label in the header, such as "ts" or "bash". Nothing is parsed. */
  language?: string;
  lineNumbers?: boolean;
  /** 1-based line numbers to emphasise. */
  highlight?: number[];
  /** 1-based line numbers shown as added, with a plus. */
  added?: number[];
  /** 1-based line numbers shown as removed, with a minus. */
  removed?: number[];
  /** Soft-wraps long lines instead of scrolling sideways. */
  wrap?: boolean;
  /** Collapses taller code to this height in pixels, with a button that shows the rest. */
  maxHeight?: number;
  /** Shows a copy button. */
  copy?: boolean;
  /** Pre-highlighted nodes, one per line, drawn instead of the plain lines of `code`. */
  children?: ReactNode[];
};

const COPIED_MS = 1600;

type CopyState = "idle" | "copied" | "failed";

/** Last resort for browsers without the async clipboard: a throwaway textarea inside the part. */
function legacyCopy(text: string, host: HTMLElement) {
  const active = document.activeElement;
  const area = document.createElement("textarea");
  area.value = text;
  area.setAttribute("readonly", "");
  area.setAttribute("aria-hidden", "true");
  area.style.position = "fixed";
  area.style.inset = "0 auto auto 0";
  area.style.opacity = "0";
  area.style.pointerEvents = "none";
  host.appendChild(area);
  area.select();
  let ok = false;
  try {
    ok = document.execCommand("copy");
  } catch {
    ok = false;
  }
  host.removeChild(area);
  if (active instanceof HTMLElement) active.focus();
  return ok;
}

export function CodeBlock({
  code,
  filename,
  language,
  lineNumbers = false,
  highlight = [],
  added = [],
  removed = [],
  wrap = false,
  maxHeight,
  copy = true,
  children,
  className,
  style,
  ...rest
}: CodeBlockProps) {
  const id = useId();
  const rootRef = useRef<HTMLElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const [copied, setCopied] = useState<CopyState>("idle");
  const [expanded, setExpanded] = useState(false);
  const [fits, setFits] = useState(false);

  const rows: ReactNode[] = children ?? code.replace(/\r?\n$/, "").split(/\r?\n/);
  const count = rows.length;
  const diff = added.length > 0 || removed.length > 0;
  const collapsible = maxHeight != null && !fits;
  const collapsed = collapsible && !expanded;
  const hasHeader = filename != null || language != null;

  // The server cannot measure, so it assumes the code is taller than maxHeight; drop the button if not.
  useEffect(() => {
    const region = scrollRef.current;
    if (maxHeight == null || !region) return;
    setFits(region.scrollHeight <= maxHeight + 1);
  }, [maxHeight, code, children]);

  useEffect(() => {
    if (copied === "idle") return;
    const t = window.setTimeout(() => setCopied("idle"), COPIED_MS);
    return () => window.clearTimeout(t);
  }, [copied]);

  async function handleCopy() {
    let ok = false;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(code);
        ok = true;
      }
    } catch {
      ok = false;
    }
    if (!ok && rootRef.current) ok = legacyCopy(code, rootRef.current);
    setCopied(ok ? "copied" : "failed");
  }

  const copyButton = copy && (
    <button
      type="button"
      className="eq-code-block-copy"
      data-state={copied}
      aria-label={filename ? `Copy ${filename}` : "Copy code"}
      onClick={handleCopy}
    >
      {copied === "copied" ? (
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M3.5 8.5l3 3 6-7" pathLength={1} />
        </svg>
      ) : (
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
          <path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" />
        </svg>
      )}
      <span className="eq-code-block-copy-label" aria-hidden="true">
        {copied === "copied" ? "Copied" : copied === "failed" ? "Failed" : "Copy"}
      </span>
    </button>
  );

  return (
    <figure
      {...rest}
      ref={rootRef}
      className={["eq-code-block", className].filter(Boolean).join(" ")}
      style={{ "--_digits": String(count).length, ...(maxHeight != null && { "--_max": `${maxHeight}px` }), ...style } as CSSProperties}
      data-numbers={lineNumbers || undefined}
      data-diff={diff || undefined}
      data-wrap={wrap || undefined}
      data-bare={!hasHeader || undefined}
    >
      {hasHeader ? (
        <figcaption className="eq-code-block-header">
          {filename != null && <span className="eq-code-block-filename">{filename}</span>}
          {language != null && <span className="eq-code-block-language">{language}</span>}
          {copyButton}
        </figcaption>
      ) : (
        copyButton
      )}
      <div
        ref={scrollRef}
        id={`${id}-code`}
        className="eq-code-block-scroll"
        role="region"
        tabIndex={0}
        aria-label={filename ?? (language ? `${language} code` : "Code")}
        data-collapsed={collapsed || undefined}
      >
        <pre className="eq-code-block-pre">
          <code className="eq-code-block-code">
            {rows.map((row, i) => {
              const n = i + 1;
              const change = added.includes(n) ? "added" : removed.includes(n) ? "removed" : undefined;
              return (
                <span
                  key={n}
                  className="eq-code-block-line"
                  data-line={n}
                  data-change={change}
                  data-highlight={highlight.includes(n) || undefined}
                >
                  {diff && <span className="eq-code-block-mark" />}
                  <span className="eq-code-block-text">
                    {row}
                    {"\n"}
                  </span>
                </span>
              );
            })}
          </code>
        </pre>
      </div>
      {collapsible && (
        <div className="eq-code-block-footer">
          <Button
            variant="ghost"
            size="s"
            aria-expanded={expanded}
            aria-controls={`${id}-code`}
            onClick={() => setExpanded((e) => !e)}
          >
            {expanded ? "Show fewer lines" : `Show all ${count} lines`}
          </Button>
        </div>
      )}
      <span className="eq-code-block-sr" role="status">
        {copied === "copied" ? "Copied" : copied === "failed" ? "Copy failed" : ""}
      </span>
    </figure>
  );
}
components/equip/code-block.css285 lines
@layer equip {
  .eq-code-block {
    position: relative;
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    min-width: 0;
    margin: 0;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-l);
    background: var(--eq-surface);
    color: var(--eq-ink);
  }

  .eq-code-block-header {
    display: flex;
    align-items: center;
    gap: var(--eq-space-2);
    min-width: 0;
    min-height: var(--eq-h-s);
    padding-inline: var(--eq-space-3) 2px;
    border-bottom: 1px solid var(--eq-line);
  }

  .eq-code-block-filename {
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    color: var(--eq-ink);
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight);
    line-height: 1.3;
    white-space: nowrap;
    text-overflow: ellipsis;
  }

  .eq-code-block-language {
    flex: none;
    color: var(--eq-ink-3);
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight);
    line-height: 1;
  }

  .eq-code-block-copy {
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: var(--eq-space-1);
    height: 24px;
    margin: 0 0 0 auto;
    padding-inline: var(--eq-space-2);
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-2);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-code-block-copy > svg {
    width: 14px;
    height: 14px;
  }

  /* Without a header the button sits over the top end of the code. */
  .eq-code-block[data-bare] > .eq-code-block-copy {
    position: absolute;
    z-index: 1;
    inset-block-start: var(--eq-space-2);
    inset-inline-end: var(--eq-space-2);
    border: 1px solid var(--eq-line);
    background: var(--eq-raised);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
  }

  @media (hover: hover) {
    .eq-code-block-copy:hover {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }

    .eq-code-block[data-bare] > .eq-code-block-copy:hover {
      background: color-mix(in srgb, var(--eq-raised) 90%, var(--eq-ink));
    }
  }

  .eq-code-block-copy:active {
    scale: var(--eq-press);
  }

  .eq-code-block-copy:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-code-block-copy[data-state="copied"] {
    color: var(--eq-ok);
  }

  .eq-code-block-copy[data-state="failed"] {
    color: var(--eq-danger);
  }

  /* Copied: the tick draws itself in. */
  .eq-code-block-copy[data-state="copied"] path {
    stroke-dasharray: 1;
    stroke-dashoffset: 0;
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-ease);
  }

  @starting-style {
    .eq-code-block-copy[data-state="copied"] path {
      stroke-dashoffset: 1;
    }
  }

  /* The region clips its own tints to the frame's corners, so buttons keep room for their focus rings. */
  .eq-code-block-scroll {
    --_r: calc(var(--eq-radius-l) - 1px);

    min-width: 0;
    overflow: auto;
  }

  .eq-code-block[data-bare] .eq-code-block-scroll {
    border-start-start-radius: var(--_r);
    border-start-end-radius: var(--_r);
  }

  .eq-code-block:not(:has(> .eq-code-block-footer)) .eq-code-block-scroll {
    border-end-start-radius: var(--_r);
    border-end-end-radius: var(--_r);
  }

  /* Inset, so the ring sits inside the frame's border rather than on it. */
  .eq-code-block-scroll:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }

  .eq-code-block-scroll[data-collapsed] {
    block-size: var(--_max);
    overflow-y: hidden;
    mask-image: linear-gradient(to bottom, var(--eq-ink) calc(100% - var(--eq-space-6)), transparent);
  }

  /* Expanding grows the height where the engine can size to auto. Elsewhere it is instant. */
  @supports (interpolate-size: allow-keywords) {
    .eq-code-block {
      interpolate-size: allow-keywords;
    }

    .eq-code-block-scroll {
      transition: block-size var(--eq-dur-move) var(--eq-ease);
    }

    .eq-code-block-scroll[data-collapsed] {
      transition-duration: var(--eq-dur);
    }
  }

  .eq-code-block-pre {
    margin: 0;
    padding-block: calc(var(--eq-space-2) - 1px);
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight);
    line-height: 1.5;
    tab-size: 2;
  }

  /* As wide as the longest line, so every tint spans the full scroll width. */
  .eq-code-block-code {
    display: block;
    width: max-content;
    min-width: 100%;
    font: inherit;
  }

  .eq-code-block[data-wrap] .eq-code-block-code {
    width: auto;
  }

  .eq-code-block-line {
    display: flex;
    min-height: 1lh;
    padding-inline: calc(var(--eq-space-3) - 2px) var(--eq-space-4);
    border-inline-start: 2px solid transparent;
  }

  .eq-code-block[data-bare]:has(> .eq-code-block-copy) .eq-code-block-line:first-child {
    padding-inline-end: calc(var(--eq-space-6) + var(--eq-space-6));
  }

  .eq-code-block-line[data-highlight] {
    border-inline-start-color: var(--eq-ink);
    background: var(--eq-tint-strong);
  }

  .eq-code-block-line[data-change="added"] {
    background: color-mix(in srgb, var(--eq-ok) 13%, transparent);
  }

  .eq-code-block-line[data-change="removed"] {
    background: color-mix(in srgb, var(--eq-danger) 13%, transparent);
  }

  /* Generated content never enters a selection or the clipboard. */
  .eq-code-block[data-numbers] .eq-code-block-line::before {
    content: attr(data-line);
    content: attr(data-line) / "";
    flex: none;
    width: calc(var(--_digits) * 1ch);
    margin-inline-end: var(--eq-space-3);
    color: var(--eq-ink-3);
    text-align: end;
    user-select: none;
    -webkit-user-select: none;
  }

  /* On a tinted line --eq-ink-3 drops under 4.5:1, so the number steps up a tone. */
  .eq-code-block[data-numbers] .eq-code-block-line:is([data-highlight], [data-change])::before {
    color: var(--eq-ink-2);
  }

  .eq-code-block-mark {
    flex: none;
    width: 2ch;
    user-select: none;
    -webkit-user-select: none;
  }

  .eq-code-block-line[data-change="added"] > .eq-code-block-mark::before {
    content: "+";
    content: "+" / "Added: ";
    color: var(--eq-ok);
  }

  .eq-code-block-line[data-change="removed"] > .eq-code-block-mark::before {
    content: "-";
    content: "-" / "Removed: ";
    color: var(--eq-danger);
  }

  .eq-code-block-text {
    flex: 1 0 auto;
  }

  .eq-code-block[data-wrap] .eq-code-block-text {
    flex: 1 1 auto;
    min-width: 0;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }

  .eq-code-block-footer {
    display: flex;
    justify-content: center;
    padding: var(--eq-space-1);
    border-top: 1px solid var(--eq-line);
  }

  /* Read by screen readers, never seen. */
  .eq-code-block-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index