Teseria
All elements

Code block

Code that writes itself in. Tokens blur in left to right, line after line, then the highlight bars draw down the gutter.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

CodeBlock.tsx199 lines
"use client";
// CodeBlock: code that writes itself in. Tokens blur in left to right, line after line, then the highlight bars draw down the gutter.
// A soft pill follows the pointer from line to line and inks the line number. Copy sweeps a light across the code and pops
// a "Copied" pill out of the button. Long code folds behind a "+N lines" bar that unfolds in place.
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check, ChevronDown, Copy } from "lucide-react";

export type CodeBlockCue = "blip" | "unfold" | "fold";

export type CodeBlockLabels = {
  /** Aria label of the copy button. */
  copy: string;
  /** Pill shown after copying, also the copy button's label then. */
  copied: string;
  /** Fold bar while folded. */
  more: (n: number) => string;
  /** Fold bar while open. */
  collapse: string;
};

export type CodeBlockProps = {
  /** The code to show. The demo is a small TypeScript module. */
  code?: string;
  /** Language name shown in the header (when there is no title). */
  lang?: string;
  /** Header text that replaces the language name. */
  title?: string;
  /** 1-based line numbers to highlight. */
  highlight?: number[];
  /** Show the line-number gutter. */
  lineNumbers?: boolean;
  /** Tokens blur in left to right, line by line, on mount. */
  reveal?: boolean;
  /** Fold everything after this many lines behind a "+N lines" bar. */
  maxLines?: number;
  /** Width in px. */
  width?: number;
  /** Visible text and aria labels. */
  labels?: Partial<CodeBlockLabels>;
  /** Called with the code after it is copied. */
  onCopy?: (code: string) => void;
  /** Sound hook: "blip" on copy, "unfold" and "fold" on the fold. */
  onSound?: (cue: CodeBlockCue) => void;
  className?: string;
  style?: CSSProperties;
};

const DEFAULT_CODE = `import { play } from "@/ui/core";

export function press(at = 0.5) {
  // every gesture phase makes a sound
  play("click", { at });
  return 42;
}

export function release() {
  play("off", { gap: 40 });
}`;

const LABELS: CodeBlockLabels = {
  copy: "Copy code",
  copied: "Copied",
  more: (n) => `+${n} lines`,
  collapse: "Collapse",
};

const KW = /\b(const|let|var|function|return|import|from|export|if|else|for|while|new|await|async|type|interface|default|class|extends)\b/;
const TOKEN = /(\/\/.*$|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_]\w*\b)/gm;

/** Split a line into tokens; every token is its own span so it can arrive on its own beat. */
function tint(line: string) {
  const out: ReactNode[] = [];
  let last = 0;
  let m: RegExpExecArray | null;
  let t = 0;
  const push = (text: string, cls: string | null) => {
    if (!text) return;
    out.push(
      <i key={t} className={cls ? `tcb-tok tcb-${cls}` : "tcb-tok"} style={{ "--tcb-t": t } as CSSProperties}>
        {text}
      </i>,
    );
    t++;
  };
  TOKEN.lastIndex = 0;
  while ((m = TOKEN.exec(line))) {
    if (m.index > last) push(line.slice(last, m.index), null);
    push(m[0], m[1] ? "c" : m[2] ? "s" : m[3] ? "n" : KW.test(m[4]) ? "k" : null);
    last = m.index + m[0].length;
  }
  if (last < line.length) push(line.slice(last), null);
  return out.length ? out : [<i key="e" className="tcb-tok"> </i>];
}

const SNAP = { type: "spring", stiffness: 420, damping: 34, mass: 0.6 } as const;

