'use client'
/**
* Install Command — the npm / pnpm / yarn / bun block, done properly.
*
* Switch package manager and only the words that differ change: `install`
* rolls out, `add` rolls in, the package name glides into place. The tab pill
* stretches toward where it's going. Pick pnpm once and every install block
* on the page follows, in a cascade outward from the one you clicked, and
* the choice sticks across visits and tabs.
*
* Functional:
* - presets: add (with dev / global), exec (npx / dlx / bunx), create, run
* - or pass exact `commands` per manager
* - copy button (with fallback), ⌘/Ctrl+C on the focused block copies too
* - one click selects the whole command; the prompt is never copied
*
* Accessible: real tablist (arrow keys, Home/End), tabpanel, copy result
* (success or failure) announced, the command read as one plain string
* while the animated letters stay hidden from assistive tech.
* Reduced motion: words crossfade, nothing slides.
*
* Corners follow --ui-radius, --ui-radius-sm (see Theming → Corners).
* Theme via library tokens (--ui-ink, --ui-surface, --ui-accent) or
* --install-command-* overrides; font via --install-command-font.
* Copy: every UI string can be replaced through `labels`.
*
* Worst case: multi-line exact commands keep their line breaks, a long prompt
* ellipsizes, a long copy label ellipsizes into the free space, emoji and
* shaped scripts never get split, and `managers={[]}` falls back to all four.
*
* Requires: react >= 19, motion >= 11.
*/
import {
AnimatePresence,
animate,
motion,
useMotionValue,
type Variants,
} from 'motion/react'
import {
createElement,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type KeyboardEvent,
} from 'react'
/* Icons: Iconoir (MIT, © 2021 Luca Burgio, iconoir.com), inlined so the component stays dependency-free. */
const UI_ICONS = {
'check': [['path', { d: 'M5 13L9 17L19 7' }]],
'copy': [['path', { d: 'M19.4 20H9.6C9.26863 20 9 19.7314 9 19.4V9.6C9 9.26863 9.26863 9 9.6 9H19.4C19.7314 9 20 9.26863 20 9.6V19.4C20 19.7314 19.7314 20 19.4 20Z' }], ['path', { d: 'M15 9V4.6C15 4.26863 14.7314 4 14.4 4H4.6C4.26863 4 4 4.26863 4 4.6V14.4C4 14.7314 4.26863 15 4.6 15H9' }]],
} as const satisfies Record<string, readonly (readonly [string, Record<string, string>])[]>
type UiIconName = keyof typeof UI_ICONS
function UiIcon({ name, size = 16, className }: { name: UiIconName; size?: number; className?: string }) {
return (
<svg className={['ui-icon', className].filter(Boolean).join(' ')} viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{UI_ICONS[name].map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
</svg>
)
}
/* Brand marks: Simple Icons (CC0, simpleicons.org). */
const BRAND_PATHS = {
npm: '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',
pnpm: 'M0 0v7.5h7.5V0zm8.25 0v7.5h7.498V0zm8.25 0v7.5H24V0zM2 2h3.5v3.5H2zm8.25 0h3.498v3.5H10.25zm8.25 0H22v3.5h-3.5zM8.25 8.25v7.5h7.498v-7.5zm8.25 0v7.5H24v-7.5zm2 2H22v3.5h-3.5zM0 16.5V24h7.5v-7.5zm8.25 0V24h7.498v-7.5zm8.25 0V24H24v-7.5z',
yarn: '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',
bun: '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',
} as const
/* ────────────────────────────────────────────────────────────────────────── */
/* Shared package-manager preference (all blocks on the page, all tabs) */
/* ────────────────────────────────────────────────────────────────────────── */
/** Every supported package manager, in default tab order. */
// oxlint-disable-next-line react/only-export-components -- public constant; the component stays one copy-paste file
export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const
/** A supported package manager: `'npm' | 'pnpm' | 'yarn' | 'bun'`. */
export type PackageManager = (typeof PACKAGE_MANAGERS)[number]
/**
* Module-level store read through useSyncExternalStore. `origin` is the id of the
* block that made the pick (null for storage / other tabs), used for the cascade.
* Note: one store per module, so every synced block shares it whatever its storageKey.
*/
type PmState = { pm: PackageManager | null; origin: string | null }
const SERVER_PM: PmState = { pm: null, origin: null }
let pmState: PmState = SERVER_PM
const pmSubs = new Set<() => void>()
const mountOrder: string[] = []
const isPm = (v: unknown): v is PackageManager => PACKAGE_MANAGERS.includes(v as PackageManager)
const subscribePm = (fn: () => void) => {
pmSubs.add(fn)
return () => {
pmSubs.delete(fn)
}
}
const getPm = () => pmState
const getServerPm = () => SERVER_PM
function setPm(pm: PackageManager, origin: string | null, key: string | null) {
pmState = { pm, origin }
if (key) {
try {
localStorage.setItem(key, pm)
} catch {
/* storage blocked */
}
}
pmSubs.forEach(fn => fn())
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Commands */
/* ────────────────────────────────────────────────────────────────────────── */
/** Every UI string. Pass any subset through `labels` to translate or reword. */
export type InstallCommandLabels = {
/**
* Accessible name of the tab list.
*
* @default `'Package manager'` (accessible name of the tab list)
*/
tabs: string
/**
* Accessible name of the copy button.
*
* @default `'Copy command'` (copy button's accessible name)
*/
copy: string
/**
* Shown beside the copy button after a copy; also the button's accessible name meanwhile.
*
* @default `'Copied'` (shown beside the button after a copy, and the button's name meanwhile)
*/
copied: string
/**
* Shown beside the copy button when copying fails.
*
* @default `'Copy failed'` (shown beside the button when copying fails)
*/
copyFailed: string
/**
* Announced to screen readers after a copy.
*
* @default `'Copied to clipboard'` (announced)
*/
copiedSpoken: string
/**
* Announced to screen readers when copying fails.
*
* @default `'Copy failed. Select the command and copy it manually.'` (announced)
*/
copyFailedSpoken: string
}
const DEFAULT_LABELS: InstallCommandLabels = {
tabs: 'Package manager',
copy: 'Copy command',
copied: 'Copied',
copyFailed: 'Copy failed',
copiedSpoken: 'Copied to clipboard',
copyFailedSpoken: 'Copy failed. Select the command and copy it manually.',
}
/** Bundler-agnostic dev check: only a literal production NODE_ENV silences warnings. */
const isDev = () => (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.NODE_ENV !== 'production'
/**
* Props for {@link InstallCommand}. Pick one preset (`add`, `exec`, `create`, `run`)
* or pass exact `commands`. Precedence: `commands` > `exec` > `create` > `run` > `add`.
*/
export type InstallCommandProps = {
/**
* Package(s) to add: `add="motion"` → npm install motion / pnpm add motion … Arrays are joined
* with spaces. Used when no other preset or exact command applies.
*/
add?: string | string[]
/**
* With `add`: install as a dev dependency (`-D`). Ignored when `global` is set (warns in
* development). @default false Ignored when `global` is set, with a console warning in
* development.
*
* @default `false`
*/
dev?: boolean
/**
* With `add`: install globally (`npm install -g`, `pnpm add -g`, `yarn global add`, `bun add
* -g`). @default false
*
* @default `false`
*/
global?: boolean
/** One-off binary: `exec="shadcn@latest add button"` → npx / pnpm dlx / yarn dlx / bunx */
exec?: string
/**
* Scaffolder: `create="vite@latest my-app"` → npm create / pnpm create … Scaffolder: `<manager>
* create <value>`.
*/
create?: string
/** package.json script: `run="dev"` → npm run dev / pnpm dev / yarn dev / bun run dev */
run?: string
/**
* Exact command per manager. Wins over every preset. Line breaks are kept (e.g. a `\` + newline
* continuation shows on two lines, exactly as it copies). Exact commands per manager; wins over
* presets. Managers left out fall back to the preset.
*/
commands?: Partial<Record<PackageManager, string>>
/**
* Which tabs to show, in order. Unknown names are dropped; an empty list falls back to all four
* (dev warning). @default PACKAGE_MANAGERS (npm, pnpm, yarn, bun)
*
* @default `PACKAGE_MANAGERS`
*/
managers?: readonly PackageManager[]
/**
* Tab selected before the user picks one (or nothing is remembered). Falls back to the first of
* `managers` when not listed there. @default 'npm'
*
* @default `'npm'`
*/
defaultManager?: PackageManager
/**
* Share the choice with every synced block on the page, save it to `localStorage`, and follow
* changes from other browser tabs. Share the choice with every block on the page and remember it
* in localStorage (also follows other tabs). @default true
*
* @default `true`
*/
sync?: boolean
/**
* localStorage key for the remembered choice when `sync` is on. @default 'install-command:pm'
*
* @default `'install-command:pm'`
*/
storageKey?: string
/**
* Shown before the command, never copied. Pass `''` to hide it. At most 40% of the row: a long
* shell prompt ends in “…” (full text on hover). At most 40% of the row (ends in "…"). @default
* '$'
*
* @default `'$'`
*/
prompt?: string
/** Called after a successful copy with the exact command text and the active manager. Not called when copying fails. */
onCopy?: (command: string, manager: PackageManager) => void
/**
* How the package-manager tabs read. `'icons'` shows only the logos (the name stays
* as the tab's accessible name and tooltip), for tight rows.
* @default 'labels'
*/
tabStyle?: 'labels' | 'icons'
/**
* Override any of the UI strings (tab list name, copy button, copy feedback). Override any UI
* string. See [InstallCommandLabels](#installcommandlabels).
*/
labels?: Partial<InstallCommandLabels>
/** Extra class on the root element (`.ic-root`). */
className?: string
/** Inline styles on the root element; handy for `--install-command-*` variables. */
style?: CSSProperties
}
function buildCommand(pm: PackageManager, p: InstallCommandProps): string {
const exact = p.commands?.[pm]
if (exact) return exact
if (p.exec) return { npm: `npx ${p.exec}`, pnpm: `pnpm dlx ${p.exec}`, yarn: `yarn dlx ${p.exec}`, bun: `bunx ${p.exec}` }[pm]
if (p.create) return `${pm} create ${p.create}`
if (p.run) return { npm: `npm run ${p.run}`, pnpm: `pnpm ${p.run}`, yarn: `yarn ${p.run}`, bun: `bun run ${p.run}` }[pm]
const pkgs = (Array.isArray(p.add) ? p.add : [p.add ?? '']).filter(Boolean).join(' ')
const d = p.dev ? ' -D' : ''
if (p.global) {
return { npm: `npm install -g ${pkgs}`, pnpm: `pnpm add -g ${pkgs}`, yarn: `yarn global add ${pkgs}`, bun: `bun add -g ${pkgs}` }[pm]
}
return { npm: `npm install${d} ${pkgs}`, pnpm: `pnpm add${d} ${pkgs}`, yarn: `yarn add${d} ${pkgs}`, bun: `bun add${d} ${pkgs}` }[pm].trim()
}
type Token = { id: number; text: string }
let tokenId = 0
/**
* Words of a command, split on spaces and tabs only. A line break (with the next line's
* indentation) is its own token, so a multi-line command (`\` + newline) shows on the lines it
* copies as.
*/
const wordsOf = (command: string) =>
command
.split(/(\n[ \t]*)/)
.flatMap(part => (part.startsWith('\n') ? [part] : part.split(/[ \t]+/)))
.filter(Boolean)
const isBreak = (text: string) => text.startsWith('\n')
/*
* Letters animate one by one, split into graphemes (emoji and combining sequences stay whole).
* Scripts that need shaping or run right to left (Arabic, Hebrew, Indic, Thai…) animate as a
* whole word instead: splitting them would break the letterforms.
*/
const SHAPED = /[\u0590-\u08FF\u0900-\u0DFF\u0E00-\u0FFF\u1000-\u109F\u1780-\u18AF\uA8E0-\uA8FF\uFB1D-\uFDFF\uFE70-\uFEFF]/
let segmenter: { segment: (s: string) => Iterable<{ segment: string }> } | null | undefined
function glyphsOf(text: string): string[] {
if (SHAPED.test(text)) return [text]
if (segmenter === undefined) {
try {
segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
} catch {
segmenter = null
}
}
return segmenter ? Array.from(segmenter.segment(text), g => g.segment) : Array.from(text)
}
/** An ellipsized one-line text gets its full text as a tooltip, set on hover so short text stays quiet. */
const tipIfClipped = (e: { currentTarget: HTMLElement }) => {
const el = e.currentTarget
if (el.scrollWidth > el.clientWidth + 1) el.title = el.textContent ?? ''
else el.removeAttribute('title')
}
/** Keep identity for words shared between commands (LCS), so they glide instead of re-rendering. */
function diffTokens(prev: Token[], words: string[]): Token[] {
const a = prev.map(t => t.text)
const m = a.length
const n = words.length
const dp = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--) dp[i][j] = a[i] === words[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
const out: Token[] = []
let i = 0
let j = 0
while (j < n) {
if (i < m && a[i] === words[j]) {
out.push(prev[i])
i++
j++
} else if (i < m && dp[i + 1][j] >= dp[i][j + 1]) i++
else {
out.push({ id: ++tokenId, text: words[j] })
j++
}
}
return out
}
/** Async Clipboard API first; falls back to a hidden textarea + execCommand for insecure contexts. Resolves false on failure. */
async function writeClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', '')
ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
ta.remove()
return ok
} catch {
return false
}
}
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Motion */
/* ────────────────────────────────────────────────────────────────────────── */
const EASE_OUT = [0.23, 1, 0.32, 1] as const
type LetterCustom = { i: number; d: number; reduced: boolean }
/* a long command rolls its first characters letter by letter and fades the rest in whole words;
so does any very long token (a URL, a scoped canary version) */
const LETTER_BUDGET = 48
const far = (tokens: readonly { text: string }[], ti: number) => {
if (tokens[ti].text.length > 32) return true
let n = 0
for (let k = 0; k < ti; k++) n += tokens[k].text.length + 1
return n > LETTER_BUDGET
}
const word: Variants = {
in: { opacity: 0 },
show: ({ d, reduced }: LetterCustom) => ({ opacity: 1, transition: { duration: reduced ? 0.15 : 0.3, ease: EASE_OUT, delay: d + (reduced ? 0 : 0.2) } }),
out: ({ d }: LetterCustom) => ({ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT, delay: d } }),
}
const letter: Variants = {
in: ({ reduced }: LetterCustom) => (reduced ? { opacity: 0 } : { y: '105%', opacity: 0 }),
show: ({ i, d, reduced }: LetterCustom) => ({
y: 0,
opacity: 1,
transition: reduced ? { duration: 0.15, delay: d } : { type: 'spring', visualDuration: 0.32, bounce: 0.22, delay: d + Math.min(i * 0.016, 0.5) },
}),
out: ({ i, d, reduced }: LetterCustom) =>
reduced
? { opacity: 0, transition: { duration: 0.1, delay: d } }
: { y: '-105%', opacity: 0, transition: { duration: 0.16, ease: EASE_OUT, delay: d + Math.min(i * 0.008, 0.25) } },
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Component */
/* ────────────────────────────────────────────────────────────────────────── */
/*
* Reduced-motion preference, hydration-safe: the server and the hydrating render
* both read `false`, then React re-renders with the real value. motion's
* useReducedMotion reads matchMedia on the first client render, which made the
* server HTML and the first client render disagree for reduced-motion users.
*/
const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'
function subscribeReduced(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
const getReduced = () => window.matchMedia(REDUCED_QUERY).matches
const getServerReduced = () => false
/**
* A package-manager install block with npm / pnpm / yarn / bun tabs and a copy button.
* Only the words that differ animate when switching; synced blocks follow in a cascade.
*/
export function InstallCommand(props: InstallCommandProps) {
const {
managers: managersProp = PACKAGE_MANAGERS,
defaultManager = 'npm',
sync = true,
storageKey = 'install-command:pm',
prompt = '$',
onCopy,
labels: labelsProp,
tabStyle = 'labels',
className,
style,
} = props
const labels = { ...DEFAULT_LABELS, ...labelsProp }
// unknown names are dropped; an empty list falls back to every manager (dev warning below)
const validManagers = managersProp.filter(isPm)
const managers: readonly PackageManager[] = validManagers.length ? validManagers : PACKAGE_MANAGERS
const reduced = useSyncExternalStore(subscribeReduced, getReduced, getServerReduced)
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const shared = useSyncExternalStore(subscribePm, getPm, getServerPm)
const [local, setLocal] = useState<PackageManager>(defaultManager)
const fallback = managers.includes(defaultManager) ? defaultManager : managers[0]
const pm: PackageManager = sync
? shared.pm && managers.includes(shared.pm)
? shared.pm
: fallback
: managers.includes(local)
? local
: fallback
const devIgnored = Boolean(props.dev && props.global)
useEffect(() => {
if (devIgnored && isDev()) console.warn('InstallCommand: `dev` is ignored when `global` is set; global installs have no dev flag.')
}, [devIgnored])
const noManagers = !validManagers.length
useEffect(() => {
if (noManagers && isDev()) console.warn('InstallCommand: `managers` has no known package manager; showing npm, pnpm, yarn and bun.')
}, [noManagers])
// a preset that is set but blank renders a bare `npm install` / `npm run`: say so in development
const blankPreset = !props.commands && !props.exec && !props.create
? props.run != null
? !props.run.trim() && 'run'
: props.add != null && !(Array.isArray(props.add) ? props.add : [props.add]).some(a => a.trim()) && 'add'
: (props.exec != null && !props.exec.trim() && 'exec') || (props.create != null && !props.create.trim() && 'create')
useEffect(() => {
if (blankPreset && isDev()) console.warn(`InstallCommand: \`${blankPreset}\` is empty, so the block shows the bare manager command.`)
}, [blankPreset])
const [copied, setCopied] = useState(false)
const [failed, setFailed] = useState(false)
const [sweep, setSweep] = useState(0)
const [overflow, setOverflow] = useState(false)
const tabRefs = useRef<Partial<Record<PackageManager, HTMLButtonElement | null>>>({})
const listRef = useRef<HTMLDivElement>(null)
const lineRef = useRef<HTMLSpanElement>(null)
const copyTimer = useRef(0)
const measured = useRef(false)
const pmRef = useRef(pm)
useLayoutEffect(() => {
pmRef.current = pm
}, [pm])
/* cascade: blocks further from the one you clicked follow a beat later */
useEffect(() => {
mountOrder.push(uid)
return () => {
const i = mountOrder.indexOf(uid)
if (i >= 0) mountOrder.splice(i, 1)
}
}, [uid])
// 70ms per block of mount-order distance from the block that was clicked
const delay =
sync && shared.origin && shared.origin !== uid && !reduced
? Math.abs(mountOrder.indexOf(uid) - mountOrder.indexOf(shared.origin)) * 0.07
: 0
/* read the remembered choice once, and follow other tabs */
useEffect(() => {
if (!sync) return
if (!pmState.pm) {
try {
const saved = localStorage.getItem(storageKey)
if (isPm(saved)) setPm(saved, null, null)
} catch {
/* storage blocked */
}
}
const onStorage = (e: StorageEvent) => {
if (e.key === storageKey && isPm(e.newValue)) setPm(e.newValue, null, null)
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [sync, storageKey])
useEffect(() => () => clearTimeout(copyTimer.current), [])
const command = buildCommand(pm, props)
// diff against the previous command's tokens, derived during render (no ref reads)
const [diffed, setDiffed] = useState(() => ({ command, tokens: diffTokens([], wordsOf(command)) }))
let tokens = diffed.tokens
if (diffed.command !== command) {
tokens = diffTokens(diffed.tokens, wordsOf(command))
setDiffed({ command, tokens })
}
/* tab pill: leading edge springs fast, trailing edge lags, so it stretches */
const edgeL = useMotionValue(0)
const edgeR = useMotionValue(0)
// Width is its own value, synced in an effect: a derived value created at
// render misses edges set in the layout effect on remount and stays 0.
const pillW = useMotionValue(0)
const pillX = useMotionValue(0)
useEffect(() => {
// the springs overshoot past the first and last tab; the row scrolls (it clips), so the pill is held
// inside the tabs' own span and its ring is never cut off at either end
const sync = () => {
const tabs = Object.values(tabRefs.current).filter(Boolean) as HTMLElement[]
const min = tabs.length ? Math.min(...tabs.map((t) => t.offsetLeft)) : -Infinity
const max = tabs.length ? Math.max(...tabs.map((t) => t.offsetLeft + t.offsetWidth)) : Infinity
const l = Math.max(min, edgeL.get()), r = Math.min(max, edgeR.get())
pillX.set(l)
pillW.set(Math.max(0, r - l))
}
sync()
const offL = edgeL.on('change', sync)
const offR = edgeR.on('change', sync)
return () => {
offL()
offR()
}
}, [edgeL, edgeR, pillW, pillX])
useLayoutEffect(() => {
const el = tabRefs.current[pm]
if (!el) return
const L = el.offsetLeft
const R = L + el.offsetWidth
if (!measured.current || reduced) {
measured.current = true
edgeL.set(L)
edgeR.set(R)
return
}
// already there (a re-render, a remount): nothing to animate. Setting the edges on mount reads
// as a huge speed to motion, so a spring started now would overshoot onto the next tab and back.
if (Math.abs(L - edgeL.get()) < 0.5 && Math.abs(R - edgeR.get()) < 0.5) return
const right = L > edgeL.get()
const lead = { type: 'spring' as const, stiffness: 620, damping: 38, delay, velocity: 0 }
const trail = { type: 'spring' as const, stiffness: 260, damping: 28, delay: delay + 0.03, velocity: 0 }
animate(edgeL, L, right ? trail : lead)
animate(edgeR, R, right ? lead : trail)
}, [pm, delay, reduced, edgeL, edgeR])
/* re-measure on resize (fonts loading, container width); one observer, reads the live pick */
useEffect(() => {
const list = listRef.current
if (!list) return
const ro = new ResizeObserver(() => {
const el = tabRefs.current[pmRef.current]
if (!el) return
edgeL.set(el.offsetLeft)
edgeR.set(el.offsetLeft + el.offsetWidth)
})
ro.observe(list)
return () => ro.disconnect()
}, [edgeL, edgeR])
/* fade the right edge only when the command overflows */
useEffect(() => {
const el = lineRef.current
if (!el) return
const check = () => setOverflow(el.scrollWidth > el.clientWidth + 1)
check()
const ro = new ResizeObserver(check)
ro.observe(el)
return () => ro.disconnect()
}, [command])
const choose = (next: PackageManager) => {
if (next === pm) return
if (sync) setPm(next, uid, storageKey)
else setLocal(next)
}
const onTabsKey = (e: KeyboardEvent<HTMLDivElement>) => {
const i = managers.indexOf(pm)
let next = -1
if (e.key === 'ArrowRight') next = (i + 1) % managers.length
else if (e.key === 'ArrowLeft') next = (i - 1 + managers.length) % managers.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = managers.length - 1
if (next < 0) return
e.preventDefault()
choose(managers[next])
tabRefs.current[managers[next]]?.focus()
}
const copy = async () => {
const ok = await writeClipboard(command)
clearTimeout(copyTimer.current)
if (!ok) {
setCopied(false)
setFailed(true)
copyTimer.current = window.setTimeout(() => setFailed(false), 2600)
return
}
setFailed(false)
setCopied(true)
setSweep(s => s + 1)
onCopy?.(command, pm)
copyTimer.current = window.setTimeout(() => setCopied(false), 1700)
}
// ⌘/Ctrl+C copies the command, unless the user has a text selection to copy themselves
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c' && !window.getSelection()?.toString()) {
e.preventDefault()
copy()
}
}
const panelId = `ic-${uid}-panel`
return (
<div className={['ic-root', className].filter(Boolean).join(' ')} style={style}>
<InstallCommandStyles />
<div className="ic-head">
<div ref={listRef} className="ic-tabs" role="tablist" aria-label={labels.tabs} onKeyDown={onTabsKey}>
<motion.span className="ic-pill" aria-hidden style={{ x: pillX, width: pillW }} />
{managers.map(m => (
<motion.button
key={m}
ref={el => {
tabRefs.current[m] = el
}}
id={`ic-${uid}-tab-${m}`}
type="button"
role="tab"
aria-selected={m === pm}
aria-controls={panelId}
tabIndex={m === pm ? 0 : -1}
className="ic-tab"
data-icon-only={tabStyle === 'icons' || undefined}
aria-label={tabStyle === 'icons' ? m : undefined}
title={tabStyle === 'icons' ? m : undefined}
onClick={() => choose(m)}
whileTap={reduced ? undefined : { scale: 0.94 }}
>
<svg className="ic-logo" viewBox="0 0 24 24" aria-hidden>
<path d={BRAND_PATHS[m]} />
</svg>
{tabStyle === 'icons' ? null : m}
</motion.button>
))}
</div>
<div className="ic-copy-wrap">
{/* label lives outside the button's box, so nothing reflows on copy */}
<motion.span
className="ic-copy-label"
aria-hidden
initial={false}
data-failed={failed || undefined}
animate={copied || failed ? { opacity: 1, x: 0 } : { opacity: 0, x: reduced ? 0 : 6 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
>
{failed ? labels.copyFailed : labels.copied}
</motion.span>
<motion.button
type="button"
className="ic-copy"
data-copied={copied || undefined}
onClick={copy}
aria-label={copied ? labels.copied : labels.copy}
whileTap={reduced ? undefined : { scale: 0.9 }}
>
<motion.span
className="ic-copy-icon"
aria-hidden
initial={false}
animate={copied ? { opacity: 0, scale: 0.5, rotate: reduced ? 0 : 20 } : { opacity: 1, scale: 1, rotate: 0 }}
transition={{ type: 'spring', visualDuration: 0.26, bounce: 0.3 }}
>
<UiIcon name="copy" size={16} />
</motion.span>
<motion.span
className="ic-copy-check"
aria-hidden
initial={false}
animate={copied ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.5 }}
transition={{ type: 'spring', visualDuration: 0.26, bounce: 0.4 }}
>
{/* a left-to-right wipe follows the stroke, so the check still draws in */}
<motion.span
className="ic-copy-check-draw"
initial={false}
animate={{ clipPath: copied || reduced ? 'inset(0 0% 0 0)' : 'inset(0 100% 0 0)' }}
transition={{ duration: copied ? 0.28 : 0, ease: EASE_OUT, delay: copied ? 0.05 : 0 }}
>
<UiIcon name="check" size={16} />
</motion.span>
</motion.span>
</motion.button>
</div>
</div>
<div
id={panelId}
className="ic-panel"
role="tabpanel"
aria-labelledby={`ic-${uid}-tab-${pm}`}
tabIndex={0}
onKeyDown={onPanelKey}
>
{prompt && (
<span className="ic-prompt" aria-hidden onPointerEnter={tipIfClipped}>
{prompt}
</span>
)}
{/* what assistive tech reads: the whole command, once, as plain text */}
<span className="ic-sr ic-sr-cmd">{command}</span>
<span ref={lineRef} className="ic-line" data-overflow={overflow || undefined}>
<code className="ic-code" aria-hidden>
<AnimatePresence mode="popLayout" initial={false}>
{tokens.map((t, ti, all) =>
isBreak(t.text) ? (
// a plain line break (and the next line's indent) inside the pre-formatted code
<span key={t.id} className="ic-br">{t.text}</span>
) : (
<motion.span
key={t.id}
layout={reduced ? false : 'position'}
className="ic-tok"
initial="in"
animate="show"
exit="out"
// letters roll one by one near the start; past LETTER_BUDGET characters a token fades as one word
variants={far(all, ti) ? word : undefined}
custom={{ i: 0, d: delay, reduced }}
transition={{ layout: { type: 'spring', visualDuration: 0.4, bounce: 0.18, delay } }}
>
{far(all, ti)
? t.text + (ti < tokens.length - 1 && !isBreak(tokens[ti + 1].text) ? ' ' : '')
: glyphsOf(t.text + (ti < tokens.length - 1 && !isBreak(tokens[ti + 1].text) ? ' ' : '')).map((ch, i) => (
<motion.span key={i} className="ic-ch" variants={letter} custom={{ i, d: delay, reduced }}>
{ch}
</motion.span>
))}
</motion.span>
),
)}
</AnimatePresence>
<AnimatePresence>
{sweep > 0 && copied && !reduced && (
<motion.span
key={sweep}
className="ic-sweep"
aria-hidden
initial={{ scaleX: 0, opacity: 1 }}
animate={{ scaleX: 1, opacity: [1, 1, 0] }}
transition={{ scaleX: { duration: 0.32, ease: EASE_OUT }, opacity: { duration: 0.9, times: [0, 0.45, 1] } }}
/>
)}
</AnimatePresence>
</code>
</span>
</div>
<span className="ic-sr" role="status" aria-live="polite">
{copied ? labels.copiedSpoken : failed ? labels.copyFailedSpoken : ''}
</span>
</div>
)
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Styles — injected once, hoisted by React 19 */
/* ────────────────────────────────────────────────────────────────────────── */
function InstallCommandStyles() {
return (
<style href="install-command" precedence="medium">
{CSS}
</style>
)
}
const CSS = /* css */ `
.ic-root {
--_ink: var(--install-command-ink, var(--ui-ink, #17140f));
--_radius: var(--install-command-radius, calc(var(--ui-radius, 16px) * 0.875));
--_radius-sm: var(--ui-radius-sm, 8px);
--_surface: var(--install-command-surface, var(--ui-surface, #fbf8f1));
--_accent: var(--install-command-accent, var(--ui-accent, #d4452b));
--_font: var(--install-command-font, var(--ui-font-mono, ui-monospace, 'SF Mono', Menlo, Consolas, monospace));
--_line: color-mix(in oklab, var(--_ink) 13%, transparent);
--_muted: color-mix(in oklab, var(--_ink) 52%, transparent);
position: relative;
width: 100%;
border: 1px solid var(--_line);
border-radius: var(--_radius);
background: var(--_surface);
color: var(--_ink);
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
.ic-sr {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.ic-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 5px 5px 5px 6px;
background: color-mix(in oklab, var(--_ink) 3.5%, var(--_surface));
border-bottom: 1px solid var(--_line);
}
/* narrow blocks: the tab row scrolls instead of pushing the copy button off the card */
.ic-tabs { position: relative; display: flex; gap: 2px; min-width: 0; padding-inline: 1px; overflow-x: auto; scrollbar-width: none; }
/* (the 1px each side: a fractional row width otherwise cuts the end tabs' pill ring by a hair) */
.ic-tabs::-webkit-scrollbar { display: none; }
.ic-pill {
position: absolute; top: 0; left: 0; height: 100%; border-radius: var(--_radius-sm);
background: var(--_surface);
/* the ring is drawn inside the pill: the tab row scrolls sideways, so it clips anything outside */
box-shadow: inset 0 0 0 1px var(--_line), 0 1px 2px light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.03));
}
.ic-root .ic-tab {
position: relative; display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; margin: 0; border: 0; border-radius: var(--_radius-sm);
background: none; cursor: pointer;
font-family: var(--_font); font-size: 12.5px; font-weight: 500; line-height: 1;
color: var(--_muted); transition: color 150ms ease;
}
.ic-root .ic-tab:hover, .ic-root .ic-tab[aria-selected="true"] { color: var(--_ink); }
.ic-root .ic-tab[data-icon-only] { width: 30px; padding: 0; justify-content: center; }
.ic-root .ic-tab[data-icon-only] .ic-logo { width: 14px; height: 14px; }
.ic-logo { width: 12px; height: 12px; flex: none; fill: currentColor; opacity: 0.8; }
.ic-tab[aria-selected="true"] .ic-logo { opacity: 1; }
.ic-tab:focus-visible, .ic-copy:focus-visible { outline: 2px solid var(--_accent); outline-offset: 1px; }
/* takes the free space between the tabs and the button, so the copy label can measure it */
.ic-copy-wrap { position: relative; flex: 1 0 28px; min-width: 28px; display: flex; justify-content: flex-end; }
.ic-copy-label {
position: absolute; right: 30px; top: 50%; margin-top: -9px;
/* the free space, or at least ~10 characters (may cover the last tab); longer ends in "…" */
max-width: max(100% - 30px, 10ch); overflow: hidden; text-overflow: ellipsis;
font-size: 12.5px; line-height: 18px; color: var(--_ink); white-space: nowrap; pointer-events: none;
/* on narrow blocks it can sit over the last tab: cover it cleanly instead of overlapping text */
padding-left: 8px; background: color-mix(in oklab, var(--_ink) 3.5%, var(--_surface));
}
.ic-root .ic-copy {
position: relative; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; margin: 0;
border: 0; border-radius: var(--_radius-sm); background: none; cursor: pointer; color: var(--_muted);
transition: color 150ms ease, background-color 150ms ease;
}
.ic-root .ic-copy:hover { color: var(--_ink); background: color-mix(in oklab, var(--_ink) 6%, transparent); }
.ic-root .ic-copy[data-copied] { color: var(--_ink); }
.ic-copy-label[data-failed] { color: var(--install-command-danger, var(--ui-danger, #b42318)); }
/* touch: 44px tabs and copy button */
@media (pointer: coarse) {
.ic-root :is(.ic-tab, .ic-copy) { height: 44px; }
.ic-root .ic-copy { width: 44px; }
.ic-copy-wrap { flex-basis: 44px; min-width: 44px; }
.ic-copy-label { right: 46px; }
}
.ic-copy-icon, .ic-copy-check, .ic-copy-check-draw { grid-area: 1 / 1; display: grid; place-items: center; }
.ic-copy-icon svg, .ic-copy-check svg { fill: none; }
.ic-sr-cmd { user-select: none; -webkit-user-select: none; }
.ic-panel {
/* top, not baseline: a multi-line command's baseline is its last line, the prompt belongs on the first */
display: flex; align-items: flex-start; gap: 10px;
padding: 14px 16px; outline: none;
font-family: var(--_font); font-size: 13.5px; line-height: 20px;
}
.ic-panel:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--_accent) 55%, transparent); }
/* one line, never more than 40% of the row: a long shell prompt ends in "…" (full text on hover) */
.ic-prompt {
flex: 0 1 auto; min-width: 0; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: color-mix(in oklab, var(--_ink) 64%, transparent); user-select: none; -webkit-user-select: none;
}
.ic-line {
position: relative; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none;
}
.ic-line::-webkit-scrollbar { display: none; }
/* a long command scrolls sideways: show a thin scrollbar while hovered or focused so it's discoverable */
.ic-line[data-overflow]:hover, .ic-panel:focus-visible .ic-line[data-overflow] { scrollbar-width: thin; }
.ic-line[data-overflow]:hover::-webkit-scrollbar, .ic-panel:focus-visible .ic-line[data-overflow]::-webkit-scrollbar { display: block; height: 4px; }
.ic-line[data-overflow] { mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
.ic-root .ic-code {
/* inline, not flex: flex children serialise with newlines when selected */
position: relative; display: inline-block; white-space: pre; padding: 0; margin: 0;
background: none; border: 0; font: inherit; color: inherit;
user-select: all; -webkit-user-select: all;
}
.ic-tok { position: relative; display: inline-block; vertical-align: top; overflow: hidden; }
.ic-ch { display: inline-block; vertical-align: top; white-space: pre; }
.ic-sweep {
position: absolute; inset: -1px -5px; border-radius: calc(var(--_radius-sm) * 0.625); pointer-events: none;
transform-origin: 0 50%;
background: color-mix(in oklab, var(--_accent) 16%, transparent);
}
/* touch: 44px targets (WCAG AAA) where they fit; visual sizes on mouse and trackpad are unchanged */
@media (pointer: coarse) {
.ic-root .ic-tab { min-inline-size: 44px; }
}
`
export default InstallCommand