Code block
Themed editor card with syntax colors, line numbers and a file tab that can type code out, focus lines and mark a diff.
CodeBlock, typing
Installation
npx shadcn add https://www.reelcn.dev/r/code-block.jsonFiles land in src/reelcn/ with the items it builds on.
Usage
<Center>
<CodeBlock
title="render.ts"
language="ts"
code={'const video = await render({\n fps: 30,\n codec: "h264",\n});'}
typing={45}
highlightLines={[3]}
/>
</Center>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.code*string
languageCodeLanguage Default
"ts".titlestringFile name shown in the tab.
typingnumberTypes the code out at this many characters per second. Omit to show it all at once.
highlightLinesnumber[]1-based lines to focus: the rest dim and the focused lines get an accent band. Default
[].highlightAtnumberFrame the focus and diff bands sweep in. Defaults to just after the code is fully shown.
diff{ add?: number[]; remove?: number[]; }1-based lines marked as added or removed.
lineNumbersboolean Default
true.fontSizenumberLargest font size in design units. It shrinks so the longest line and every row fit. Default
30.widthnumberLargest card width as a fraction of the safe area (0–1). Defaults to 0.72 in landscape, 1 otherwise.
colorsPartial<Record<TokenKind, string>>Per-token colors. Defaults derive from the theme (see `tokenColors`).
backgroundstringEditor background. Defaults to the theme surface.
borderColorstring
addColorstringAdded-line color. Defaults to green, because the theme has no status colors.
removeColorstringRemoved-line color. Defaults to red.
radiusnumberCorner radius in design units. Defaults to 70% of the theme radius.
followCaretFollowZooms the camera to trail the caret as it types. Omitted (default): today's behavior, unchanged.
styleCSSProperties
classNamestring
Built from
Use
- Showing an API, config file or snippet in a launch, docs or tutorial video
- Typing code on screen, then focusing the lines that matter
Avoid
- Shell sessions with commands and output. Use
terminal
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/code-block.tsx
/**
* @title Code Block
* @category product
* @description Themed editor card with syntax colors, line numbers and a file tab that can type code out, focus lines and mark a diff.
* @duration data-driven
* @use Showing an API, config file or snippet in a launch, docs or tutorial video
* @use Typing code on screen, then focusing the lines that matter
* @avoid Shell sessions with commands and output. Use `terminal`
* @tags code, editor, syntax, snippet, diff, typing
* @example
* <Center>
* <CodeBlock
* title="render.ts"
* language="ts"
* code={'const video = await render({\n fps: 30,\n codec: "h264",\n});'}
* typing={45}
* highlightLines={[3]}
* />
* </Center>
*/
import type React from "react";
import { type CodeLanguage, type Token, type TokenKind, tokenColors, tokenize } from "./code-tokens";
import {
alpha,
type CaretFollow,
FollowCaret,
type MotionProps,
tween,
useMotion,
useRoleMotion,
useTextMetrics,
useTheme,
useViewport,
} from "./core";
export type CodeBlockProps = MotionProps & {
code: string;
language?: CodeLanguage;
/** File name shown in the tab. */
title?: string;
/** Types the code out at this many characters per second. Omit to show it all at once. */
typing?: number;
/** 1-based lines to focus: the rest dim and the focused lines get an accent band. */
highlightLines?: number[];
/** Frame the focus and diff bands sweep in. Defaults to just after the code is fully shown. */
highlightAt?: number;
/** 1-based lines marked as added or removed. */
diff?: { add?: number[]; remove?: number[] };
lineNumbers?: boolean;
/** Largest font size in design units. It shrinks so the longest line and every row fit. */
fontSize?: number;
/** Largest card width as a fraction of the safe area (0–1). Defaults to 0.72 in landscape, 1 otherwise. */
width?: number;
/** Per-token colors. Defaults derive from the theme (see `tokenColors`). */
colors?: Partial<Record<TokenKind, string>>;
/** Editor background. Defaults to the theme surface. */
background?: string;
borderColor?: string;
/** Added-line color. Defaults to green, because the theme has no status colors. */
addColor?: string;
/** Removed-line color. Defaults to red. */
removeColor?: string;
/** Corner radius in design units. Defaults to 70% of the theme radius. */
radius?: number;
/** Zooms the camera to trail the caret as it types. Omitted (default): today's behavior, unchanged. */
follow?: CaretFollow;
style?: React.CSSProperties;
className?: string;
};
/** Advance width of one monospace glyph, in em. */
const CHAR = 0.6;
const LINE = 1.65;
/** The first `count` characters of a tokenized line. */
function sliceTokens(tokens: Token[], count: number): Token[] {
const out: Token[] = [];
let left = count;
for (const token of tokens) {
if (left <= 0) break;
out.push(left >= token.text.length ? token : { text: token.text.slice(0, left), kind: token.kind });
left -= token.text.length;
}
return out;
}
export function CodeBlock({
code,
language = "ts",
title,
typing,
highlightLines = [],
highlightAt,
diff,
lineNumbers = true,
fontSize = 30,
width: widthFraction,
colors,
background,
borderColor,
addColor,
removeColor,
radius,
follow,
style,
className,
...motion
}: CodeBlockProps) {
const theme = useTheme();
const add = addColor ?? theme.colors.success;
const remove = removeColor ?? theme.colors.danger;
const { u, width, height, safe, isLandscape } = useViewport();
const m = useMotion(motion);
// opacityLeadFrames: 0 keeps this item's default render pixel-identical to its pre-retrofit hardcoded
// exit (opacity and geometry finishing together); useRoleMotion's own default (3) is unchanged for other adopters.
const role = useRoleMotion(m, { exit: { opacityLeadFrames: 0 } });
const palette = { ...tokenColors(theme.colors), ...colors };
const surface = background ?? theme.colors.surface;
const border = borderColor ?? theme.colors.border;
const lines = tokenize(code, language);
const raw = code.split("\n");
// Layout: the font shrinks until the widest line and every row fit inside the safe area.
const adds = diff?.add ?? [];
const removes = diff?.remove ?? [];
const marked = adds.concat(removes).sort((a, b) => a - b);
const digits = String(raw.length).length;
const columns = (diff ? 2 : 0) + (lineNumbers ? digits + 2 : 0) + Math.max(1, ...raw.map((l) => l.length)) + 1;
const padX = u(32);
const padY = u(26);
const tabH = u(66);
const maxW = (width - safe.x * 2) * (widthFraction ?? (isLandscape ? 0.72 : 1));
const maxH = height - safe.top - safe.bottom;
const fontPx = Math.min(
u(fontSize),
(maxW - padX * 2) / (columns * CHAR),
(maxH - tabH - padY * 2) / (raw.length * LINE),
);
const lineH = fontPx * LINE;
const cardW = Math.min(maxW, Math.max(u(560), padX * 2 + columns * CHAR * fontPx));
// Timing: typing starts halfway through the entrance, focus follows once everything is on screen.
const typed = typing !== undefined && typing > 0;
const start = m.delay + Math.round(m.enterFrames * 0.5);
const shown = typed ? Math.max(0, Math.floor(((m.frame - start) * (typing as number)) / m.fps)) : code.length;
const typedAt = typed ? start + Math.ceil((code.length * m.fps) / (typing as number)) : start;
const focusFrom = highlightAt ?? typedAt + Math.round(m.fps * 0.3);
const sweep = (order: number) =>
Math.min(
1,
tween(m.frame, m.fps, {
from: focusFrom + order * Math.round(m.fps * (2 / 30)),
duration: Math.round(m.fps * 0.45),
motion: m.preset,
}),
);
const focus = highlightLines.length > 0 ? sweep(0) : 0;
const blinkOn = Math.floor(m.frame / Math.round(m.fps * 0.5)) % 2 === 0;
const showCaret = typed && (shown < code.length || (m.frame < focusFrom && blinkOn));
// Follow: the caret position `lag` frames ago, from the same shown-character formula above evaluated
// at an earlier frame. Always computed (cheap, pure), only measured/used when `follow` is set.
const laggedShown = typed
? Math.max(0, Math.floor(((m.frame - (follow?.lag ?? 6) - start) * (typing as number)) / m.fps))
: code.length;
const laggedCode = code.slice(0, laggedShown);
const laggedCodeLines = laggedCode.split("\n");
const laggedLineText = laggedCodeLines[laggedCodeLines.length - 1];
const caretMetrics = useTextMetrics(
laggedLineText,
{ fontFamily: theme.fonts.mono, fontSize: fontPx, fontWeight: 500 },
{ skip: !follow },
);
const caretPosition = { x: caretMetrics.width, y: (laggedCodeLines.length - 1) * lineH };
let offset = 0;
const rows = lines.map((tokens, index) => {
const lineStart = offset;
const length = raw[index].length;
offset += length + 1;
const n = index + 1;
const added = adds.indexOf(n) >= 0;
const removed = removes.indexOf(n) >= 0;
const focused = highlightLines.indexOf(n) >= 0;
const bandColor = added ? add : removed ? remove : focused ? theme.colors.accent : undefined;
const band = added || removed ? sweep(marked.indexOf(n)) : focused ? focus : 0;
const reached = shown >= lineStart;
return (
<div
key={index}
style={{
position: "relative",
display: "flex",
alignItems: "center",
height: lineH,
padding: `0 ${padX}px`,
opacity: focused || highlightLines.length === 0 ? 1 : 1 - 0.6 * focus,
}}
>
{bandColor && (
<div
style={{
position: "absolute",
inset: 0,
background: alpha(bandColor, 0.14),
borderLeft: `${u(4)}px solid ${bandColor}`,
scale: `${band} 1`,
transformOrigin: "left",
}}
/>
)}
{diff && (
<span style={{ position: "relative", width: "2ch", flexShrink: 0, color: bandColor, opacity: band }}>
{added ? "+" : removed ? "-" : ""}
</span>
)}
{lineNumbers && (
<span
style={{
position: "relative",
width: `${digits}ch`,
marginRight: "2ch",
flexShrink: 0,
textAlign: "right",
color: theme.colors.muted,
opacity: reached ? 0.65 : 0,
}}
>
{n}
</span>
)}
<span style={{ position: "relative", whiteSpace: "pre", opacity: removed ? 1 - 0.35 * band : 1 }}>
{sliceTokens(tokens, Math.max(0, Math.min(length, shown - lineStart))).map((token, k) => (
<span key={k} style={{ color: palette[token.kind] }}>
{token.text}
</span>
))}
{showCaret && reached && shown <= lineStart + length && (
<span
style={{
display: "inline-block",
width: Math.max(2, fontPx * 0.12),
height: "1.2em",
marginLeft: 1,
verticalAlign: "-0.22em",
background: theme.colors.accent,
}}
/>
)}
</span>
</div>
);
});
return (
<FollowCaret follow={follow} caret={caretPosition}>
<div
className={className}
style={{
width: cardW,
borderRadius: u(radius ?? theme.radius * 0.7),
overflow: "hidden",
background: surface,
border: `1px solid ${border}`,
boxShadow: `0 ${u(2)}px ${u(6)}px ${alpha(theme.colors.shadow, 0.12)}, 0 ${u(28)}px ${u(72)}px ${alpha(theme.colors.shadow, 0.3)}`,
fontFamily: theme.fonts.mono,
color: theme.colors.foreground,
opacity: role.opacity,
translate: `0 ${(1 - m.enter) * u(40) - m.exit * role.travel(u(40)).exitPx}px`,
scale: String(0.97 + 0.03 * m.enter),
...style,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: u(20),
height: tabH,
padding: `0 ${u(24)}px`,
background: `color-mix(in srgb, ${surface} 55%, ${theme.colors.background})`,
borderBottom: `1px solid ${border}`,
fontFamily: theme.fonts.body,
fontSize: u(22),
}}
>
<div style={{ display: "flex", gap: u(9) }}>
{[0, 1, 2].map((dot) => (
<div
key={dot}
style={{ width: u(13), height: u(13), borderRadius: "50%", background: alpha(theme.colors.muted, 0.4) }}
/>
))}
</div>
{title && (
<div
style={{
display: "flex",
alignItems: "center",
alignSelf: "stretch",
gap: u(10),
padding: `0 ${u(20)}px`,
marginBottom: -1,
background: surface,
borderLeft: `1px solid ${border}`,
borderRight: `1px solid ${border}`,
boxShadow: `inset 0 ${u(2)}px 0 ${theme.colors.accent}`,
}}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
width={u(20)}
height={u(20)}
fill="none"
stroke={theme.colors.muted}
strokeWidth={2}
>
<path d="M6 3h8l4 4v14H6z" strokeLinejoin="round" />
<path d="M14 3v4h4" strokeLinejoin="round" />
</svg>
{title}
</div>
)}
<div
style={{
marginLeft: "auto",
fontFamily: theme.fonts.mono,
fontSize: u(18),
letterSpacing: "0.08em",
color: theme.colors.muted,
}}
>
{language.toUpperCase()}
</div>
</div>
<div style={{ padding: `${padY}px 0`, fontSize: fontPx, lineHeight: `${lineH}px` }}>{rows}</div>
</div>
</FollowCaret>
);
}