data displayclient
code block
code on a raised face, with the filename up top, light highlighting and a copy key.
app.tsxtsx
1import { Button } from "@/components/ui/Button";2
3export default function App() {4 return <Button variant="primary">Get started</Button>;5}installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add code-blockor by hand: drop this file into components/ui and import from there.
components/ui/CodeBlock.tsxtsx
1"use client";2
3import React, { useState, useCallback, useMemo, useRef } from "react";4
5interface CodeBlockProps {6 code: string;7 language?: string;8 showLineNumbers?: boolean;9 title?: string;10 className?: string;11}12
13const KEYWORDS = new Set([14 "import", "from", "export", "default", "function", "return",15 "const", "let", "var", "if", "else", "for", "while", "do",16 "class", "extends", "new", "typeof", "instanceof",17 "null", "undefined", "true", "false",18 "async", "await", "try", "catch", "throw", "finally",19 "interface", "type", "enum", "as", "in", "of",20 "switch", "case", "break", "continue",21 "void", "never", "any", "string", "number", "boolean",22 "public", "private", "protected", "static", "abstract", "readonly",23]);24
25const TOKEN_RE = /(\/\/.*$|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|<\/?\s*[A-Z][a-zA-Z0-9.]*|<\/?\s*[a-z][a-zA-Z0-9-]*|=>|\b\d+(?:\.\d+)?\b|\b[a-zA-Z_$][a-zA-Z0-9_$]*\b)/;26
27const TOKEN_STYLES: Record<string, string> = {28 keyword: "text-violet-600",29 string: "text-emerald-700",30 comment: "text-neutral-400 italic",31 tag: "text-sky-700",32 number: "text-amber-700",33};34
35function classifyToken(val: string): string {36 if (val.startsWith("//")) return "comment";37 if (val[0] === '"' || val[0] === "'" || val[0] === "`") return "string";38 if (val[0] === "<") return "tag";39 if (val === "=>") return "keyword";40 if (/^\d/.test(val)) return "number";41 if (KEYWORDS.has(val)) return "keyword";42 return "plain";43}44
45function tokenizeLine(line: string): { type: string; value: string }[] {46 const tokens: { type: string; value: string }[] = [];47 const re = new RegExp(TOKEN_RE.source, "g");48 let lastIndex = 0;49 let match;50
51 while ((match = re.exec(line)) !== null) {52 if (match.index > lastIndex) {53 tokens.push({ type: "plain", value: line.slice(lastIndex, match.index) });54 }55 tokens.push({ type: classifyToken(match[0]), value: match[0] });56 lastIndex = re.lastIndex;57 }58
59 if (lastIndex < line.length) {60 tokens.push({ type: "plain", value: line.slice(lastIndex) });61 }62
63 return tokens;64}65
66const HIGHLIGHTABLE = new Set(["js", "jsx", "ts", "tsx", "javascript", "typescript"]);67
68export function CodeBlock({69 code,70 language,71 showLineNumbers = false,72 title,73 className = "",74}: CodeBlockProps) {75 const [copied, setCopied] = useState(false);76 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);77
78 const handleCopy = useCallback(() => {79 navigator.clipboard.writeText(code).then(() => {80 setCopied(true);81 if (timer.current) clearTimeout(timer.current);82 timer.current = setTimeout(() => setCopied(false), 2000);83 });84 }, [code]);85
86 const highlight = language ? HIGHLIGHTABLE.has(language.toLowerCase()) : false;87
88 const lines = useMemo(() => code.split("\n"), [code]);89 const tokenized = useMemo(90 () => (highlight ? lines.map((line) => tokenizeLine(line)) : null),91 [lines, highlight],92 );93
94 return (95 /* The header lives in the bezel and the code sits on the raised face,96 so the filename reads as a label on the object, not part of it. */97 <figure98 className={["rounded-[18px] p-1.5 pt-0 m-0", className].filter(Boolean).join(" ")}99 style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}100 >101 <figcaption className="flex items-center justify-between gap-3 pl-3 pr-0.5 h-10">102 <div className="flex items-center gap-2 min-w-0">103 {title && (104 <span className="text-[12px] font-medium text-neutral-700 truncate">{title}</span>105 )}106 {language && (107 <span108 className="text-[11px] font-semibold text-neutral-500 lowercase px-1.5 py-0.5 rounded-[5px]"109 style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}110 >111 {language}112 </span>113 )}114 </div>115 {/* Both labels share one grid cell, so the key is always as wide as116 the longer one and never jumps. Swapping rolls the old label up117 and out of a small window while the new one rolls in beneath118 it; the check draws itself as it arrives. */}119 <button120 type="button"121 onClick={handleCopy}122 aria-label={copied ? "copied to clipboard" : "copy code"}123 className="inline-flex items-center h-7 px-2.5 rounded-[8px] text-[11.5px] font-medium cursor-pointer transition-[color,transform,scale] duration-150 active:scale-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900"124 style={{125 background: "var(--face-light)",126 boxShadow: "var(--face-light-shadow)",127 color: copied ? "#047857" : undefined,128 }}129 >130 <span className="grid overflow-hidden text-neutral-600" style={{ color: "inherit" }}>131 <span132 aria-hidden133 className="[grid-area:1/1] inline-flex items-center justify-center gap-1.5"134 style={{135 transform: copied ? "translateY(-110%)" : "none",136 opacity: copied ? 0 : 1,137 filter: copied ? "blur(2px)" : "none",138 transition: "transform 320ms var(--ease-out-soft), opacity 220ms ease, filter 220ms ease",139 }}140 >141 <svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">142 <rect x="9" y="9" width="13" height="13" rx="2" />143 <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />144 </svg>145 copy146 </span>147 <span148 aria-hidden149 className="[grid-area:1/1] inline-flex items-center justify-center gap-1.5"150 style={{151 transform: copied ? "none" : "translateY(110%)",152 opacity: copied ? 1 : 0,153 filter: copied ? "none" : "blur(2px)",154 transition: "transform 380ms var(--ease-spring), opacity 220ms ease, filter 220ms ease",155 }}156 >157 <svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.25" viewBox="0 0 24 24">158 <path159 key={String(copied)}160 strokeLinecap="round"161 strokeLinejoin="round"162 d="M5 13l4 4L19 7"163 pathLength={15}164 className={copied ? "animate-draw-check" : ""}165 />166 </svg>167 copied168 </span>169 </span>170 </button>171 <span aria-live="polite" className="sr-only">{copied ? "copied to clipboard" : ""}</span>172 </figcaption>173 <div174 className="bg-white rounded-[13px] overflow-x-auto scroll-thin"175 style={{ boxShadow: "var(--panel-face-shadow)" }}176 >177 <pre className="py-4 px-4 text-[13px] leading-[1.7] font-mono text-neutral-800">178 <code>179 {lines.map((line, i) => (180 <div key={i} className="flex">181 {showLineNumbers && (182 <span aria-hidden className="select-none w-8 shrink-0 text-right pr-4 text-neutral-300 tabular-nums">183 {i + 1}184 </span>185 )}186 <span className="flex-1">187 {tokenized && tokenized[i].length188 ? tokenized[i].map((tok, j) => (189 <span key={j} className={TOKEN_STYLES[tok.type] || ""}>190 {tok.value}191 </span>192 ))193 : (line || "\n")}194 </span>195 </div>196 ))}197 </code>198 </pre>199 </div>200 </figure>201 );202}usage
tsx
import { CodeBlock } from "@blurrow/ui";tsx
<CodeBlock language="tsx" title="app.tsx" code={source} />examples
plain text
languages other than js/ts are shown without highlighting.
bash
npm install next react react-domprops
straight from the source, so they're always up to date.
propsts
interface CodeBlockProps { code: string; language?: string; showLineNumbers?: boolean; title?: string; className?: string;}