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
















