For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Model Select

A catalogue you can walk: what is recommended first, then every maker in one scroll, with a rail that indexes the list and follows you down it.

"use client";

import { Frame, Image, Music } from "lucide-react";

Installation

$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/model-select.json

Usage

import {
  ModelSelect,
  ModelSelectContent,
  ModelSelectIndex,
  ModelSelectList,
  ModelSelectSearch,
  ModelSelectTrigger,
} from "@/components/ui/model-select";
 
export function Generation() {
  const [model, setModel] = useState("seedance-2-5");
 
  return (
    <ModelSelect
      models={MODELS}
      onValueChange={setModel}
      providers={PROVIDERS}
      value={model}
    >
      <ModelSelectTrigger />
      <ModelSelectContent>
        <ModelSelectSearch />
        <div className="flex min-h-0 flex-1">
          <ModelSelectIndex />
          <ModelSelectList />
        </div>
      </ModelSelectContent>
    </ModelSelect>
  );
}

One list, not two

A catalogue is long and mostly unread: someone wants the two or three models that fit what they are making, and wants to know the rest are there. So the panel holds one scroll — the recommended models first, then every maker in turn — and puts an index beside it instead of splitting the catalogue into a maker view and a model view.

The recommended section is not a second list either. A model marked recommended is lifted to the top of the same scroll and left where it is, so the row is in both places at once and the maker's section still holds its full set.

{ id: "seedance-2-5", name: "Seedance 2.5", provider: "bytedance", recommended: true }

recommendedLabel names that section, recommendedHint explains it where the heading carries an info glyph — "fits this run: 30s, native audio, a reference image" is what the demo says, and the caller is the only one who can know it. recommendedIcon replaces the glyph that heads it, which defaults to a target: these are the models that fit.

The rail is an index, not a filter

This is the part worth being careful about. Clicking a maker in the rail does not narrow the list; it scrolls the list you already have to that maker's section. That is what makes the index trustworthy: it can never disagree with what is on screen, because it is not choosing what is on screen.

Both are derived from the same two props — models and providers — so they cannot drift: providers gives the sections their order and their names, and a model whose maker is not in that array still gets a section under its own id rather than disappearing from the list.

providers takes { id, name, icon }. icon is the mark — see Provider Mark, which is what the demo passes — and a provider without one falls back to its initial, because an empty tile is a button with nothing to aim at.

While a search owns the list the rail marks nothing. The results are not the browse list, and lighting a maker up would claim a position the list does not have; clicking a maker anyway is how you leave the search.

<ModelSelectIndex />

Two sets of columns

The panel has a rail and a list, and the field above them is laid out on the same two columns the rail and the list are: the magnifier sits centred in the rail's column, on the same line as every mark in the rail, and the text you type starts exactly on the rail's edge — the line the list starts at.

