Environment Switcher
Navigation

Environment Switcher

An environment selector with a sliding colored tone band; switching to a guarded environment like Production opens a hold-to-confirm gate.

Install

npx shadcn@latest add @paragon/environment-switcher

Also installs: dropdown-menu, confirm-hold-button

environment-switcher.tsx

"use client";

import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { ConfirmHoldButton } from "@/registry/paragon/ui/confirm-hold-button";
import { cn } from "@/lib/utils";

export interface Environment {
  id: string;
  name: string;
  /** Sub-label, e.g. the target host. */
  detail?: string;
  /** Semantic tint. Production defaults to a guarded switch. */
  tone?: "neutral" | "info" | "warning" | "danger";
  /** Requires a hold-to-confirm before switching. Defaults on for danger. */
  guarded?: boolean;
}

export interface EnvironmentSwitcherProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  environments?: Environment[];
  /** Controlled selected id. */
  value?: string;
  /** Uncontrolled initial id. */
  defaultValue?: string;
  onChange?: (id: string) => void;
}

const toneBand: Record<NonNullable<Environment["tone"]>, string> = {
  neutral: "bg-muted-foreground/40",
  info: "bg-primary",
  warning: "bg-warning",
  danger: "bg-destructive",
};

const toneDot: Record<NonNullable<Environment["tone"]>, string> = {
  neutral: "bg-muted-foreground/50",
  info: "bg-primary",
  warning: "bg-warning",
  danger: "bg-destructive",
};

const DEFAULTS: Environment[] = [
  { id: "dev", name: "Development", detail: "localhost:3000", tone: "neutral" },
  { id: "staging", name: "Staging", detail: "staging.acme.io", tone: "info" },
  { id: "preview", name: "Preview", detail: "pr-482.acme.dev", tone: "warning" },
  {
    id: "prod",
    name: "Production",
    detail: "api.acme.com",
    tone: "danger",
    guarded: true,
  },
];

/**
 * An environment selector. A pill-shaped tone rail on the trigger's left edge
 * recolors to the active environment's tone (a clear status accent, matched by
 * the dot and the named label). Picking a guarded environment — Production by
 * default — doesn't switch immediately; it opens a hold-to-confirm gate, since
 * promoting yourself into prod should cost a deliberate beat. Everything else
 * switches on click.
 */
export function EnvironmentSwitcher({
  environments = DEFAULTS,
  value,
  defaultValue,
  onChange,
  className,
  ...props
}: EnvironmentSwitcherProps) {
  const [internal, setInternal] = React.useState(
    defaultValue ?? environments[0]?.id,
  );
  const activeId = value ?? internal;
  const active =
    environments.find((e) => e.id === activeId) ?? environments[0];

  const [open, setOpen] = React.useState(false);
  const [pending, setPending] = React.useState<Environment | null>(null);
  // The gate keeps rendering its last environment while it collapses, so a
  // cancel animates closed over real content instead of an emptied box.
  const lastPendingRef = React.useRef<Environment | null>(null);
  if (pending) lastPendingRef.current = pending;
  const gate = pending ?? lastPendingRef.current;

  const commit = (id: string) => {
    setInternal(id);
    onChange?.(id);
  };

  const select = (env: Environment) => {
    const guarded = env.guarded ?? env.tone === "danger";
    if (env.id === active?.id) {
      setOpen(false);
      return;
    }
    if (guarded) {
      setPending(env);
      setOpen(false);
    } else {
      commit(env.id);
      setOpen(false);
    }
  };

  if (!active) return null;

  return (
    <div data-slot="environment-switcher" className={cn("w-64", className)} {...props}>
      {/* No overflow-hidden: the tone rail is self-rounded and inset, so
          nothing needs clipping — which also stops the trigger's focus ring
          and the portalled menu from being cut off. */}
      <div className="relative rounded-xl bg-card shadow-border">
        {/* Tone rail — an unmistakable status accent pinned to the trigger's
            left, inset and pill-shaped so it reads as deliberate (not a stray
            border). It recolors to the active environment's tone; the color IS
            the signal, and the dot + label name it explicitly. */}
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute top-2.5 bottom-2.5 left-2 w-1 rounded-full",
            "transition-colors duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
            toneBand[active.tone ?? "neutral"],
          )}
        />

        <DropdownMenu open={open} onOpenChange={setOpen}>
          <DropdownMenuTrigger
            className={cn(
              "flex w-full items-center gap-3 rounded-xl py-2.5 pr-3 pl-5 text-left outline-none",
              "transition-colors duration-(--duration-fast) hover:bg-accent/50",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            )}
          >
            <span
              className={cn(
                "size-2 shrink-0 rounded-full transition-colors duration-(--duration-base) ease-(--ease-out)",
                toneDot[active.tone ?? "neutral"],
              )}
            />
            <span className="min-w-0 flex-1">
              <span className="block text-xs text-muted-foreground">
                Environment
              </span>
              <span className="block truncate text-sm font-medium">
                {active.name}
              </span>
            </span>
            <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
          </DropdownMenuTrigger>
          <DropdownMenuContent
            align="start"
            className="w-(--radix-dropdown-menu-trigger-width)"
          >
            <DropdownMenuLabel>Switch environment</DropdownMenuLabel>
            <DropdownMenuSeparator />
            {environments.map((env) => (
              <DropdownMenuItem
                key={env.id}
                onSelect={(e) => {
                  e.preventDefault();
                  select(env);
                }}
                className="gap-2.5"
              >
                <span
                  className={cn(
                    "size-2 shrink-0 rounded-full",
                    toneDot[env.tone ?? "neutral"],
                  )}
                />
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm">{env.name}</span>
                  {env.detail && (
                    <span className="block truncate font-mono text-[11px] text-muted-foreground">
                      {env.detail}
                    </span>
                  )}
                </span>
                {env.id === active.id && (
                  <Check className="size-4 shrink-0 !text-foreground" />
                )}
              </DropdownMenuItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      {/* Confirmation gate for guarded environments. */}
      <div
        className={cn(
          "grid transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
          pending ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
        )}
      >
        <div inert={!pending} className="min-h-0 overflow-hidden">
          {gate && (
            <div className="mt-2 rounded-xl bg-card p-3 text-card-foreground shadow-border">
              <p className="text-xs text-muted-foreground">
                Switching to{" "}
                <span className="font-medium text-destructive">
                  {gate.name}
                </span>{" "}
                — hold to confirm.
              </p>
              <div className="mt-2.5 flex items-center gap-2">
                <ConfirmHoldButton
                  variant="destructive"
                  holdDuration={1000}
                  onConfirm={() => {
                    commit(gate.id);
                    setPending(null);
                  }}
                  className="h-9 flex-1 text-[13px]"
                >
                  Hold to switch to {gate.name}
                </ConfirmHoldButton>
                <button
                  type="button"
                  onClick={() => setPending(null)}
                  className={cn(
                    "pressable h-9 rounded-lg px-3 text-[13px] font-medium text-muted-foreground",
                    "transition-colors duration-(--duration-fast) hover:bg-accent hover:text-foreground",
                    "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                  )}
                >
                  Cancel
                </button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}