Skip to content
loopkit
free128 lines · framer-motion

Mode Switch

Human, Hybrid or AI. One control. A three-way autonomy switch with a sliding pill and a one-line explanation of what each mode means for the agent.

Use it for
Settings pages, agent headers, per-task overrides.
Themes
Light + dark
License
Commercial use (draft)
Live
Autonomy

The agent acts on low-risk steps and asks on the rest.

tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

export type AgentMode = "human" | "hybrid" | "ai";

const MODES: { value: AgentMode; label: string; hint: string }[] = [
  { value: "human", label: "Human", hint: "The agent drafts. You make every call." },
  { value: "hybrid", label: "Hybrid", hint: "The agent acts on low-risk steps and asks on the rest." },
  { value: "ai", label: "AI", hint: "The agent runs on its own. Everything still lands in the audit log." },
];

export interface ModeSwitchProps {
  value?: AgentMode;
  defaultValue?: AgentMode;
  onValueChange?: (mode: AgentMode) => void;
  size?: "sm" | "md" | "lg";
  showHint?: boolean;
  label?: string;
  className?: string;
}

const sizes = {
  sm: "h-8 text-[11px] px-3",
  md: "h-10 text-xs px-4",
  lg: "h-12 text-sm px-6",
};

export function ModeSwitch({
  value,
  defaultValue = "hybrid",
  onValueChange,
  size = "md",
  showHint = true,
  label = "Autonomy",
  className,
}: ModeSwitchProps) {
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState<AgentMode>(defaultValue);
  const current = value ?? inner;
  const id = React.useId();
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const select = (m: AgentMode, focus = false) => {
    if (value === undefined) setInner(m);
    onValueChange?.(m);
    if (focus) refs.current[MODES.findIndex((x) => x.value === m)]?.focus();
  };

  const onKey = (e: React.KeyboardEvent) => {
    const i = MODES.findIndex((m) => m.value === current);
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      select(MODES[(i + 1) % MODES.length].value, true);
    }
    if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      select(MODES[(i + MODES.length - 1) % MODES.length].value, true);
    }
  };

  const hint = MODES.find((m) => m.value === current)!.hint;

  return (
    <div className={cn("inline-flex flex-col gap-2", className)}>
      <span id={`${id}-l`} className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
        {label}
      </span>
      <div
        role="radiogroup"
        aria-labelledby={`${id}-l`}
        onKeyDown={onKey}
        className="relative inline-flex w-fit rounded-full border border-border bg-muted p-1"
      >
        {MODES.map((m, i) => {
          const active = m.value === current;
          return (
            <button
              key={m.value}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={active}
              tabIndex={active ? 0 : -1}
              onClick={() => select(m.value)}
              className={cn(
                "relative z-10 rounded-full font-mono font-bold uppercase tracking-[0.14em] transition-colors",
                sizes[size],
                active ? (m.value === "ai" ? "text-signal-foreground" : "text-primary-foreground") : "text-muted-foreground hover:text-foreground",
              )}
            >
              {active && (
                <motion.span
                  layoutId={`${id}-pill`}
                  transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }}
                  className={cn("absolute inset-0 -z-10 rounded-full", m.value === "ai" ? "bg-signal" : "bg-primary")}
                />
              )}
              {m.label}
            </button>
          );
        })}
      </div>
      {showHint && (
        <div className="relative min-h-[2.5em] max-w-xs text-xs leading-snug text-muted-foreground" aria-live="polite">
          <AnimatePresence mode="wait" initial={false}>
            <motion.p
              key={current}
              initial={reduce ? false : { opacity: 0, y: 4 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
              transition={{ duration: 0.15 }}
            >
              {hint}
            </motion.p>
          </AnimatePresence>
        </div>
      )}
    </div>
  );
}

export default ModeSwitch;

How it behaves

  • →Segmented control with HUMAN / HYBRID / AI in uppercase monospace.
  • →Active pill slides between options with a spring (shared layoutId). AI uses the signal colour, others use primary.
  • →Hint text under the control cross-fades when the mode changes.
  • →Works controlled (value + onValueChange) or uncontrolled (defaultValue).

Accessibility

  • ✓role=radiogroup with role=radio buttons and roving tabindex.
  • ✓Arrow keys move and select.
  • ✓Hint is aria-live=polite.
  • ✓Honours prefers-reduced-motion.