# Install Tabs

> An install command in npm, pnpm, yarn and bun, with a sliding pill, a copy button and a choice that follows to every block on the page.

Category: Navigation · Free · Updated 2026-10-23 · Live demo: https://motionkitui.com/components/install-tabs

## When to use it

Documentation, READMEs on the web, library landing pages and getting-started guides: anywhere you'd otherwise write the same install command four times.

## How it works

The chosen package manager lives in a tiny external store backed by localStorage, read with useSyncExternalStore. Every Install Tabs on the page subscribes to it, so picking pnpm in one block switches them all, and the choice is still there on the next visit. The selected tab has a pill shared through layoutId that springs between tabs, and the command swaps with a short slide via AnimatePresence. On the server it renders npm, so nothing mismatches during hydration.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/install-tabs.json
```

Or copy `components/motionkit/install-tabs.tsx` below. Requires react, motion and Tailwind CSS v4.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `packages` | `string` |  | Packages to install, e.g. "motion lucide-react". |
| `dev` | `boolean` | `false` | Install as development dependencies. |

## Accessibility

- **Keyboard: Pass.** A WAI-ARIA tablist: Left and Right switch package manager, and only the selected tab is in the Tab order.
- **Screen readers: Pass.** Tabs announce their selected state and control a labelled panel; copying is announced.
- **Reduced motion: Pass.** With reduced motion on, the pill jumps and the commands crossfade without sliding.
- **Colour contrast: Pass.** Commands measure 17:1, the selected tab 17:1 and the others 7.7:1.

## Source

```tsx
"use client";

import { useId, useState, useSyncExternalStore, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Manager = "npm" | "pnpm" | "yarn" | "bun";

type InstallTabsProps = {
  /** Packages to install, e.g. "motion lucide-react". */
  packages: string;
  /** Install as development dependencies. */
  dev?: boolean;
  className?: string;
};

const managers: Manager[] = ["npm", "pnpm", "yarn", "bun"];
const command = (manager: Manager, packages: string, dev: boolean) =>
  manager === "npm" ? `npm install ${dev ? "-D " : ""}${packages}` : `${manager} add ${dev ? "-D " : ""}${packages}`;

// one choice shared by every InstallTabs on the page, and remembered between visits
const STORAGE_KEY = "motionkit:package-manager";
const listeners = new Set<() => void>();
const read = (): Manager => {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    return managers.includes(stored as Manager) ? (stored as Manager) : "npm";
  } catch {
    return "npm";
  }
};
const choose = (manager: Manager) => {
  try {
    localStorage.setItem(STORAGE_KEY, manager);
  } catch {}
  listeners.forEach((listener) => listener());
};
const subscribe = (listener: () => void) => {
  listeners.add(listener);
  return () => listeners.delete(listener);
};

/**
 * An install command in npm, pnpm, yarn and bun flavours. The pill slides to the chosen manager, the command
 * swaps, and the choice follows to every other install block on the page.
 */
export function InstallTabs({ packages, dev = false, className = "" }: InstallTabsProps) {
  const reduceMotion = useReducedMotion();
  const id = useId();
  const manager = useSyncExternalStore(subscribe, read, () => "npm" as Manager);
  const [copied, setCopied] = useState(false);
  const text = command(manager, packages, dev);

  function onKey(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
    if (!step) return;
    event.preventDefault();
    const next = managers[(index + step + managers.length) % managers.length];
    choose(next);
    document.getElementById(`${id}-${next}`)?.focus();
  }

  async function copyCommand() {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1600);
    } catch {}
  }

  return (
    <div className={`w-full max-w-md overflow-hidden rounded-xl border border-white/10 bg-[#0C1010] text-[#F6F1E8] ${className}`}>
      <div className="flex items-center justify-between border-b border-white/[0.08] px-2 py-1.5">
        <div role="tablist" aria-label="Package manager" className="flex">
          {managers.map((option, index) => (
            <button
              key={option}
              id={`${id}-${option}`}
              role="tab"
              aria-selected={manager === option}
              aria-controls={`${id}-panel`}
              tabIndex={manager === option ? 0 : -1}
              onClick={() => choose(option)}
              onKeyDown={(event) => onKey(event, index)}
              className={`relative rounded-md px-2.5 py-1 font-mono text-xs transition-colors focus-visible:outline-2 focus-visible:outline-[#5FAEB4] ${manager === option ? "text-[#F6F1E8]" : "text-[#94A9A7] hover:text-[#DCE3E2]"}`}
            >
              {manager === option && (
                <motion.span layoutId={`${id}-pill`} aria-hidden className="absolute inset-0 rounded-md bg-white/[0.08]" transition={reduceMotion ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.35 }} />
              )}
              <span className="relative">{option}</span>
            </button>
          ))}
        </div>
        <button type="button" onClick={copyCommand} className="rounded-md px-2 py-1 text-xs text-[#94A9A7] transition-colors hover:text-[#F6F1E8] focus-visible:outline-2 focus-visible:outline-[#5FAEB4]">
          {copied ? "Copied" : "Copy"}
        </button>
      </div>
      <div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${manager}`} className="relative overflow-hidden px-4 py-3 font-mono text-sm">
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.code
            key={manager}
            className="block whitespace-nowrap"
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
            transition={{ duration: 0.18 }}
          >
            <span className="select-none text-[#5FAEB4]">$ </span>
            {text}
          </motion.code>
        </AnimatePresence>
        <span role="status" className="sr-only">{copied ? "Copied" : ""}</span>
      </div>
    </div>
  );
}
```
