Code Block

PreviousNext

A syntax-highlighted code block component with copy functionality.

Docs
opticscomponent

Preview

Loading preview…
registry/optics/code-block.jsx
"use client";

import { useControlledState } from "@/registry/optics/hooks/use-controlled-state";
import {
	transformerNotationDiff,
	transformerNotationErrorLevel,
	transformerNotationFocus,
	transformerNotationHighlight,
	transformerNotationWordHighlight,
} from "@shikijs/transformers";
import { CheckIcon, CopyIcon, Loader2 } from "lucide-react";
import {
	createContext,
	useContext,
	useEffect,
	useState,
	useLayoutEffect,
	useRef,
} from "react";
import { useRender } from "@base-ui/react/use-render";
import { mergeProps } from "@base-ui/react/merge-props";
import { motion, AnimatePresence } from "motion/react";
import {
	SiAstro,
	SiBiome,
	SiBower,
	SiBun,
	SiC,
	SiCircleci,
	SiCoffeescript,
	SiCplusplus,
	SiCss3,
	SiCssmodules,
	SiDart,
	SiDocker,
	SiDocusaurus,
	SiDotenv,
	SiEditorconfig,
	SiEslint,
	SiGatsby,
	SiGitignoredotio,
	SiGnubash,
	SiGo,
	SiGraphql,
	SiGrunt,
	SiGulp,
	SiHandlebarsdotjs,
	SiHtml5,
	SiJavascript,
	SiJest,
	SiJson,
	SiLess,
	SiMarkdown,
	SiMdx,
	SiMintlify,
	SiMocha,
	SiMysql,
	SiNextdotjs,
	SiPerl,
	SiPhp,
	SiPostcss,
	SiPrettier,
	SiPrisma,
	SiPug,
	SiPython,
	SiR,
	SiReact,
	SiReadme,
	SiRedis,
	SiRemix,
	SiRive,
	SiRollupdotjs,
	SiRuby,
	SiSanity,
	SiSass,
	SiScala,
	SiSentry,
	SiShadcnui,
	SiStorybook,
	SiStylelint,
	SiSublimetext,
	SiSvelte,
	SiSvg,
	SiSwift,
	SiTailwindcss,
	SiToml,
	SiTypescript,
	SiVercel,
	SiVite,
	SiVuedotjs,
	SiWebassembly,
} from "react-icons/si";
import { codeToHtml } from "shiki";
import { Button } from "@/registry/optics/button";
import {
	Select,
	SelectContent,
	SelectItem,
	SelectTrigger,
	SelectValue,
} from "@/registry/optics/select";
import { toast } from "@/registry/optics/sonner";
import { ScrollArea, ScrollBar } from "@/registry/optics/scroll-area";
import { cn } from "@/registry/optics/lib/utils";

const filenameIconMap = {
	".env": SiDotenv,
	"*.astro": SiAstro,
	"biome.json": SiBiome,
	".bowerrc": SiBower,
	"bun.lockb": SiBun,
	"*.c": SiC,
	"*.cpp": SiCplusplus,
	".circleci/config.yml": SiCircleci,
	"*.coffee": SiCoffeescript,
	"*.module.css": SiCssmodules,
	"*.css": SiCss3,
	"*.dart": SiDart,
	Dockerfile: SiDocker,
	"docusaurus.config.js": SiDocusaurus,
	".editorconfig": SiEditorconfig,
	".eslintrc": SiEslint,
	"eslint.config.*": SiEslint,
	"gatsby-config.*": SiGatsby,
	".gitignore": SiGitignoredotio,
	"*.go": SiGo,
	"*.graphql": SiGraphql,
	"*.sh": SiGnubash,
	"Gruntfile.*": SiGrunt,
	"gulpfile.*": SiGulp,
	"*.hbs": SiHandlebarsdotjs,
	"*.html": SiHtml5,
	"*.js": SiJavascript,
	"*.json": SiJson,
	"*.test.js": SiJest,
	"*.less": SiLess,
	"*.md": SiMarkdown,
	"*.mdx": SiMdx,
	"mintlify.json": SiMintlify,
	"mocha.opts": SiMocha,
	"*.mustache": SiHandlebarsdotjs,
	"*.sql": SiMysql,
	"next.config.*": SiNextdotjs,
	"*.pl": SiPerl,
	"*.php": SiPhp,
	"postcss.config.*": SiPostcss,
	"prettier.config.*": SiPrettier,
	"*.prisma": SiPrisma,
	"*.pug": SiPug,
	"*.py": SiPython,
	"*.r": SiR,
	"*.rb": SiRuby,
	"*.jsx": SiReact,
	"*.tsx": SiReact,
	"readme.md": SiReadme,
	"*.rdb": SiRedis,
	"remix.config.*": SiRemix,
	"*.riv": SiRive,
	"rollup.config.*": SiRollupdotjs,
	"sanity.config.*": SiSanity,
	"*.sass": SiSass,
	"*.scss": SiSass,
	"*.sc": SiScala,
	"*.scala": SiScala,
	"sentry.client.config.*": SiSentry,
	"components.json": SiShadcnui,
	"storybook.config.*": SiStorybook,
	"stylelint.config.*": SiStylelint,
	".sublime-settings": SiSublimetext,
	"*.svelte": SiSvelte,
	"*.svg": SiSvg,
	"*.swift": SiSwift,
	"tailwind.config.*": SiTailwindcss,
	"*.toml": SiToml,
	"*.ts": SiTypescript,
	"vercel.json": SiVercel,
	"vite.config.*": SiVite,
	"*.vue": SiVuedotjs,
	"*.wasm": SiWebassembly,
};

