Copy Command
An install command with package-manager tabs and a copy button that swaps to a check in place.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/copy-command.json'use client'
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'
import './copy-command.css'
export interface CommandOption {
/** Tab label, e.g. "pnpm". */
label: string
command: string
}
export interface CopyCommandProps {
commands: CommandOption[]
/** Show the package-manager tabs. With one command they are hidden anyway. */
tabs?: boolean
/** Prompt glyph before the command. */
prompt?: string
fill?: 'dark' | 'light'
/** Controlled tab index (for demos); users can still switch. */
active?: number
/** Increment to trigger the copy feedback without a click (for demos). */
pulse?: number
onCopy?: (command: string) => void
className?: string
}
/** An install command with package-manager tabs and a copy button that confirms in place. */
export function CopyCommand({ commands, tabs = true, prompt = '$', fill = 'dark', active, pulse, onCopy, className }: CopyCommandProps) {
const [picked, setPicked] = useState(0)
const [copied, setCopied] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
const id = useId()
const index = Math.min(active ?? picked, commands.length - 1)
const cmd = commands[index]
const flash = () => {
setCopied(true)
clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 1600)
}
useEffect(() => {
if (pulse) flash()
}, [pulse])
useEffect(() => () => clearTimeout(timer.current), [])
const copy = async () => {
try {
await navigator.clipboard?.writeText(cmd.command)
} catch {
/* clipboard can be blocked; the feedback still shows the intent */
}
flash()
onCopy?.(cmd.command)
}
const onTabKey = (e: KeyboardEvent<HTMLDivElement>) => {
const n = commands.length
const next = e.key === 'ArrowRight' ? (index + 1) % n : e.key === 'ArrowLeft' ? (index - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1
if (next < 0) return
e.preventDefault()
setPicked(next)
document.getElementById(`${id}-tab-${next}`)?.focus()
}
return (
<div className={['sd-cc', className].filter(Boolean).join(' ')} data-fill={fill}>
{tabs && commands.length > 1 && (
<div className="sd-cc__tabs" role="tablist" aria-label="Package manager" onKeyDown={onTabKey}>
{commands.map((c, i) => (
<button
key={c.label}
id={`${id}-tab-${i}`}
role="tab"
type="button"
aria-selected={i === index}
aria-controls={`${id}-panel`}
tabIndex={i === index ? 0 : -1}
className="sd-cc__tab"
onClick={() => setPicked(i)}
>
{c.label}
</button>
))}
</div>
)}
<div className="sd-cc__row" id={`${id}-panel`} role={tabs && commands.length > 1 ? 'tabpanel' : undefined} aria-labelledby={tabs && commands.length > 1 ? `${id}-tab-${index}` : undefined}>
<code className="sd-cc__code">
<span className="sd-cc__prompt" aria-hidden>
{prompt}
</span>
<span key={cmd.command} className="sd-cc__text">
{cmd.command}
</span>
</code>
<button type="button" className="sd-cc__copy" onClick={copy} data-copied={copied || undefined} aria-label={copied ? 'Copied' : 'Copy command'}>
<svg viewBox="0 0 16 16" aria-hidden className="sd-cc__ico sd-cc__ico--copy">
<rect x="5" y="5" width="8.5" height="8.5" rx="2" />
<path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" />
</svg>
<svg viewBox="0 0 16 16" aria-hidden className="sd-cc__ico sd-cc__ico--ok">
<path d="M3 8.5l3.2 3L13 4.5" />
</svg>
</button>
</div>
<p className="sd-cc__status" role="status" aria-live="polite">
{copied ? 'Copied to clipboard' : ''}
</p>
</div>
)
}.sd-cc { --bg: #1d1d1d; --fg: #f7f7f7; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.09); --soft: rgb(255 255 255 / 0.07);
width: min(100%, 300px); padding: 6px; border-radius: 18px; background: var(--bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-cc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.08); --soft: rgb(0 0 0 / 0.05); }
.sd-cc__tabs { display: flex; gap: 2px; padding: 2px 4px 6px; }
.sd-cc__tab { position: relative; height: 26px; padding: 0 10px; border-radius: 999px; font: 500 12px/1 ui-monospace, 'JetBrains Mono', monospace; color: var(--mute); transition: color 200ms, background-color 200ms; }
.sd-cc__tab:hover { color: var(--fg); }
.sd-cc__tab[aria-selected='true'] { color: var(--fg); background: var(--soft); }
.sd-cc__row { display: flex; align-items: center; gap: 6px; height: 44px; padding: 0 6px 0 12px; border-radius: 13px; background: var(--soft); }
.sd-cc__code { flex: 1; min-width: 0; display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; white-space: nowrap; font: 12.5px/1 ui-monospace, 'JetBrains Mono', monospace; }
.sd-cc__code::-webkit-scrollbar { display: none; }
.sd-cc__prompt { color: var(--mute); user-select: none; }
.sd-cc__text { animation: sd-cc-in 320ms cubic-bezier(0.2, 1, 0.3, 1); }
@keyframes sd-cc-in { from { opacity: 0; transform: translateY(5px); } }
.sd-cc__copy { position: relative; flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; color: var(--fg); background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); transition: transform 180ms, background-color 200ms; }
.sd-cc__copy:hover { background: color-mix(in srgb, var(--bg) 85%, var(--fg)); }
.sd-cc__copy:active { transform: scale(0.9); }
.sd-cc__ico { position: absolute; width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; transition: opacity 200ms, transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1); }
.sd-cc__ico--ok { opacity: 0; transform: scale(0.4); stroke-width: 1.8; }
.sd-cc__copy[data-copied] .sd-cc__ico--copy { opacity: 0; transform: scale(0.4); }
.sd-cc__copy[data-copied] .sd-cc__ico--ok { opacity: 1; transform: none; }
.sd-cc__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.sd-cc button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .sd-cc *, .sd-cc *::before { animation: none !important; transition: none !important; } }Overview
The command sits in a quiet well with a prompt glyph. Tabs switch between package managers and the new command slides up into place.
Pressing copy writes to the clipboard, swaps the icon to a check for a moment and tells screen readers it worked. Long commands scroll sideways instead of wrapping.
When to use
- Install steps in docs, READMEs and component pages.
- Any short string people paste somewhere else: API keys, invite links, CLI flags.
When not to use
- Multi-line code: use a code block with its own copy button.
- Secrets that should stay masked until revealed.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/copy-command.json$
npx shadcn@latest add https://sundaeui.dev/r/copy-command.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/copy-command.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/copy-command.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy copy-command.tsx and copy-command.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { CopyCommand } from '@/components/sundae/copy-command'
<CopyCommand
commands={[
{ label: 'pnpm', command: 'pnpm dlx shadcn add @acme/button' },
{ label: 'npm', command: 'npx shadcn add @acme/button' },
]}
/>Props
| Prop | Type | Default |
|---|---|---|
commands*Label and command per package manager. | CommandOption[] | — |
tabsShow the package-manager tabs. | boolean | true |
promptGlyph before the command. Empty string hides it. | string | '$' |
onCopyCalled after a copy. | (command: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Tabs follow the WAI-ARIA tabs pattern with roving focus; the command is the tabpanel.
- The copy button has an accessible name that changes to "Copied", and a polite live region announces the result.
- Reduced motion removes the slide and icon spring.
Customization
- Colours are CSS custom properties on .sd-cc.
- Pass tabs={false} for a single command.
Keyboard
- ← / →
- Switch package manager
- Home / End
- First or last package manager
- Tab
- Move to the copy button
- Enter / Space
- Copy the command
FAQ
- What happens if the clipboard is blocked?
- The write is wrapped in a try/catch. The check still shows, so wire onCopy to your own fallback if you need one.
- Can I add yarn or deno?
- Yes. Tabs are generated from the commands array, in the order you pass it.
- Is Copy Command free?
- Yes. Copy Command is free for personal and commercial projects, with full source on this page.
- How do I install Copy Command?
- Run npx shadcn@latest add https://sundaeui.dev/r/copy-command.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Copy Command support keyboard and screen readers?
- Tabs follow the WAI-ARIA tabs pattern with roving focus; the command is the tabpanel. The copy button has an accessible name that changes to "Copied", and a polite live region announces the result.
- Does Copy Command respect reduced motion?
- Reduced motion removes the slide and icon spring.
More like this
All components →Code Tabs
Components · RefinedFold Accordion
Components · UnderstatedConfirm Dialog
Components · Understated- Pro
Team Members
Components · Understated - Pro
AI Composer
Components · Understated Agent Steps
Components · Understated