Inside the list it is two more lines: marks at 76px (the rail's 56 plus the list's padding and a row's) and text at 106px, so a maker's mark, a row's mark and the heading's mark are one column, and a section's label, a model's name, its meta and the line under it are another.

The scroll is what marks the section

The heading of the section at the top of the box is the one the rail lights up, so the two answers to "where am I" are the same answer. At the end of the list the last section is marked even if it never reaches the top, because the bottom of a short section is still the bottom of the list.

The headings scroll with their models rather than sticking under the top edge. A heading that sticks parks itself over the row that came before it — half a row, hanging out above the one thing meant to explain it — and a list whose whole point is that it lines up cannot afford that.

Walking to a section is deferred by one render on purpose: leaving a search means the sections that were hidden have to be laid out again before there is anywhere to scroll to. Reduced motion gets an instant jump instead of a glide.

A row

ModelSelectItem draws the mark, the name, whatever the caller hangs off it, the line under it, and the check that says this is the one in use.

prop
descriptionThe line under the name. Leave it out and the row is one line tall.
metaAnything drawn beside the name — a duration, capability glyphs, a price.
keywordsWords the search may match that are not on screen, e.g. "audio".

Filtering, arrow keys, Enter and the listbox/option semantics are cmdk's, which is what the list is built on: a searchable list is a solved problem, and none of it is this component's idea. What is this component's idea is the rail, the recommended section, and the fact that both come from the same data.

Matching is command-score, which reads the query as a subsequence of a row's text — so sd25 finds Seedance 2.5, hail finds Hailuo, and a typo still lands — and then scores how good the match is, which is what floats the closest row to the top of its section. What it scores is the model's name, its maker, its description and whatever the caller added to keywords, so a word that is not on screen can still be searchable: a duration, a capability, a price.

Parts

The root owns the state and draws nothing, so everything you see is a part you placed:

part
ModelSelectTriggerOne mark, one name, one chevron. children replaces the face.
ModelSelectContentThe panel. It draws the surface and the field's chrome.
ModelSelectSearchThe field, holding the query in the root so the rail can clear it.
ModelSelectIndexThe rail. Leave it out and the picker is a searchable list, which is all a short list needs.
ModelSelectListThe scroll, the section headings, and the spy.
ModelSelectSectionOne section: heading plus rows. children replaces the rows.
ModelSelectItemOne model. children replaces the row's content.

Leaving the rail out is the whole difference between a browsable catalogue and a searchable one, and it is an omission rather than a prop:

Accessibility

The list is role="listbox" with role="option" rows, the field is a combobox wired to it, and cmdk keeps aria-activedescendant on the row under the arrow keys — so the arrow keys move the selection and the rail follows it, without a second keyboard model. The rail is a set of buttons with aria-current="true" on the section you are in.

Every mark is decorative: the name sits next to it in every place it is drawn.

Component source

index.tsx
"use client";

import { useCallback, useMemo, useRef, useState } from "react";

import { Popover } from "@/components/ui/popover";

import { ModelSelectContext } from "./context";
import type { ModelSelectState } from "./context";
import type { ModelSelectProps, ModelSelectSectionData } from "./types";

export {
  ModelSelectContent,
  ModelSelectIndex,
  ModelSelectItem,
  ModelSelectList,
  ModelSelectSearch,
  ModelSelectSection,
  ModelSelectTrigger,
  ProviderGlyph,
} from "./parts";
export type {
  ModelSelectContentProps,
  ModelSelectItemProps,
  ModelSelectSectionProps,
  ModelSelectTriggerProps,
} from "./parts";
export { useModelSelect } from "./context";
export type { ModelSelectState } from "./context";
export type {
  ModelSelectModel,
  ModelSelectProps,
  ModelSelectProvider,
  ModelSelectSectionData,
} from "./types";

/** The id the recommended section answers to. A section id is also the `data-section` the
 *  rail walks to, so it has to be a string a provider id cannot be. */
const RECOMMENDED = "recommended";

/* -- The picker ----------------------------------------------------------------
 * A catalogue is long, and most of it is never read: a person wants the two or three
 * models that fit what they are making, and wants to know the rest are there. So the
 * surface is one scroll of everything with a rail beside it — and the rail, unlike a
 * filter, never changes what the list holds. It says where you are, it walks you to a
 * maker, and it follows the scroll back.
 *
 * The root owns the state and draws nothing: the trigger, the panel, the field, the rail
 * and the list are parts, and a caller assembles them. Leaving the rail out is a picker
 * for a short list; leaving the panel out is a caller who wants their own. What the root
 * owns instead is the one thing parts cannot hold between them — which model is chosen,
 * what is typed, and which section the list is showing.
 *
 * `cmdk` owns the filtering, the arrow keys and the listbox semantics, because a
 * searchable list is a solved problem and none of it is this component's idea. What is
 * this component's idea is the rail, the recommended section, and the fact that both are
 * derived from the same two props — so the index can never disagree with the list.
 * --------------------------------------------------------------------------- */

export const ModelSelect = ({
  children,
  defaultOpen = false,
  defaultValue,
  disabled = false,
  emptyLabel = "No model matches",
  models,
  onOpenChange,
  onValueChange,
  open,
  providers,
  recommendedHint,
  recommendedIcon,
  recommendedLabel = "Recommended",
  triggerPlaceholder = "Select a model",
  value,
}: ModelSelectProps) => {
  // The pick is the picker's own until `value` is passed, like every other
  // value/defaultValue pair here: a surface that only needs to know which model to call
  // should not have to hold the answer in the page that renders it.
  const [picked, setPicked] = useState(() => defaultValue ?? models[0]?.id);
  const [query, setQuery] = useState("");
  const [activeSection, setActiveSection] = useState<string | null>(null);
  const [pendingSection, setPendingSection] = useState<string | null>(null);
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
  const listRef = useRef<HTMLDivElement>(null);
  const selected = value === undefined ? picked : value;
  const openState = open ?? uncontrolledOpen;

  /** The list, as sections: what is recommended first, then a section per maker in the
   *  order `providers` gives them. The rail is this array too, which is what keeps the
   *  two in step. */
  const sections = useMemo(() => {
    const list: ModelSelectSectionData[] = [];
    const recommended = models.filter((model) => model.recommended);
    if (recommended.length) {
      list.push({
        hint: recommendedHint,
        icon: recommendedIcon,
        id: RECOMMENDED,
        label: recommendedLabel,
        models: recommended,
      });
    }

    for (const provider of providers) {
      const owned = models.filter((model) => model.provider === provider.id);
      if (owned.length) {
        list.push({
          id: provider.id,
          label: provider.name,
          models: owned,
          provider,
        });
      }
    }

    // A model whose maker is missing from `providers` would be in the catalogue and
    // nowhere in the list: it gets a section under its own id rather than vanishing.
    const known = new Set(providers.map((provider) => provider.id));
    const orphans = models.filter((model) => !known.has(model.provider));
    for (const id of new Set(orphans.map((model) => model.provider))) {
      list.push({
        id,
        label: id,
        models: orphans.filter((model) => model.provider === id),
      });
    }

    return list;
  }, [models, providers, recommendedHint, recommendedIcon, recommendedLabel]);

  const setOpenState = useCallback(
    (next: boolean) => {
      if (open === undefined) {
        setUncontrolledOpen(next);
      }
      onOpenChange?.(next);
    },
    [onOpenChange, open]
  );

  const select = useCallback(
    (id: string) => {
      if (value === undefined) {
        setPicked(id);
      }
      onValueChange?.(id);
      // A pick ends the question. The trigger is the receipt, and leaving the panel open
      // over the thing it was opened from is a menu that will not get out of the way.
      setOpenState(false);
    },
    [onValueChange, setOpenState, value]
  );

  const walkTo = useCallback((id: string) => {
    // The rail walks the browse list, so a section it is asked for is a section the
    // search has to give back first.
    setQuery("");
    setPendingSection(id);
  }, []);

  const clearPendingSection = useCallback(() => setPendingSection(null), []);

  const handleOpenChange = useCallback(
    (next: boolean) => {
      setOpenState(next);
      if (!next) {
        setQuery("");
        setActiveSection(null);
      }
    },
    [setOpenState]
  );

  const state = useMemo<ModelSelectState>(
    () => ({
      activeSection,
      clearPendingSection,
      disabled,
      emptyLabel,
      listRef,
      models,
      pendingSection,
      providers,
      query,
      sections,
      select,
      setActiveSection,
      setQuery,
      triggerPlaceholder,
      value: selected,
      walkTo,
    }),
    [
      activeSection,
      clearPendingSection,
      disabled,
      emptyLabel,
      models,
      pendingSection,
      providers,
      query,
      sections,
      select,
      selected,
      triggerPlaceholder,
      walkTo,
    ]
  );

  return (
    <Popover onOpenChange={handleOpenChange} open={openState}>
      <ModelSelectContext.Provider value={state}>
        {children}
      </ModelSelectContext.Provider>
    </Popover>
  );
};
parts.tsx
"use client";

import { Check, ChevronDown, Info, Target } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
  createContext,
  useContext,
  useEffect,
  useLayoutEffect,
  useRef,
} from "react";

