Aadi

Model Picker

AI chats

About

A model picker for chat composers: models grouped by provider with search, capability glyphs for vision, tools and reasoning, a New tag and unavailable models with a reason. Bring your own provider marks.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Model Picker to your project and fits it to your theme.

Code

How Model Picker is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { Icon, IconNode, ModelOption, ModelPicker, createIcon } from "@/components/ui/model-picker"

/** How long a choice holds a tour before it carries on. */
const RESUME_MS = 1800

/** True until `RESUME_MS` after the last `hold()`, for tours with their own state. */
function useHold() {
  const [held, setHeld] = React.useState(false)
  const timer = React.useRef(0)
  React.useEffect(() => () => window.clearTimeout(timer.current), [])
  const hold = React.useCallback(() => {
    setHeld(true)
    window.clearTimeout(timer.current)
    timer.current = window.setTimeout(() => setHeld(false), RESUME_MS)
  }, [])
  return [held, hold] as const
}

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches

/** The tour: the list opens, a model is picked, the list closes on it, then the next. */
const PICKS = ["atlas", "meridian-3", "atlas-pro"]

const OPEN_MS = 1500

const CLOSED_MS = 900

const models: ModelOption[] = [
  {
    id: "atlas-pro",
    name: "Atlas Pro",
    provider: "northwind",
    description: "Deepest reasoning for long agent runs",
    capabilities: ["vision", "tools", "reasoning"],
    isNew: true,
  },
  {
    id: "atlas",
    name: "Atlas",
    provider: "northwind",
    description: "Fast and strong at code",
    capabilities: ["vision", "tools", "reasoning"],
  },
  {
    id: "meridian-3",
    name: "Meridian 3",
    provider: "meridian",
    description: "Long context for whole repositories",
    capabilities: ["vision", "tools", "reasoning"],
  },
]

/** Made up providers and models, so nothing here reads as a claim about a real product. */
const providers = [
  { id: "northwind", name: "Northwind" },
  { id: "meridian", name: "Meridian" },
]

const iconNode: IconNode = [
  ["path", {"d":"M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0"}],
]

const CircleIcon: Icon = /* @__PURE__ */ createIcon("circle", iconNode)

const iconNode2: IconNode = [
  ["path", {"d":"m13 2.68 6.57 3.79a2 2 0 0 1 1 1.73v7.6a2 2 0 0 1-1 1.73L13 21.32a2 2 0 0 1-2 0l-6.57-3.79a2 2 0 0 1-1-1.73V8.2a2 2 0 0 1 1-1.73L11 2.68a2 2 0 0 1 2 0"}],
]

const HexagonIcon: Icon = /* @__PURE__ */ createIcon("hexagon", iconNode2)

/** Placeholder marks: chatcn bundles no provider logos. */
const marks: Record<string, React.ReactNode> = {
  northwind: <CircleIcon className="size-full" />,
  meridian: <HexagonIcon className="size-full" />,
}

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/**
 * A model picker for a chat composer: the list opens grouped by provider with capability glyphs,
 * a New tag, a model is picked and the trigger updates, then it opens
 * again for the next one. Loops. Open it yourself and the tour waits for you.
 */
export default function ModelPickerPreview() {
  const [step, setStep] = React.useState({ pick: 0, open: true })
  const [held, hold] = useHold()
  const [mine, setMine] = React.useState<{ value: string; open: boolean } | null>(null)

  React.useEffect(() => {
    if (reducedMotion() || held) return
    setMine(null)
    const id = window.setTimeout(
      () =>
        setStep((s) =>
          s.open ? { pick: (s.pick + 1) % PICKS.length, open: false } : { pick: s.pick, open: true },
        ),
      step.open ? OPEN_MS : CLOSED_MS,
    )
    return () => window.clearTimeout(id)
  }, [step, held])

  const tourValue = PICKS[step.open ? (step.pick + PICKS.length - 1) % PICKS.length : step.pick]!
  const value = mine?.value ?? tourValue
  // A phone sized frame has no room for the list above the composer, so the tour only switches
  // the model there.
  const [short, setShort] = React.useState(() => typeof window !== "undefined" && window.innerHeight < 360)
  React.useEffect(() => {
    const read = () => setShort(window.innerHeight < 360)
    read()
    window.addEventListener("resize", read)
    return () => window.removeEventListener("resize", read)
  }, [])
  const open = mine?.open ?? (step.open && !short)
  // When the tour closes the list, focus returns to the trigger and draws its keyboard ring;
  // nobody used the keyboard, so let it go.
  React.useEffect(() => {
    if (open || mine) return
    const id = window.setTimeout(() => (document.activeElement as HTMLElement | null)?.blur(), 260)
    return () => window.clearTimeout(id)
  }, [open, mine])

  return (
    // Laid out at full size, never scaled: the list opens in a portal that a scaled frame would
    // not shrink, so the composer sits at the bottom and the list opens up from it.
    <div className="flex h-dvh w-full flex-col items-center justify-end gap-3 overflow-hidden p-4">
      <div className="bg-(color:--chatcn-card) shadow-(--chatcn-shadow-sm) rounded-(--chatcn-radius-xl) flex w-full max-w-md flex-col p-3">
        <div className="text-[0.875rem]/[1.25rem] tracking-[-0.003em] text-(color:--chatcn-fg-faint) px-2 pb-5">Ask anything…</div>
        <div className="flex items-center justify-between">
          <ModelPicker
            models={models}
            providers={providers}
            value={value}
            onValueChange={(v) => {
              hold()
              setMine({ value: v, open: false })
            }}
            open={open}
            onOpenChange={(o) => {
              hold()
              setMine({ value, open: o })
            }}
            side="top"
            align="start"
            showLegend={false}
            renderProviderMark={(id) => marks[id]}
          />
          <span
            aria-hidden
            className="bg-(color:--chatcn-fg) text-(color:--chatcn-bg) grid size-8 place-items-center rounded-full"
          >
            <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.8">
              <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </span>
        </div>
      </div>
      <MoreOn slug="model-picker">Models by provider, with search and capabilities. On</MoreOn>
    </div>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Voice Session

    Voice Session

    1

More in AI chats

View all
  • AI Actions

    AI Actions

    338
  • Illos Knowledge Sources

    Illos Knowledge Sources

    40
  • Aquarium Prompt

    Aquarium Prompt

    48
  • Attachments

    Attachments

    38
  • Chat Search

    Chat Search

    1
  • Color Picker

    Color Picker

    171
  • Commit

    Commit

    32
  • Chat Bubble

    Chat Bubble

    199

Explore more

Part of chatcn — browse the full library on 21st.dev.