Model Switch Morph
Choosing another model glides the selection across and eases the badge into that model's name and accent.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A model chooser beside the composer where the current choice is named and coloured.
Related patterns
- Source Fan OutA tilted deck of reference cards fans apart to show what an answer was grounded in.
- Summary CondenseThe dropped lines fade out from the bottom up, and only then does the card close the height they held.
- Translate CrossfadeCopy hands over to its translation while the block eases to the new text's height.