const lineNumberClassNames = cn(
	"[&_code]:[counter-reset:line]",
	"[&_code]:[counter-increment:line_0]",
	"[&_.line]:before:content-[counter(line)]",
	"[&_.line]:before:inline-block",
	"[&_.line]:before:[counter-increment:line]",
	"[&_.line]:before:w-4",
	"[&_.line]:before:mr-4",
	"[&_.line]:before:text-[13px]",
	"[&_.line]:before:text-right",
	"[&_.line]:before:text-muted-foreground/50",
	"[&_.line]:before:font-mono",
	"[&_.line]:before:select-none",
);

const darkModeClassNames = cn(
	"dark:[&_.shiki]:!text-[var(--shiki-dark)]",
	// "dark:[&_.shiki]:!bg-[var(--shiki-dark-bg)]",
	"dark:[&_.shiki]:![font-style:var(--shiki-dark-font-style)]",
	"dark:[&_.shiki]:![font-weight:var(--shiki-dark-font-weight)]",
	"dark:[&_.shiki]:![text-decoration:var(--shiki-dark-text-decoration)]",
	"dark:[&_.shiki_span]:!text-[var(--shiki-dark)]",
	"dark:[&_.shiki_span]:![font-style:var(--shiki-dark-font-style)]",
	"dark:[&_.shiki_span]:![font-weight:var(--shiki-dark-font-weight)]",
	"dark:[&_.shiki_span]:![text-decoration:var(--shiki-dark-text-decoration)]",
);

const lineHighlightClassNames = cn(
	"[&_.line.highlighted]:bg-blue-50",
	"[&_.line.highlighted]:after:bg-blue-500",
	"[&_.line.highlighted]:after:absolute",
	"[&_.line.highlighted]:after:left-0",
	"[&_.line.highlighted]:after:top-0",
	"[&_.line.highlighted]:after:bottom-0",
	"[&_.line.highlighted]:after:w-0.5",
	"dark:[&_.line.highlighted]:!bg-blue-500/10",
);

const lineDiffClassNames = cn(
	"[&_.line.diff]:after:absolute",
	"[&_.line.diff]:after:left-0",
	"[&_.line.diff]:after:top-0",
	"[&_.line.diff]:after:bottom-0",
	"[&_.line.diff]:after:w-0.5",
	"[&_.line.diff.add]:bg-emerald-50",
	"[&_.line.diff.add]:after:bg-emerald-500",
	"[&_.line.diff.remove]:bg-rose-50",
	"[&_.line.diff.remove]:after:bg-rose-500",
	"dark:[&_.line.diff.add]:!bg-emerald-500/10",
	"dark:[&_.line.diff.remove]:!bg-rose-500/10",
);

const lineFocusedClassNames = cn(
	"[&_code:has(.focused)_.line]:blur-[2px]",
	"[&_code:has(.focused)_.line.focused]:blur-none",
);

const wordHighlightClassNames = cn(
	"[&_.highlighted-word]:bg-blue-50",
	"dark:[&_.highlighted-word]:!bg-blue-500/10",
);

const codeBlockClassName = cn(
	"mt-0 bg-card text-sm",
	"[&_pre]:py-4",
	// "[&_.shiki]:!bg-[var(--shiki-bg)]",
	"[&_.shiki]:!bg-transparent",
	"[&_code]:w-full",
	"[&_code]:grid",
	"[&_code]:bg-transparent",
	"[&_code]:min-w-0",
	"[&_.line]:px-4",
	"[&_.line]:w-full",
	"[&_.line]:relative",
	"[&_.line]:min-w-0",
);