import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";
import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";

import { useModelSelect } from "./context";
import type {
  ModelSelectModel,
  ModelSelectProvider,
  ModelSelectSectionData,
} from "./types";

/* -- The parts -----------------------------------------------------------------
 * A model list is long and mostly unread, so it is assembled the way a list of places
 * is: a rail down the side that says what is in the list, and one scroll that holds all
 * of it — what is recommended first, then every maker in turn. The rail is an index, not
 * a filter: it never changes what the list holds, it only says where you are in it, and
 * it follows the scroll so the two can never disagree.
 *
 * The parts below are the whole surface. A caller assembles them, which is what makes a
 * one-maker list, or a picker with the rail left off, the same component arranged
 * differently rather than a second component.
 *
 * The list itself is `cmdk`: filtering, arrow keys, the roving selection and the
 * listbox/option semantics are the primitive's, so a part here only has to say what a row
 * looks like. The query is held by the root and handed back down, so a part that is not
 * the field can still clear it.
 * --------------------------------------------------------------------------- */

/** How far into the list a heading has to have reached to count as the one you are in.
 *  A few pixels of tolerance keeps the marker from flip-flopping on a partial row. */
const STICKY_LEAD = 12;

/** Breathing room left above a section the rail walked to, so its heading is not pinned
 *  to the very edge of the box. */
const WALK_GAP = 6;

/**
 * Which section a row is drawn in. A row's identity has to be unique across the whole
 * list, because a model that is recommended is also in its maker's section — the same
 * model, twice — and the list tracks which row is picked by that string. Two rows sharing
 * one light up together.
 */
const SectionContext = createContext<string | null>(null);

const prefersReducedMotion = () =>
  typeof window !== "undefined" &&
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/**
 * The maker's mark where the caller gave one, and its initial where they did not: a rail
 * with empty tiles in it is a row of buttons with nothing to aim at, and the initial is
 * the one thing every provider has.
 *
 * The box is the glyph's, not the artwork's — the mark is whatever node the caller passed,
 * and it is stretched to the box this part is drawn in, so one icon can sit in a heading,
 * a rail and a row without the caller sizing it three times.
 */
export const ProviderGlyph = ({
  className,
  provider,
}: {
  className?: string;
  provider?: ModelSelectProvider;
}) => {
  if (!provider) {
    return null;
  }

  return (
    <span
      aria-hidden="true"
      className={cn(
        "flex size-4.5 shrink-0 items-center justify-center font-medium text-[11px] text-muted-foreground [&_img]:size-full [&_svg]:size-full",
        className
      )}
    >
      {provider.icon ?? provider.name.slice(0, 1)}
    </span>
  );
};

export type ModelSelectContentProps = ComponentProps<typeof PopoverContent>;

/**
 * The panel. It draws the surface and the field's chrome and nothing else — what is
 * inside is the caller's arrangement of the search, the rail and the list.
 */
export const ModelSelectContent = ({
  children,
  className,
  ...props
}: ModelSelectContentProps) => (
  <PopoverContent
    align="start"
    className={cn(
      // A floating surface, so `popover`: the panel is nearer than the card it is opened
      // over, in both themes. The height is whatever the window leaves Radix.
      "flex max-h-(--radix-popover-content-available-height) w-[27rem] flex-col overflow-hidden rounded-2xl border-border p-0 shadow-xl",
      // The search row is drawn by `command`'s input, at this panel's scale rather than a
      // form's, and it is laid on the panel's two structural columns rather than the list's:
      // the glyph in the rail's column (19 + 18 + 19 = the rail's 56, so the magnifier is
      // centred on the same line as every mark in the rail), and the text starting exactly
      // on the rail's edge — the line the list beside it starts at.
      "[&_[data-slot=command-input-wrapper]]:h-11 [&_[data-slot=command-input-wrapper]]:gap-4.75 [&_[data-slot=command-input-wrapper]]:border-border [&_[data-slot=command-input-wrapper]]:pr-5 [&_[data-slot=command-input-wrapper]]:pl-4.75 [&_[data-slot=command-input-wrapper]_svg]:size-4.5 [&_[data-slot=command-input]]:h-11 [&_[data-slot=command-input]]:py-0",
      className
    )}
    sideOffset={6}
    {...props}
  >
    <Command className="flex h-full w-full flex-col" loop>
      {children}
    </Command>
  </PopoverContent>
);