export function CodeBlock({
  code = DEFAULT_CODE,
  lang = "tsx",
  title,
  highlight = [4, 5],
  lineNumbers = true,
  reveal = true,
  maxLines,
  width = 300,
  labels,
  onCopy,
  onSound,
  className,
  style,
}: CodeBlockProps) {
  const L = { ...LABELS, ...labels };
  const id = useId();
  const [done, setDone] = useState(false);
  const [hot, setHot] = useState<number | null>(null);
  const [open, setOpen] = useState(false);
  const [revealing, setRevealing] = useState(reveal);
  const [overflow, setOverflow] = useState(false);
  const pre = useRef<HTMLPreElement>(null);
  const doneTimer = useRef(0);
  const lines = code.replace(/\n$/, "").split("\n");
  const folded = maxLines !== undefined && !open && lines.length > maxLines;
  const shown = folded ? lines.slice(0, maxLines) : lines;
  const rest = lines.length - (maxLines ?? lines.length);

  // The reveal plays once on mount, not when the fold toggles, so it reads the line count through a ref.
  const count = useRef(shown.length);
  useEffect(() => {
    if (!reveal) return;
    const t = window.setTimeout(() => setRevealing(false), count.current * 36 + 520);
    return () => window.clearTimeout(t);
  }, [reveal]);
  useEffect(() => () => window.clearTimeout(doneTimer.current), []);
  useLayoutEffect(() => {
    const el = pre.current;
    if (!el) return;
    const check = () => setOverflow(el.scrollWidth > el.clientWidth + 1);
    check();
    const ro = new ResizeObserver(check);
    ro.observe(el);
    return () => ro.disconnect();
  }, [shown.length]);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(code);
    } catch {
      // clipboard blocked: the confirmation still plays, like the original tile
    }
    onSound?.("blip");
    onCopy?.(code);
    setDone(true);
    window.clearTimeout(doneTimer.current);
    doneTimer.current = window.setTimeout(() => setDone(false), 1500);
  };

  return (
    <div className={className ? `tcb-root ${className}` : "tcb-root"} style={{ width, ...style }} data-reveal={revealing || undefined} data-copied={done || undefined} data-overflow={overflow || undefined}>
      <div className="tcb-head">
        <span className="tcb-lang">
          <i className="tcb-langdot" aria-hidden />
          {title ?? lang}
        </span>
        <span className="tcb-copywrap">
          <AnimatePresence>
            {done && (
              <motion.span key="p" className="tcb-copied" initial={{ opacity: 0, x: 6, scale: 0.8, filter: "blur(4px)" }} animate={{ opacity: 1, x: 0, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, x: 4, scale: 0.9, filter: "blur(3px)", transition: { duration: 0.14 } }} transition={{ type: "spring", stiffness: 500, damping: 24, mass: 0.6, opacity: { duration: 0.14 } }}>
                {L.copied}
              </motion.span>
            )}
          </AnimatePresence>
          <button type="button" className="tcb-copy" aria-label={done ? L.copied : L.copy} data-done={done || undefined} onClick={copy}>
            <Copy size={13} strokeWidth={2.2} />
            <Check size={13} strokeWidth={2.6} />
          </button>
        </span>
      </div>
      <pre ref={pre} className="tcb-pre" tabIndex={0} role="region" aria-label={title ?? lang} onPointerLeave={() => setHot(null)}>
        <span className="tcb-sweep" aria-hidden />
        <code>
          {shown.map((l, i) => (
            <span key={i} className="tcb-line" data-hl={highlight.includes(i + 1) || undefined} data-hot={hot === i || undefined} style={{ "--tcb-l": i } as CSSProperties} onPointerEnter={(e) => e.pointerType === "mouse" && setHot(i)}>
              {hot === i && <motion.span className="tcb-pill" layoutId={`${id}-pill`} transition={SNAP} aria-hidden />}
              {lineNumbers && <span className="tcb-no">{i + 1}</span>}
              <span className="tcb-src">{tint(l)}</span>
            </span>
          ))}
        </code>
        {maxLines !== undefined && lines.length > maxLines && (
          <button type="button" className="tcb-fold" aria-expanded={open} onClick={() => (onSound?.(open ? "fold" : "unfold"), setOpen((o) => !o))}>
            <ChevronDown size={12} strokeWidth={2.4} />
            {open ? L.collapse : L.more(rest)}
          </button>
        )}
      </pre>
    </div>
  );
}
CodeBlock.css269 lines
/* CodeBlock. Prefix tcb-. */
.tcb-root, .tcb-root * { box-sizing: border-box; }
.tcb-root { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.tcb-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; }
:where(.tcb-root) :focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }

