AI & Chat

Model Select

A model picker popover with provider marks, context and pricing meta, capability badges, a blur-swapping selection check, and a width-animating trigger.

Install

npx shadcn@latest add @paragon/model-select

model-select.tsx

"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>
  );
}