shubhra.dev
Loading...
shubhra.dev
Loading...
A React code block component that strips shell prompts and line numbers before copying. Fixed-position tooltips, keyboard accessible, SSR-safe, HTTP fallback.
Click the copy button to see prompt cleaning in action. Shell prompts and line numbers are removed automatically.
# Install dependencies
$ npm install lucide-react clsx tailwind-merge
# Start development
$ npm run devAutomatically strips $, #, and user@host prompts so commands run immediately.
Uses useSyncExternalStore to prevent hydration flickering in Next.js 14/15.
Full keyboard support (Esc to dismiss) and screen reader ARIA announcements.
Uses modern Clipboard API with a robust execCommand fallback for HTTP sites.
// Loading...src/components/Love this snippet?
Share it with your friends and colleagues
Get practical tutorials, engineering insights, and new developer resources delivered.
One-click confirmation required. No spam. Unsubscribe anytime.
Help keep Shubhra.dev creating free tutorials, articles, snippets, quizzes, and developer resources for developers everywhere.
Support shubhra.devBrowse production-ready hooks, components, and utilities, built for serious developers.
Browse All SnippetsProduction caching utilities that make Next.js 16 caching production-safe. Type-safe tag registry, dual-invalidation for Server Actions, parallel prefetching, and Suspense boundary enforcement. One file, zero guessing.
A single-file React live code editor. Five themes, line numbers, SSR-safe fallback, scoped style isolation, copy/reset, and noInline auto-detection.
You've seen it happen. Someone copies a terminal command from your docs:
$ npm install react
$ npm run devThey paste it. Terminal chokes on the literal $ character. Now they think your docs are broken and you get the support ticket.
Same story with syntax highlighters that show line numbers. Users paste 1 | const x = 5 into their editor and spend five minutes wondering why it doesn't run.
Ask for a "copy to clipboard" button and you'll get a tidy five-line snippet back. It works in a demo. It breaks the moment it touches a real codebase:
$ npm install copies verbatim, dollar sign and all1 | const x lands in the editor as-isoverflow: hidden container or modalnavigator.clipboard during SSR crashes Next.jsThis component handles all of that so you don't have to.
| Feature | CleanCopy | Basic Snippet |
|---|---|---|
Removes $, >, # prompts | ✅ | ❌ |
| Strips line numbers | ✅ | ❌ |
| Fixed-position tooltip | ✅ | ❌ |
| Keyboard accessible | ✅ | ❌ |
| Screen reader announcements | ✅ | ❌ |
| HTTP clipboard fallback | ✅ | ❌ |
| SSR-safe (no hydration crash) | ✅ | ❌ |
| React 19 Strict Mode safe | ✅ | ❌ |
| Reduced motion support | ✅ | ❌ |
aria-live regionsuseSyncExternalStore for hydration-safe mountingdocument.execCommand('copy') when navigator.clipboard is unavailableimport { CopyCode } from "@/components/CopyCode";
const code = `const greeting = "Hello!";
console.log(greeting);`;
<CopyCode code={code} language="typescript">
<code>{code}</code>
</CopyCode>;Shell prompts are detected automatically from the language prop (bash, sh, zsh, shell, terminal) or by scanning the content with detectShellPrompts(). No config needed.
const terminalCode = `$ npm install next react react-dom
$ npm run dev`;
<CopyCode code={terminalCode} language="bash">
<code>{terminalCode}</code>
</CopyCode>;<CopyCode code={code} filename="components/Button.tsx">
<code>{code}</code>
</CopyCode>Pass the raw code string to code and the highlighted output as children. The component copies the raw string, not the rendered HTML.
pre: ({ children, ...props }) => {
const rawCode = extractRawCode(children);
return (
<CopyCode code={rawCode} language={props["data-language"]}>
{children}
</CopyCode>
);
};Both cleaning functions are exported for use outside the component:
import { cleanCodeForCopy, detectShellPrompts } from "@/components/CopyCode";
const cleaned = cleanCodeForCopy("$ npm install react", true);
// "npm install react"
const hasPrompts = detectShellPrompts("$ npm run dev");
// true| Prop | Type | Default | Description |
|---|---|---|---|
code | string | Required | Raw code to copy -- pass this before syntax highlighting |
language | string | -- | Language badge. Also triggers terminal mode for shell langs |
filename | string | -- | Shown in the header instead of language badge |
isTerminal | boolean | Auto | Force terminal mode. Auto-detected from language or content when omitted |
showLineNumbers | boolean | false | Adjusts left padding when your highlighter adds line numbers |
className | string | -- | Additional classes on the outer container |
children | ReactNode | Required | The rendered (highlighted) code content |
| Input | What Gets Copied |
|---|---|
$ npm install | npm install |
> Get-Process | Get-Process |
# sudo apt update | sudo apt update |
% source ~/.zshrc | source ~/.zshrc |
user@host:~$ ls -la | ls -la |
(venv) $ python app.py | python app.py |
| Format | Cleaned |
|---|---|
1 | const x | const x |
1: const x | const x |
001 const x | const x |
A multi-pass regex scan runs across all lines. It catches standard Unix prompts ($, >, #, %), user@host patterns (user@host:~$), and virtual environment wrappers ((venv) $). Detection is per-line, so mixed-content blocks where some lines have prompts and others are output get handled correctly. Only prompt-prefixed lines are cleaned.
Most copy buttons render their tooltip inline. Works fine until your code block sits inside a modal, sidebar, or any container with overflow: hidden. The tooltip gets clipped. Here, the tooltip renders with position: fixed and positions itself using getBoundingClientRect(). It repositions on scroll and resize via event listeners.
navigator.clipboard doesn't exist on the server. The usual workaround, checking for it during render, causes hydration mismatches in Next.js. Instead, useSyncExternalStore safely detects client-side mounting: the server always returns false, the client returns true after hydration. The tooltip only renders client-side. The initial render always matches server output.
When navigator.clipboard isn't available (HTTP contexts, older browsers), the component creates a temporary <textarea>, selects its contents, and runs document.execCommand('copy'). The fallback is wrapped in try/catch with proper cleanup.
Follows WAI-ARIA button patterns. Enter and Space trigger copy. Escape dismisses the tooltip and blurs the button. Tab moves focus naturally. The tooltip uses role="tooltip" and a separate aria-live="polite" region announces the copy result to screen readers.
aria-label updates with state ("Copy code" then "Copied!")aria-live="polite" region announces copy result to screen readersfocus-visible ring for keyboard users only (no click rings)motion-reduce:animate-none respects reduced motion preferences| Key | Action |
|---|---|
Tab | Focus the copy button |
Enter / Space | Copy code |
Escape | Dismiss tooltip + blur |
| Browser | Support |
|---|---|
| Chrome 66+ | ✅ Full |
| Firefox 63+ | ✅ Full |
| Safari 13.1+ | ✅ Full |
| Edge 79+ | ✅ Full |
| Framework | Support |
|---|---|
| Next.js 13-15 | ✅ |
| Remix | ✅ |
| Vite + React | ✅ |
| React 18 & 19 | ✅ |
| TypeScript strict | ✅ |
| Dependency | Version |
|---|---|
| React | 18 or 19 |
| lucide-react | Any recent version |
I got tired of watching users paste $ npm install react and file bug reports because the terminal errored out. Every copy button I found online was the same basic navigator.clipboard.writeText wrapper -- no prompt cleaning, no line number handling, no thought given to what actually lands in the clipboard.
So I built one that handles every prompt format I've run into, strips line numbers from any major syntax highlighter, and still works over plain HTTP with a clean fallback.