UI

Copy Command Block

A multi-package manager terminal command box with tab switcher (npm, pnpm, yarn, bun), persistent storage, copy feedback animation, and blueprint styling.

Props

Package manager tabsSwitcher for npm, pnpm, yarn, and bun
Prompt$ before the command

Installation

Copy Command Block is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/copy-command-block

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the components page.

Usage

import { CopyCommandBlock } from "@/components/ui/copy-command-block";
 
export function InstallationGuide() {
  return (
    <CopyCommandBlock
      commands={{
        npm: "npm install @tanstack/react-query",
        pnpm: "pnpm add @tanstack/react-query",
        yarn: "yarn add @tanstack/react-query",
        bun: "bun add @tanstack/react-query",
      }}
    />
  );
}

Props

PropTypeDefaultNotes
commandsRecord<"npm" | "pnpm" | "yarn" | "bun", string> | stringundefinedPer-package-manager command mapping or template string with {pm}, {runner}, or {add}.
commandstringundefinedSingle fixed command string (disables tabs).
storageKeystring"akoder:package-manager"LocalStorage key used to synchronize the user's preferred package manager across the entire site.
showTabsbooleantrueWhen true and multiple commands provided, displays package manager switcher tabs.
showPromptbooleantrueRenders terminal prompt indicator ($).
classNamestring—Extra CSS classes for wrapper container.

Notes & Features

  • Global Package Preference Sync. Remembers the selected package manager in localStorage so switching from npm to pnpm on one card updates all command blocks across the site.
  • Copy Feedback State. Provides visual icon feedback (Check / Error / Copy) accompanied by a tooltip.
  • Copy-block Chrome. Same dual-border frame, blueprint grid, and inner ring as the site's install blocks, with the tab strip sitting flush against the top-left corner.

Manual installation

Rather not use the CLI? Everything Copy Command Block needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install lucide-react motion radix-ui

shadcn primitives

$npx shadcn@latest add tooltip

Source

components/ui/copy-command-block.tsx279 lines
"use client";

import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { Check, Copy, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion, type Transition } from "motion/react";
import React, { useCallback, useId, useState } from "react";

export type PackageManagerType = "npm" | "pnpm" | "yarn" | "bun";

const DEFAULT_PACKAGE_MANAGERS: readonly PackageManagerType[] = [
  "npm",
  "pnpm",
  "yarn",
  "bun",
];

/* -------------------------------------------------------------------------- */
/*  Package manager logo icons (Simple Icons paths, currentColor)             */
/* -------------------------------------------------------------------------- */

function NpmIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
      <path d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z" />
    </svg>
  );
}

function PnpmIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
      <path d="M0 0v7.5h7.5V0zm8.25 0v7.5h7.5V0zm8.25 0v7.5H24V0zM8.25 8.25v7.5h7.5v-7.5zm8.25 0v7.5H24v-7.5zM0 16.5V24h7.5v-7.5zm8.25 0V24h7.5v-7.5zm8.25 0V24H24v-7.5z" />
    </svg>
  );
}

function YarnIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
      <path d="M12 0C5.375 0 0 5.375 0 12s5.375 12 12 12 12-5.375 12-12S18.625 0 12 0zm.768 4.105c.183 0 .363.053.525.157.125.083.287.185.755 1.154.31-.088.468-.042.551-.019.204.056.366.19.463.375.477.917.542 2.553.334 3.605-.241 1.232-.755 2.029-1.131 2.576.324.329.778.899 1.117 1.825.278.774.31 1.478.273 2.015a5.51 5.51 0 0 0 .602-.329c.593-.366 1.487-.917 2.553-.931.714-.009 1.269.445 1.353 1.103a1.23 1.23 0 0 1-.945 1.362c-.649.158-.95.278-1.821.843-1.232.797-2.539 1.242-3.012 1.39a1.686 1.686 0 0 1-.704.343c-.737.181-3.266.315-3.466.315h-.046c-.783 0-1.214-.241-1.45-.491-.658.329-1.51.19-2.122-.134a1.078 1.078 0 0 1-.58-1.153 1.243 1.243 0 0 1-.153-.195c-.162-.25-.528-.936-.454-1.946.056-.723.556-1.367.88-1.71a5.522 5.522 0 0 1 .408-2.256c.306-.727.885-1.348 1.32-1.737-.32-.537-.644-1.367-.329-2.21.227-.602.412-.936.82-1.08h-.005c.199-.074.389-.153.486-.259a3.418 3.418 0 0 1 2.298-1.103c.037-.093.079-.185.125-.283.31-.658.639-1.029 1.024-1.168a.94.94 0 0 1 .328-.06zm.006.7c-.507.016-1.001 1.519-1.001 1.519s-1.27-.204-2.266.871c-.199.218-.468.334-.746.44-.079.028-.176.023-.417.672-.371.991.625 2.094.625 2.094s-1.186.839-1.626 1.881c-.486 1.144-.338 2.261-.338 2.261s-.843.732-.899 1.487c-.051.663.139 1.2.343 1.515.227.343.51.176.51.176s-.561.653-.037.931c.477.25 1.283.394 1.71-.037.31-.31.371-1.001.486-1.283.028-.065.12.111.209.199.097.093.264.195.264.195s-.755.324-.445 1.066c.102.246.468.403 1.066.398.222-.005 2.664-.139 3.313-.296.375-.088.505-.283.505-.283s1.566-.431 2.998-1.357c.917-.598 1.293-.76 2.034-.936.612-.148.57-1.098-.241-1.084-.839.009-1.575.44-2.196.825-1.163.718-1.742.672-1.742.672l-.018-.032c-.079-.13.371-1.293-.134-2.678-.547-1.515-1.413-1.881-1.344-1.997.297-.5 1.038-1.297 1.334-2.78.176-.899.13-2.377-.269-3.151-.074-.144-.732.241-.732.241s-.616-1.371-.788-1.483a.271.271 0 0 0-.157-.046z" />
    </svg>
  );
}

function BunIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
      <path d="M12 22.596c6.628 0 12-4.338 12-9.688 0-3.318-2.057-6.248-5.219-7.986-1.286-.715-2.297-1.357-3.139-1.89C14.058 2.025 13.08 1.404 12 1.404c-1.097 0-2.334.785-3.966 1.821a49.92 49.92 0 0 1-2.816 1.697C2.057 6.66 0 9.59 0 12.908c0 5.35 5.372 9.687 12 9.687v.001ZM10.599 4.715c.334-.759.503-1.58.498-2.409 0-.145.202-.187.23-.029.658 2.783-.902 4.162-2.057 4.624-.124.048-.199-.121-.103-.209a5.763 5.763 0 0 0 1.432-1.977Zm2.058-.102a5.82 5.82 0 0 0-.782-2.306v-.016c-.069-.123.086-.263.185-.172 1.962 2.111 1.307 4.067.556 5.051-.082.103-.23-.003-.189-.126a5.85 5.85 0 0 0 .23-2.431Zm1.776-.561a5.727 5.727 0 0 0-1.612-1.806v-.014c-.112-.085-.024-.274.114-.218 2.595 1.087 2.774 3.18 2.459 4.407a.116.116 0 0 1-.049.071.11.11 0 0 1-.153-.026.122.122 0 0 1-.022-.083 5.891 5.891 0 0 0-.737-2.331Zm-5.087.561c-.617.546-1.282.76-2.063 1-.117 0-.195-.078-.156-.181 1.752-.909 2.376-1.649 2.999-2.778 0 0 .155-.118.188.085 0 .304-.349 1.329-.968 1.874Zm4.945 11.237a2.957 2.957 0 0 1-.937 1.553c-.346.346-.8.565-1.286.62a2.178 2.178 0 0 1-1.327-.62 2.955 2.955 0 0 1-.925-1.553.244.244 0 0 1 .064-.198.234.234 0 0 1 .193-.069h3.965a.226.226 0 0 1 .19.07c.05.053.073.125.063.197Zm-5.458-2.176a1.862 1.862 0 0 1-2.384-.245 1.98 1.98 0 0 1-.233-2.447c.207-.319.503-.566.848-.713a1.84 1.84 0 0 1 1.092-.11c.366.075.703.261.967.531a1.98 1.98 0 0 1 .408 2.114 1.931 1.931 0 0 1-.698.869v.001Zm8.495.005a1.86 1.86 0 0 1-2.381-.253 1.964 1.964 0 0 1-.547-1.366c0-.384.11-.76.32-1.079.207-.319.503-.567.849-.713a1.844 1.844 0 0 1 1.093-.108c.367.076.704.262.968.534a1.98 1.98 0 0 1 .4 2.117 1.932 1.932 0 0 1-.702.868Z" />
    </svg>
  );
}

