UI Elements

Install Switcher

Install command with the package manager as a gliding segmented switch inside the pill: pick pnpm, npm, yarn or bun and the command rewrites; the copy button copies the one showing. Narrow, the switch takes the pill's top row. Add the dark class over art.

Free, MIT licenseOpen in Browse
"use client"

import * as React from "react"
import { CheckIcon, CopyIcon } from "@phosphor-icons/react"
import { cn } from "cn"

import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/toggle/segmented-control"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
} from "@/components/ui/input-group"

// The runners a `shadcn add …` command can start with. Add one here to add a
// segment; the label is what the segment shows.
const MANAGERS = [
  { id: "pnpm", label: "pnpm", run: "pnpm dlx" },
  { id: "npm", label: "npm", run: "npx" },
  { id: "yarn", label: "yarn", run: "yarn dlx" },
  { id: "bun", label: "bun", run: "bunx" },
] as const

type Manager = (typeof MANAGERS)[number]["id"]

/**
 * An install command with a package manager switch in the same pill, for a
 * hero or docs page. Picking pnpm, npm, yarn or bun rewrites the command's
 * runner, and the copy button copies the command as it shows.
 *
 * When the component is narrower than 32rem, the switch takes its own row
 * above the command. After a copy, the button shows a check for two
 * seconds; if copying fails, the command is selected and a message below
 * asks the reader to press ⌘C or Ctrl+C. Over artwork, add
 * `className="dark"`. `className` lands on the outer `div`.
 */
function InstallSwitcher({
  command,
  defaultManager = "pnpm",
  className,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  /**
   * Everything after the runner, such as `shadcn add @01orbital/stars`. The
   * chosen manager's runner, such as `pnpm dlx` or `npx`, goes in front.
   */
  command: string
  /** The package manager shown first: `pnpm`, `npm`, `yarn` or `bun`. */
  defaultManager?: Manager
}) {
  const [manager, setManager] = React.useState<Manager>(defaultManager)
  const { copied, failed, copyToClipboard } = useCopyToClipboard()
  const snippetRef = React.useRef<HTMLElement>(null)
  const run = MANAGERS.find((m) => m.id === manager)?.run ?? MANAGERS[0].run

  // On failure the command is selected, so one keystroke copies it by hand.
  const copy = async () => {
    const code = snippetRef.current
    if (!code) return
    if (!(await copyToClipboard(code.textContent ?? ""))) {
      window.getSelection()?.selectAllChildren(code)
    }
  }

  return (
    <div
      data-slot="install-switcher"
      className={cn("@container/install w-full min-w-0", className)}
      {...props}
    >
      <InputGroup
        data-copied={copied || undefined}
        className="h-11 border-border bg-card font-mono text-card-foreground @max-lg/install:h-auto @max-lg/install:flex-wrap @max-lg/install:pt-1.5 @max-lg/install:pb-1"
      >
        <InputGroupAddon className="pl-1.5 @max-lg/install:w-full @max-lg/install:pb-1">
          <SegmentedControl
            aria-label="Package manager"
            value={[manager]}
            onValueChange={(next) => {
              if (next[0]) setManager(next[0] as Manager)
            }}
            className="h-8 gap-0.5 p-0.5 font-sans"
          >
            {MANAGERS.map((m) => (
              <SegmentedControlItem
                key={m.id}
                value={m.id}
                className="h-7 px-2.5 text-xs"
              >
                {m.label}
              </SegmentedControlItem>
            ))}
          </SegmentedControl>
        </InputGroupAddon>
        <InputGroupAddon aria-hidden="true" className="pr-2 pl-2 font-normal">
          $
        </InputGroupAddon>
        <InputGroupText className="min-w-0 flex-1 text-sm/7 text-current">
          <code ref={snippetRef} className="truncate">
            {run} {command}
          </code>
        </InputGroupText>
        <InputGroupAddon align="inline-end">
          <InputGroupButton
            size="icon-sm"
            onClick={copy}
            aria-label={
              copied ? "Copied" : failed ? "Copy failed" : "Copy command"
            }
          >
            {copied ? (
              <CheckIcon aria-hidden="true" />
            ) : (
              <CopyIcon aria-hidden="true" />
            )}
          </InputGroupButton>
        </InputGroupAddon>
        <span
          role="status"
          className={
            failed
              ? "absolute top-full left-0 mt-2 px-4 font-sans text-xs"
              : "sr-only"
          }
        >
          {copied
            ? "Copied"
            : failed
              ? "Couldn't copy. It's selected: press ⌘C or Ctrl+C."
              : ""}
        </span>
      </InputGroup>
    </div>
  )
}

export { InstallSwitcher, MANAGERS }
export default InstallSwitcher

Installation

pnpm dlx shadcn add @01orbital/install-switcher
npx shadcn add @01orbital/install-switcher
yarn shadcn add @01orbital/install-switcher
bunx --bun shadcn add @01orbital/install-switcher

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/code/install-switcher.tsx, as source you own and edit.

The example also uses conic-glow. Install it too:

pnpm dlx shadcn add @01orbital/conic-glow
npx shadcn add @01orbital/conic-glow
yarn shadcn add @01orbital/conic-glow
bunx --bun shadcn add @01orbital/conic-glow

Usage

import { InstallSwitcher } from "@/components/code/install-switcher"
import { ConicGlow } from "@/components/glow/conic-glow"

const COMMAND = "shadcn add @01orbital/conic-glow"

// The second command sits on a dark panel, so it takes the dark theme.
export default function InstallSwitcherDemo() {
  return (
    <div className="grid w-full max-w-2xl gap-6">
      <InstallSwitcher command={COMMAND} />
      <div className="dark relative isolate overflow-hidden rounded-2xl bg-background p-8">
        <ConicGlow className="-z-10" />
        <InstallSwitcher className="relative" command={COMMAND} />
      </div>
    </div>
  )
}

API reference

Prop

Type

It also accepts the HTML attributes of the element it renders.

Dependencies

npm: @phosphor-icons/react, cn. The CLI installs them for you.

Registry pieces it installs with it: