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 colored band on the left edge slides (via a
 * transform, so it's compositor-only) and recolors to the active tone. 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);

  const activeIndex = Math.max(
    environments.findIndex((e) => e.id === active?.id),
    0,
  );

  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}>
      <div className="relative overflow-hidden rounded-xl bg-card shadow-border">
        {/* Sliding tone band — parked full-height, moved by transform. */}
        <div
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 h-[calc(100%/var(--env-count))] w-1"
          style={
            {
              "--env-count": environments.length,
              transform: `translateY(${activeIndex * 100}%)`,
              transition:
                "transform var(--duration-base) var(--ease-in-out), background-color var(--duration-base) var(--ease-out)",
            } as React.CSSProperties
          }
        >
          <span
            className={cn(
              "block h-full w-full rounded-full transition-colors duration-(--duration-base)",
              toneBand[active.tone ?? "neutral"],
            )}
          />
        </div>

        <DropdownMenu open={open} onOpenChange={setOpen}>
          <DropdownMenuTrigger
            className={cn(
              "flex w-full items-center gap-3 rounded-xl py-2.5 pr-3 pl-4 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 focus-visible:ring-inset",
            )}
          >
            <span
              className={cn(
                "size-2 shrink-0 rounded-full transition-colors duration-(--duration-base)",
                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">
          {pending && (
            <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">
                  {pending.name}
                </span>{" "}
                — hold to confirm.
              </p>
              <div className="mt-2.5 flex items-center gap-2">
                <ConfirmHoldButton
                  variant="destructive"
                  holdDuration={1000}
                  onConfirm={() => {
                    commit(pending.id);
                    setPending(null);
                  }}
                  className="h-9 flex-1 text-[13px]"
                >
                  Hold to switch to {pending.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>
  );
}