A model picker popover with provider marks, context and pricing meta, capability badges, a blur-swapping selection check, and a width-animating trigger.
npx shadcn@latest add @paragon/model-select"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
/** Mirrors var(--ease-out). */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
export interface ModelOption {
id: string;
name: string;
provider: string;
/** Context window, e.g. "200K". */
context?: string;
/** Pricing summary, e.g. "$3 / MTok". */
price?: string;
/** Capability badges, e.g. ["Tools", "Vision"]. */
badges?: string[];
}
const DEFAULT_MODELS: ModelOption[] = [
{
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
provider: "Anthropic",
context: "200K",
price: "$3 / MTok",
badges: ["Tools", "Vision"],
},
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "Anthropic",
context: "200K",
price: "$5 / MTok",
badges: ["Tools", "Vision", "Reasoning"],
},
{
id: "gpt-5",
name: "GPT-5",
provider: "OpenAI",
context: "256K",
price: "$4 / MTok",
badges: ["Tools", "Vision"],
},
{
id: "gemini-2-5-pro",
name: "Gemini 2.5 Pro",
provider: "Google",
context: "1M",
price: "$2.50 / MTok",
badges: ["Tools", "Vision"],
},
];
function ProviderBadge({ provider }: { provider: string }) {
return (
<span
aria-hidden
className="flex size-5 shrink-0 items-center justify-center rounded-[5px] bg-muted text-[10px] font-semibold text-muted-foreground select-none"
>
{provider[0]?.toUpperCase() ?? "?"}
</span>
);
}
export interface ModelSelectProps
extends Omit<
React.ComponentProps<"button">,
| "value"
| "defaultValue"
| "onChange"
| "onDrag"
| "onDragStart"
| "onDragEnd"
| "onAnimationStart"
> {
models?: ModelOption[];
/** Controlled selected model id. */
value?: string;
defaultValue?: string;
onValueChange?: (id: string) => void;
/** Disables the width/layout and swap motion. */
static?: boolean;
}
/**
* Model picker: a popover listing models with provider mark, context and
* pricing meta, and capability badges. The selected row's check blur-swaps
* between rows, and the trigger animates its width (motion layout) when the
* selection changes, crossfading the model name.
*/
export function ModelSelect({
models = DEFAULT_MODELS,
value,
defaultValue,
onValueChange,
static: isStatic = false,
className,
disabled,
...props
}: ModelSelectProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reducedMotion = useReducedMotion();
const [open, setOpen] = React.useState(false);
const [internal, setInternal] = React.useState(
defaultValue ?? models[0]?.id ?? "",
);
const selectedId = value ?? internal;
const selected =
models.find((model) => model.id === selectedId) ?? models[0];
const select = (nextId: string) => {
setInternal(nextId);
onValueChange?.(nextId);
setOpen(false);
};
const animate = !isStatic && !reducedMotion;
const blur = animate ? "blur(4px)" : "blur(0px)";
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<motion.button
type="button"
layout={animate}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
aria-label={`Model: ${selected?.name ?? "none"}`}
data-slot="model-select"
className={cn(
"group inline-flex h-8 items-center gap-1.5 overflow-hidden rounded-lg bg-card pr-2 pl-1.5 text-[13px] font-medium text-card-foreground shadow-border transition-[box-shadow,scale] duration-150 ease-out hover:shadow-border-hover disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.98]",
className,
)}
{...props}
>
<motion.span layout={animate ? "position" : false} className="flex">
<ProviderBadge provider={selected?.provider ?? "?"} />
</motion.span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={selected?.id ?? "none"}
layout={animate ? "position" : false}
className="whitespace-nowrap"
initial={{ opacity: 0, filter: blur }}
animate={{
opacity: 1,
filter: "blur(0px)",
transition: { duration: 0.2, ease: EASE_OUT },
}}
exit={{
opacity: 0,
filter: blur,
transition: { duration: 0.1 },
}}
>
{selected?.name ?? "Select model"}
</motion.span>
</AnimatePresence>
<motion.span layout={animate ? "position" : false} className="flex">
<ChevronDown
aria-hidden
className="size-3.5 text-muted-foreground transition-transform duration-200 ease-out group-data-[state=open]:rotate-180"
/>
</motion.span>
</motion.button>
</PopoverPrimitive.Trigger>
{/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
child node, and the Radix Portal enforces a single child. */}
<style href={`paragon-model-select-${id}`} precedence="paragon">{`
@keyframes model-select-in-${id} {
from { opacity: 0; transform: scale(0.97); filter: blur(2px); }
to { opacity: 1; transform: scale(1); filter: blur(0px); }
}
@keyframes model-select-out-${id} {
to { opacity: 0; transform: scale(0.99); filter: blur(2px); }
}
[data-model-select="${id}"][data-state="open"] {
animation: model-select-in-${id} 150ms var(--ease-out);
}
[data-model-select="${id}"][data-state="closed"] {
animation: model-select-out-${id} 100ms var(--ease-exit) forwards;
}
@media (prefers-reduced-motion: reduce) {
[data-model-select="${id}"] { animation: none !important; }
}
`}</style>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align="start"
sideOffset={6}
collisionPadding={8}
data-model-select={id}
className="z-50 w-72 rounded-xl bg-popover p-1 text-popover-foreground shadow-overlay outline-none [transform-origin:var(--radix-popover-content-transform-origin)]"
>
<div role="listbox" aria-label="Models">
{models.map((model) => {
const isSelected = model.id === selectedId;
return (
<button
key={model.id}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => select(model.id)}
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-[background-color] duration-100 ease-out hover:bg-accent focus-visible:bg-accent"
>
<ProviderBadge provider={model.provider} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-[13px] font-medium">
{model.name}
</span>
{model.badges?.map((badge) => (
<span
key={badge}
className="shrink-0 rounded bg-muted px-1 py-px text-[10px] font-medium text-muted-foreground"
>
{badge}
</span>
))}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground tabular-nums">
{[
model.context && `${model.context} context`,
model.price,
]
.filter(Boolean)
.join(" · ")}
</span>
</span>
<span className="flex size-4 shrink-0 items-center justify-center">
<AnimatePresence initial={false}>
{isSelected && (
<motion.span
key="check"
className="flex"
initial={{ opacity: 0, scale: 0.25, filter: blur }}
animate={{
opacity: 1,
scale: 1,
filter: "blur(0px)",
}}
exit={{ opacity: 0, scale: 0.25, filter: blur }}
transition={{
type: "spring",
duration: 0.3,
bounce: 0,
}}
>
<Check className="size-4" />
</motion.span>
)}
</AnimatePresence>
</span>
</button>
);
})}
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}