/**
 * The way in: one mark, one name, one chevron. The mark is the loud part because it is
 * the part read at a glance; the name sits in the muted tone the rest of the row uses, so
 * the trigger has one subject instead of two.
 */
export interface ModelSelectTriggerProps {
  /** Replaces the face — mark, name, chevron. A caller who wants a different trigger
   *  draws it here and keeps the popover's wiring. */
  children?: ReactNode;
  className?: string;
  /** Said instead of the model name when nothing is selected. */
  placeholder?: string;
}

export const ModelSelectTrigger = ({
  children,
  className,
  placeholder,
}: ModelSelectTriggerProps) => {
  const { disabled, models, providers, triggerPlaceholder, value } =
    useModelSelect();
  const active = models.find((model) => model.id === value);
  const provider = providers.find((item) => item.id === active?.provider);

  return (
    <PopoverTrigger asChild disabled={disabled}>
      <button
        aria-label={active ? `Model: ${active.name}` : undefined}
        className={cn(
          "flex h-10 max-w-52 shrink-0 cursor-pointer items-center gap-2 rounded-full pr-2 pl-2.5 outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent disabled:pointer-events-none disabled:opacity-50",
          className
        )}
        type="button"
      >
        {children ?? (
          <>
            <ProviderGlyph provider={provider} />
            {/* The name is the part that changes, so it is the part that truncates. */}
            <span className="truncate text-muted-foreground text-sm">
              {active?.name ?? placeholder ?? triggerPlaceholder}
            </span>
            <ChevronDown className="size-4 shrink-0 text-muted-foreground/60" />
          </>
        )}
      </button>
    </PopoverTrigger>
  );
};

/**
 * The field. It is `command`'s input, which already carries the glyph and the hairline —
 * this part only holds the query in the root, so the rail can clear it and a section can
 * tell whether the list is a search.
 */
export const ModelSelectSearch = ({
  className,
  placeholder = "Search models",
  ...props
}: Omit<ComponentProps<typeof CommandInput>, "onValueChange" | "value">) => {
  const { query, setQuery } = useModelSelect();

  return (
    <CommandInput
      className={cn("text-sm", className)}
      onValueChange={setQuery}
      placeholder={placeholder}
      value={query}
      {...props}
    />
  );
};

/**
 * The rail: one entry per section, in the order the list walks them. It is the index into
 * a list that is already on screen, so an entry walks to its section instead of filtering
 * — and while a search owns the list it marks nothing, because lighting a maker up would
 * claim a position the list does not have.
 */
export const ModelSelectIndex = ({
  className,
  ...props
}: ComponentProps<"div">) => {
  const { activeSection, query, sections, walkTo } = useModelSelect();
  const railRef = useRef<HTMLDivElement>(null);
  const browsing = !query.trim();
  const current = sections.some((section) => section.id === activeSection)
    ? activeSection
    : sections[0]?.id;

  // A long enough rail scrolls, and the arrow keys can walk the list past the entry that
  // marks where you are: the index follows its own highlight the way it follows the scroll.
  useEffect(() => {
    railRef.current
      ?.querySelector('[aria-current="true"]')
      ?.scrollIntoView({ block: "nearest" });
  }, [current]);

  return (
    <div
      ref={railRef}
      className={cn(
        "no-scrollbar flex w-14 shrink-0 flex-col items-center gap-1 overflow-y-auto border-border border-r py-2",
        className
      )}
      {...props}
    >
      {sections.map((section) => {
        const on = browsing && current === section.id;

        return (
          <button
            aria-current={on ? "true" : undefined}
            aria-label={section.label}
            className={cn(
              "flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-xl outline-none transition-colors duration-150 focus-visible:bg-accent",
              on
                ? "bg-accent text-foreground"
                : "text-muted-foreground/70 hover:bg-accent/60"
            )}
            key={section.id}
            onClick={() => walkTo(section.id)}
            title={section.label}
            type="button"
          >
            {section.provider ? (
              <ProviderGlyph provider={section.provider} />
            ) : (
              (section.icon ?? <Target className="size-4.5" />)
            )}
          </button>
        );
      })}
    </div>
  );
};

