An environment selector with a sliding colored tone band; switching to a guarded environment like Production opens a hold-to-confirm gate.
npx shadcn@latest add @paragon/environment-switcherAlso installs: dropdown-menu, confirm-hold-button
"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>
);
}