All patterns

Model Switch Morph

Choosing another model glides the selection across and eases the badge into that model's name and accent.

aipremiumelegantinteraction · finite · intermediate · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

301 lines · react + motion only
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Model Switch Morph
 *
 * Picking a different model slides one selection pill between segments
 * while the badge underneath cross-fades its name and eases to the new
 * accent — a settings change that reads as a change of character.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The track and card are mixed from the inherited text color, so the
 * control reads correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `models`, `initialId`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ModelOption = {
  id: string;
  /** Segment label and badge name. */
  name: string;
  /** One line under the badge — what choosing this one costs you. */
  note: string;
  /** Accent this model owns. Semantic, so it stays literal. */
  accent: string;
};

export type ModelSwitchMorphProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Two or more options. Defaults to a fast / balanced / deep trio. */
  models?: ModelOption[];
  /** Which option is selected on mount. Defaults to the middle one. */
  initialId?: string;
  /** Fires with the newly selected option. */
  onChange?: (id: string) => void;
};

type VariantConfig = {
  /** Spring the selection pill rides between segments. */
  slide: { type: "spring"; stiffness: number; damping: number };
  /** Cross-fade of the name and the note. */
  fade: number;
  /** Tween of every color that follows the selection. */
  tint: number;
};

// The pill slides, the words cross-fade, and nothing scales: a label that
// pops on every switch turns a settings control into a toy. Damping ratios
// (ζ = damping / 2√stiffness) stay at or above 0.86 so the pill lands once
// even when the reader taps quickly along the track.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.05 — the pill arrives without a hint of overshoot. For dense
  // settings panels where this control is one row among many.
  subtle: {
    slide: { type: "spring", stiffness: 560, damping: 50 },
    fade: 0.14,
    tint: 0.22,
  },
  // ζ ≈ 0.93 — one clean settle. The all-purpose setting.
  default: {
    slide: { type: "spring", stiffness: 420, damping: 38 },
    fade: 0.18,
    tint: 0.3,
  },
  // ζ ≈ 0.86 — a slower, wider glide for a prominent model picker.
  playful: {
    slide: { type: "spring", stiffness: 300, damping: 30 },
    fade: 0.22,
    tint: 0.38,
  },
};