const highlight = (html, language, themes) =>
	codeToHtml(html, {
		lang: language ?? "typescript",
		themes: themes ?? {
			light: "github-light",
			dark: "github-dark-default",
		},
		transformers: [
			transformerNotationDiff({
				matchAlgorithm: "v3",
			}),
			transformerNotationHighlight({
				matchAlgorithm: "v3",
			}),
			transformerNotationWordHighlight({
				matchAlgorithm: "v3",
			}),
			transformerNotationFocus({
				matchAlgorithm: "v3",
			}),
			transformerNotationErrorLevel({
				matchAlgorithm: "v3",
			}),
		],
	});

const CodeBlockContext = createContext({
	value: undefined,
	onValueChange: undefined,
	data: [],
});

export const CodeBlock = ({
	value: controlledValue = undefined,
	onValueChange: controlledOnValueChange = undefined,
	defaultValue = "",
	className = "",
	data = [],
	...props
}) => {
	const [value, onValueChange] = useControlledState({
		defaultValue: defaultValue ?? "",
		value: controlledValue,
		onChange: controlledOnValueChange,
	});

	return (
		<CodeBlockContext.Provider value={{ value, onValueChange, data }}>
			<div
				className={cn("w-full overflow-hidden rounded-md border", className)}
				{...props}
			/>
		</CodeBlockContext.Provider>
	);
};

export const CodeBlockHeader = ({ className = "", ...props }) => (
	<div
		className={cn(
			"flex flex-row items-center border-b bg-secondary p-1",
			className,
		)}
		{...props}
	/>
);

export const CodeBlockFiles = ({
	className = "",
	children = null,
	...props
}) => {
	const { data } = useContext(CodeBlockContext);

	return (
		<div
			className={cn("flex grow flex-row items-center gap-2", className)}
			{...props}
		>
			{data.map(children)}
		</div>
	);
};

export const CodeBlockFilename = ({
	className = "",
	icon = undefined,
	value = undefined,
	children = null,
	...props
}) => {
	const { value: activeValue } = useContext(CodeBlockContext);
	const defaultIcon = Object.entries(filenameIconMap).find(([pattern]) => {
		const regex = new RegExp(
			`^${pattern.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/\*/g, ".*")}$`,
		);
		return regex.test(children);
	})?.[1];
	const Icon = icon ?? defaultIcon;

	if (value !== activeValue) {
		return null;
	}

	return (
		<div
			className="flex items-center gap-2 bg-secondary px-4 py-1.5 text-muted-foreground text-xs"
			{...props}
		>
			{Icon && <Icon className="h-4 w-4 shrink-0" />}
			<span className="flex-1 truncate">{children}</span>
		</div>
	);
};

export const CodeBlockSelect = (props = {}) => {
	const { value, onValueChange } = useContext(CodeBlockContext);

	return <Select onValueChange={onValueChange} value={value} {...props} />;
};

export const CodeBlockSelectTrigger = ({ className = "", ...props }) => (
	<SelectTrigger
		className={cn(
			"w-fit border-none text-muted-foreground text-xs shadow-none",
			className,
		)}
		{...props}
	/>
);

export const CodeBlockSelectValue = (props = {}) => <SelectValue {...props} />;

export const CodeBlockSelectContent = ({
	children = null,
	className = "",
	...props
}) => {
	const { data } = useContext(CodeBlockContext);

	return (
		<SelectContent className={cn("w-[calc(100%+2rem)]", className)} {...props}>
			{data.map(children)}
		</SelectContent>
	);
};

export const CodeBlockSelectItem = ({ className = "", ...props }) => (
	<SelectItem className={cn("text-sm", className)} {...props} />
);

