Mode Switcher

A field that behaves differently depending on a mode set somewhere else lets people type the wrong kind of thing, because nothing in the field said which kind it wanted.

InputAIProductivitySearchadvanced
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
Ask Anything
npx shadcn@latest add https://design-recreation.vercel.app/r/mode-switcher.json

The problem

A field that behaves differently depending on a mode set somewhere else is a trap. You type a question into what turns out to be an image prompt, because nothing in the field said which kind of thing it wanted — the mode was indicated by a control off to one side that you had already stopped looking at.

Putting the answer in the placeholder puts it where the eye is already resting before typing starts, and retyping it on every switch means the change is impossible to miss even in peripheral vision.

Use cases

  • An AI composer that switches between chat, image, and search
  • A single search field that scopes to people, files, or messages
  • Any input whose parsing or destination changes with a mode

Not for: more than about four modes — cycling through them one press at a time gets tedious fast, so use a real listbox. Not for modes that change the field's shape rather than its meaning; if one needs three inputs and another needs one, they are different forms. Not where the placeholder is already carrying an example or a hint you cannot afford to lose.

Installation

bash
npm install gsap @gsap/react motion lucide-react

Then copy ModeSwitcher.tsx. It depends only on @/lib/cn.

Usage

tsx
import ModeSwitcher from "@/patterns/mode-switcher/ModeSwitcher";
import { Image as ImageIcon, Sparkles } from "lucide-react";

<ModeSwitcher
  modes={[
    { id: "ask", label: "Ask Anything", icon: <Sparkles /> },
    { id: "image", label: "Generate Image", icon: <ImageIcon /> },
  ]}
  onSubmit={(value, modeId) => send(value, modeId)}
/>;

Each label is doing two jobs — the visible placeholder and the field's accessible name — so write it as something worth reading in both.

API

PropTypeDefaultDescription
modesreadonly SwitcherMode[]—Two or more. The button cycles in order.
defaultModestringfirst modeUncontrolled initial mode.
modestringuncontrolledControlled mode. Pass with onModeChange.
onModeChange(id: string) => void—Fires on every switch.
defaultValuestring""Uncontrolled initial field value.
valuestringuncontrolledControlled value. Pass with onValueChange.
onValueChange(value: string) => void—Fires on every keystroke.
onSubmit(value, modeId) => void—Fires on Enter or the send button.
submitLabelstring"Send"Accessible name for the send button.
classNamestring—Merged onto the root.

SwitcherMode: { id: string; label: string; icon: ReactNode }.

Keyboard

KeyAction
TabMode button → field → send
EnterSubmit, from anywhere in the field
Space / Enter on the mode buttonSwitch to the next mode

The root is a real <form>, so Enter submits. The original was a bare div with an input in it and a send button wired to nothing, so Enter did nothing at all — in a composer, which is the one control people expect to submit on Enter without thinking about it.

Accessibility

The field had no name. Its placeholder was the empty string, and the text you can see is a separate absolutely-positioned div painted over the top. So the one thing telling you which mode you are in was invisible to assistive tech, and the field announced as just "edit text". It carries aria-label from the current mode now, so the name changes with the mode exactly as the visible placeholder does.

That overlay is also split into one <span> per character to animate, which some screen readers spell out letter by letter. It is aria-hidden — the aria-label carries the same words properly.

The mode button said "Toggle input mode", which names the action but not the state. It reads "Mode: Generate Image. Change mode" now, and a polite live region announces each switch, because a name that changes underneath you is not reliably re-announced on its own.

The send button did nothing. No handler, no form. It submits now.

Under prefers-reduced-motion the switch is instant: the icon does not blur out, the characters do not fly in, and the placeholder swaps directly. Nothing is lost, because the information was always in the words rather than the motion.

Known limitation: the ChevronsUpDown icon on the mode button implies a dropdown, and this is a cycle button. It is kept because it is the original's design, and with two modes the distinction rarely bites — but with three or more it is a promise the control does not keep, and a listbox is the honest answer at that point.

Performance

The character split is imperative DOM work — replaceChildren and one span per character — done on a node React does not own. That is the standard way to do a per-character reveal without a paid plugin, and it is safe here precisely because the node is aria-hidden and childless in JSX.

The switch is a single GSAP timeline with a named syncPoint, so the icon's elastic return and the text's stagger start together rather than in sequence. The tl.to({}, { duration: 0.001 }) between them is not superstition: it gives React a beat to commit the new icon before it is flown back in, and without it the outgoing glyph is what reappears.

Two things went. The icon carried a permanent will-change: transform, which holds a compositor layer for the page's whole life to buy a few frames of a 200ms tween. And the button was disabled for the full second the animation runs — disabling a control mid-press drops focus to the body in several browsers, so the guard is a ref inside the handler instead.

