# Command Tabs
URL: /design/components/command-tabs

One command in several dialects, with synced tabs and a copy button.

> For AI agents: a documentation index is available at [llms.txt](/llms.txt). Use `.md` for canonical markdown pages; `.mdx` is kept as a backwards-compatible alias on supported URL paths.

\[interactive preview component CommandTabsSpecimen omitted]

Code for CommandTabsSpecimen preview:

```tsx
"use client";

import { useState, type ReactNode } from "react";
import { CommandTabs } from "@/components/ui/command-tabs";

function CommandTabsSpecimen(): ReactNode {
  return (
    <CommandTabs
      className="my-0 w-full"
      storageKey="specimen-command-tabs"
      commands={{
        npm: "npm install @assistant-ui/react",
        pnpm: "pnpm add @assistant-ui/react",
        yarn: "yarn add @assistant-ui/react",
        bun: "bun add @assistant-ui/react",
      }}
    />
  );
}
```

## Installation

Install the dependencies:

- packages

  - react-shiki

Copy the source into `components/ui/command-tabs.tsx`. Registry items that depend on it install it automatically.

- code

  "use client"; import { useCallback, useEffect, useRef, useState, type ComponentProps, } from "react"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useShikiHighlighter } from "react-shiki"; import { cn } from "@/lib/utils"; export interface CommandTabsProps extends Omit< ComponentProps<"figure">, "onChange" > { /\*\* Tab label to command line, in display order. \*/ commands: Record\<string, string>; /\*\* \* Persists the selected tab in localStorage and keeps every CommandTabs \* with the same key in sync. \*/ storageKey?: string; /\*\* Called when the user picks a tab. \*/ onValueChange?: (value: string) => void; } function syncEventName(storageKey: string) { return \`command-tabs:${storageKey}\`; } /\*\* \* One command in several dialects: a tab per variant, the active command as \* highlighted Bash, and a copy button. With a \`storageKey\`, picking a tab \* switches every instance sharing that key and survives reloads. \*/ export function CommandTabs({ commands, storageKey, onValueChange, className, ...props }: CommandTabsProps) { const labels = Object.keys(commands); const \[active, setActive] = useState(labels\[0] ?? ""); const \[copied, setCopied] = useState(false); const copyTimer = useRef\<ReturnType\<typeof setTimeout> | undefined>( undefined, ); const commandsRef = useRef(commands); commandsRef.current = commands; useEffect(() => { if (!storageKey) return; let stored: string | null = null; try { stored = localStorage.getItem(storageKey); } catch {} if (stored && Object.hasOwn(commandsRef.current, stored)) setActive(stored); const onSync = (event: Event) => { const value = (event as CustomEvent\<string>).detail; if (Object.hasOwn(commandsRef.current, value)) setActive(value); }; window\.addEventListener(syncEventName(storageKey), onSync); return () => window\.removeEventListener(syncEventName(storageKey), onSync); }, \[storageKey]); const select = useCallback( (value: string) => { setActive(value); onValueChange?.(value); if (!storageKey) return; try { localStorage.setItem(storageKey, value); } catch {} window\.dispatchEvent( new CustomEvent(syncEventName(storageKey), { detail: value }), ); }, \[storageKey, onValueChange], ); const activeLabel = Object.hasOwn(commands, active) ? active : (labels\[0] ?? ""); const command = commands\[activeLabel] ?? ""; const highlighted = useShikiHighlighter(command, "bash", { light: "catppuccin-latte", dark: "catppuccin-mocha", }); return ( \<figure className={cn( "not-prose border-foreground/10 bg-foreground/\[0.025] dark:bg-foreground/\[0.04] my-6 flex min-w-0 flex-col overflow-hidden rounded-sm border", className, )} {...props} > \<div className="border-foreground/10 flex h-9 shrink-0 items-center justify-between gap-2 border-b py-0 ps-1.5 pe-2"> \<div role="group" aria-label="Command variants" className="flex h-full min-w-0 items-center gap-1 overflow-x-auto" > {labels.map((label) => ( \<button key={label} type="button" aria-pressed={label === activeLabel} onClick={() => select(label)} className={cn( "after:bg-foreground relative flex h-full shrink-0 items-center px-2 font-mono text-\[11px] font-medium tracking-wide transition-colors after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:opacity-0 after:transition-opacity", label === activeLabel ? "text-foreground after:opacity-100" : "text-muted-foreground hover:text-foreground", )} > {label} \</button> ))} \</div> \<button type="button" aria-label="Copy command" onClick={async () => { try { await navigator.clipboard.writeText(command); } catch { return; } setCopied(true); clearTimeout(copyTimer.current); copyTimer.current = setTimeout(() => setCopied(false), 1500); }} className="text-muted-foreground hover:text-foreground grid size-6 shrink-0 place-items-center rounded-sm transition-colors" > {copied ? ( \<CheckIcon className="size-3.5" /> ) : ( \<CopyIcon className="size-3.5" /> )} \</button> \</div> \<div className={cn( "min-w-0 overflow-x-auto", "\[&\_pre]:w-max \[&\_pre]:min-w-full \[&\_pre]:bg-transparent! \[&\_pre]:px-3.5 \[&\_pre]:py-4 \[&\_pre]:font-mono \[&\_pre]:text-\[13px] \[&\_pre]:leading-relaxed \[&\_pre]:whitespace-pre \[&\_pre]:\[font-variant-ligatures:none]", "\[&\_code\_span]:\[color:var(--shiki-light,inherit)]", "dark:\[&\_code\_span]:\[color:var(--shiki-dark)]!", )} > {highlighted ?? ( \<pre> \<code>{command}\</code> \</pre> )} \</div> \</figure> ); }

* title

  components/ui/command-tabs.tsx

* copyText

  "use client"; import { useCallback, useEffect, useRef, useState, type ComponentProps, } from "react"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useShikiHighlighter } from "react-shiki"; import { cn } from "@/lib/utils"; export interface CommandTabsProps extends Omit< ComponentProps<"figure">, "onChange" > { /\*\* Tab label to command line, in display order. \*/ commands: Record\<string, string>; /\*\* \* Persists the selected tab in localStorage and keeps every CommandTabs \* with the same key in sync. \*/ storageKey?: string; /\*\* Called when the user picks a tab. \*/ onValueChange?: (value: string) => void; } function syncEventName(storageKey: string) { return \`command-tabs:${storageKey}\`; } /\*\* \* One command in several dialects: a tab per variant, the active command as \* highlighted Bash, and a copy button. With a \`storageKey\`, picking a tab \* switches every instance sharing that key and survives reloads. \*/ export function CommandTabs({ commands, storageKey, onValueChange, className, ...props }: CommandTabsProps) { const labels = Object.keys(commands); const \[active, setActive] = useState(labels\[0] ?? ""); const \[copied, setCopied] = useState(false); const copyTimer = useRef\<ReturnType\<typeof setTimeout> | undefined>( undefined, ); const commandsRef = useRef(commands); commandsRef.current = commands; useEffect(() => { if (!storageKey) return; let stored: string | null = null; try { stored = localStorage.getItem(storageKey); } catch {} if (stored && Object.hasOwn(commandsRef.current, stored)) setActive(stored); const onSync = (event: Event) => { const value = (event as CustomEvent\<string>).detail; if (Object.hasOwn(commandsRef.current, value)) setActive(value); }; window\.addEventListener(syncEventName(storageKey), onSync); return () => window\.removeEventListener(syncEventName(storageKey), onSync); }, \[storageKey]); const select = useCallback( (value: string) => { setActive(value); onValueChange?.(value); if (!storageKey) return; try { localStorage.setItem(storageKey, value); } catch {} window\.dispatchEvent( new CustomEvent(syncEventName(storageKey), { detail: value }), ); }, \[storageKey, onValueChange], ); const activeLabel = Object.hasOwn(commands, active) ? active : (labels\[0] ?? ""); const command = commands\[activeLabel] ?? ""; const highlighted = useShikiHighlighter(command, "bash", { light: "catppuccin-latte", dark: "catppuccin-mocha", }); return ( \<figure className={cn( "not-prose border-foreground/10 bg-foreground/\[0.025] dark:bg-foreground/\[0.04] my-6 flex min-w-0 flex-col overflow-hidden rounded-sm border", className, )} {...props} > \<div className="border-foreground/10 flex h-9 shrink-0 items-center justify-between gap-2 border-b py-0 ps-1.5 pe-2"> \<div role="group" aria-label="Command variants" className="flex h-full min-w-0 items-center gap-1 overflow-x-auto" > {labels.map((label) => ( \<button key={label} type="button" aria-pressed={label === activeLabel} onClick={() => select(label)} className={cn( "after:bg-foreground relative flex h-full shrink-0 items-center px-2 font-mono text-\[11px] font-medium tracking-wide transition-colors after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:opacity-0 after:transition-opacity", label === activeLabel ? "text-foreground after:opacity-100" : "text-muted-foreground hover:text-foreground", )} > {label} \</button> ))} \</div> \<button type="button" aria-label="Copy command" onClick={async () => { try { await navigator.clipboard.writeText(command); } catch { return; } setCopied(true); clearTimeout(copyTimer.current); copyTimer.current = setTimeout(() => setCopied(false), 1500); }} className="text-muted-foreground hover:text-foreground grid size-6 shrink-0 place-items-center rounded-sm transition-colors" > {copied ? ( \<CheckIcon className="size-3.5" /> ) : ( \<CopyIcon className="size-3.5" /> )} \</button> \</div> \<div className={cn( "min-w-0 overflow-x-auto", "\[&\_pre]:w-max \[&\_pre]:min-w-full \[&\_pre]:bg-transparent! \[&\_pre]:px-3.5 \[&\_pre]:py-4 \[&\_pre]:font-mono \[&\_pre]:text-\[13px] \[&\_pre]:leading-relaxed \[&\_pre]:whitespace-pre \[&\_pre]:\[font-variant-ligatures:none]", "\[&\_code\_span]:\[color:var(--shiki-light,inherit)]", "dark:\[&\_code\_span]:\[color:var(--shiki-dark)]!", )} > {highlighted ?? ( \<pre> \<code>{command}\</code> \</pre> )} \</div> \</figure> ); }

* viewportClassName

  max-h-\[450px]

- language

  tsx

- code

  "use client"; import { useCallback, useEffect, useRef, useState, type ComponentProps, } from "react"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useShikiHighlighter } from "react-shiki"; import { cn } from "@/lib/utils"; export interface CommandTabsProps extends Omit< ComponentProps<"figure">, "onChange" > { /\*\* Tab label to command line, in display order. \*/ commands: Record\<string, string>; /\*\* \* Persists the selected tab in localStorage and keeps every CommandTabs \* with the same key in sync. \*/ storageKey?: string; /\*\* Called when the user picks a tab. \*/ onValueChange?: (value: string) => void; } function syncEventName(storageKey: string) { return \`command-tabs:${storageKey}\`; } /\*\* \* One command in several dialects: a tab per variant, the active command as \* highlighted Bash, and a copy button. With a \`storageKey\`, picking a tab \* switches every instance sharing that key and survives reloads. \*/ export function CommandTabs({ commands, storageKey, onValueChange, className, ...props }: CommandTabsProps) { const labels = Object.keys(commands); const \[active, setActive] = useState(labels\[0] ?? ""); const \[copied, setCopied] = useState(false); const copyTimer = useRef\<ReturnType\<typeof setTimeout> | undefined>( undefined, ); const commandsRef = useRef(commands); commandsRef.current = commands; useEffect(() => { if (!storageKey) return; let stored: string | null = null; try { stored = localStorage.getItem(storageKey); } catch {} if (stored && Object.hasOwn(commandsRef.current, stored)) setActive(stored); const onSync = (event: Event) => { const value = (event as CustomEvent\<string>).detail; if (Object.hasOwn(commandsRef.current, value)) setActive(value); }; window\.addEventListener(syncEventName(storageKey), onSync); return () => window\.removeEventListener(syncEventName(storageKey), onSync); }, \[storageKey]); const select = useCallback( (value: string) => { setActive(value); onValueChange?.(value); if (!storageKey) return; try { localStorage.setItem(storageKey, value); } catch {} window\.dispatchEvent( new CustomEvent(syncEventName(storageKey), { detail: value }), ); }, \[storageKey, onValueChange], ); const activeLabel = Object.hasOwn(commands, active) ? active : (labels\[0] ?? ""); const command = commands\[activeLabel] ?? ""; const highlighted = useShikiHighlighter(command, "bash", { light: "catppuccin-latte", dark: "catppuccin-mocha", }); return ( \<figure className={cn( "not-prose border-foreground/10 bg-foreground/\[0.025] dark:bg-foreground/\[0.04] my-6 flex min-w-0 flex-col overflow-hidden rounded-sm border", className, )} {...props} > \<div className="border-foreground/10 flex h-9 shrink-0 items-center justify-between gap-2 border-b py-0 ps-1.5 pe-2"> \<div role="group" aria-label="Command variants" className="flex h-full min-w-0 items-center gap-1 overflow-x-auto" > {labels.map((label) => ( \<button key={label} type="button" aria-pressed={label === activeLabel} onClick={() => select(label)} className={cn( "after:bg-foreground relative flex h-full shrink-0 items-center px-2 font-mono text-\[11px] font-medium tracking-wide transition-colors after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:opacity-0 after:transition-opacity", label === activeLabel ? "text-foreground after:opacity-100" : "text-muted-foreground hover:text-foreground", )} > {label} \</button> ))} \</div> \<button type="button" aria-label="Copy command" onClick={async () => { try { await navigator.clipboard.writeText(command); } catch { return; } setCopied(true); clearTimeout(copyTimer.current); copyTimer.current = setTimeout(() => setCopied(false), 1500); }} className="text-muted-foreground hover:text-foreground grid size-6 shrink-0 place-items-center rounded-sm transition-colors" > {copied ? ( \<CheckIcon className="size-3.5" /> ) : ( \<CopyIcon className="size-3.5" /> )} \</button> \</div> \<div className={cn( "min-w-0 overflow-x-auto", "\[&\_pre]:w-max \[&\_pre]:min-w-full \[&\_pre]:bg-transparent! \[&\_pre]:px-3.5 \[&\_pre]:py-4 \[&\_pre]:font-mono \[&\_pre]:text-\[13px] \[&\_pre]:leading-relaxed \[&\_pre]:whitespace-pre \[&\_pre]:\[font-variant-ligatures:none]", "\[&\_code\_span]:\[color:var(--shiki-light,inherit)]", "dark:\[&\_code\_span]:\[color:var(--shiki-dark)]!", )} > {highlighted ?? ( \<pre> \<code>{command}\</code> \</pre> )} \</div> \</figure> ); }

## Usage

```
import { CommandTabs } from "@/components/ui/command-tabs";

export function Example() {
  return (
    <CommandTabs
      storageKey="package-manager"
      commands={{
        npm: "npm install @assistant-ui/react",
        pnpm: "pnpm add @assistant-ui/react",
      }}
    />
  );
}
```

The command renders as Shiki-highlighted Bash; the copy button always copies the active dialect.

## Examples

### Synced instances

Two blocks sharing a `storageKey` switch together, and the choice survives reloads. Every install command on this site shares one key.

\[interactive preview component CommandTabsSyncSpecimen omitted]

Code for CommandTabsSyncSpecimen preview:

```tsx
"use client";

import { useState, type ReactNode } from "react";
import { CommandTabs } from "@/components/ui/command-tabs";

function CommandTabsSyncSpecimen(): ReactNode {
  return (
    <CommandTabs
      className="my-0 w-full"
      storageKey="specimen-command-tabs"
      commands={{
        npm: "npx assistant-ui init",
        pnpm: "pnpm dlx assistant-ui init",
        yarn: "yarn dlx assistant-ui init",
        bun: "bunx assistant-ui init",
      }}
    />
    <CommandTabs
      className="my-0 w-full"
      storageKey="specimen-command-tabs"
      commands={{
        npm: "npx shadcn@latest add @assistant-ui/thread",
        pnpm: "pnpm dlx shadcn@latest add @assistant-ui/thread",
        yarn: "npx shadcn@latest add @assistant-ui/thread",
        bun: "bunx --bun shadcn@latest add @assistant-ui/thread",
      }}
    />
  );
}
```

## API Reference

### CommandTabs

- `commands`: `Record<string, string>` — Tab label to command line, in display order.
- `storageKey?`: `string` — Persists the selected tab in localStorage and keeps every instance with the same key in sync.
- `onValueChange?`: `(value: string) => void` — Called when the user picks a tab.
- `className?`: `string` — Additional CSS classes on the outer figure.