export const CodeBlockCopyButton = ({
	render = undefined,
	onCopy = undefined,
	onError = undefined,
	timeout = 2000,
	className = "",
	variant = "outline",
	size = "icon",
	...props
}) => {
	const [isCopied, setIsCopied] = useState(false);
	const [isLoading, setIsLoading] = useState(false);
	const { data, value } = useContext(CodeBlockContext);
	const code = data.find((item) => item.filename === value)?.code;

	const copyToClipboard = async () => {
		if (isLoading) return;

		setIsLoading(true);

		if (typeof window === "undefined") {
			onError?.(new Error("Window is not available"));
			setIsLoading(false);
			return;
		}

		if (typeof navigator === "undefined") {
			onError?.(new Error("Navigator is not available"));
			setIsLoading(false);
			return;
		}

		if (!code) {
			onError?.(new Error("No code to copy"));
			setIsLoading(false);
			return;
		}

		if (navigator?.clipboard?.writeText) {
			try {
				await navigator.clipboard.writeText(code);
				setIsCopied(true);
				onCopy?.();

				setTimeout(() => setIsCopied(false), timeout);
				setIsLoading(false);
				return;
			} catch (error) {
				// fallthrough to fallback
			}
		}

		try {
			const textArea = document.createElement("textarea");
			textArea.value = code;
			textArea.style.position = "fixed";
			textArea.style.left = "-999999px";
			textArea.style.top = "-999999px";
			textArea.style.opacity = "0";
			textArea.setAttribute("readonly", "");
			document.body.appendChild(textArea);

			textArea.select();
			textArea.setSelectionRange(0, 99999);

			const successful = document.execCommand("copy");
			document.body.removeChild(textArea);

			if (successful) {
				setIsCopied(true);
				onCopy?.();

				setTimeout(() => setIsCopied(false), timeout);
			} else {
				throw new Error("execCommand('copy') returned false");
			}
		} catch (fallbackError) {
			toast({
				type: "error",
				title: "Copy Failed",
				description:
					"Unable to copy code to clipboard. Please try manually selecting and copying the text.",
				duration: 4000,
			});

			onError?.(fallbackError);
		} finally {
			setIsLoading(false);
		}
	};

	const buttonContent = (
		<>
			<div className="relative">
				<div
					className={cn(
						"absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out will-change-[transform,opacity,filter]",
						isCopied
							? "scale-100 opacity-100 blur-0"
							: "blur-xs scale-[0.25] opacity-0",
					)}
				>
					<CheckIcon className="text-muted-foreground" size={14} />
				</div>
				<div
					className={cn(
						"absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out will-change-[transform,opacity,filter]",
						isLoading
							? "scale-100 opacity-100 blur-0"
							: "blur-xs scale-[0.25] opacity-0",
					)}
				>
					<Loader2 className="text-muted-foreground animate-spin" size={14} />
				</div>
				<div
					className={cn(
						"transition-[transform, opacity, filter] duration-300 ease-in-out will-change-[transform,opacity,filter]",
						isCopied || isLoading
							? "blur-xs scale-[0.25] opacity-0"
							: "scale-100 opacity-100 blur-0",
					)}
				>
					<CopyIcon className="text-muted-foreground" size={14} />
				</div>
			</div>
			<span className="sr-only">Copy to clipboard</span>
		</>
	);

	const defaultProps = {
		role: "button",
		"aria-label": isCopied ? "Copied!" : "Copy to clipboard",
		disabled: isLoading,
		className: cn("shrink-0 z-50 pointer-events-auto selection-all", className),
		onClick: copyToClipboard,
		variant,
		size,
		children: buttonContent,
	};

	if (render) {
		const element = useRender({
			defaultTagName: "button",
			render:
				typeof render === "function"
					? (props, state) => {
							const mergedProps = mergeProps("button", defaultProps, props);
							return render(mergedProps, { isCopied, isLoading, ...state });
						}
					: render,
			props: mergeProps("button", defaultProps, props),
			state: {
				isCopied,
				isLoading,
			},
		});
		return element;
	}

	return <Button {...defaultProps} {...props} />;
};

const CodeBlockFallback = ({ children = null, ...props } = {}) => (
	<div {...props}>
		<pre className="w-full">
			<code>
				{children
					?.toString()
					.split("\n")
					.map((line, i) => (
						<span className="line" key={i}>
							{line}
						</span>
					))}
			</code>
		</pre>
	</div>
);

export const CodeBlockBody = ({ children = null, ...props }) => {
	const { data } = useContext(CodeBlockContext);

	return <div {...props}>{data.map(children)}</div>;
};

export const CodeBlockItem = ({
	children = null,
	lineNumbers = true,
	className = "",
	value = undefined,
	...props
}) => {
	const { value: activeValue } = useContext(CodeBlockContext);

	if (value !== activeValue) {
		return null;
	}

	return (
		// ScrollArea viene de un .jsx, no hay tipos
		<ScrollArea
			maskColor="from-sidebar"
			className={cn(
				codeBlockClassName,
				lineHighlightClassNames,
				lineDiffClassNames,
				lineFocusedClassNames,
				wordHighlightClassNames,
				darkModeClassNames,
				lineNumbers && lineNumberClassNames,
				"min-w-0",
				className,
			)}
			viewportClassName="[&_code]:min-w-full overflow-x-auto [&_code]:max-w-full"
			{...props}
		>
			{children}
			<ScrollBar orientation="horizontal" />
		</ScrollArea>
	);
};