/**
 * One model. The row is the mark, the name with whatever the caller hangs off it, the
 * line under it, and the check that says this is the one in use.
 */
export interface ModelSelectItemProps extends Omit<
  ComponentProps<typeof CommandItem>,
  "value"
> {
  /** Replaces the row's own content — the name, its `meta` and its description. */
  children?: ReactNode;
  model: ModelSelectModel;
}

export const ModelSelectItem = ({
  children,
  className,
  model,
  ...props
}: ModelSelectItemProps) => {
  const section = useContext(SectionContext);
  const { providers, select, value } = useModelSelect();
  const provider = providers.find((item) => item.id === model.provider);

  return (
    <CommandItem
      className={cn(
        "items-start gap-3 rounded-xl px-3 py-2.5 data-[selected=true]:bg-accent data-[selected=true]:text-foreground",
        className
      )}
      // What the search reads: the name first, then everything else that answers "which
      // model is this" — the maker, the line under it, and whatever the caller added.
      keywords={[
        model.name,
        provider?.name ?? model.provider,
        model.description ?? "",
        ...(model.keywords ?? []),
      ]}
      onSelect={() => select(model.id)}
      // Where the row is, not what it says: unique per row, stable across renders.
      value={`${section ?? "catalogue"}:${model.id}`}
      {...props}
    >
      <ProviderGlyph className="mt-0.5" provider={provider} />
      <span className="min-w-0 flex-1">
        {children ?? (
          <>
            <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
              <span className="truncate font-medium text-[13.5px] text-foreground">
                {model.name}
              </span>
              {model.meta ? (
                <span className="flex shrink-0 items-center gap-1.5">
                  {model.meta}
                </span>
              ) : null}
            </span>
            {model.description ? (
              <span className="mt-1 block truncate text-muted-foreground text-xs">
                {model.description}
              </span>
            ) : null}
          </>
        )}
      </span>
      <span className="mt-0.5 flex w-5 shrink-0 justify-center">
        {value === model.id ? (
          <Check className="size-4 text-foreground" />
        ) : null}
      </span>
    </CommandItem>
  );
};

/**
 * One section of the list: a heading that stays put while its models go by, and the
 * models themselves. The heading is the marker the rail reads and the list spies on, so a
 * caller who replaces the rows keeps the heading they were given.
 */
export interface ModelSelectSectionProps {
  /** Replaces the rows. A caller with their own row content keeps the heading — and the
   *  rail entry — that the section was given. */
  children?: ReactNode;
  className?: string;
  section: ModelSelectSectionData;
}

export const ModelSelectSection = ({
  children,
  className,
  section,
}: ModelSelectSectionProps) => (
  <CommandGroup
    className={cn(
      // The heading is one 36px row — the height of a rail entry, so the first maker lines
      // up with the first icon — and it scrolls with its models. A sticking heading parks
      // itself over the row that came before it, which reads as a broken grid: the row is
      // half covered and nothing above it lines up any more.
      "p-0 pt-2 [&_[cmdk-group-heading]]:flex [&_[cmdk-group-heading]]:h-9 [&_[cmdk-group-heading]]:items-center [&_[cmdk-group-heading]]:gap-3 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:tracking-wide",
      className
    )}
    data-section={section.id}
    heading={
      <>
        {section.provider ? (
          <ProviderGlyph provider={section.provider} />
        ) : (
          (section.icon ?? <Target className="size-4.5" />)
        )}
        {section.label}
        {section.hint ? (
          <span className="text-muted-foreground/60" title={section.hint}>
            <Info className="size-3.5" />
          </span>
        ) : null}
      </>
    }
  >
    <SectionContext.Provider value={section.id}>
      {children ??
        section.models.map((model) => (
          <ModelSelectItem key={model.id} model={model} />
        ))}
    </SectionContext.Provider>
  </CommandGroup>
);