One real bug came out of that guard: the original set its animating flag before checking the refs, so a null ref left the button permanently dead. The check comes first now.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

ModeSwitcher.tsx
"use client";

import { useCallback, useRef, useState } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowRight, ChevronsUpDown } from "lucide-react";

import { cn } from "@/lib/cn";

gsap.registerPlugin(useGSAP);

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface SwitcherMode {
  id: string;
  /** Doubles as the field's placeholder and its accessible name. */
  label: string;
  icon: React.ReactNode;
}

export interface ModeSwitcherProps extends Omit<
  React.ComponentPropsWithoutRef<"form">,
  "onSubmit" | "onChange"
> {
  /** Two or more. The button cycles through them in order. */
  modes: readonly SwitcherMode[];
  defaultMode?: string;
  mode?: string;
  onModeChange?: (id: string) => void;

  defaultValue?: string;
  value?: string;
  onValueChange?: (value: string) => void;
  onSubmit?: (value: string, modeId: string) => void;

  /** Accessible name for the send button. @default "Send" */
  submitLabel?: string;
}

/* -------------------------------------------------------------------------- */
/* Motion — GSAP timings kept verbatim from the original                       */
/* -------------------------------------------------------------------------- */

const ICON_OUT = { duration: 0.2, ease: "power2.in" };
const ICON_IN = { duration: 0.9, ease: "elastic.out(1, 0.7)" };
const CHARS_IN = { duration: 0.5, ease: "elastic.out(1, 0.8)", stagger: 0.02 };

/**
 * Splits the label into per-character spans, hidden and nudged down, ready to
 * be flown in. Done imperatively because React does not own this node's
 * children — the whole element is `aria-hidden`, and the field carries the
 * real accessible name, so nothing here is load-bearing for assistive tech.
 */
function splitChars(
  element: HTMLElement | null,
  text: string,
): HTMLSpanElement[] {
  if (!element) return [];
  element.replaceChildren();
  return text.split("").map((char) => {
    const span = document.createElement("span");
    span.textContent = char === " " ? " " : char;
    span.style.display = "inline-block";
    span.style.opacity = "0";
    span.style.transform = "translateY(7px)";
    element.appendChild(span);
    return span;
  });
}

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * One composer that serves several modes, with the placeholder as the readout.
 *
 * A field that behaves differently depending on a mode set elsewhere is a trap:
 * you type the wrong kind of thing because nothing in the field said which kind
 * it wanted. Retyping the placeholder on every switch puts the answer in the
 * one place the eye is already resting before typing starts.
 */