export const CodeBlockContent = ({
	children = "",
	themes = undefined,
	language = undefined,
	syntaxHighlighting = true,
	...props
}) => {
	const [html, setHtml] = useState(null);
	const [isExpanded, setIsExpanded] = useState(false);
	const [fullHeight, setFullHeight] = useState(null);
	const [isReady, setIsReady] = useState(false);
	const codeLines = children.split("\n");
	const hasMoreThan10Lines = codeLines.length > 10;
	const measureRef = useRef(null);
	const contentRef = useRef(null);

	useEffect(() => {
		if (!syntaxHighlighting) {
			return;
		}

		highlight(children, language, themes)
			.then(setHtml)
			// biome-ignore lint/suspicious/noConsole: "it's fine"
			.catch(console.error);
	}, [children, themes, syntaxHighlighting, language]);

	useLayoutEffect(() => {
		if (!measureRef.current) return;

		const measureHeight = () => {
			if (measureRef.current) {
				const height = measureRef.current.getBoundingClientRect().height;
				setFullHeight(height);
				setIsReady(true);
			}
		};

		const timeoutId = setTimeout(measureHeight, 0);

		const resizeObserver = new ResizeObserver(() => {
			measureHeight();
		});

		if (measureRef.current) {
			resizeObserver.observe(measureRef.current);
		}

		return () => {
			clearTimeout(timeoutId);
			resizeObserver.disconnect();
		};
	}, [html, children, syntaxHighlighting]);

	const collapsedHeight = 240;

	const transition = {
		type: "spring",
		stiffness: 200,
		damping: 25,
	};

	const shouldCollapse =
		hasMoreThan10Lines && (fullHeight ? fullHeight > collapsedHeight : true);
	const targetHeight = !isReady
		? collapsedHeight
		: shouldCollapse && !isExpanded
			? collapsedHeight
			: fullHeight || "auto";
	const shouldShowExpand = isReady && shouldCollapse && !isExpanded;
	const motionTransition = isReady ? transition : { duration: 0 };

	return (
		<div className="relative w-full flex flex-col min-h-0 min-w-0" {...props}>
			<div
				ref={measureRef}
				className="invisible pointer-events-none absolute -z-50 w-full"
				style={{ visibility: "hidden" }}
			>
				{!(syntaxHighlighting && html) ? (
					<CodeBlockFallback>{children}</CodeBlockFallback>
				) : (
					<div
						// Shiki
						dangerouslySetInnerHTML={{ __html: html }}
					/>
				)}
			</div>
			<motion.div
				className="relative overflow-hidden w-full min-h-0 min-w-0"
				style={{ maxHeight: "100%" }}
				animate={{ height: targetHeight }}
				transition={motionTransition}
			>
				<ScrollArea
					maskColor="from-sidebar"
					className="w-full min-w-0"
					viewportClassName="[&_code]:min-w-full overflow-x-auto [&_code]:max-w-full"
				>
					<div ref={contentRef}>
						{!(syntaxHighlighting && html) ? (
							<CodeBlockFallback>{children}</CodeBlockFallback>
						) : (
							<div
								// Shiki
								dangerouslySetInnerHTML={{ __html: html }}
							/>
						)}
					</div>
					<ScrollBar orientation="horizontal" />
				</ScrollArea>
			</motion.div>
			<AnimatePresence>
				{shouldShowExpand && (
					<motion.div
						initial={{ opacity: 0 }}
						animate={{ opacity: 1 }}
						exit={{ opacity: 0 }}
						transition={{ duration: 0.2 }}
						className="absolute bottom-0 left-0 right-0 flex items-center justify-center bg-gradient-to-t from-card via-card/95 to-transparent pb-4 pt-12 pointer-events-none z-30"
						style={{ width: "100%" }}
					>
						<Button
							variant="ghost"
							size="sm"
							onClick={() => setIsExpanded(true)}
							className="z-10 text-muted-foreground hover:bg-transparent hover:text-foreground transition-colors duration-500 ease-in-out pointer-events-auto"
						>
							Expand
						</Button>
					</motion.div>
				)}
			</AnimatePresence>
		</div>
	);
};

Installation

npx shadcn@latest add @optics/code-block

Usage

import { CodeBlock } from "@/components/code-block"
<CodeBlock />