/**
 * The list: the whole catalogue in one scroll, and the spy that reads it. What is at the
 * top of the box is what the rail marks, and a section the rail walked to is brought up
 * here — after the render that un-hid it, never before.
 */
export const ModelSelectList = ({
  children,
  className,
  onScroll,
  ...props
}: ComponentProps<typeof CommandList>) => {
  const {
    clearPendingSection,
    emptyLabel,
    listRef,
    pendingSection,
    query,
    sections,
    setActiveSection,
  } = useModelSelect();

  const read = (event?: React.UIEvent<HTMLDivElement>) => {
    onScroll?.(event as React.UIEvent<HTMLDivElement>);
    const list = listRef.current;
    if (!list) {
      return;
    }

    // Hidden sections are sections a search filtered out; they have no position to spy on.
    const markers = [
      ...list.querySelectorAll<HTMLElement>("[data-section]"),
    ].filter((marker) => !marker.hidden);
    if (!markers.length) {
      return;
    }

    const { top } = list.getBoundingClientRect();
    let current = markers[0]?.dataset.section;
    for (const marker of markers) {
      if (marker.getBoundingClientRect().top - top > STICKY_LEAD) {
        break;
      }
      current = marker.dataset.section;
    }

    // The last section is short, so it can never reach the top on its own: at the end of
    // the box, whatever is left is the one you are looking at.
    if (
      list.scrollHeight > list.clientHeight &&
      list.scrollTop + list.clientHeight >= list.scrollHeight - 2
    ) {
      current = markers.at(-1)?.dataset.section ?? current;
    }

    if (current) {
      setActiveSection(current);
    }
  };

  useLayoutEffect(() => {
    if (!pendingSection) {
      return;
    }

    const list = listRef.current;
    const marker = list?.querySelector<HTMLElement>(
      `[data-section="${pendingSection}"]`
    );
    if (list && marker) {
      const top =
        list.scrollTop +
        marker.getBoundingClientRect().top -
        list.getBoundingClientRect().top -
        WALK_GAP;
      list.scrollTo({
        behavior: prefersReducedMotion() ? "auto" : "smooth",
        top: Math.max(0, top),
      });
    }
    clearPendingSection();
  }, [clearPendingSection, listRef, pendingSection]);

  return (
    <CommandList
      className={cn(
        // No top padding: the section carries its own, so the first maker's heading and
        // the rail's first entry land on the same line.
        "no-scrollbar max-h-[26rem] min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-2 pb-2",
        className
      )}
      onScroll={read}
      ref={listRef}
      {...props}
    >
      {children ??
        sections.map((section) => (
          <ModelSelectSection key={section.id} section={section} />
        ))}
      <CommandEmpty className="px-4 py-10 text-center text-muted-foreground text-xs">
        {emptyLabel} “{query.trim()}”
      </CommandEmpty>
    </CommandList>
  );
};
context.ts
"use client";

import type { RefObject } from "react";
import { createContext, useContext } from "react";

import type {
  ModelSelectModel,
  ModelSelectProvider,
  ModelSelectSectionData,
} from "./types";

/**
 * What a part needs from the root to do its job: the catalogue, where the selection is,
 * and the three pieces of state the parts trade through — the query, the list's own
 * scroll box, and which section the list is showing. The parts that only lay out (the
 * content shell, a section) read this too, because a section cannot know where it sits in
 * the list without knowing what the list holds.
 */