.tcb-root {
  position: relative;
  border-radius: 14px;
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  overflow: hidden;
  font-size: 12px;
}
.tcb-head {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 36px;
  padding: 0 6px 0 8px;
  border-bottom: 1px solid rgba(var(--ts-fg-rgb), 0.08);
}
.tcb-lang {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 24px;
  padding: 0 8px 0 7px;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: -0.01em;
  opacity: 0.6;
}
.tcb-langdot {
  width: 6px;
  height: 6px;
  border-radius: 999px;
  background: var(--ts-accent);
}
.tcb-copywrap {
  position: relative;
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.tcb-copied {
  height: 22px;
  padding: 0 8px;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  background: var(--ts-success);
  color: var(--ts-fg-inverse);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: -0.01em;
}
.tcb-copy {
  position: relative;
  width: 26px;
  height: 26px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: inherit;
  display: grid;
  place-items: center;
  opacity: 0.6;
  cursor: pointer;
  transition: opacity 0.16s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tcb-copy:hover {
  opacity: 1;
  background: rgba(var(--ts-fg-rgb), 0.06);
}
.tcb-copy:active {
  scale: 0.9;
}
.tcb-copy:focus-visible,
.tcb-fold:focus-visible {
  outline: 0;
  box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
.tcb-copy svg {
  grid-area: 1 / 1;
  transition: opacity 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.42s cubic-bezier(.24, 1.34, .38, 1), filter 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tcb-copy svg:last-child,
.tcb-copy[data-done] svg:first-child {
  opacity: 0;
  scale: 0.5;
  filter: blur(3px);
}
.tcb-copy[data-done] svg:last-child {
  opacity: 1;
  scale: 1;
  filter: none;
  color: var(--ts-success);
}
.tcb-pre {
  position: relative;
  margin: 0;
  padding: 8px 0;
  overflow-x: auto;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 20px;
  outline: 0;
  scrollbar-width: none;
  touch-action: pan-x pan-y;
}
.tcb-pre::-webkit-scrollbar {
  display: none;
}
.tcb-root[data-overflow] .tcb-pre {
  mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent);
}
.tcb-pre:focus-visible {
  box-shadow: inset 0 0 0 2px var(--ts-accent);
}
.tcb-sweep {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(105deg, transparent 35%, color-mix(in srgb, var(--ts-success) 16%, transparent) 50%, transparent 65%);
  background-size: 60% 100%;
  background-repeat: no-repeat;
  background-position: -80% 0;
  z-index: 2;
}
.tcb-root[data-copied] .tcb-sweep {
  opacity: 1;
  animation: tcb-sweep 0.7s cubic-bezier(.22, 1, .36, 1) both;
}
@keyframes tcb-sweep {
  to {
    background-position: 180% 0;
  }
}
.tcb-line {
  --tcb-l: 0;
  position: relative;
  display: flex;
  padding: 0 14px 0 12px;
  white-space: pre;
}
.tcb-pill {
  position: absolute;
  inset: 0 6px;
  border-radius: 6px;
  background: rgba(var(--ts-fg-rgb), 0.05);
}
.tcb-line[data-hl] {
  background: color-mix(in srgb, var(--ts-accent) 7%, transparent);
}
.tcb-line[data-hl]::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--ts-accent);
  transform-origin: 50% 0;
}
.tcb-root[data-reveal] .tcb-line[data-hl]::before {
  animation: tcb-bar 0.42s cubic-bezier(.22, 1, .36, 1) both;
  animation-delay: calc(var(--tcb-l) * 36ms + 260ms);
}
@keyframes tcb-bar {
  from {
    scale: 1 0;
  }
}
.tcb-no {
  position: relative;
  width: 24px;
  flex: none;
  opacity: 0.3;
  user-select: none;
  font-variant-numeric: tabular-nums;
  transition: opacity 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tcb-line[data-hot] .tcb-no {
  opacity: 0.9;
}
.tcb-root[data-reveal] .tcb-no {
  animation: tcb-no 0.3s cubic-bezier(.22, 1, .36, 1) both;
  animation-delay: calc(var(--tcb-l) * 36ms);
}
@keyframes tcb-no {
  from {
    opacity: 0;
  }
}
.tcb-src {
  position: relative;
}
.tcb-tok {
  --tcb-t: 0;
  font-style: normal;
}
.tcb-root[data-reveal] .tcb-tok {
  animation: tcb-tok 0.32s cubic-bezier(.22, 1, .36, 1) backwards;
  animation-delay: calc(var(--tcb-l) * 36ms + var(--tcb-t) * 14ms + 40ms);
}
@keyframes tcb-tok {
  0% {
    opacity: 0;
    filter: blur(5px);
    translate: -3px 0;
  }
}
.tcb-k {
  color: var(--ts-accent);
}
.tcb-s {
  color: var(--ts-success);
}
.tcb-n {
  opacity: 0.75;
}
.tcb-c {
  opacity: 0.4;
}
.tcb-fold {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  margin: 4px 12px 0;
  height: 24px;
  padding: 0 9px 0 7px;
  border: 0;
  border-radius: 999px;
  background: rgba(var(--ts-fg-rgb), 0.06);
  color: inherit;
  font: inherit;
  font-family: var(--ts-font, system-ui, sans-serif);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: -0.01em;
  opacity: 0.75;
  cursor: pointer;
  transition: opacity 0.16s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tcb-fold:hover {
  opacity: 1;
  background: rgba(var(--ts-fg-rgb), 0.1);
}
.tcb-fold:active {
  scale: 0.94;
}
.tcb-fold svg {
  transition: rotate 0.42s cubic-bezier(.24, 1.34, .38, 1);
}
.tcb-fold[aria-expanded="true"] svg {
  rotate: 180deg;
}
@media (prefers-reduced-motion: reduce) {
  .tcb-root[data-reveal] .tcb-tok,
  .tcb-root[data-reveal] .tcb-no,
  .tcb-root[data-reveal] .tcb-line[data-hl]::before,
  .tcb-root[data-copied] .tcb-sweep {
    animation: none;
  }
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { CodeBlock } from "./CodeBlock";
import "./CodeBlock.css";

export default function Demo() {
  return <CodeBlock />;
}