const SAMPLE_MODELS: ModelOption[] = [
  {
    id: "instant",
    name: "Instant",
    note: "Quick replies for everyday questions",
    accent: "#38A3C9",
  },
  {
    id: "balanced",
    name: "Balanced",
    note: "The default mix of speed and depth",
    accent: "#7C7CF0",
  },
  {
    id: "deep",
    name: "Deep",
    note: "Longer reasoning for hard problems",
    accent: "#A86BE0",
  },
];

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` yields a track and a card that are correctly toned on a
 *  light page and on a dark one. Model accents stay literal — they are
 *  identity colors, not surfaces. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ModelSwitchMorph({
  variant = "default",
  models = SAMPLE_MODELS,
  initialId,
  onChange,
}: ModelSwitchMorphProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  // The pill's layout id has to be unique per instance: two of these on
  // one page sharing an id would send the pill flying between them.
  const pillId = `model-switch-pill-${useId()}`;

  const options = models.length > 0 ? models : SAMPLE_MODELS;
  const fallback = options[Math.min(1, options.length - 1)];
  const [selectedId, setSelectedId] = useState(initialId ?? fallback.id);
  const selected =
    options.find((option) => option.id === selectedId) ?? fallback;

  // Reduced motion: the pill still moves — its position *is* the answer to
  // "which one is selected" — but it teleports instead of travelling, and
  // every colour lands on the same frame as the label.
  const slide = reduceMotion ? { duration: 0 } : cfg.slide;
  const fade = { duration: reduceMotion ? 0 : cfg.fade, ease: "easeOut" as const };
  const tint = { duration: reduceMotion ? 0 : cfg.tint, ease: "easeOut" as const };

  const select = (id: string) => {
    if (id === selectedId) return;
    setSelectedId(id);
    onChange?.(id);
  };

  return (
    <div
      style={{
        width: 292,
        display: "flex",
        flexDirection: "column",
        gap: 14,
        fontSize: 13,
      }}
    >
      <div
        role="radiogroup"
        aria-label="Model"
        style={{
          position: "relative",
          display: "grid",
          gridTemplateColumns: `repeat(${options.length}, 1fr)`,
          gap: 2,
          padding: 3,
          borderRadius: 11,
          background: tone(7),
          border: `1px solid ${tone(11)}`,
        }}
      >
        {options.map((option) => {
          const active = option.id === selected.id;
          return (
            <button
              key={option.id}
              type="button"
              role="radio"
              aria-checked={active}
              onClick={() => select(option.id)}
              style={{
                position: "relative",
                padding: "7px 4px",
                borderRadius: 8,
                background: "transparent",
                border: "none",
                color: "inherit",
                font: "inherit",
                fontSize: 12.5,
                fontWeight: 600,
                lineHeight: 1.2,
                cursor: "pointer",
              }}
            >
              {/* One pill, shared across segments: the layout animation
                  moves the same element rather than fading two, which is
                  what makes the selection feel physically dragged over. */}
              {active && (
                <motion.span
                  layoutId={pillId}
                  aria-hidden
                  transition={slide}
                  style={{
                    position: "absolute",
                    inset: 0,
                    borderRadius: 8,
                    background: tone(10),
                    border: `1px solid ${tone(14)}`,
                  }}
                />
              )}
              {/* Only the opacity of the words changes — the glyph beside
                  them carries the colour, so no text is ever re-tinted
                  mid-slide. */}
              <motion.span
                initial={false}
                animate={{ opacity: active ? 1 : 0.55 }}
                transition={fade}
                style={{ position: "relative" }}
              >
                {option.name}
              </motion.span>
            </button>
          );
        })}
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 11,
          padding: "12px 13px",
          borderRadius: 13,
          background: tone(6),
          border: `1px solid ${tone(12)}`,
        }}
      >
        {/* The accent tween lives on the chip and its glow, never on the
            copy: colour is the thing that morphs, legibility is not. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={{
            backgroundColor: `${selected.accent}26`,
            boxShadow: `0 0 0 1px ${selected.accent}40`,
          }}
          transition={tint}
          style={{
            display: "grid",
            placeItems: "center",
            width: 30,
            height: 30,
            borderRadius: 9,
            flexShrink: 0,
          }}
        >
          <motion.svg
            width="15"
            height="15"
            viewBox="0 0 16 16"
            fill="none"
            initial={false}
            animate={{ color: selected.accent }}
            transition={tint}
          >
            <path
              d="M8 1.8 9.5 5.9 13.6 7.4 9.5 8.9 8 13 6.5 8.9 2.4 7.4 6.5 5.9z"
              fill="currentColor"
            />
          </motion.svg>
        </motion.span>

        <span style={{ display: "grid", gap: 3, minWidth: 0 }}>
          {/* Both states share a grid cell so the card reserves the wider
              label up front and the swap can never reflow the note. */}
          <span style={{ display: "grid", whiteSpace: "nowrap" }}>
            {options.map((option) => (
              <motion.span
                key={option.id}
                initial={false}
                animate={{ opacity: option.id === selected.id ? 1 : 0 }}
                transition={fade}
                style={{
                  gridArea: "1 / 1",
                  fontSize: 13.5,
                  fontWeight: 640,
                  lineHeight: 1.25,
                }}
              >
                {option.name}
              </motion.span>
            ))}
          </span>
          <span style={{ display: "grid" }}>
            {options.map((option) => (
              <motion.span
                key={option.id}
                initial={false}
                animate={{ opacity: option.id === selected.id ? 0.58 : 0 }}
                transition={fade}
                style={{
                  gridArea: "1 / 1",
                  fontSize: 11.5,
                  lineHeight: 1.35,
                }}
              >
                {option.note}
              </motion.span>
            ))}
          </span>
        </span>
      </div>
    </div>
  );
}

About this pattern

Choosing which model answers is a small setting with a large consequence, so the control should feel considered rather than clicked. A single selection pill travels between segments — the same element moving, not two fading — while the badge below cross-fades its name and its accent tweens to the colour the new option owns. Nothing scales: a label that pops on every switch turns a settings control into a toy, and this one is touched dozens of times a day.

Model pickerQuality or speed settingAssistant persona switchReasoning effort control

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    A model chooser beside the composer where the current choice is named and coloured.

Related patterns