shadimage
akoder
uicopy-command-blocksource: ok · refreshed

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.

npx shadcn add "https://akoder.xyz/r/copy-command-block.json"
free

Visual style

terminal-mono

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
iconslucide-react
animationmotion

Registry dependencies

tooltip

Files1 · source cached 10/8/2026, 5:20:57 AM

components/ui/copy-command-block.tsx
"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>
  );
}

More from akoder

Browse all components in this library →