Skip to content
Block

Package manager tabs

One install command per package manager, remembering the reader's choice.

  • #arc
  • #block
  • #install
  • #tabs
  • #npm
  • #pnpm
  • #code

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/package-manager-tabs.json

package-manager-tabs.module.csscss
/* The tab row and the block share one outline, so the command reads as one thing rather than a control above a box. */
.tabs { display: flex; flex-direction: column; gap: var(--space-3); }
.tabs pre { margin: 0; }
package-manager-tabs.tsxtsx
"use client";

import { useSyncExternalStore } from "react";
import { CodeBlock } from "../../code-block/code-block";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../tabs/tabs";
import styles from "./package-manager-tabs.module.css";

export const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
export type PackageManager = (typeof PACKAGE_MANAGERS)[number];

/* One choice for the whole page, kept outside React so every block on the page moves together — a reader who picks
   pnpm once should not have to pick it again further down. It is remembered in this browser and nowhere else. */
const listeners = new Set<() => void>();
let chosen: string | null = null;

function read(managers: readonly string[], storageKey: string): string {
  if (chosen) return chosen;
  try {
    const stored = window.localStorage.getItem(storageKey);
    if (stored && managers.includes(stored)) chosen = stored;
  } catch {
    // Storage can be blocked (private mode, sandboxed previews): fall back to the first manager.
  }
  return chosen ?? managers[0];
}

function choose(value: string, storageKey: string) {
  chosen = value;
  try {
    window.localStorage.setItem(storageKey, value);
  } catch {
    // Not remembered, still switched for this page.
  }
  for (const listener of listeners) listener();
}

function subscribe(listener: () => void) {
  listeners.add(listener);
  return () => { listeners.delete(listener); };
}

export interface PackageManagerTabsProps {
  /** The command per manager, keyed by its name. */
  commands: Record<string, string>;
  /** Which managers to show, in order. Default: npm, pnpm, yarn, bun. */
  managers?: readonly string[];
  /** Where the choice is remembered in this browser. */
  storageKey?: string;
  /** The accessible name of the tab row. */
  label?: string;
  /** The name shown on the block's header. */
  filename?: string;
  className?: string;
}

/**
 * One install command, shown for each package manager. Choosing a manager switches every block on the page and this
 * browser remembers it; nothing is stored on the server.
 */
export function PackageManagerTabs({
  commands,
  managers = PACKAGE_MANAGERS,
  storageKey = "package-manager",
  label = "패키지 관리자",
  filename = "터미널",
  className,
}: PackageManagerTabsProps) {
  const active = useSyncExternalStore(subscribe, () => read(managers, storageKey), () => managers[0]);
  return (
    <Tabs value={active} onValueChange={value => choose(value, storageKey)} className={[styles.tabs, className].filter(Boolean).join(" ")}>
      <TabsList aria-label={label}>
        {managers.map(pm => <TabsTrigger key={pm} value={pm}>{pm}</TabsTrigger>)}
      </TabsList>
      {managers.map(pm => (
        <TabsContent key={pm} value={pm}>
          <CodeBlock code={commands[pm] ?? ""} language="bash" filename={filename} />
        </TabsContent>
      ))}
    </Tabs>
  );
}

PackageManagerTabs.displayName = "PackageManagerTabs";

export default PackageManagerTabs;

Used items