const PM_ICONS: Record<PackageManagerType, React.ComponentType<{ className?: string }>> = {
  npm: NpmIcon,
  pnpm: PnpmIcon,
  yarn: YarnIcon,
  bun: BunIcon,
};

const RUNNERS: Record<PackageManagerType, string> = {
  npm: "npx",
  pnpm: "pnpm dlx",
  yarn: "yarn dlx",
  bun: "bunx --bun",
};

export interface CopyCommandBlockProps {
  /** Map of package manager to full command string, or string template containing `{pm}` / `{runner}` / `{add}`. */
  commands?: Partial<Record<PackageManagerType, string>> | string;
  /** Direct single command string (ignores package manager tabs). */
  command?: string;
  /** Storage key to sync selected package manager across instances. */
  storageKey?: string;
  /** Show package manager switcher tabs when multiple commands provided. */
  showTabs?: boolean;
  /** Show the terminal `$` prompt. */
  showPrompt?: boolean;
  /** Extra class names. */
  className?: string;
}

export function CopyCommandBlock({
  commands,
  command: singleCommand,
  storageKey = "akoder:package-manager",
  showTabs = true,
  showPrompt = true,
  className,
}: CopyCommandBlockProps) {
  const [activePm, setActivePm] = useState<PackageManagerType>(() => {
    if (typeof window === "undefined" || !storageKey) return "npm";
    try {
      const stored = window.localStorage.getItem(storageKey);
      if (stored && DEFAULT_PACKAGE_MANAGERS.includes(stored as PackageManagerType)) {
        return stored as PackageManagerType;
      }
    } catch {
      // Ignore localStorage access issues
    }
    return "npm";
  });
  const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">("idle");

  const selectPm = useCallback(
    (pm: PackageManagerType) => {
      setActivePm(pm);
      if (storageKey && typeof window !== "undefined") {
        window.localStorage.setItem(storageKey, pm);
      }
    },
    [storageKey],
  );

  // Compute command to display and copy
  const activeCommand = React.useMemo(() => {
    if (singleCommand) return singleCommand;
    if (!commands) return "";
    if (typeof commands === "string") {
      const runner = RUNNERS[activePm];
      return commands
        .replaceAll("{pm}", activePm)
        .replaceAll("{runner}", runner)
        .replaceAll("{add}", activePm === "npm" ? "install" : "add");
    }
    return commands[activePm] ?? commands.npm ?? Object.values(commands)[0] ?? "";
  }, [singleCommand, commands, activePm]);

  const copyToClipboard = useCallback(async () => {
    if (!activeCommand) return;
    if (typeof navigator === "undefined" || !navigator.clipboard) {
      setCopyStatus("error");
      return;
    }
    try {
      await navigator.clipboard.writeText(activeCommand);
      setCopyStatus("copied");
      setTimeout(() => setCopyStatus("idle"), 2000);
    } catch {
      setCopyStatus("error");
      setTimeout(() => setCopyStatus("idle"), 2000);
    }
  }, [activeCommand]);

  const reducedMotion = useReducedMotion();
  // Per-instance id: several blocks can share a page, and a shared layoutId
  // would make the pill fly between them.
  const pillId = useId();
  const pillTransition: Transition = reducedMotion
    ? { duration: 0 }
    : { type: "spring", stiffness: 420, damping: 34, mass: 0.7 };

  const hasTabs = !singleCommand && showTabs && typeof commands === "object";

  return (
    <div className={cn("relative", className)}>
      {hasTabs && (
        /* Package manager tabs — the active background slides between tabs. */
        <div className="flex w-fit items-center gap-1 rounded-t-lg border bg-muted">
          {DEFAULT_PACKAGE_MANAGERS.map((pm) => {
            const Icon = PM_ICONS[pm];
            const isActive = activePm === pm;
            return (
              <button
                key={pm}
                type="button"
                onClick={() => selectPm(pm)}
                aria-pressed={isActive}
                className={cn(
                  "relative flex items-center gap-1.5 rounded-t-lg px-2 py-1 font-mono text-[10px] tracking-wider micro-transition",
                  isActive
                    ? "text-foreground"
                    : "text-muted-foreground hover:text-foreground",
                )}
              >
                {isActive && (
                  <motion.span
                    layoutId={`pm-tab-pill-${pillId}`}
                    initial={false}
                    transition={pillTransition}
                    className="absolute inset-0 rounded-t-lg bg-background"
                    aria-hidden="true"
                  />
                )}
                <Icon className="relative z-10 size-3 shrink-0" />
                <AnimatePresence initial={false}>
                  {isActive && (
                    <motion.span
                      key="label"
                      initial={
                        reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }
                      }
                      animate={{ opacity: 1, scale: 1 }}
                      exit={
                        reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }
                      }
                      transition={
                        reducedMotion
                          ? { duration: 0 }
                          : { duration: 0.15, ease: [0.22, 1, 0.36, 1] }
                      }
                      className="relative z-10 origin-left whitespace-nowrap"
                    >
                      {pm}
                    </motion.span>
                  )}
                </AnimatePresence>
              </button>
            );
          })}
        </div>
      )}

      {/* With tabs the top-left corner stays square so the strip sits flush
          against the frame; a standalone block rounds all four corners. */}
      <div
        className={cn(
          "relative border bg-muted/10 p-0.5",
          hasTabs ? "rounded-tl-none rounded-b-lg rounded-r-lg" : "rounded-lg",
        )}
      >
        <div className="group/copy relative overflow-hidden rounded-md border bg-muted/10">
          <div className="blueprint-bg pointer-events-none absolute inset-0 opacity-40" />
          <div className="pointer-events-none absolute inset-0 rounded-md ring-1 ring-inset ring-muted-foreground/5" />

          <div className="relative flex items-center gap-3 px-4 py-3">
            <AnimatePresence mode="wait" initial={false}>
              <motion.code
                key={activePm}
                initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 3 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -3 }}
                transition={
                  reducedMotion
                    ? { duration: 0 }
                    : { duration: 0.12, ease: [0.22, 1, 0.36, 1] }
                }
                className="min-w-0 flex-1 overflow-x-auto no-scrollbar [scrollbar-width:none] [&::-webkit-scrollbar]:hidden whitespace-pre font-mono text-[12.5px] leading-relaxed text-foreground"
              >
                {showPrompt && (
                  <span className="select-none pr-2 text-muted-foreground">$</span>
                )}
                {activeCommand}
              </motion.code>
            </AnimatePresence>

            <Tooltip>
              <TooltipTrigger asChild>
                <button
                  type="button"
                  onClick={copyToClipboard}
                  aria-label="copy command"
                  className="flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground micro-transition hover:bg-muted/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
                >
                  {copyStatus === "copied" ? (
                    <Check className="size-3.5 text-green-500" />
                  ) : copyStatus === "error" ? (
                    <X className="size-3.5 text-destructive" />
                  ) : (
                    <Copy className="size-3.5" />
                  )}
                </button>
              </TooltipTrigger>
              <TooltipContent>
                <p>
                  {copyStatus === "copied"
                    ? "copied!"
                    : copyStatus === "error"
                      ? "copy failed"
                      : "copy command"}
                </p>
              </TooltipContent>
            </Tooltip>
          </div>
        </div>
      </div>
    </div>
  );
}