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
npm i framer-motion lucide-react
2. Files
"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. 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;
}
}
/*
* 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
import { CodeBlock } from "./CodeBlock";
import "./CodeBlock.css";
export default function Demo() {
return <CodeBlock />;
}