export interface ModelSelectState {
  /** The section at the top of the list, as the list last saw it. */
  activeSection: string | null;
  clearPendingSection: () => void;
  disabled: boolean;
  emptyLabel: string;
  /** The list's scroll box, so the rail can walk to a section it is not holding. */
  listRef: RefObject<HTMLDivElement | null>;
  models: ModelSelectModel[];
  /** A section the rail asked for, waiting for the list to be laid out again. */
  pendingSection: string | null;
  providers: ModelSelectProvider[];
  query: string;
  sections: ModelSelectSectionData[];
  select: (id: string) => void;
  setActiveSection: (id: string) => void;
  setQuery: (query: string) => void;
  triggerPlaceholder: string;
  value?: string;
  /** Ask the list to bring a section to the top, dropping the query first if the search
   *  is what the list is showing. */
  walkTo: (id: string) => void;
}

export const ModelSelectContext = createContext<ModelSelectState | null>(null);

export const useModelSelect = () => {
  const context = useContext(ModelSelectContext);
  if (!context) {
    throw new Error("ModelSelect parts must be used within a ModelSelect.");
  }

  return context;
};
types.ts
import type { ReactNode } from "react";

/** One maker a model can come from. `icon` is a mark drawn at 18px in `currentColor`
 *  (see `ProviderMark`); without one the rail and the rows fall back to the maker's
 *  initial, because a slot that draws nothing is a control with nothing to aim at. */
export interface ModelSelectProvider {
  id: string;
  name: string;
  icon?: ReactNode;
}

/** One model the picker can be pointed at. */
export interface ModelSelectModel {
  /** What `value` and `onValueChange` trade in. */
  id: string;
  name: string;
  /** A `ModelSelectProvider.id`. */
  provider: string;
  /** The line under the name. Omit it and the row is one line tall. */
  description?: string;
  /** Drawn beside the name — a duration, a set of capability glyphs, a price. */
  meta?: ReactNode;
  /** Lifts the model into the recommended section that heads the list. */
  recommended?: boolean;
  /** Extra words the search may match that are not on screen, e.g. `"audio"`. */
  keywords?: string[];
}

/** One section of the list, and one entry in the rail that indexes it. Derived from
 *  `models` and `providers` rather than passed in: the rail and the list read the same
 *  array, which is what keeps the index from disagreeing with what is on screen. */
export interface ModelSelectSectionData {
  /** Said under the heading, e.g. what "recommended" was measured against here. */
  hint?: string;
  /** Drawn at the head of the section. Defaults to the maker's mark, or a sparkle for
   *  the recommended section. */
  icon?: ReactNode;
  /** Also the `data-section` the rail walks to. */
  id: string;
  label: string;
  models: ModelSelectModel[];
  /** Set on a maker's section: the mark, the name and the rail entry come from here. */
  provider?: ModelSelectProvider;
}

export interface ModelSelectProps {
  children: ReactNode;
  defaultOpen?: boolean;
  /** Model the picker opens on when it holds the choice itself. Defaults to the first
   *  model in the list: a picker that opens on nothing makes every caller write the
   *  same fallback. */
  defaultValue?: string;
  disabled?: boolean;
  /** Said when nothing matches the query. */
  emptyLabel?: string;
  models: ModelSelectModel[];
  onOpenChange?: (open: boolean) => void;
  onValueChange?: (id: string) => void;
  open?: boolean;
  /** The makers, in the order the rail and the list walk them. */
  providers: ModelSelectProvider[];
  /** Explains what "recommended" means for this run, e.g. `"Fits your 30s · audio
   *  setup"`. Omit it and the heading carries no hint. */
  recommendedHint?: string;
  /** Drawn at the head of the recommended section and in the rail. Defaults to a target:
   *  these are the models that fit what is being made. */
  recommendedIcon?: ReactNode;
  /** Heading over the recommended models. */
  recommendedLabel?: string;
  /** Said by the trigger when no model is selected. */
  triggerPlaceholder?: string;
  /** Controlled model id. */
  value?: string;
}