export function ModeSwitcher({
  modes,
  defaultMode,
  mode: controlledMode,
  onModeChange,
  defaultValue = "",
  value: controlledValue,
  onValueChange,
  onSubmit,
  submitLabel = "Send",
  className,
  ...rest
}: ModeSwitcherProps) {
  const reduce = useReducedMotion();

  const iconRef = useRef<HTMLSpanElement>(null);
  const textRef = useRef<HTMLSpanElement>(null);
  const busy = useRef(false);

  const [uncontrolledMode, setUncontrolledMode] = useState(
    defaultMode ?? modes[0]?.id,
  );
  const modeId = controlledMode ?? uncontrolledMode;

  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
  const value = controlledValue ?? uncontrolledValue;

  const index = Math.max(
    0,
    modes.findIndex((m) => m.id === modeId),
  );
  const current = modes[index];

  const setValue = useCallback(
    (next: string) => {
      if (controlledValue === undefined) setUncontrolledValue(next);
      onValueChange?.(next);
    },
    [controlledValue, onValueChange],
  );

  const cycle = useCallback(() => {
    /*
     * Guarded with a ref rather than by disabling the button. Disabling a
     * control the user is mid-press on drops focus to the body in several
     * browsers, and this animation runs for a full second.
     */
    if (busy.current) return;

    const iconEl = iconRef.current;
    const textEl = textRef.current;
    // Checked *before* claiming the lock. The original set the flag first and
    // returned on a null ref, which left the button dead for good.
    if (!iconEl || !textEl) return;

    const next = modes[(index + 1) % modes.length];
    const commit = () => {
      if (controlledMode === undefined) setUncontrolledMode(next.id);
      onModeChange?.(next.id);
    };

    if (reduce) {
      commit();
      splitChars(textEl, next.label).forEach((span) => {
        span.style.opacity = "1";
        span.style.transform = "none";
      });
      return;
    }

    busy.current = true;
    const tl = gsap.timeline({
      onComplete: () => {
        busy.current = false;
      },
    });

    tl.to(iconEl, { opacity: 0, scale: 0.7, filter: "blur(7px)", ...ICON_OUT });
    tl.call(commit);
    // A beat of nothing, so React has committed the new icon before it is
    // flown back in. Without it the outgoing glyph is what reappears.
    tl.to({}, { duration: 0.001 });

    tl.to(
      iconEl,
      { opacity: 1, scale: 1, filter: "blur(0px)", ...ICON_IN },
      "syncPoint",
    );
    tl.call(
      () => {
        gsap.to(splitChars(textEl, next.label), {
          opacity: 1,
          y: 0,
          ...CHARS_IN,
        });
      },
      undefined,
      "syncPoint",
    );
  }, [controlledMode, index, modes, onModeChange, reduce]);

  /* First paint: type the starting label in. */
  useGSAP(() => {
    const chars = splitChars(textRef.current, current?.label ?? "");
    if (!chars.length) return;
    if (reduce) {
      gsap.set(chars, { opacity: 1, y: 0 });
      return;
    }
    gsap.to(chars, { opacity: 1, y: 0, ...CHARS_IN });
    // Empty deps on purpose: every later run is driven by `cycle`.
  }, []);

  /* The placeholder is a real overlay, so it has to get out of the way. */
  useGSAP(() => {
    if (!textRef.current) return;
    gsap.to(textRef.current, {
      opacity: value === "" ? 1 : 0,
      duration: reduce ? 0 : 0.2,
    });
  }, [value, reduce]);

  if (modes.length === 0) return null;

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        onSubmit?.(value, current.id);
      }}
      className={cn(
        "flex w-full min-w-0 justify-center px-4 sm:px-6",
        className,
      )}
      {...rest}
    >
      <div className="relative flex h-14 w-full max-w-2xl items-center overflow-hidden rounded-full bg-surface-hover shadow-lg">
        <motion.button
          type="button"
          onClick={cycle}
          whileTap={reduce ? undefined : { scale: 0.8 }}
          // Names the current mode, not just the action — otherwise the only
          // readout of which mode you are in is a placeholder that is painted
          // over the field and hidden from assistive tech.
          aria-label={`Mode: ${current.label}. Change mode`}
          className={cn(
            "focus-ring ml-1 flex h-12 w-18 shrink-0 items-center justify-center",
            "rounded-4xl bg-surface",
          )}
        >
          <span ref={iconRef} className="flex items-center justify-center">
            <span
              aria-hidden="true"
              className="text-content-muted [&>svg]:size-6"
            >
              {current.icon}
            </span>
          </span>
          <ChevronsUpDown
            className="size-5 text-content-subtle"
            aria-hidden="true"
          />
        </motion.button>

        {/* `h-full` here as well as on the field: the bar centres its items, so
            without an explicit height this wrapper is only as tall as its text
            and the field's `h-full` resolves to 23px. */}
        <div className="relative flex h-full min-w-0 flex-1 items-center">
          <input
            type="text"
            value={value}
            onChange={(event) => setValue(event.target.value)}
            aria-label={current.label}
            // `h-full`, not the original's `py-5`: that padding made the field
            // 68px tall inside a 56px bar, so its hit area was clipped by the
            // bar's `overflow-hidden` above and below the text.
            className={cn(
              "h-full w-full min-w-0 bg-transparent px-1 text-lg",
              "text-content caret-content outline-none",
            )}
          />
          {/*
            The visible placeholder. `aria-hidden` because it is split into one
            span per character, which some screen readers spell out letter by
            letter — the field's `aria-label` carries the same words properly.
            Positioned inside the field's own box rather than at a hardcoded
            offset from the bar, so it cannot drift from the text it imitates.
          */}
          <span
            aria-hidden="true"
            className="pointer-events-none absolute left-1 text-lg text-content-subtle"
          >
            <span ref={textRef} />
          </span>
        </div>

        {/*
          Announces the mode change to assistive tech. The button's own label
          updates too, but a name that changes under you is not reliably
          re-announced.
        */}
        <span aria-live="polite" className="sr-only">
          {current.label}
        </span>

        <button
          type="submit"
          aria-label={submitLabel}
          className={cn(
            "focus-ring mr-1 flex size-12 shrink-0 items-center justify-center",
            "rounded-full bg-surface",
          )}
        >
          <ArrowRight className="size-6 text-content" aria-hidden="true" />
        </button>
      </div>
    </form>
  );
}

export default ModeSwitcher;

Technologies

FrameworkReact 19
MotionGSAP 3 (@gsap/react useGSAP), Motion 12 for the press
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original design by nitishkmrk. Renamed from Switcher Interaction, which named the mechanism rather than the job. Rebuilt with a props API, a real form, an accessible name that tracks the mode, and reduced-motion support. The GSAP timings are unchanged — power2.in out, elastic.out(1, 0.7) back, and elastic.out(1, 0.8) with a 20ms stagger on the characters.