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.